diff --git a/backend/src/db/migrations/202610181400-secrets.ts b/backend/src/db/migrations/202610181400-secrets.ts new file mode 100644 index 000000000..7192c20a3 --- /dev/null +++ b/backend/src/db/migrations/202610181400-secrets.ts @@ -0,0 +1,26 @@ +import type { Migration } from '../migration-runner' + +const migration: Migration = { + id: '202610181400-secrets', + up(db) { + db.run(` + CREATE TABLE IF NOT EXISTS secrets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL CHECK (kind IN ('secret','variable')), + repo_id INTEGER REFERENCES repos(id) ON DELETE CASCADE, + environment TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL, + value TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `) + + db.run('CREATE UNIQUE INDEX IF NOT EXISTS idx_secrets_scope ON secrets(kind, COALESCE(repo_id, 0), environment, name)') + }, + down(db) { + db.run('DROP TABLE IF EXISTS secrets') + }, +} + +export default migration diff --git a/backend/src/db/migrations/202610181410-secrets-global-scope-index.ts b/backend/src/db/migrations/202610181410-secrets-global-scope-index.ts new file mode 100644 index 000000000..47b0fa886 --- /dev/null +++ b/backend/src/db/migrations/202610181410-secrets-global-scope-index.ts @@ -0,0 +1,57 @@ +import type { Database } from 'bun:sqlite' +import type { Migration } from '../migration-runner' +import { GLOBAL_SECRETS_SCOPE_ID } from '../secrets-scope' + +const LEGACY_GLOBAL_SCOPE_ID = 0 + +function rebuildScopeIndex(db: Database, globalScopeId: number): void { + db.run('DROP INDEX IF EXISTS idx_secrets_scope') + db.run( + `CREATE UNIQUE INDEX idx_secrets_scope ON secrets(kind, COALESCE(repo_id, ${globalScopeId}), environment, name)`, + ) +} + +/** + * Deletes the rows the legacy scope key cannot hold. + * + * The legacy index merged global rows (`repo_id IS NULL`) with the Assistant repository's scope + * (`repo_id = 0`), and the old upsert let the last write win, so the newest row of each merged + * group is the state legacy code would hold. Ordering by `updated_at`, then `id`, keeps that row + * and deletes the rest, which lets the legacy unique index be recreated. + */ +function collapseLegacyScopeDuplicates(db: Database): void { + db.run(` + DELETE FROM secrets + WHERE (repo_id IS NULL OR repo_id = ${LEGACY_GLOBAL_SCOPE_ID}) + AND EXISTS ( + SELECT 1 + FROM secrets AS newer + WHERE (newer.repo_id IS NULL OR newer.repo_id = ${LEGACY_GLOBAL_SCOPE_ID}) + AND newer.kind = secrets.kind + AND newer.environment = secrets.environment + AND newer.name = secrets.name + AND ( + newer.updated_at > secrets.updated_at + OR (newer.updated_at = secrets.updated_at AND newer.id > secrets.id) + ) + ) + `) +} + +export const secretsGlobalScopeIndexMigration: Migration = { + id: '202610181410-secrets-global-scope-index', + up(db) { + rebuildScopeIndex(db, GLOBAL_SECRETS_SCOPE_ID) + }, + down(db) { + db.run('BEGIN TRANSACTION') + try { + collapseLegacyScopeDuplicates(db) + rebuildScopeIndex(db, LEGACY_GLOBAL_SCOPE_ID) + db.run('COMMIT') + } catch (error) { + db.run('ROLLBACK') + throw error + } + }, +} diff --git a/backend/src/db/migrations/index.ts b/backend/src/db/migrations/index.ts index 5ef12db80..56feebfbf 100644 --- a/backend/src/db/migrations/index.ts +++ b/backend/src/db/migrations/index.ts @@ -37,6 +37,8 @@ import migration202610181200 from './202610181200-opencode-servers' import migration202610181300 from './202610181300-session-server-scope' import migration202610181310 from './202610181310-multi-run-targets' import migration202610181320 from './202610181320-remote-schedule-targets' +import migration202610181400 from './202610181400-secrets' +import { secretsGlobalScopeIndexMigration } from './202610181410-secrets-global-scope-index' export const allMigrations: Migration[] = [ migration001, @@ -77,4 +79,6 @@ export const allMigrations: Migration[] = [ migration202610181300, migration202610181310, migration202610181320, + migration202610181400, + secretsGlobalScopeIndexMigration, ] diff --git a/backend/src/db/queries.ts b/backend/src/db/queries.ts index 9b045c215..602b3aa30 100644 --- a/backend/src/db/queries.ts +++ b/backend/src/db/queries.ts @@ -4,6 +4,7 @@ import { getReposPath } from '@opencode-manager/shared/config/env' import { ASSISTANT_REPO_ID, ASSISTANT_REPO_PATH, getRepoDisplayName } from '@opencode-manager/shared/utils' import { getErrorMessage } from '../utils/error-utils' import { canonicalPathSync } from '../utils/fs-safe' +import { invalidateRepoEnvCache } from '../utils/repo-env-cache' import path from 'path' interface RepoRow { @@ -93,6 +94,12 @@ export function setRepoSandboxGitCredentials(db: Database, repoId: number, allow setRepoSetting(db, repoId, REPO_SANDBOX_GIT_CREDENTIALS_SETTING_KEY, allowed === null ? null : String(allowed)) } +export function getRepoByExactDirectory(db: Database, directory: string): Repo | null { + const resolvedDirectory = canonicalPathSync(path.resolve(directory)) + + return listRepos(db).find((repo) => canonicalPathSync(path.resolve(repo.fullPath)) === resolvedDirectory) ?? null +} + export function getRepoByDirectory(db: Database, directory: string): Repo | null { const resolvedDirectory = canonicalPathSync(path.resolve(directory)) const repos = listRepos(db) @@ -185,6 +192,7 @@ export function ensureAssistantRepo(db: Database): Repo { } export function createRepo(db: Database, repo: CreateRepoInput): Repo { + invalidateRepoEnvCache() const normalizedPath = repo.localPath.trim().replace(/\/+$/, '') const existing = repo.isLocal @@ -351,10 +359,12 @@ export function deleteRepo(db: Database, id: number): void { db.prepare('DELETE FROM multi_run_fusions WHERE multi_run_id IN (SELECT id FROM multi_runs WHERE repo_id = ?)').run(id) db.prepare('DELETE FROM multi_run_entries WHERE multi_run_id IN (SELECT id FROM multi_runs WHERE repo_id = ?)').run(id) db.prepare('DELETE FROM multi_runs WHERE repo_id = ?').run(id) + db.prepare('DELETE FROM secrets WHERE repo_id = ?').run(id) for (const table of TABLES_WITH_REPO_ID) { db.prepare(`DELETE FROM ${table} WHERE repo_id = ?`).run(id) } db.prepare('DELETE FROM repos WHERE id = ?').run(id) }) remove() + invalidateRepoEnvCache() } diff --git a/backend/src/db/secrets-scope.ts b/backend/src/db/secrets-scope.ts new file mode 100644 index 000000000..1e1a0e9d9 --- /dev/null +++ b/backend/src/db/secrets-scope.ts @@ -0,0 +1,7 @@ +/** + * Repo scope stored in place of a NULL `repo_id` for global secrets, so the unique scope index + * and its conflict target treat every global entry as one scope while repository scopes keep + * their own. Real repo ids are `0` (the Assistant repository) or positive, so a negative + * sentinel can never collide with one. + */ +export const GLOBAL_SECRETS_SCOPE_ID = -1 diff --git a/backend/src/index.ts b/backend/src/index.ts index 7068b7325..189800ccc 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -46,6 +46,7 @@ import { createChangeWalkthroughRoutes } from './routes/change-walkthroughs' import { getChangeWalkthroughByWalkthroughSession } from './db/change-walkthroughs' import { createLogRoutes } from './routes/logs' import { createPreviewRoutes, createPreviewAvailability } from './routes/preview' +import { createGlobalSecretRoutes } from './routes/secrets' import { createPreviewGatewayApp, PreviewSessionStore } from './services/preview/gateway' import { createInternalRoutes } from './routes/internal' import { sweepStaleUploadSessions } from './routes/internal/repo-mirror-helpers' @@ -76,6 +77,8 @@ import { ScheduleRunner, ScheduleService } from './services/schedules' import { CredentialProvider } from './services/credential-provider' import { TerminalService } from './services/terminal' import { ProjectConfigService } from './services/project-config' +import { SecretsService } from './services/secrets' +import { RepoEnvService } from './services/repo-env' import { RepoWorkspaceService } from './services/repo-workspace' import { createGitService } from './services/git/GitService' import { ScheduleWorktreeManager } from './services/schedule-worktree' @@ -141,6 +144,10 @@ import { DEFAULT_AGENTS_MD } from './constants' let ipcServer: IPCServer | undefined const gitAuthService = new GitAuthService() +const gitService = createGitService(gitAuthService) +const projectConfigService = new ProjectConfigService(db, gitService, gitAuthService) +const secretsService = new SecretsService(db) +const repoEnvService = new RepoEnvService(db, projectConfigService, secretsService) let openCodeSupervisor: OpenCodeSupervisor | undefined async function ensureOpenCodeConfigFileExists(): Promise { await withOpenCodeConfigLock(foldLegacyConfigJsonSource) @@ -239,7 +246,7 @@ try { opencodeServerManager.setDatabase(db) detectSandboxCapability() - void new SandboxRuntimeService(db).prepareSandboxOnBoot().catch((error) => { + void new SandboxRuntimeService(db, repoEnvService).prepareSandboxOnBoot().catch((error) => { logger.warn('Sandbox preparation failed:', error) }) const openCodeStatus = await openCodeSupervisor.start() @@ -257,20 +264,19 @@ const terminalService = new TerminalService( { client: openCodeClient, baseUrl: () => getOpenCodeUpstreamBaseUrl(opencodeServerManager.getEffectiveServerHost()), - includeLocalCredentials: true, + includeLocalEnv: true, }, new CredentialProvider(db), + repoEnvService, ) const resolveServerTerminalService = (serverId: string): TerminalService => { const connection = opencodeServerRegistry.getConnection(serverId) return terminalService.forTarget({ client: opencodeServerRegistry.getClient(serverId), baseUrl: () => connection.baseUrl, - includeLocalCredentials: false, + includeLocalEnv: false, }) } -const gitService = createGitService(gitAuthService) -const projectConfigService = new ProjectConfigService(db, gitService, gitAuthService) const scheduleWorktreeManager = new ScheduleWorktreeManager(gitAuthService, db) const scheduleService = new ScheduleService(db, openCodeClient, scheduleWorktreeManager, opencodeServerRegistry) scheduleService.setWorktreeRemovedHandler(async (directory) => { @@ -385,14 +391,14 @@ app.route('/api/auth-info', createAuthInfoRoutes(auth, db)) app.route('/api/health', createHealthRoutes(db, openCodeSupervisor)) app.route('/api/mcp-oauth-proxy', createMcpOauthProxyRoutes(openCodeClient, requireAuth)) -app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, sessionPermissionModeService, repoWorkspaces, gitAuthService, sessionGoalService, multiRunService, changeWalkthroughService, sessionSandboxService)) +app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, sessionPermissionModeService, repoWorkspaces, gitAuthService, sessionGoalService, multiRunService, changeWalkthroughService, sessionSandboxService, repoEnvService)) app.route('/api/opencode-proxy', createOpenCodeProxyRoutes(db, settingsService, upgradeWebSocket)) const protectedApi = new Hono() protectedApi.use('/*', requireAuth) protectedApi.route('/repos', createRepoTerminalSocketRoutes(db, gitAuthService, openCodeClient, terminalService, upgradeWebSocket)) -protectedApi.route('/repos', createRepoRoutes(db, gitAuthService, scheduleService, openCodeClient, terminalService, projectConfigService, repoWorkspaces)) +protectedApi.route('/repos', createRepoRoutes(db, gitAuthService, scheduleService, openCodeClient, terminalService, projectConfigService, repoWorkspaces, secretsService)) protectedApi.route('/settings', createSettingsRoutes(db, gitAuthService, openCodeClient, openCodeSupervisor)) protectedApi.route('/files', createFileRoutes()) protectedApi.route('/filesystem', createFilesystemRoutes()) @@ -404,6 +410,8 @@ protectedApi.route('/sse', createSSERoutes()) protectedApi.route('/ssh', createSSHRoutes(gitAuthService)) protectedApi.route('/notifications', createNotificationRoutes(notificationService)) protectedApi.route('/prompt-templates', createPromptTemplateRoutes(db)) +protectedApi.route('/secrets', createGlobalSecretRoutes(secretsService, 'secret')) +protectedApi.route('/variables', createGlobalSecretRoutes(secretsService, 'variable')) protectedApi.route('/session-pins', createSessionPinRoutes(db)) protectedApi.route('/opencode-servers/:serverId/schedules', createServerScheduleRoutes(scheduleService)) protectedApi.route('/opencode-servers', createOpenCodeServerTerminalSocketRoutes(resolveServerTerminalService, upgradeWebSocket)) diff --git a/backend/src/routes/internal/git-credentials.ts b/backend/src/routes/internal/git-credentials.ts index 830e4aca5..12bbf15ca 100644 --- a/backend/src/routes/internal/git-credentials.ts +++ b/backend/src/routes/internal/git-credentials.ts @@ -2,6 +2,12 @@ import { Hono } from 'hono' import type { Database } from 'bun:sqlite' import { CredentialProvider } from '../../services/credential-provider' +/** + * Legacy GitHub CLI environment route, kept for one release for OpenCode + * processes that were adopted before the upgrade: those processes still run the + * removed gh-env plugin and keep calling this path, so it answers with the same + * flat env a fresh install fetches from `/shell-env`. + */ export function createInternalGitCredentialsRoutes(db: Database) { const app = new Hono() diff --git a/backend/src/routes/internal/index.ts b/backend/src/routes/internal/index.ts index 1d1d3870a..2dfeb2a2e 100644 --- a/backend/src/routes/internal/index.ts +++ b/backend/src/routes/internal/index.ts @@ -15,6 +15,7 @@ import { createInternalRepoMirrorRoutes as mirrorRoutes } from './repo-mirror' import { createInternalOpenCodeWorkspacesRoutes } from './opencode-workspaces' import { createInternalSessionRoutes } from './sessions' import { createInternalAssistantRoutes } from './assistant' +import { createInternalShellEnvRoutes } from './shell-env' import { createInternalGitCredentialsRoutes } from './git-credentials' import { createInternalSandboxRoutes } from './sandbox' import { createInternalSessionArtifactRoutes } from './session-artifacts' @@ -29,6 +30,7 @@ import type { GitAuthService } from '../../services/git-auth' import type { SessionGoalService } from '../../services/session-goals' import type { MultiRunService } from '../../services/multi-runs' import type { ChangeWalkthroughService } from '../../services/change-walkthroughs' +import type { RepoEnvService } from '../../services/repo-env' export function createInternalRoutes( db: Database, @@ -43,6 +45,7 @@ export function createInternalRoutes( multiRuns: MultiRunService, changeWalkthroughService: ChangeWalkthroughService, sessionSandbox: SessionSandboxService, + repoEnvService: RepoEnvService, ) { const app = new Hono() app.use('/*', createInternalTokenMiddleware(db)) @@ -64,7 +67,8 @@ export function createInternalRoutes( app.route('/change-walkthroughs', createChangeWalkthroughRoutes(changeWalkthroughService)) app.route('/walkthrough-sessions', createInternalWalkthroughSessionRoutes(changeWalkthroughService)) app.route('/session-artifacts', createInternalSessionArtifactRoutes(openCodeClient)) + app.route('/shell-env', createInternalShellEnvRoutes(db, repoEnvService)) app.route('/git-credentials', createInternalGitCredentialsRoutes(db)) - app.route('/sandbox', createInternalSandboxRoutes(db, sessionSandbox)) + app.route('/sandbox', createInternalSandboxRoutes(db, sessionSandbox, repoEnvService)) return app } diff --git a/backend/src/routes/internal/sandbox.ts b/backend/src/routes/internal/sandbox.ts index db86686f2..e2a2cc2f9 100644 --- a/backend/src/routes/internal/sandbox.ts +++ b/backend/src/routes/internal/sandbox.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import type { Database } from 'bun:sqlite' import { SandboxRuntimeService } from '../../services/sandbox/runtime' import type { SessionSandboxService } from '../../services/session-sandbox' +import type { RepoEnvService } from '../../services/repo-env' import { logger } from '../../utils/logger' const SandboxShellRequestSchema = z.object({ @@ -12,7 +13,11 @@ const SandboxShellRequestSchema = z.object({ enforced: z.boolean().optional(), }) -export function createInternalSandboxRoutes(db: Database, sessionSandbox: SessionSandboxService) { +export function createInternalSandboxRoutes( + db: Database, + sessionSandbox: SessionSandboxService, + repoEnvService: RepoEnvService, +) { const app = new Hono() app.get('/sessions/:sessionId', async (c) => { @@ -40,7 +45,7 @@ export function createInternalSandboxRoutes(db: Database, sessionSandbox: Sessio try { const target = await sessionSandbox.resolveShellTarget(parsed.data.sessionId) return c.json( - await new SandboxRuntimeService(db).planShell( + await new SandboxRuntimeService(db, repoEnvService).planShell( parsed.data.directory, { ...target, locationDirectory: parsed.data.locationDirectory }, parsed.data.enforced === true, diff --git a/backend/src/routes/internal/shell-env.ts b/backend/src/routes/internal/shell-env.ts new file mode 100644 index 000000000..b13f18bc6 --- /dev/null +++ b/backend/src/routes/internal/shell-env.ts @@ -0,0 +1,22 @@ +import { Hono } from 'hono' +import type { Database } from 'bun:sqlite' +import { CredentialProvider } from '../../services/credential-provider' +import type { RepoEnvService } from '../../services/repo-env' + +export function createInternalShellEnvRoutes(db: Database, repoEnvService: RepoEnvService) { + const app = new Hono() + + app.get('/', async (c) => { + const provider = new CredentialProvider(db) + const cwd = c.req.query('cwd') + const ghEnv = provider.getGhCliEnv({ cwd }) + if (!cwd || c.req.query('sandboxed') === '1') { + return c.json({ env: ghEnv, mask: [] }) + } + + const repoEnv = await repoEnvService.resolveForDirectory(cwd) + return c.json({ env: { ...ghEnv, ...repoEnv.env }, mask: repoEnv.maskValues }) + }) + + return app +} diff --git a/backend/src/routes/repos.test.ts b/backend/src/routes/repos.test.ts index 4c3927595..78374e1d2 100644 --- a/backend/src/routes/repos.test.ts +++ b/backend/src/routes/repos.test.ts @@ -8,6 +8,7 @@ import { createRepo, getRepoById, setRepoSetting } from '../db/queries' import { createStubOpenCodeClient } from '../../test/helpers/stub-opencode-client' import { createGitService } from '../services/git/GitService' import { ProjectConfigService } from '../services/project-config' +import type { SecretsService } from '../services/secrets' import { RepoWorkspaceService } from '../services/repo-workspace' import type { GitAuthService } from '../services/git-auth' import type { TerminalService } from '../services/terminal' @@ -68,7 +69,7 @@ function createTestApp( } as any const projectConfigService = new ProjectConfigService(db, createGitService(stubGitAuthService), stubGitAuthService) const repoWorkspaces = new RepoWorkspaceService(db, openCodeClient, stubGitAuthService, projectConfigService, terminalService, scheduleService) - app.route('/repos', createRepoRoutes(db, stubGitAuthService, scheduleService, openCodeClient, terminalService, projectConfigService, repoWorkspaces)) + app.route('/repos', createRepoRoutes(db, stubGitAuthService, scheduleService, openCodeClient, terminalService, projectConfigService, repoWorkspaces, {} as unknown as SecretsService)) return app } diff --git a/backend/src/routes/repos.ts b/backend/src/routes/repos.ts index 6e384aa22..b90916768 100644 --- a/backend/src/routes/repos.ts +++ b/backend/src/routes/repos.ts @@ -18,10 +18,12 @@ import { isWorktreeError, openCodeLocation } from '@opencode-manager/shared/open import { createRepoGitRoutes } from './repo-git' import { createRepoTerminalRoutes } from './repo-terminals' import { createRepoProjectConfigRoutes } from './repo-project-config' +import { createRepoSecretRoutes } from './secrets' import { createScheduleRoutes } from './schedules' import type { GitAuthService } from '../services/git-auth' import type { TerminalService } from '../services/terminal' import type { ProjectConfigService } from '../services/project-config' +import type { SecretsService } from '../services/secrets' import type { RepoWorkspaceService } from '../services/repo-workspace' import { createGitService } from '../services/git/GitService' import { ScheduleService } from '../services/schedules' @@ -47,6 +49,7 @@ export function createRepoRoutes( terminalService: TerminalService, projectConfigService: ProjectConfigService, repoWorkspaces: RepoWorkspaceService, + secretsService: SecretsService, ) { const app = new Hono() const git = createGitService(gitAuthService) @@ -55,6 +58,8 @@ export function createRepoRoutes( app.route('/', createRepoTerminalRoutes(database, gitAuthService, openCodeClient, terminalService)) app.route('/', createRepoProjectConfigRoutes(database, gitAuthService, openCodeClient, projectConfigService, terminalService)) app.route('/:id/schedules', createScheduleRoutes(scheduleService)) + app.route('/', createRepoSecretRoutes({ database, projectConfigService, secretsService }, 'secret')) + app.route('/', createRepoSecretRoutes({ database, projectConfigService, secretsService }, 'variable')) app.post('/', async (c) => { try { diff --git a/backend/src/routes/secrets.ts b/backend/src/routes/secrets.ts new file mode 100644 index 000000000..3956e437c --- /dev/null +++ b/backend/src/routes/secrets.ts @@ -0,0 +1,129 @@ +import { Hono } from 'hono' +import type { Context } from 'hono' +import type { Database } from 'bun:sqlite' +import type { Repo } from '@opencode-manager/shared/types' +import { EnvironmentNameSchema, SecretNameSchema, SetSecretRequestSchema, type SecretKind } from '@opencode-manager/shared/schemas' +import { getRepoById } from '../db/queries' +import { SecretsService, SecretsServiceError } from '../services/secrets' +import type { ProjectConfigService } from '../services/project-config' +import { handleServiceError, parseJsonBody } from '../utils/route-helpers' + +function readName(c: Context): { name: string } | Response { + const parsed = SecretNameSchema.safeParse(c.req.param('name')) + if (!parsed.success) { + return c.json({ error: parsed.error.issues[0]?.message ?? 'Invalid name' }, 400) + } + return { name: parsed.data } +} + +function readEnvironmentQuery(c: Context): { environment: string | null } | Response { + const value = c.req.query('environment') + if (value === undefined) { + return { environment: null } + } + + const parsed = EnvironmentNameSchema.safeParse(value) + if (!parsed.success) { + return c.json({ error: parsed.error.issues[0]?.message ?? 'Invalid environment' }, 400) + } + return { environment: parsed.data } +} + +async function putEntry( + c: Context, + secretsService: SecretsService, + kind: SecretKind, + repoId: number | null, +): Promise { + const name = readName(c) + if (name instanceof Response) return name + + const body = await parseJsonBody(c, SetSecretRequestSchema) + if (!body.ok) return body.response + + try { + secretsService.set(kind, repoId, body.data.environment ?? null, name.name, body.data.value) + return c.body(null, 204) + } catch (error: unknown) { + return handleServiceError(c, error, 'Failed to store secret', SecretsServiceError) + } +} + +export function createGlobalSecretRoutes(secretsService: SecretsService, kind: SecretKind) { + const app = new Hono() + + app.get('/', (c) => c.json({ items: secretsService.list(kind, null) })) + + app.put('/:name', (c) => putEntry(c, secretsService, kind, null)) + + app.delete('/:name', (c) => { + const name = readName(c) + if (name instanceof Response) return name + + const deleted = secretsService.delete(kind, null, null, name.name) + if (!deleted) { + return c.json({ error: 'Not found' }, 404) + } + return c.body(null, 204) + }) + + return app +} + +export interface RepoSecretRouteDeps { + database: Database + projectConfigService: ProjectConfigService + secretsService: SecretsService +} + +export function createRepoSecretRoutes(deps: RepoSecretRouteDeps, kind: SecretKind) { + const { database, projectConfigService, secretsService } = deps + const segment = kind === 'secret' ? 'secrets' : 'variables' + const app = new Hono() + + const resolveProjectRepo = async (c: Context): Promise => { + const id = Number.parseInt(c.req.param('id') ?? '', 10) + if (Number.isNaN(id)) { + return c.json({ error: 'Invalid repo id' }, 400) + } + + const repo = getRepoById(database, id) + if (!repo) { + return c.json({ error: 'Repo not found' }, 404) + } + + return projectConfigService.resolveProjectRepo(repo) + } + + app.get(`/:id/${segment}`, async (c) => { + const repo = await resolveProjectRepo(c) + if (repo instanceof Response) return repo + return c.json({ items: secretsService.list(kind, repo.id) }) + }) + + app.put(`/:id/${segment}/:name`, async (c) => { + const repo = await resolveProjectRepo(c) + if (repo instanceof Response) return repo + + return putEntry(c, secretsService, kind, repo.id) + }) + + app.delete(`/:id/${segment}/:name`, async (c) => { + const name = readName(c) + if (name instanceof Response) return name + + const environment = readEnvironmentQuery(c) + if (environment instanceof Response) return environment + + const repo = await resolveProjectRepo(c) + if (repo instanceof Response) return repo + + const deleted = secretsService.delete(kind, repo.id, environment.environment, name.name) + if (!deleted) { + return c.json({ error: 'Not found' }, 404) + } + return c.body(null, 204) + }) + + return app +} diff --git a/backend/src/services/opencode-gh-env-plugin.ts b/backend/src/services/opencode-gh-env-plugin.ts index 339628228..02c35e628 100644 --- a/backend/src/services/opencode-gh-env-plugin.ts +++ b/backend/src/services/opencode-gh-env-plugin.ts @@ -1,36 +1,149 @@ +import { SANDBOX_SHELL_MARKER_PREFIX, SANDBOX_SHELL_STATE_KEY, SECRET_MASK_STORE_SOURCE } from './opencode-plugin-state' + +/** + * Builds the generated gh-env plugin source. + * + * The plugin keeps secret masks per cwd and replaces them on every successful + * `/shell-env` fetch for that cwd, so a rotated or removed secret stops being + * masked. A tool result is masked with the masks of the cwd the shell ran in + * when the tool event exposes one (`cwd` or `workdir` on the input); a tool + * result that exposes no cwd is masked with the union of the current per-cwd + * mask sets, never with values that were already replaced. + * + * A spawn that belongs to a sandboxed shell asks `/shell-env` to skip the + * repository env, because the sandbox plan resolves it once for the microVM and + * registers its mask values through the shared mask store. + */ export function buildGhEnvPluginSource(id: string): string { - return `const TTL_MS = 5000 -let cache = new Map() + return `var TTL_MS = 5000 +var cache = new Map() +var SHELL_STATE_KEY = Symbol.for(${JSON.stringify(SANDBOX_SHELL_STATE_KEY)}) +var SHELL_MARKER_PREFIX = ${JSON.stringify(SANDBOX_SHELL_MARKER_PREFIX)} + +${SECRET_MASK_STORE_SOURCE} + +function emptyBody() { + return { env: {}, mask: [] } +} + +function isSandboxedShell(event) { + var state = globalThis[SHELL_STATE_KEY] + if (state === null || typeof state !== 'object') { + return typeof event.command === 'string' && event.command.indexOf(SHELL_MARKER_PREFIX) === 0 + } + var inFlight = state.inFlightSandboxedCommands + if (!(inFlight instanceof Map)) return false + return inFlight.has(event.command) +} -async function fetchGhEnv(cwd) { +async function fetchShellEnv(cwd, sandboxed) { const baseUrl = process.env.OCM_INTERNAL_API_URL const token = process.env.OCM_INTERNAL_TOKEN - if (!baseUrl || !token) return {} + if (!baseUrl || !token) return emptyBody() const now = Date.now() - const cacheKey = cwd || '' + const cacheKey = (cwd || '') + (sandboxed ? '\\u0000sandboxed' : '') const cached = cache.get(cacheKey) - if (cached && now < cached.expiry) return cached.env + if (cached && now < cached.expiry) return cached try { - const url = new URL(baseUrl + '/git-credentials/gh-env') + const url = new URL(baseUrl + '/shell-env') if (cwd) url.searchParams.set('cwd', cwd) + if (sandboxed) url.searchParams.set('sandboxed', '1') const res = await fetch(url, { headers: { Authorization: 'Bearer ' + token }, }) - if (!res.ok) return cached?.env || {} - const env = await res.json() - const next = { expiry: now + TTL_MS, env: env && typeof env === 'object' ? env : {} } + if (!res.ok) return cached || emptyBody() + const body = await res.json() + const env = body && typeof body.env === 'object' && body.env !== null ? body.env : {} + const mask = body && Array.isArray(body.mask) ? body.mask : [] + const next = { expiry: now + TTL_MS, env, mask } cache.set(cacheKey, next) - return next.env + return next } catch { - return cached?.env || {} + return cached || emptyBody() } } +function isPlainObject(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false + const proto = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null +} + +function resultCwd(event) { + const input = event.input + if (input === null || typeof input !== 'object') return null + if (typeof input.cwd === 'string' && input.cwd.length > 0) return input.cwd + if (typeof input.workdir === 'string' && input.workdir.length > 0) return input.workdir + return null +} + +function masksForResult(event) { + const cwd = resultCwd(event) + if (cwd === null) return allCurrentMaskValues() + return currentMaskValues(cwd) || allCurrentMaskValues() +} + +function containsMask(text, values) { + for (const value of values) { + if (text.indexOf(value) !== -1) return true + } + return false +} + +function applyMasks(text, values) { + let result = text + for (const value of values) { + if (result.indexOf(value) !== -1) result = result.split(value).join('***') + } + return result +} + +function maskValue(value, values) { + if (typeof value === 'string') { + return containsMask(value, values) ? applyMasks(value, values) : value + } + if (Array.isArray(value)) { + let copy = null + for (let index = 0; index < value.length; index += 1) { + const masked = maskValue(value[index], values) + if (masked !== value[index]) { + if (copy === null) copy = value.slice() + copy[index] = masked + } + } + return copy === null ? value : copy + } + if (isPlainObject(value)) { + let copy = null + for (const key of Object.keys(value)) { + const masked = maskValue(value[key], values) + if (masked !== value[key]) { + if (copy === null) copy = Object.assign({}, value) + copy[key] = masked + } + } + return copy === null ? value : copy + } + return value +} + export default { id: '${id}', async setup(ctx) { await ctx.shell.hook('create.before', async (event) => { - Object.assign(event.env, await fetchGhEnv(event.cwd)) + const sandboxed = isSandboxedShell(event) + const body = await fetchShellEnv(event.cwd, sandboxed) + if (!sandboxed) replaceMaskValues(event.cwd, body.mask) + Object.assign(event.env, body.env) + }) + await ctx.tool.hook('execute.after', async (event) => { + if (event.status !== 'completed') return + if (event.result === null || typeof event.result !== 'object') return + const values = masksForResult(event) + if (values.length === 0) return + const source = { ...event.result } + const masked = maskValue(source, values) + if (masked !== source) event.result = masked }) }, } diff --git a/backend/src/services/opencode-plugin-state.ts b/backend/src/services/opencode-plugin-state.ts new file mode 100644 index 000000000..b1b9161ca --- /dev/null +++ b/backend/src/services/opencode-plugin-state.ts @@ -0,0 +1,70 @@ +/** + * Process-wide global key under which the Managed OpenCode plugins share shell + * sandbox state. Both plugins run inside the OpenCode process, so the key has to + * be identical in their generated sources. + */ +export const SANDBOX_SHELL_STATE_KEY = 'ocm-sandbox.shell-state.v3' + +/** + * Prefix of the marker prepended to every command that must run in a sandboxed + * shell. The sandbox plugin records a marked command as in flight, which is how + * the gh-env plugin knows the spawn is sandboxed without a session identifier. + */ +export const SANDBOX_SHELL_MARKER_PREFIX = 'ocm-sandbox-required-' + +/** + * Generated JS for the process-wide secret-mask store. The gh-env plugin applies + * the values it keeps and the sandbox plugin registers the values a sandbox plan + * resolved, so both plugins interpolate this same source and share the state it + * creates. + */ +export const SECRET_MASK_STORE_SOURCE = `var MASK_STATE_KEY = Symbol.for('ocm-secret-masks.v1') + +function maskState() { + var state = globalThis[MASK_STATE_KEY] + if (state === undefined) { + state = { byCwd: new Map(), version: 0, unionVersion: -1, union: [] } + globalThis[MASK_STATE_KEY] = state + } + return state +} + +function replaceMaskValues(cwd, values) { + var state = maskState() + var seen = new Set() + var sorted = [] + if (Array.isArray(values)) { + for (var index = 0; index < values.length; index += 1) { + var value = values[index] + if (typeof value !== 'string' || value.length === 0 || seen.has(value)) continue + seen.add(value) + sorted.push(value) + } + } + sorted.sort(function (a, b) { return b.length - a.length }) + if (sorted.length === 0) state.byCwd.delete(cwd || '') + else state.byCwd.set(cwd || '', sorted) + state.version += 1 +} + +function currentMaskValues(cwd) { + return maskState().byCwd.get(cwd || '') || null +} + +function allCurrentMaskValues() { + var state = maskState() + if (state.unionVersion === state.version) return state.union + var seen = new Set() + var union = [] + for (var list of state.byCwd.values()) { + for (var index = 0; index < list.length; index += 1) { + if (seen.has(list[index])) continue + seen.add(list[index]) + union.push(list[index]) + } + } + union.sort(function (a, b) { return b.length - a.length }) + state.unionVersion = state.version + state.union = union + return union +}` diff --git a/backend/src/services/opencode-sandbox-plugin.ts b/backend/src/services/opencode-sandbox-plugin.ts index 4faac98a9..c1aa84780 100644 --- a/backend/src/services/opencode-sandbox-plugin.ts +++ b/backend/src/services/opencode-sandbox-plugin.ts @@ -1,16 +1,20 @@ +import { ENV_NAME_PATTERN, SANDBOX_CONTROL_ENV_PREFIXES } from '@opencode-manager/shared/schemas' import { sandboxPlanTimeoutMs, SANDBOX_UNAVAILABLE_PREFIX } from './sandbox/command' import { - SANDBOX_FORWARDED_ENV_NAMES, + SANDBOX_SHELL_ENV_FORWARD, SANDBOX_SHELL_ENV_NAME, SANDBOX_SHELL_ENV_WORKDIR, } from './sandbox/shell-shim' +import { + SANDBOX_SHELL_MARKER_PREFIX, + SANDBOX_SHELL_STATE_KEY, + SECRET_MASK_STORE_SOURCE, +} from './opencode-plugin-state' export const SANDBOX_PLAN_TIMEOUT_MS = sandboxPlanTimeoutMs() export const SANDBOX_CLAIM_TIMEOUT_MS = 15000 -export const SANDBOX_SHELL_STATE_KEY = 'ocm-sandbox.shell-state.v2' - const SANDBOX_DOCKER_HINT = '\n\nCommands run inside an isolated sandbox microVM that includes Docker (docker, docker compose, docker buildx). The Docker daemon is not started automatically: run `ocm-dockerd-start` once before your first docker command; it is safe to run again and exits immediately if the daemon is already running.' export function buildSandboxPluginSource(id: string, shellShimPath: string, serviceSettingsPath: string): string { @@ -24,29 +28,52 @@ var CLAIM_TIMEOUT_MS = ${SANDBOX_CLAIM_TIMEOUT_MS} var SHELL_SHIM_PATH = ${JSON.stringify(shellShimPath)} var ENV_WORKDIR = ${JSON.stringify(SANDBOX_SHELL_ENV_WORKDIR)} var ENV_SANDBOX_NAME = ${JSON.stringify(SANDBOX_SHELL_ENV_NAME)} -var FORWARDED_ENV_NAMES = ${JSON.stringify(SANDBOX_FORWARDED_ENV_NAMES)} +var ENV_FORWARD = ${JSON.stringify(SANDBOX_SHELL_ENV_FORWARD)} +var ENV_NAME_PATTERN = new RegExp(${JSON.stringify(ENV_NAME_PATTERN.source)}) +var CONTROL_ENV_PREFIXES = ${JSON.stringify(SANDBOX_CONTROL_ENV_PREFIXES)} var SERVICE_SETTINGS_PATH = path.resolve(${JSON.stringify(serviceSettingsPath)}) var SERVICE_SETTINGS_DIRECTORY = path.dirname(SERVICE_SETTINGS_PATH) var SERVICE_SETTINGS_DENIED = 'OpenCode Manager denies access to its service settings file (service.json), which holds the OpenCode server password.' var DOCKER_HINT = ${JSON.stringify(SANDBOX_DOCKER_HINT)} var SHELL_TOOL_NAME = 'shell' -var SHELL_MARKER_PREFIX = 'ocm-sandbox-required-' -var SHELL_MARKER_PATTERN = /^(ocm-sandbox-required-[0-9a-f-]{36}) && / +var SHELL_MARKER_PREFIX = ${JSON.stringify(SANDBOX_SHELL_MARKER_PREFIX)} +var SHELL_MARKER_PATTERN = /^(${SANDBOX_SHELL_MARKER_PREFIX}[0-9a-f-]{36}) && / var PLAN_NAME_PATTERN = /^ocm-session-[0-9a-f]{12}$/ var ARBITER_KEY = Symbol.for('opencode.sandbox-arbiter.v1') var SHELL_STATE_KEY = Symbol.for(${JSON.stringify(SANDBOX_SHELL_STATE_KEY)}) var OCM_PROVIDER_ID = 'ocm' var TIER_RANK = { workload: 3, explicit: 2, default: 1 } +${SECRET_MASK_STORE_SOURCE} + +function isControlEnvName(name) { + return CONTROL_ENV_PREFIXES.some(function (prefix) { return name.indexOf(prefix) === 0 }) +} + function shellSandboxState() { var state = globalThis[SHELL_STATE_KEY] if (state === undefined) { - state = { pendingMarkers: new Map(), sandboxedCallIDs: new Set(), foreignOwners: new Map() } + state = { pendingMarkers: new Map(), sandboxedCallIDs: new Set(), foreignOwners: new Map(), inFlightSandboxedCommands: new Map() } globalThis[SHELL_STATE_KEY] = state } return state } +function pinInFlightSandboxCommand(command, markedCommand) { + var inFlight = shellState.inFlightSandboxedCommands + inFlight.set(command, (inFlight.get(command) || 0) + 1) + inFlight.set(markedCommand, (inFlight.get(markedCommand) || 0) + 1) +} + +function releaseInFlightSandboxCommand(command, markedCommand) { + var inFlight = shellState.inFlightSandboxedCommands + for (var key of [command, markedCommand]) { + var count = inFlight.get(key) || 0 + if (count <= 1) inFlight.delete(key) + else inFlight.set(key, count - 1) + } +} + var shellState = shellSandboxState() function sandboxArbiter() { @@ -312,13 +339,18 @@ async function pinSandboxShell(event, sessionID, locationDirectory) { throw unavailable('the sandbox shell shim is missing at ' + SHELL_SHIM_PATH) } var plan = await planSandboxShell(event.cwd, sessionID, locationDirectory) + var forwardNames = [] if (plan.env !== null && typeof plan.env === 'object') { - for (var name of FORWARDED_ENV_NAMES) { - if (typeof plan.env[name] === 'string') { - event.env[name] = plan.env[name] - } + for (var name of Object.keys(plan.env)) { + var value = plan.env[name] + if (typeof value !== 'string') continue + if (!ENV_NAME_PATTERN.test(name)) continue + if (isControlEnvName(name)) continue + event.env[name] = value + forwardNames.push(name) } } + replaceMaskValues(event.cwd, plan.mask) if (!lockAccessor(event, 'shell', SHELL_SHIM_PATH)) { throw unavailable('sandbox enforcement could not pin the sandbox shell; aborting before the command runs on the host') } @@ -328,6 +360,9 @@ async function pinSandboxShell(event, sessionID, locationDirectory) { if (!lockAccessor(event.env, ENV_SANDBOX_NAME, plan.name)) { throw unavailable('sandbox enforcement could not pin the sandbox session name; aborting before the command runs on the host') } + if (!lockAccessor(event.env, ENV_FORWARD, forwardNames.join(' '))) { + throw unavailable('sandbox enforcement could not pin the forwarded environment list; aborting before the command runs on the host') + } } async function wrapSandboxedShellTool(ctx) { @@ -348,11 +383,14 @@ async function wrapSandboxedShellTool(ctx) { throw unavailable('refusing to run a sandboxed shell call without a command') } var marker = SHELL_MARKER_PREFIX + randomUUID() + var markedCommand = marker + ' && ' + input.command shellState.pendingMarkers.set(marker, { callID: context.id, sessionID: context.sessionID }) shellState.sandboxedCallIDs.add(context.id) + pinInFlightSandboxCommand(input.command, markedCommand) try { - return await execute({ ...input, command: marker + ' && ' + input.command }, context) + return await execute({ ...input, command: markedCommand }, context) } finally { + releaseInFlightSandboxCommand(input.command, markedCommand) shellState.pendingMarkers.delete(marker) } } finally { diff --git a/backend/src/services/project-config.ts b/backend/src/services/project-config.ts index 2fc2765e0..55a2ce195 100644 --- a/backend/src/services/project-config.ts +++ b/backend/src/services/project-config.ts @@ -31,6 +31,14 @@ const REPO_CONFIG_TRUST_KEY = 'repoConfigTrustHash' const REPO_FILE_RELATIVE_PATH = path.join('.ocm', 'project.json') const REPO_CONFIG_SYMLINK_MESSAGE = '.ocm/project.json must not be a symbolic link' +function describeZodIssue(issue: { path?: PropertyKey[]; message: string } | undefined): string { + if (!issue) { + return 'Invalid value' + } + const issuePath = (issue.path ?? []).map(String).join('.') + return issuePath ? `${issuePath}: ${issue.message}` : issue.message +} + export interface RepoFileState { exists: boolean file: RepoProjectFile | null @@ -280,7 +288,7 @@ export class ProjectConfigService { } } - private isRepoFileTrusted(projectRepo: Repo, repoFile: RepoFileState): boolean { + isRepoFileTrusted(projectRepo: Repo, repoFile: RepoFileState): boolean { return ( repoFile.hash !== null && repoFile.hash === getRepoSetting(this.database, projectRepo.id, REPO_CONFIG_TRUST_KEY) @@ -306,7 +314,7 @@ export class ProjectConfigService { const parsed = RepoProjectFileSchema.safeParse(parsedJson) if (!parsed.success) { - return { exists: true, file: null, hash: null, error: 'Does not match the project file schema' } + return { exists: true, file: null, hash: null, error: `Does not match the project file schema: ${describeZodIssue(parsed.error.issues[0])}` } } return { exists: true, file: parsed.data, hash: this.hashRepoFile(parsed.data) } } catch { @@ -315,7 +323,7 @@ export class ProjectConfigService { } buildRepoFileExecutable(file: RepoProjectFile): NonNullable { - return { + const executable: NonNullable = { actions: (file.projectActions ?? []).map((action) => ({ id: action.id, name: action.name, @@ -325,6 +333,13 @@ export class ProjectConfigService { })), setup: file.setupWorktree ?? [], } + if (file.environment !== undefined) { + executable.environment = file.environment + } + if (file.env && Object.keys(file.env).length > 0) { + executable.env = file.env + } + return executable } hashRepoFile(file: RepoProjectFile): string { @@ -459,7 +474,10 @@ export class ProjectConfigService { return false } const hasExecutable = - (state.file.projectActions?.length ?? 0) > 0 || (state.file.setupWorktree?.length ?? 0) > 0 + (state.file.projectActions?.length ?? 0) > 0 || + (state.file.setupWorktree?.length ?? 0) > 0 || + (state.file.env !== undefined && Object.keys(state.file.env).length > 0) || + state.file.environment !== undefined return hasExecutable && !this.isRepoFileTrusted(projectRepo, state) } diff --git a/backend/src/services/repo-env.ts b/backend/src/services/repo-env.ts new file mode 100644 index 000000000..d4a9caeca --- /dev/null +++ b/backend/src/services/repo-env.ts @@ -0,0 +1,179 @@ +import fs from 'node:fs' +import path from 'node:path' +import type { Database } from 'bun:sqlite' +import type { Repo } from '@opencode-manager/shared/types' +import { interpolateEnvExpressions, type EnvExpressionReference } from '@opencode-manager/shared/utils' +import { getRepoByExactDirectory } from '../db/queries' +import { canonicalPathSync } from '../utils/fs-safe' +import { logger } from '../utils/logger' +import { repoEnvCacheEpoch } from '../utils/repo-env-cache' +import { findGitRepoRoot, resolveRepoForDirectory } from './repo' +import type { ProjectConfigService, RepoFileState } from './project-config' +import type { SecretsService } from './secrets' + +const DIRECTORY_CACHE_TTL_MS = 60_000 +const DIRECTORY_CACHE_LIMIT = 500 +const REPO_FILE_RELATIVE_PATH = path.join('.ocm', 'project.json') + +export interface ResolvedRepoEnv { + env: Record + maskValues: string[] +} + +interface DirectoryResolution { + topLevel: string | null + repo: Repo | null + projectRepo: Repo | null + expiresAt: number +} + +interface RepoFileResolution { + signature: string + state: RepoFileState +} + +function emptyResult(): ResolvedRepoEnv { + return { env: {}, maskValues: [] } +} + +function entrySignature(target: string): string { + try { + const stats = fs.lstatSync(target) + return `${stats.isSymbolicLink() ? 'link' : 'entry'}:${stats.mtimeMs}:${stats.size}` + } catch { + return 'missing' + } +} + +function repoFileSignature(filePath: string): string { + return `${entrySignature(path.dirname(filePath))}|${entrySignature(filePath)}` +} + +export class RepoEnvService { + private readonly directoryCache = new Map() + private readonly repoFileCache = new Map() + private cacheEpoch = repoEnvCacheEpoch() + + constructor( + private readonly db: Database, + private readonly projectConfigService: ProjectConfigService, + private readonly secretsService: SecretsService, + ) {} + + /** + * Resolves the repository environment for a directory. + * + * Never rejects: any internal failure is logged as a warning naming the directory + * and resolves to an empty result. + */ + async resolveForDirectory(cwd: string): Promise { + try { + this.syncCacheEpoch() + const directory = await this.resolveDirectory(cwd) + if (!directory.topLevel || !directory.repo || !directory.projectRepo) { + return emptyResult() + } + + const repoFile = this.readRepoFile(directory.topLevel) + if (!repoFile.file || !this.projectConfigService.isRepoFileTrusted(directory.projectRepo, repoFile)) { + return emptyResult() + } + + const mapping = repoFile.file.env + if (!mapping || Object.keys(mapping).length === 0) { + return emptyResult() + } + + const resolved = this.secretsService.resolveValues(directory.projectRepo.id, repoFile.file.environment ?? null) + const maskValues = new Set() + + const lookup = (reference: EnvExpressionReference): string | undefined => { + const values = reference.context === 'secrets' ? resolved.secrets : resolved.vars + const found = values[reference.name] + if (found === undefined) { + return undefined + } + if (reference.context === 'secrets') { + maskValues.add(found) + } + return found + } + + const env: Record = {} + for (const [name, template] of Object.entries(mapping)) { + env[name] = interpolateEnvExpressions(template, lookup).value + } + + return { + env, + maskValues: [...maskValues].filter((value) => value.length >= 4).sort((a, b) => b.length - a.length), + } + } catch (error) { + logger.warn(`Failed to resolve repository environment for ${cwd}`, error) + return emptyResult() + } + } + + private syncCacheEpoch(): void { + const epoch = repoEnvCacheEpoch() + if (epoch === this.cacheEpoch) { + return + } + this.cacheEpoch = epoch + this.directoryCache.clear() + } + + private async resolveDirectory(cwd: string): Promise { + const key = canonicalPathSync(path.resolve(cwd)) + const now = Date.now() + const cached = this.directoryCache.get(key) + if (cached && cached.expiresAt > now) { + return cached + } + + const topLevel = await findGitRepoRoot(cwd) + const repo = topLevel + ? getRepoByExactDirectory(this.db, topLevel) ?? (await resolveRepoForDirectory(this.db, topLevel)) + : null + const projectRepo = repo ? await this.projectConfigService.resolveProjectRepo(repo) : null + + this.pruneDirectoryCache(now) + const resolution: DirectoryResolution = { + topLevel, + repo, + projectRepo, + expiresAt: now + DIRECTORY_CACHE_TTL_MS, + } + this.directoryCache.set(key, resolution) + return resolution + } + + private pruneDirectoryCache(now: number): void { + if (this.directoryCache.size < DIRECTORY_CACHE_LIMIT) { + return + } + for (const [key, resolution] of this.directoryCache) { + if (resolution.expiresAt <= now) { + this.directoryCache.delete(key) + } + } + for (const key of this.directoryCache.keys()) { + if (this.directoryCache.size < DIRECTORY_CACHE_LIMIT) { + break + } + this.directoryCache.delete(key) + } + } + + private readRepoFile(topLevel: string): RepoFileState { + const signature = repoFileSignature(path.join(topLevel, REPO_FILE_RELATIVE_PATH)) + const cached = this.repoFileCache.get(topLevel) + if (cached?.signature === signature) { + return cached.state + } + + const state = this.projectConfigService.readRepoFile(topLevel) + this.repoFileCache.set(topLevel, { signature, state }) + return state + } +} diff --git a/backend/src/services/repo.ts b/backend/src/services/repo.ts index ed36f9c55..da349ebbd 100644 --- a/backend/src/services/repo.ts +++ b/backend/src/services/repo.ts @@ -252,11 +252,12 @@ async function safeGetCurrentBranch(repoPath: string, env: Record): Promise { +export async function findGitRepoRoot(targetPath: string, env: Record = {}): Promise { try { const resolvedPath = normalizeAbsolutePath(targetPath) const repoRoot = await executeCommand(['git', '-C', resolvedPath, 'rev-parse', '--show-toplevel'], { env, silent: true }) - return normalizeAbsolutePath(repoRoot.trim()) + const trimmed = repoRoot.trim() + return trimmed ? normalizeAbsolutePath(trimmed) : null } catch { return null } @@ -1209,6 +1210,11 @@ export async function resolveRepoProjectId(openCodeClient: OpenCodeClient, direc return project.id } +/** + * Resolves the registered repository a directory belongs to. Beyond the repository's own + * location, a directory is only attributed to a repository that shares its Git metadata, so a + * separate clone of the same origin, whose project id matches, is not mistaken for it. + */ export async function resolveRepoForDirectory( database: Database, directory: string, @@ -1224,13 +1230,22 @@ export async function resolveRepoForDirectory( const readyRepos = listRepos(database).filter( (candidate) => candidate.cloneStatus === 'ready', ) + const candidates: Repo[] = [] for (const candidate of readyRepos) { const candidateProjectId = await resolveProjectId(candidate.fullPath).catch( () => null, ) - if (candidateProjectId === projectId) return candidate + if (candidateProjectId === projectId) candidates.push(candidate) } - return null + if (candidates.length === 0) return null + + const mainCheckout = await resolveMainCheckoutPath(directory) + if (!mainCheckout) return null + const canonicalMainCheckout = canonicalPathSync(path.resolve(mainCheckout)) + + return candidates.find( + (candidate) => canonicalPathSync(path.resolve(candidate.fullPath)) === canonicalMainCheckout, + ) ?? null } export function resolveRepoOrAssistant(database: Database, id: number): Repo | null { diff --git a/backend/src/services/sandbox/runtime.ts b/backend/src/services/sandbox/runtime.ts index 7583331c4..4b9bd131c 100644 --- a/backend/src/services/sandbox/runtime.ts +++ b/backend/src/services/sandbox/runtime.ts @@ -13,6 +13,7 @@ import { isPathWithinRoot, mkdirSafe } from '../../utils/fs-safe' import { logger } from '../../utils/logger' import { SettingsService } from '../settings' import { CredentialProvider } from '../credential-provider' +import type { RepoEnvService } from '../repo-env' import { getGitIdentityEnvForDirectory } from '../git-identity' import { getProcessIdentityAttestationError } from '../opencode/process-identity' import { detectSandboxCapability } from './capability' @@ -66,7 +67,7 @@ export type SandboxShellTarget = { export type SandboxShellPlan = | { mode: 'host' } - | { mode: 'sandbox'; name: string; workdir: string; env?: Record } + | { mode: 'sandbox'; name: string; workdir: string; env?: Record; mask?: string[] } | { mode: 'blocked'; reason: string } export type SandboxStatus = { @@ -1049,7 +1050,10 @@ function sandboxMaxRunningVms(db: Database): number { } export class SandboxRuntimeService { - constructor(private readonly db: Database) {} + constructor( + private readonly db: Database, + private readonly repoEnvService?: RepoEnvService, + ) {} async prepareSandboxOnBoot(): Promise { if (!this.isSandboxEnabled()) return @@ -1119,9 +1123,19 @@ export class SandboxRuntimeService { ...new CredentialProvider(this.db).getSandboxGitEnv({ cwd: directory }), ...await getGitIdentityEnvForDirectory(directory, this.db), } - return Object.keys(env).length > 0 - ? { mode: 'sandbox', name: spec.name, workdir: workDirectory, env } - : { mode: 'sandbox', name: spec.name, workdir: workDirectory } + let mask: string[] = [] + if (this.repoEnvService) { + const repoEnv = await this.repoEnvService.resolveForDirectory(directory) + Object.assign(env, repoEnv.env) + mask = repoEnv.maskValues + } + return { + mode: 'sandbox', + name: spec.name, + workdir: workDirectory, + ...(Object.keys(env).length > 0 ? { env } : {}), + ...(mask.length > 0 ? { mask } : {}), + } } catch (error) { logger.error('Failed to prepare the session sandbox', error) return { mode: 'blocked', reason: error instanceof Error ? error.message : String(error) } diff --git a/backend/src/services/sandbox/shell-shim.ts b/backend/src/services/sandbox/shell-shim.ts index 461968368..c2ab2419a 100644 --- a/backend/src/services/sandbox/shell-shim.ts +++ b/backend/src/services/sandbox/shell-shim.ts @@ -1,5 +1,6 @@ import path from 'path' import { ENV } from '@opencode-manager/shared/config/env' +import { SANDBOX_CONTROL_ENV_PREFIXES } from '@opencode-manager/shared/schemas' import { isPathWithinRoot, writeFileAtomic } from '../../utils/fs-safe' import { quoteForShell, @@ -15,38 +16,27 @@ export const SANDBOX_SHELL_ENV_WORKDIR = 'OCM_SANDBOX_WORKDIR' export const SANDBOX_SHELL_ENV_NAME = 'OCM_SANDBOX_NAME' /** - * Upper bound on `http..extraheader` pairs forwarded into the microVM. - * The shim enumerates a fixed set of variable names rather than expanding - * `GIT_CONFIG_KEY_$i` indirectly, which would require `eval` on values holding - * credentials. Credentials are configured per host, so this bounds the number - * of distinct git hosts a sandboxed command can authenticate against. + * Space-separated allow-list of variable names the shim forwards from its own + * environment into the microVM with `msb exec -e`. The plugin pins it from the + * sandbox plan, so only names the plan approved reach a sandboxed command; the + * guest environment is otherwise empty. */ -export const SANDBOX_MAX_FORWARDED_GIT_CONFIGS = 16 +export const SANDBOX_SHELL_ENV_FORWARD = 'OCM_SANDBOX_FORWARD_ENV' /** - * Variables the shim copies from its own environment into the microVM with - * `msb exec -e`. The guest environment is otherwise empty, so anything absent - * from this list never reaches a sandboxed command. + * Upper bound on `http..extraheader` pairs the planner forwards into the + * microVM. Credentials are configured per host, so this bounds the number of + * distinct git hosts a sandboxed command can authenticate against. */ -export const SANDBOX_FORWARDED_ENV_NAMES: readonly string[] = [ - 'GIT_TERMINAL_PROMPT', - 'GIT_CONFIG_COUNT', - ...Array.from({ length: SANDBOX_MAX_FORWARDED_GIT_CONFIGS }, (_, index) => [ - `GIT_CONFIG_KEY_${index}`, - `GIT_CONFIG_VALUE_${index}`, - ]).flat(), - 'GIT_AUTHOR_NAME', - 'GIT_AUTHOR_EMAIL', - 'GIT_COMMITTER_NAME', - 'GIT_COMMITTER_EMAIL', - 'GH_TOKEN', - 'GITHUB_TOKEN', -] +export const SANDBOX_MAX_FORWARDED_GIT_CONFIGS = 16 + +const SANDBOX_CONTROL_ENV_CASE_PATTERN = SANDBOX_CONTROL_ENV_PREFIXES.map((prefix) => `${prefix}*`).join('|') /** - * Drops `http..extraheader` pairs the shim cannot forward, so the guest - * never sees a `GIT_CONFIG_COUNT` that overstates the pairs actually present. - * A count larger than the pairs in the environment makes git fail outright. + * Drops `http..extraheader` pairs beyond the forwarding cap, so the + * guest never sees a `GIT_CONFIG_COUNT` that overstates the pairs actually + * present. A count larger than the pairs in the environment makes git fail + * outright. */ export function limitForwardedGitConfigs(env: Record): { env: Record @@ -85,9 +75,19 @@ export function buildSandboxShellShimScript(): string { '--timeout', `${timeoutSeconds}s`, ].join(' ') - const forwardEnv = SANDBOX_FORWARDED_ENV_NAMES.map( - (name) => ` if [ -n "\${${name}+x}" ]; then set -- "$@" -e "${name}=$${name}"; fi`, - ).join('\n') + const forwardEnv = [ + 'set -f', + `for OCM_SHIM_NAME in \${${SANDBOX_SHELL_ENV_FORWARD}}; do`, + ` case "$OCM_SHIM_NAME" in`, + ` ''|[0-9]*|*[!A-Za-z0-9_]*|${SANDBOX_CONTROL_ENV_CASE_PATTERN}) continue ;;`, + ' esac', + ` eval "OCM_SHIM_SET=\\\${$OCM_SHIM_NAME+x}"`, + ` [ -n "$OCM_SHIM_SET" ] || continue`, + ` eval "OCM_SHIM_VALUE=\\\${$OCM_SHIM_NAME}"`, + ` set -- "$@" -e "$OCM_SHIM_NAME=$OCM_SHIM_VALUE"`, + 'done', + 'set +f', + ].join('\n') return `#!/bin/sh if [ -z "\${${SANDBOX_SHELL_ENV_WORKDIR}:-}" ]; then echo 'ocm-sandbox-shell: sandbox working directory missing; refusing to run on the host' >&2 @@ -100,16 +100,16 @@ case "\${${SANDBOX_SHELL_ENV_NAME}:-}" in exit 126 ;; esac -ocm_argc=$# +OCM_SHIM_ARGC=$# set -- "$@" ${execArgs} ${forwardEnv} set -- "$@" -- sh -ocm_i=0 -while [ "$ocm_i" -lt "$ocm_argc" ]; do - ocm_arg=$1 +OCM_SHIM_I=0 +while [ "$OCM_SHIM_I" -lt "$OCM_SHIM_ARGC" ]; do + OCM_SHIM_ARG=$1 shift - set -- "$@" "$ocm_arg" - ocm_i=$((ocm_i + 1)) + set -- "$@" "$OCM_SHIM_ARG" + OCM_SHIM_I=$((OCM_SHIM_I + 1)) done exec "$@" ` diff --git a/backend/src/services/secrets.ts b/backend/src/services/secrets.ts new file mode 100644 index 000000000..ca38c5261 --- /dev/null +++ b/backend/src/services/secrets.ts @@ -0,0 +1,110 @@ +import type { Database } from 'bun:sqlite' +import type { SecretEntry, SecretKind } from '@opencode-manager/shared/schemas' +import { GLOBAL_SECRETS_SCOPE_ID } from '../db/secrets-scope' +import { decryptSecret, encryptSecret } from '../utils/crypto' +import { logger } from '../utils/logger' + +export class SecretsServiceError extends Error { + constructor(message: string, public statusCode: number = 500) { + super(message) + this.name = 'SecretsServiceError' + } +} + +interface SecretRow { + kind: SecretKind + repo_id: number | null + environment: string + name: string + value: string + updated_at: number +} + +type SecretValueRow = Omit + +export interface ResolvedSecretValues { + secrets: Record + vars: Record +} + +export class SecretsService { + constructor(private readonly db: Database) {} + + list(kind: SecretKind, repoId: number | null): SecretEntry[] { + return this.selectRows(kind, repoId).map((row) => this.toEntry(kind, row)) + } + + set(kind: SecretKind, repoId: number | null, environment: string | null, name: string, value: string): void { + const normalizedEnvironment = environment ?? '' + if (repoId === null && normalizedEnvironment !== '') { + throw new SecretsServiceError('Environments are only supported for repository secrets', 400) + } + + const now = Date.now() + const storedValue = kind === 'secret' ? encryptSecret(value) : value + + this.db + .prepare( + `INSERT INTO secrets (kind, repo_id, environment, name, value, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(kind, COALESCE(repo_id, ${GLOBAL_SECRETS_SCOPE_ID}), environment, name) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, + ) + .run(kind, repoId, normalizedEnvironment, name, storedValue, now, now) + } + + delete(kind: SecretKind, repoId: number | null, environment: string | null, name: string): boolean { + const result = this.db + .prepare('DELETE FROM secrets WHERE kind = ? AND repo_id IS ? AND environment = ? AND name = ?') + .run(kind, repoId, environment ?? '', name) + + return result.changes > 0 + } + + resolveValues(repoId: number, environment: string | null): ResolvedSecretValues { + const rows = this.db + .prepare( + `SELECT kind, repo_id, environment, name, value FROM secrets + WHERE repo_id IS NULL OR repo_id = ? + ORDER BY CASE WHEN repo_id IS NULL THEN 0 WHEN environment = '' THEN 1 ELSE 2 END`, + ) + .all(repoId) as SecretValueRow[] + + const secrets: Record = {} + const vars: Record = {} + + for (const row of rows) { + if (row.repo_id !== null && row.environment !== '' && row.environment !== environment) { + continue + } + + if (row.kind === 'secret') { + try { + secrets[row.name] = decryptSecret(row.value) + } catch { + logger.warn(`Skipping secret "${row.name}" because it could not be decrypted`) + } + } else { + vars[row.name] = row.value + } + } + + return { secrets, vars } + } + + private selectRows(kind: SecretKind, repoId: number | null): SecretRow[] { + return this.db + .prepare( + 'SELECT kind, repo_id, environment, name, value, updated_at FROM secrets WHERE kind = ? AND repo_id IS ? ORDER BY name', + ) + .all(kind, repoId) as SecretRow[] + } + + private toEntry(kind: SecretKind, row: SecretRow): SecretEntry { + return { + name: row.name, + environment: row.environment === '' ? null : row.environment, + updatedAt: row.updated_at, + ...(kind === 'variable' ? { value: row.value } : {}), + } + } +} diff --git a/backend/src/services/terminal.ts b/backend/src/services/terminal.ts index c7704e23b..f415a4633 100644 --- a/backend/src/services/terminal.ts +++ b/backend/src/services/terminal.ts @@ -3,6 +3,7 @@ import { formatTerminalTitle, parseTerminalTitle } from '@opencode-manager/share import type { TerminalInfo, TerminalKind } from '@opencode-manager/shared/types' import type { OpenCodeClient } from './opencode/client' import type { CredentialProvider } from './credential-provider' +import type { RepoEnvService } from './repo-env' import { toWebSocketUrl } from './opencode/upstream' import { bridgeWebSocket, type WebSocketBridge, type WebSocketPeer } from '../utils/websocket-bridge' import { TerminalHub, TerminalHubRegistry } from './terminal-hub' @@ -27,7 +28,7 @@ export interface TerminalSize { export interface TerminalTarget { client: OpenCodeClient baseUrl: () => string - includeLocalCredentials: boolean + includeLocalEnv: boolean } export class TerminalNotFoundError extends Error { @@ -56,13 +57,21 @@ export class TerminalService { constructor( private readonly target: TerminalTarget, private readonly credentialProvider: CredentialProvider, + private readonly repoEnvService: RepoEnvService, private readonly openSocket: (url: string) => WebSocket = (url) => new WebSocket(url), private readonly hubs: TerminalHubRegistry = new TerminalHubRegistry(), private readonly deferredReleases: Map = new Map(), ) {} forTarget(target: TerminalTarget): TerminalService { - return new TerminalService(target, this.credentialProvider, this.openSocket, this.hubs, this.deferredReleases) + return new TerminalService( + target, + this.credentialProvider, + this.repoEnvService, + this.openSocket, + this.hubs, + this.deferredReleases, + ) } async list(directory: string): Promise { @@ -75,6 +84,12 @@ export class TerminalService { input.command !== undefined && DEFERRED_START_KINDS.has(input.kind) && process.platform !== 'win32' ? deferCommandStart(input.command, input.args ?? []) : undefined + const localEnv = this.target.includeLocalEnv + ? { + ...this.credentialProvider.getGhCliEnv({ cwd: directory }), + ...(await this.repoEnvService.resolveForDirectory(directory)).env, + } + : {} const result = await this.target.client.api.pty.create({ location: { directory }, title: formatTerminalTitle({ kind: input.kind, name: input.name, actionId: input.actionId }), @@ -85,7 +100,7 @@ export class TerminalService { ...(input.args !== undefined ? { args: input.args } : {}), }), env: { - ...(this.target.includeLocalCredentials ? this.credentialProvider.getGhCliEnv({ cwd: directory }) : {}), + ...localEnv, ...input.env, }, }) diff --git a/backend/src/utils/repo-env-cache.ts b/backend/src/utils/repo-env-cache.ts new file mode 100644 index 000000000..007d5342f --- /dev/null +++ b/backend/src/utils/repo-env-cache.ts @@ -0,0 +1,13 @@ +let cacheEpoch = 0 + +/** + * Marks every cached repository lookup as stale so the next resolution sees the + * current set of registered repositories. + */ +export function invalidateRepoEnvCache(): void { + cacheEpoch += 1 +} + +export function repoEnvCacheEpoch(): number { + return cacheEpoch +} diff --git a/backend/test/db/queries.test.ts b/backend/test/db/queries.test.ts index b3fff30c7..18d97848d 100644 --- a/backend/test/db/queries.test.ts +++ b/backend/test/db/queries.test.ts @@ -245,6 +245,7 @@ describe('Database Queries', () => { const deleteFusionsStmt = { run: vi.fn().mockReturnValue({ changes: 3 }) } const deleteEntriesStmt = { run: vi.fn().mockReturnValue({ changes: 2 }) } const deleteMultiRunsStmt = { run: vi.fn().mockReturnValue({ changes: 1 }) } + const deleteSecretsStmt = { run: vi.fn().mockReturnValue({ changes: 2 }) } const deleteRunsStmt = { run: vi.fn().mockReturnValue({ changes: 2 }) } const deleteJobsStmt = { run: vi.fn().mockReturnValue({ changes: 1 }) } const deleteSettingsStmt = { run: vi.fn().mockReturnValue({ changes: 0 }) } @@ -253,6 +254,7 @@ describe('Database Queries', () => { .mockReturnValueOnce(deleteFusionsStmt) .mockReturnValueOnce(deleteEntriesStmt) .mockReturnValueOnce(deleteMultiRunsStmt) + .mockReturnValueOnce(deleteSecretsStmt) .mockReturnValueOnce(deleteJobsStmt) .mockReturnValueOnce(deleteRunsStmt) .mockReturnValueOnce(deleteSettingsStmt) @@ -271,13 +273,15 @@ describe('Database Queries', () => { expect(deleteEntriesStmt.run).toHaveBeenCalledWith(1) expect(mockDb.prepare).toHaveBeenNthCalledWith(3, 'DELETE FROM multi_runs WHERE repo_id = ?') expect(deleteMultiRunsStmt.run).toHaveBeenCalledWith(1) - expect(mockDb.prepare).toHaveBeenNthCalledWith(4, 'DELETE FROM schedule_jobs WHERE repo_id = ?') + expect(mockDb.prepare).toHaveBeenNthCalledWith(4, 'DELETE FROM secrets WHERE repo_id = ?') + expect(deleteSecretsStmt.run).toHaveBeenCalledWith(1) + expect(mockDb.prepare).toHaveBeenNthCalledWith(5, 'DELETE FROM schedule_jobs WHERE repo_id = ?') expect(deleteJobsStmt.run).toHaveBeenCalledWith(1) - expect(mockDb.prepare).toHaveBeenNthCalledWith(5, 'DELETE FROM schedule_runs WHERE repo_id = ?') + expect(mockDb.prepare).toHaveBeenNthCalledWith(6, 'DELETE FROM schedule_runs WHERE repo_id = ?') expect(deleteRunsStmt.run).toHaveBeenCalledWith(1) - expect(mockDb.prepare).toHaveBeenNthCalledWith(6, 'DELETE FROM repo_settings WHERE repo_id = ?') + expect(mockDb.prepare).toHaveBeenNthCalledWith(7, 'DELETE FROM repo_settings WHERE repo_id = ?') expect(deleteSettingsStmt.run).toHaveBeenCalledWith(1) - expect(mockDb.prepare).toHaveBeenNthCalledWith(7, + expect(mockDb.prepare).toHaveBeenNthCalledWith(8, 'DELETE FROM repos WHERE id = ?' ) expect(deleteRepoStmt.run).toHaveBeenCalledWith(1) diff --git a/backend/test/db/secrets-scope-migration.test.ts b/backend/test/db/secrets-scope-migration.test.ts new file mode 100644 index 000000000..f466fac79 --- /dev/null +++ b/backend/test/db/secrets-scope-migration.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { Database } from 'bun:sqlite' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import { secretsGlobalScopeIndexMigration } from '../../src/db/migrations/202610181410-secrets-global-scope-index' +import { SecretsService } from '../../src/services/secrets' +import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' + +const SECRETS_TABLE_MIGRATION_ID = '202610181400-secrets' +const SCOPE_INDEX_MIGRATION_ID = '202610181410-secrets-global-scope-index' + +function scopeIndexSql(db: Database): string { + const row = db + .prepare("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'idx_secrets_scope'") + .get() as { sql: string } | null + + return row?.sql ?? '' +} + +function migrateThroughSecretsTable(db: Database): void { + const index = allMigrations.findIndex((migration) => migration.id === SECRETS_TABLE_MIGRATION_ID) + migrate(db, allMigrations.slice(0, index + 1)) +} + +describe('secrets global scope index migration', () => { + let db: Database + let service: SecretsService + + beforeEach(() => { + db = new Database(':memory:') + service = new SecretsService(db) + }) + + afterEach(() => { + db.close() + }) + + it('rebuilds the scope index so global and Assistant repository entries coexist', () => { + migrateThroughSecretsTable(db) + expect(scopeIndexSql(db)).toContain('COALESCE(repo_id, 0)') + expect(() => service.set('variable', null, null, 'LOG_LEVEL', 'global')).toThrow(/ON CONFLICT/) + + migrate(db, allMigrations) + + expect(scopeIndexSql(db)).toContain('COALESCE(repo_id, -1)') + + service.set('variable', null, null, 'LOG_LEVEL', 'global') + service.set('variable', ASSISTANT_REPO_ID, null, 'LOG_LEVEL', 'assistant') + service.set('variable', null, null, 'LOG_LEVEL', 'global-updated') + service.set('variable', ASSISTANT_REPO_ID, null, 'LOG_LEVEL', 'assistant-updated') + + expect(service.list('variable', null)).toEqual([ + expect.objectContaining({ name: 'LOG_LEVEL', value: 'global-updated' }), + ]) + expect(service.list('variable', ASSISTANT_REPO_ID)).toEqual([ + expect.objectContaining({ name: 'LOG_LEVEL', value: 'assistant-updated' }), + ]) + }) + + it('applies the scope index migration on a fresh database', () => { + migrate(db, allMigrations) + + const applied = db.prepare('SELECT id FROM applied_migrations WHERE id = ?').get(SCOPE_INDEX_MIGRATION_ID) + expect(applied).toBeTruthy() + expect(scopeIndexSql(db)).toContain('COALESCE(repo_id, -1)') + }) + + it('restores the legacy index expression on rollback', () => { + migrate(db, allMigrations) + + secretsGlobalScopeIndexMigration.down(db) + + expect(scopeIndexSql(db)).toContain('COALESCE(repo_id, 0)') + }) + + it('collapses merged global and Assistant scopes to the newest row on rollback', () => { + migrate(db, allMigrations) + service.set('secret', null, null, 'TOKEN', 'global-token') + service.set('secret', ASSISTANT_REPO_ID, null, 'TOKEN', 'assistant-token') + db.prepare('UPDATE secrets SET updated_at = ? WHERE repo_id IS NULL').run(1_000) + db.prepare('UPDATE secrets SET updated_at = ? WHERE repo_id = ?').run(2_000, ASSISTANT_REPO_ID) + + expect(() => secretsGlobalScopeIndexMigration.down(db)).not.toThrow() + + expect(scopeIndexSql(db)).toContain('COALESCE(repo_id, 0)') + expect( + db.prepare('SELECT repo_id, name, updated_at FROM secrets').all(), + ).toEqual([{ repo_id: ASSISTANT_REPO_ID, name: 'TOKEN', updated_at: 2_000 }]) + expect(service.resolveValues(ASSISTANT_REPO_ID, null).secrets.TOKEN).toBe('assistant-token') + }) +}) diff --git a/backend/test/helpers/internal-test-app.ts b/backend/test/helpers/internal-test-app.ts index c5830a092..34d7e20f1 100644 --- a/backend/test/helpers/internal-test-app.ts +++ b/backend/test/helpers/internal-test-app.ts @@ -1,5 +1,6 @@ import { createInternalRoutes } from '../../src/routes/internal' import { createStubOpenCodeClient } from './stub-opencode-client' +import { createStubRepoEnvService } from './stub-repo-env-service' type CreateInternalRoutesParams = Parameters @@ -15,6 +16,7 @@ type InternalTestAppOverrides = Partial<{ multiRuns: CreateInternalRoutesParams[9] changeWalkthroughService: CreateInternalRoutesParams[10] sessionSandbox: CreateInternalRoutesParams[11] + repoEnvService: CreateInternalRoutesParams[12] }> export function createInternalTestApp( @@ -34,5 +36,6 @@ export function createInternalTestApp( overrides.multiRuns ?? ({} as CreateInternalRoutesParams[9]), overrides.changeWalkthroughService ?? ({} as CreateInternalRoutesParams[10]), overrides.sessionSandbox ?? ({} as CreateInternalRoutesParams[11]), + overrides.repoEnvService ?? createStubRepoEnvService(), ) } diff --git a/backend/test/helpers/stub-repo-env-service.ts b/backend/test/helpers/stub-repo-env-service.ts new file mode 100644 index 000000000..57fe30448 --- /dev/null +++ b/backend/test/helpers/stub-repo-env-service.ts @@ -0,0 +1,11 @@ +import { vi } from 'vitest' +import type { RepoEnvService } from '../../src/services/repo-env' + +/** + * Creates a RepoEnvService stub that resolves a fixed environment. + */ +export function createStubRepoEnvService(env: Record = {}): RepoEnvService { + return { + resolveForDirectory: vi.fn(async () => ({ env, maskValues: [] })), + } as unknown as RepoEnvService +} diff --git a/backend/test/routes/git.test.ts b/backend/test/routes/git.test.ts index 4465887e0..91faf7470 100644 --- a/backend/test/routes/git.test.ts +++ b/backend/test/routes/git.test.ts @@ -8,6 +8,7 @@ import type { GitAuthService } from '../../src/services/git-auth' import type { ScheduleService } from '../../src/services/schedules' import type { TerminalService } from '../../src/services/terminal' import type { ProjectConfigService } from '../../src/services/project-config' +import type { SecretsService } from '../../src/services/secrets' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' import { createStubOpenCodeClient } from '../helpers/stub-opencode-client' @@ -63,7 +64,7 @@ describe('Git Routes', () => { mockGitAuthService = { getGitEnvironment: vi.fn().mockReturnValue({}), } as unknown as GitAuthService - app = createRepoRoutes(mockDatabase, mockGitAuthService, {} as unknown as ScheduleService, createStubOpenCodeClient(), {} as unknown as TerminalService, {} as unknown as ProjectConfigService, {} as unknown as RepoWorkspaceService) + app = createRepoRoutes(mockDatabase, mockGitAuthService, {} as unknown as ScheduleService, createStubOpenCodeClient(), {} as unknown as TerminalService, {} as unknown as ProjectConfigService, {} as unknown as RepoWorkspaceService, {} as unknown as SecretsService) }) describe('GET /:id/git/status', () => { diff --git a/backend/test/routes/internal/git-credentials.test.ts b/backend/test/routes/internal/git-credentials.test.ts index 8b8a2a053..63935277e 100644 --- a/backend/test/routes/internal/git-credentials.test.ts +++ b/backend/test/routes/internal/git-credentials.test.ts @@ -6,49 +6,36 @@ import { SettingsService } from '../../../src/services/settings' import { createInternalGitCredentialsRoutes } from '../../../src/routes/internal/git-credentials' import type { GitCredential } from '@opencode-manager/shared' -describe('internal git-credentials routes', () => { +describe('legacy internal git-credentials routes', () => { let db: Database let settingsService: SettingsService - let app: ReturnType beforeEach(() => { db = new Database(':memory:') migrate(db, allMigrations) settingsService = new SettingsService(db) - app = createInternalGitCredentialsRoutes(db) }) - it('GET /gh-env returns GH_TOKEN and GITHUB_TOKEN for a GitHub PAT', async () => { + it('answers the adopted-process gh-env path with the flat GitHub CLI env', async () => { settingsService.updateSettings({ gitCredentials: [ - { name: 'github', host: 'github.com', type: 'pat', token: 'ghp_test_token' } as GitCredential, + { name: 'github', host: 'github.com', type: 'pat', token: 'ghp_legacy_token' } as GitCredential, ], }) + const app = createInternalGitCredentialsRoutes(db) const res = await app.request('/gh-env') expect(res.status).toBe(200) - expect(await res.json()).toEqual({ GH_TOKEN: 'ghp_test_token', GITHUB_TOKEN: 'ghp_test_token' }) + expect(await res.json()).toEqual({ GH_TOKEN: 'ghp_legacy_token', GITHUB_TOKEN: 'ghp_legacy_token' }) }) - it('GET /gh-env returns an empty object when no GitHub credential exists', async () => { - const res = await app.request('/gh-env') - - expect(res.status).toBe(200) - expect(await res.json()).toEqual({}) - }) - - it('GET /gh-env returns only GH env for a repo cwd', async () => { - settingsService.updateSettings({ - gitCredentials: [ - { name: 'github', host: 'github.com', type: 'pat', token: 'ghp_repo_token' } as GitCredential, - ], - gitIdentities: [{ id: 'work', name: 'Work User', email: 'work@example.com' }], - }) + it('accepts the legacy cwd query parameter', async () => { + const app = createInternalGitCredentialsRoutes(db) const res = await app.request('/gh-env?cwd=/tmp/any-repo') expect(res.status).toBe(200) - expect(await res.json()).toEqual({ GH_TOKEN: 'ghp_repo_token', GITHUB_TOKEN: 'ghp_repo_token' }) + expect(await res.json()).toEqual({}) }) }) diff --git a/backend/test/routes/internal/shell-env.test.ts b/backend/test/routes/internal/shell-env.test.ts new file mode 100644 index 000000000..29c56eddc --- /dev/null +++ b/backend/test/routes/internal/shell-env.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { Database } from 'bun:sqlite' +import { migrate } from '../../../src/db/migration-runner' +import { allMigrations } from '../../../src/db/migrations' +import { SettingsService } from '../../../src/services/settings' +import { createInternalShellEnvRoutes } from '../../../src/routes/internal/shell-env' +import type { RepoEnvService, ResolvedRepoEnv } from '../../../src/services/repo-env' +import type { GitCredential } from '@opencode-manager/shared' + +function createRepoEnvService(resolve: (cwd: string) => Promise): RepoEnvService { + return { resolveForDirectory: vi.fn(resolve) } as unknown as RepoEnvService +} + +describe('internal shell-env routes', () => { + let db: Database + let settingsService: SettingsService + + function appWith(repoEnvService: RepoEnvService) { + return createInternalShellEnvRoutes(db, repoEnvService) + } + + beforeEach(() => { + db = new Database(':memory:') + migrate(db, allMigrations) + settingsService = new SettingsService(db) + }) + + it('returns GH_TOKEN and GITHUB_TOKEN for a GitHub PAT when no cwd is given', async () => { + settingsService.updateSettings({ + gitCredentials: [ + { name: 'github', host: 'github.com', type: 'pat', token: 'ghp_test_token' } as GitCredential, + ], + }) + const repoEnvService = createRepoEnvService(async () => ({ env: {}, maskValues: [] })) + const app = appWith(repoEnvService) + + const res = await app.request('/') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ env: { GH_TOKEN: 'ghp_test_token', GITHUB_TOKEN: 'ghp_test_token' }, mask: [] }) + expect(repoEnvService.resolveForDirectory).not.toHaveBeenCalled() + }) + + it('returns an empty GH env when no GitHub credential exists', async () => { + const app = appWith(createRepoEnvService(async () => ({ env: {}, maskValues: [] }))) + + const res = await app.request('/') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ env: {}, mask: [] }) + }) + + it('merges the mapped repository env and mask after the GitHub env for a cwd', async () => { + settingsService.updateSettings({ + gitCredentials: [ + { name: 'github', host: 'github.com', type: 'pat', token: 'ghp_repo_token' } as GitCredential, + ], + gitIdentities: [{ id: 'work', name: 'Work User', email: 'work@example.com' }], + }) + const repoEnvService = createRepoEnvService(async () => ({ + env: { API_URL: 'https://internal.example', GITHUB_TOKEN: 'mapped' }, + maskValues: ['super-secret'], + })) + const app = appWith(repoEnvService) + + const res = await app.request('/?cwd=/tmp/any-repo') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + env: { GH_TOKEN: 'ghp_repo_token', GITHUB_TOKEN: 'mapped', API_URL: 'https://internal.example' }, + mask: ['super-secret'], + }) + expect(repoEnvService.resolveForDirectory).toHaveBeenCalledWith('/tmp/any-repo') + }) + + it('skips the repository env resolution when the shell is sandboxed', async () => { + settingsService.updateSettings({ + gitCredentials: [ + { name: 'github', host: 'github.com', type: 'pat', token: 'ghp_repo_token' } as GitCredential, + ], + }) + const repoEnvService = createRepoEnvService(async () => ({ + env: { API_URL: 'https://internal.example' }, + maskValues: ['super-secret'], + })) + const app = appWith(repoEnvService) + + const res = await app.request('/?cwd=/tmp/any-repo&sandboxed=1') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + env: { GH_TOKEN: 'ghp_repo_token', GITHUB_TOKEN: 'ghp_repo_token' }, + mask: [], + }) + expect(repoEnvService.resolveForDirectory).not.toHaveBeenCalled() + }) +}) diff --git a/backend/test/routes/opencode-server-terminal-socket.test.ts b/backend/test/routes/opencode-server-terminal-socket.test.ts index e881b81fb..bc386095b 100644 --- a/backend/test/routes/opencode-server-terminal-socket.test.ts +++ b/backend/test/routes/opencode-server-terminal-socket.test.ts @@ -10,6 +10,7 @@ import { TerminalService } from '../../src/services/terminal' import { OpenCodeServerNotFoundError } from '../../src/services/opencode-server-registry' import type { OpenCodeClient } from '../../src/services/opencode/client' import type { CredentialProvider } from '../../src/services/credential-provider' +import { createStubRepoEnvService } from '../helpers/stub-repo-env-service' import { startWebSocketServer, type WebSocketTestServer } from '../helpers/websocket-server' import { rawWebSocketHandshake } from '../helpers/raw-websocket-handshake' @@ -59,8 +60,9 @@ const managerApp = new Hono() const managerWs = createNodeWebSocket({ app: managerApp }) const terminalService = new TerminalService( - { client: remoteClient, baseUrl: () => `http://127.0.0.1:${upstream.port}`, includeLocalCredentials: false }, + { client: remoteClient, baseUrl: () => `http://127.0.0.1:${upstream.port}`, includeLocalEnv: false }, credentialProvider, + createStubRepoEnvService(), ) const resolveTerminalService: ServerTerminalServiceResolver = (serverId) => { diff --git a/backend/test/routes/opencode-server-terminals.test.ts b/backend/test/routes/opencode-server-terminals.test.ts index 069b24a08..600b208c8 100644 --- a/backend/test/routes/opencode-server-terminals.test.ts +++ b/backend/test/routes/opencode-server-terminals.test.ts @@ -9,6 +9,7 @@ import { TerminalService } from '../../src/services/terminal' import { OpenCodeServerNotFoundError } from '../../src/services/opencode-server-registry' import type { OpenCodeClient } from '../../src/services/opencode/client' import type { CredentialProvider } from '../../src/services/credential-provider' +import { createStubRepoEnvService } from '../helpers/stub-repo-env-service' const DIRECTORY = '/home/me/app' const SERVER_ID = 'srv_kitchen' @@ -43,14 +44,15 @@ function createRemoteClient(terminals: Array> = [createP return { client, list, create, update, remove } } -function createService(includeLocalCredentials = false, terminals?: Array>) { +function createService(includeLocalEnv = false, terminals?: Array>) { const remote = createRemoteClient(terminals) const credentialProvider = { getGhCliEnv: vi.fn(() => ({ GH_TOKEN: 'local-token' })), } as unknown as CredentialProvider const service = new TerminalService( - { client: remote.client, baseUrl: () => 'https://remote.internal', includeLocalCredentials }, + { client: remote.client, baseUrl: () => 'https://remote.internal', includeLocalEnv }, credentialProvider, + createStubRepoEnvService(), ) return { service, credentialProvider, ...remote } } diff --git a/backend/test/routes/repo-terminal-socket.test.ts b/backend/test/routes/repo-terminal-socket.test.ts index 9ec905a7f..efebec97b 100644 --- a/backend/test/routes/repo-terminal-socket.test.ts +++ b/backend/test/routes/repo-terminal-socket.test.ts @@ -11,6 +11,7 @@ import type { OpenCodeClient } from '../../src/services/opencode/client' import type { CredentialProvider } from '../../src/services/credential-provider' import { startWebSocketServer, type WebSocketTestServer } from '../helpers/websocket-server' import { rawWebSocketHandshake } from '../helpers/raw-websocket-handshake' +import { createStubRepoEnvService } from '../helpers/stub-repo-env-service' vi.mock('../../src/services/repo', () => ({ resolveRepoOrAssistant: vi.fn(), @@ -79,8 +80,9 @@ upstreamApp.get( ) const terminalService = new TerminalService( - { client: openCodeClient, baseUrl: () => upstreamBase, includeLocalCredentials: true }, + { client: openCodeClient, baseUrl: () => upstreamBase, includeLocalEnv: true }, credentialProvider, + createStubRepoEnvService(), ) const managerApp = new Hono() diff --git a/backend/test/routes/repos-git-identity.test.ts b/backend/test/routes/repos-git-identity.test.ts index 04d199166..244dbcd0c 100644 --- a/backend/test/routes/repos-git-identity.test.ts +++ b/backend/test/routes/repos-git-identity.test.ts @@ -17,6 +17,7 @@ import { createGitAuthService, cloneOrigin, createOrigin, git, uniqueName } from import { createStubOpenCodeClient } from '../helpers/stub-opencode-client' import type { TerminalService } from '../../src/services/terminal' import type { ProjectConfigService } from '../../src/services/project-config' +import type { SecretsService } from '../../src/services/secrets' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' const workspaceRoot = mkdtempSync(path.join(tmpdir(), 'repos-git-identity-')) @@ -52,7 +53,7 @@ describe('repo git identity routes', () => { mkdirSync(reposPath, { recursive: true }) const scheduleService = { prepareRepoDelete: () => {} } as unknown as Parameters[2] app = new Hono() - app.route('/repos', createRepoRoutes(db, createGitAuthService(), scheduleService, createStubOpenCodeClient(), {} as unknown as TerminalService, {} as unknown as ProjectConfigService, {} as unknown as RepoWorkspaceService)) + app.route('/repos', createRepoRoutes(db, createGitAuthService(), scheduleService, createStubOpenCodeClient(), {} as unknown as TerminalService, {} as unknown as ProjectConfigService, {} as unknown as RepoWorkspaceService, {} as unknown as SecretsService)) }) afterEach(() => { diff --git a/backend/test/routes/repos.test.ts b/backend/test/routes/repos.test.ts index 5d853999a..7b7b36666 100644 --- a/backend/test/routes/repos.test.ts +++ b/backend/test/routes/repos.test.ts @@ -84,6 +84,7 @@ import * as archiveService from '../../src/services/archive' import { createRepoRoutes } from '../../src/routes/repos' import { opencodeServerManager } from '../../src/services/opencode-single-server' import { ProjectConfigService } from '../../src/services/project-config' +import type { SecretsService } from '../../src/services/secrets' import { RepoWorkspaceService } from '../../src/services/repo-workspace' import { createGitService } from '../../src/services/git/GitService' import type { GitAuthService } from '../../src/services/git-auth' @@ -112,7 +113,7 @@ const mockTerminalService = { function createTestRoutes(openCodeClient: ReturnType = createStubOpenCodeClient()): ReturnType { const projectConfigService = new ProjectConfigService(mockDb, createGitService(mockGitAuthService), mockGitAuthService) const repoWorkspaces = new RepoWorkspaceService(mockDb, openCodeClient, mockGitAuthService, projectConfigService, mockTerminalService, mockScheduleService) - return createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, openCodeClient, mockTerminalService, projectConfigService, repoWorkspaces) + return createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, openCodeClient, mockTerminalService, projectConfigService, repoWorkspaces, {} as unknown as SecretsService) } function createMockRepo(overrides: Partial = {}): Repo { diff --git a/backend/test/routes/secrets.test.ts b/backend/test/routes/secrets.test.ts new file mode 100644 index 000000000..06e082886 --- /dev/null +++ b/backend/test/routes/secrets.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { Hono } from 'hono' +import { Database } from 'bun:sqlite' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import { createRepo } from '../../src/db/queries' +import { SecretsService } from '../../src/services/secrets' +import { ProjectConfigService } from '../../src/services/project-config' +import { createGitService } from '../../src/services/git/GitService' +import { createGlobalSecretRoutes, createRepoSecretRoutes } from '../../src/routes/secrets' +import type { GitAuthService } from '../../src/services/git-auth' +import type { SecretEntry } from '@opencode-manager/shared/schemas' + +const gitAuthService = { getGitEnvironment: () => ({}) } as unknown as GitAuthService +const jsonHeaders = { 'Content-Type': 'application/json' } + +function createTestDb(): Database { + const db = new Database(':memory:') + migrate(db, allMigrations) + return db +} + +function createApp(db: Database): Hono { + const secretsService = new SecretsService(db) + const projectConfigService = new ProjectConfigService(db, createGitService(gitAuthService), gitAuthService) + const app = new Hono() + app.route('/secrets', createGlobalSecretRoutes(secretsService, 'secret')) + app.route('/variables', createGlobalSecretRoutes(secretsService, 'variable')) + app.route('/repos', createRepoSecretRoutes({ database: db, projectConfigService, secretsService }, 'secret')) + app.route('/repos', createRepoSecretRoutes({ database: db, projectConfigService, secretsService }, 'variable')) + return app +} + +function seedRepo(db: Database): number { + return createRepo(db, { + localPath: 'repo-a', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + isLocal: true, + }).id +} + +async function readItems(res: Response): Promise { + const body = await res.json() as { items: SecretEntry[] } + return body.items +} + +describe('secret routes', () => { + let db: Database + let app: Hono + + beforeEach(() => { + db = createTestDb() + app = createApp(db) + }) + + afterEach(() => { + db.close() + }) + + it('lists global secrets and variables as empty initially', async () => { + const secrets = await app.request('/secrets') + const variables = await app.request('/variables') + expect(secrets.status).toBe(200) + expect(variables.status).toBe(200) + expect(await readItems(secrets)).toEqual([]) + expect(await readItems(variables)).toEqual([]) + }) + + it('stores a secret without ever returning its value', async () => { + const put = await app.request('/secrets/token', { + method: 'PUT', + headers: jsonHeaders, + body: JSON.stringify({ value: 'super-secret' }), + }) + expect(put.status).toBe(204) + + const list = await app.request('/secrets') + const items = await readItems(list) + expect(items).toHaveLength(1) + expect(items[0]).toMatchObject({ name: 'TOKEN', environment: null }) + expect(items[0]).not.toHaveProperty('value') + + const stored = db.prepare('SELECT value FROM secrets WHERE name = ?').get('TOKEN') as { value: string } + expect(stored.value).not.toBe('super-secret') + }) + + it('stores a variable and returns its value', async () => { + const put = await app.request('/variables/log_level', { + method: 'PUT', + headers: jsonHeaders, + body: JSON.stringify({ value: 'debug' }), + }) + expect(put.status).toBe(204) + + const items = await readItems(await app.request('/variables')) + expect(items).toEqual([expect.objectContaining({ name: 'LOG_LEVEL', environment: null, value: 'debug' })]) + }) + + it('normalizes names to upper case', async () => { + await app.request('/secrets/my_token', { + method: 'PUT', + headers: jsonHeaders, + body: JSON.stringify({ value: 'v' }), + }) + + const items = await readItems(await app.request('/secrets')) + expect(items[0]?.name).toBe('MY_TOKEN') + }) + + it('rejects invalid, GITHUB_, and reserved names with 400', async () => { + for (const name of ['1BAD', 'GITHUB_TOKEN', 'PATH', 'OCM_SOMETHING']) { + const res = await app.request(`/secrets/${name}`, { + method: 'PUT', + headers: jsonHeaders, + body: JSON.stringify({ value: 'v' }), + }) + expect(res.status, name).toBe(400) + } + }) + + it('enforces the 48 KiB value limit', async () => { + const atLimit = await app.request('/variables/big', { + method: 'PUT', + headers: jsonHeaders, + body: JSON.stringify({ value: 'a'.repeat(48 * 1024) }), + }) + expect(atLimit.status).toBe(204) + + const overLimit = await app.request('/variables/too_big', { + method: 'PUT', + headers: jsonHeaders, + body: JSON.stringify({ value: 'a'.repeat(48 * 1024 + 1) }), + }) + expect(overLimit.status).toBe(400) + }) + + it('rejects an environment on a global secret', async () => { + const res = await app.request('/secrets/token', { + method: 'PUT', + headers: jsonHeaders, + body: JSON.stringify({ value: 'v', environment: 'prod' }), + }) + expect(res.status).toBe(400) + }) + + it('rejects an invalid JSON body', async () => { + const global = await app.request('/secrets/token', { + method: 'PUT', + headers: jsonHeaders, + body: '{not json', + }) + expect(global.status).toBe(400) + expect(await global.json()).toEqual({ error: 'Invalid JSON' }) + + const repoId = seedRepo(db) + const repo = await app.request(`/repos/${repoId}/secrets/token`, { + method: 'PUT', + headers: jsonHeaders, + body: '{not json', + }) + expect(repo.status).toBe(400) + expect(await repo.json()).toEqual({ error: 'Invalid JSON' }) + }) + + it('rejects a body that fails validation', async () => { + const res = await app.request('/secrets/token', { + method: 'PUT', + headers: jsonHeaders, + body: JSON.stringify({ value: '' }), + }) + expect(res.status).toBe(400) + expect(await res.json()).toMatchObject({ error: expect.any(String) }) + }) + + it('deletes a global secret and returns 404 when missing', async () => { + await app.request('/secrets/token', { + method: 'PUT', + headers: jsonHeaders, + body: JSON.stringify({ value: 'v' }), + }) + + expect((await app.request('/secrets/token', { method: 'DELETE' })).status).toBe(204) + expect((await app.request('/secrets/token', { method: 'DELETE' })).status).toBe(404) + }) + + it('stores repository secrets against the repo and never returns values', async () => { + const repoId = seedRepo(db) + + const put = await app.request(`/repos/${repoId}/secrets/token`, { + method: 'PUT', + headers: jsonHeaders, + body: JSON.stringify({ value: 'repo-secret' }), + }) + expect(put.status).toBe(204) + + const items = await readItems(await app.request(`/repos/${repoId}/secrets`)) + expect(items).toEqual([expect.objectContaining({ name: 'TOKEN', environment: null })]) + expect(items[0]).not.toHaveProperty('value') + }) + + it('supports repository environments and scoped deletes', async () => { + const repoId = seedRepo(db) + + await app.request(`/repos/${repoId}/variables/deploy_url`, { + method: 'PUT', + headers: jsonHeaders, + body: JSON.stringify({ value: 'https://example.test', environment: 'Production' }), + }) + + const items = await readItems(await app.request(`/repos/${repoId}/variables`)) + expect(items).toEqual([expect.objectContaining({ name: 'DEPLOY_URL', environment: 'production', value: 'https://example.test' })]) + + const deleted = await app.request(`/repos/${repoId}/variables/deploy_url?environment=production`, { method: 'DELETE' }) + expect(deleted.status).toBe(204) + + const missing = await app.request(`/repos/${repoId}/variables/deploy_url?environment=production`, { method: 'DELETE' }) + expect(missing.status).toBe(404) + }) + + it('returns 404 for an unknown repository', async () => { + const res = await app.request('/repos/9999/secrets') + expect(res.status).toBe(404) + }) +}) diff --git a/backend/test/schemas/project-config.test.ts b/backend/test/schemas/project-config.test.ts new file mode 100644 index 000000000..157fcfd59 --- /dev/null +++ b/backend/test/schemas/project-config.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest' +import { REPO_PROJECT_ENV_MAX_ENTRIES, RepoProjectFileSchema } from '@opencode-manager/shared/schemas' + +function parseEnv(env: Record) { + return RepoProjectFileSchema.safeParse({ version: 1, env }) +} + +describe('RepoProjectFileSchema env', () => { + it('accepts valid expressions and keeps key case', () => { + const result = parseEnv({ API_URL: 'https://${{ vars.HOST }}', plainVar: 'literal' }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.env).toEqual({ API_URL: 'https://${{ vars.HOST }}', plainVar: 'literal' }) + } + }) + + it('lower-cases the environment name', () => { + const result = RepoProjectFileSchema.safeParse({ version: 1, environment: 'Prod', env: { A: 'x' } }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.environment).toBe('prod') + } + }) + + it('rejects a reserved environment variable name with an actionable message', () => { + const result = parseEnv({ OCM_TOKEN: 'x' }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0]?.message).toBe('OCM_TOKEN is reserved for Manager or the runtime') + expect(result.error.issues[0]?.path).toEqual(['env', 'OCM_TOKEN']) + } + expect(parseEnv({ PATH: '/tmp' }).success).toBe(false) + expect(parseEnv({ OPENCODE_TOKEN: 'x' }).success).toBe(false) + }) + + it('rejects git authentication names with an actionable message', () => { + for (const name of ['GH_TOKEN', 'GITHUB_TOKEN', 'GIT_AUTHOR_NAME', 'git_committer_email']) { + const result = parseEnv({ [name]: 'x' }) + + expect(result.success, name).toBe(false) + if (!result.success) { + expect(result.error.issues[0]?.message).toBe( + `${name} is reserved for git authentication; map the value in a local env file or use a secret instead`, + ) + expect(result.error.issues[0]?.path).toEqual(['env', name]) + } + } + expect(parseEnv({ GITHUB_ACTIONS_SUMMARY: 'x' }).success).toBe(false) + expect(parseEnv({ GITLAB_TOKEN: 'x' }).success).toBe(true) + }) + + it('rejects an invalid environment variable name with an actionable message', () => { + const result = parseEnv({ '1BAD': 'x' }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0]?.message).toBe('1BAD is not a valid environment variable name') + expect(result.error.issues[0]?.path).toEqual(['env', '1BAD']) + } + }) + + it('rejects an unsupported expression with an actionable error', () => { + const result = parseEnv({ TOKEN: '${{ github.sha }}' }) + + expect(result.success).toBe(false) + if (!result.success) { + const issue = result.error.issues[0] + expect(issue?.message).toContain('Unsupported expression') + expect(issue?.message).toContain('github.sha') + expect(issue?.path).toEqual(['env', 'TOKEN']) + } + }) + + it('rejects an environment value over 4000 characters', () => { + expect(parseEnv({ TOKEN: 'a'.repeat(4001) }).success).toBe(false) + expect(parseEnv({ TOKEN: 'a'.repeat(4000) }).success).toBe(true) + }) + + it('allows up to the environment entry limit and rejects more', () => { + const atLimit = Object.fromEntries( + Array.from({ length: REPO_PROJECT_ENV_MAX_ENTRIES }, (_, index) => [`VAR_${index}`, 'x']), + ) + const overLimit = Object.fromEntries( + Array.from({ length: REPO_PROJECT_ENV_MAX_ENTRIES + 1 }, (_, index) => [`VAR_${index}`, 'x']), + ) + + expect(parseEnv(atLimit).success).toBe(true) + expect(parseEnv(overLimit).success).toBe(false) + }) +}) diff --git a/backend/test/services/git/GitService.real.test.ts b/backend/test/services/git/GitService.real.test.ts index 0166fab31..894208764 100644 --- a/backend/test/services/git/GitService.real.test.ts +++ b/backend/test/services/git/GitService.real.test.ts @@ -20,6 +20,7 @@ import type { GitAuthService } from '../../../src/services/git-auth' import type { Repo } from '../../../src/types/repo' import type { TerminalService } from '../../../src/services/terminal' import type { ProjectConfigService } from '../../../src/services/project-config' +import type { SecretsService } from '../../../src/services/secrets' import type { RepoWorkspaceService } from '../../../src/services/repo-workspace' const workspaceRoot = mkdtempSync(path.join(tmpdir(), 'git-service-real-')) @@ -1198,7 +1199,7 @@ describe('GitService real git', () => { remove: async () => undefined, removeRepoTerminals: async () => undefined, } as unknown as RepoWorkspaceService - app.route('/repos', createRepoRoutes(db, gitAuth, scheduleService, createStubOpenCodeClient(), {} as unknown as TerminalService, {} as unknown as ProjectConfigService, repoWorkspaces)) + app.route('/repos', createRepoRoutes(db, gitAuth, scheduleService, createStubOpenCodeClient(), {} as unknown as TerminalService, {} as unknown as ProjectConfigService, repoWorkspaces, {} as unknown as SecretsService)) return app } diff --git a/backend/test/services/opencode-gh-env-plugin.test.ts b/backend/test/services/opencode-gh-env-plugin.test.ts index feb93cfb4..0f173411e 100644 --- a/backend/test/services/opencode-gh-env-plugin.test.ts +++ b/backend/test/services/opencode-gh-env-plugin.test.ts @@ -3,9 +3,41 @@ import { promises as fs } from 'fs' import path from 'path' import os from 'os' import { installManagedPlugins, getOpenCodePluginDir } from '../../src/services/opencode/plugin-registry' -import { loadGeneratedPlugin } from '../helpers/opencode-plugin-context' +import { SANDBOX_SHELL_STATE_KEY } from '../../src/services/opencode-plugin-state' +import { + loadGeneratedPlugin, + type GeneratedPlugin, + type GeneratedTool, + type GeneratedToolExecuteContext, + type ShellCreateBeforeEvent, + type ToolExecuteAfterEvent, +} from '../helpers/opencode-plugin-context' const INTERNAL_API_URL = 'http://localhost:5003/api/internal' +const MASK_STATE_KEY = Symbol.for('ocm-secret-masks.v1') +const PLAN_NAME = 'ocm-session-0123456789ab' + +type MaskState = { byCwd: Map; version: number; unionVersion: number; union: string[] } + +type ShellSandboxState = { inFlightSandboxedCommands: Map } + +function maskStateForTests(): MaskState | undefined { + return (globalThis as Record)[MASK_STATE_KEY] as MaskState | undefined +} + +function shellSandboxStateForTests(): ShellSandboxState | undefined { + return (globalThis as Record)[Symbol.for(SANDBOX_SHELL_STATE_KEY)] as ShellSandboxState | undefined +} + +function shellTool(plugin: GeneratedPlugin, execute: (input: unknown, context: GeneratedToolExecuteContext) => Promise): GeneratedTool { + const tool = plugin.applyToolTransforms([{ name: 'shell', execute }]).find((candidate) => candidate.name === 'shell') + if (!tool) throw new Error('the shell tool was not found after transforms') + return tool +} + +function shellCreateEvent(command: string, cwd: string): ShellCreateBeforeEvent { + return { command, cwd, env: {} } +} describe('ocm-gh-env plugin', () => { let configHome: string @@ -22,10 +54,10 @@ describe('ocm-gh-env plugin', () => { return loadGeneratedPlugin(pluginPath()) } - function stubGhEnvFetch(env: Record) { + function stubShellEnvFetch(body: { env?: Record; mask?: string[] }) { const fetchMock = vi.fn().mockResolvedValue({ ok: true, - json: async () => env, + json: async () => body, }) vi.stubGlobal('fetch', fetchMock) return fetchMock @@ -41,6 +73,9 @@ describe('ocm-gh-env plugin', () => { afterEach(async () => { vi.unstubAllGlobals() vi.restoreAllMocks() + delete (globalThis as Record)[MASK_STATE_KEY] + delete (globalThis as Record)[Symbol.for(SANDBOX_SHELL_STATE_KEY)] + delete process.env.OCM_SANDBOX_ENFORCED delete process.env.OCM_INTERNAL_API_URL delete process.env.OCM_INTERNAL_TOKEN await fs.rm(configHome, { recursive: true, force: true }) @@ -97,8 +132,8 @@ describe('ocm-gh-env plugin', () => { await expect(fs.access(pluginPath())).resolves.toBeUndefined() }) - it('merges the fetched GH env into the create.before event env', async () => { - const fetchMock = stubGhEnvFetch({ GH_TOKEN: 'ghp', GITHUB_TOKEN: 'ghp' }) + it('merges the fetched shell env into the create.before event env', async () => { + const fetchMock = stubShellEnvFetch({ env: { GH_TOKEN: 'ghp', GITHUB_TOKEN: 'ghp' }, mask: [] }) const plugin = await loadPlugin() const event = { command: 'git push', cwd: '/repo', env: { PATH: '/usr/bin' } } @@ -106,7 +141,7 @@ describe('ocm-gh-env plugin', () => { expect(event.env).toEqual({ PATH: '/usr/bin', GH_TOKEN: 'ghp', GITHUB_TOKEN: 'ghp' }) const [url, options] = fetchMock.mock.calls[0]! - expect(url.toString()).toBe('http://localhost:5003/api/internal/git-credentials/gh-env?cwd=%2Frepo') + expect(url.toString()).toBe('http://localhost:5003/api/internal/shell-env?cwd=%2Frepo') expect(options).toEqual({ headers: { Authorization: 'Bearer secret-token' } }) }) @@ -135,7 +170,7 @@ describe('ocm-gh-env plugin', () => { }) it('caches results within the TTL so rapid calls fetch once', async () => { - const fetchMock = stubGhEnvFetch({ GH_TOKEN: 'ghp' }) + const fetchMock = stubShellEnvFetch({ env: { GH_TOKEN: 'ghp' } }) const plugin = await loadPlugin() const first = { command: 'git push', cwd: '/repo', env: {} } const second = { command: 'git push', cwd: '/repo', env: {} } @@ -148,7 +183,7 @@ describe('ocm-gh-env plugin', () => { }) it('caches results per cwd', async () => { - const fetchMock = stubGhEnvFetch({ GH_TOKEN: 'ghp' }) + const fetchMock = stubShellEnvFetch({ env: { GH_TOKEN: 'ghp' } }) const plugin = await loadPlugin() await plugin.triggerShellCreateBefore({ command: 'git push', cwd: '/repo-a', env: {} }) @@ -161,7 +196,7 @@ describe('ocm-gh-env plugin', () => { it('keeps the cached env when a later fetch fails', async () => { const realNow = Date.now - const fetchMock = stubGhEnvFetch({ GH_TOKEN: 'ghp' }) + const fetchMock = stubShellEnvFetch({ env: { GH_TOKEN: 'ghp' } }) const plugin = await loadPlugin() const first = { command: 'git push', cwd: '/repo', env: {} } await plugin.triggerShellCreateBefore(first) @@ -175,6 +210,192 @@ describe('ocm-gh-env plugin', () => { expect(second.env).toEqual({ GH_TOKEN: 'ghp' }) }) + it('masks learned secret values in output, content text and nested metadata for every tool', async () => { + stubShellEnvFetch({ env: {}, mask: ['s3cret-value'] }) + const plugin = await loadPlugin() + await plugin.triggerShellCreateBefore({ command: 'ls', cwd: '/repo', env: {} }) + + const event: ToolExecuteAfterEvent = { + tool: 'read', + status: 'completed', + result: { + output: 'token s3cret-value here', + content: [{ type: 'text', text: 'also s3cret-value' }], + metadata: { output: 'nested s3cret-value' }, + }, + } + + await plugin.triggerToolExecuteAfter(event) + + expect(event.result).toEqual({ + output: 'token *** here', + content: [{ type: 'text', text: 'also ***' }], + metadata: { output: 'nested ***' }, + }) + }) + + it('masks a completed result that is not a plain object', async () => { + stubShellEnvFetch({ env: {}, mask: ['s3cret-value'] }) + const plugin = await loadPlugin() + await plugin.triggerShellCreateBefore({ command: 'ls', cwd: '/repo', env: {} }) + + class ToolResult { + [key: string]: unknown + output = 'token s3cret-value' + } + const event: ToolExecuteAfterEvent = { tool: 'shell', status: 'completed', result: new ToolResult() } + + await plugin.triggerToolExecuteAfter(event) + + expect(event.result).toEqual({ output: 'token ***' }) + }) + + it('leaves error results untouched', async () => { + stubShellEnvFetch({ env: {}, mask: ['s3cret-value'] }) + const plugin = await loadPlugin() + await plugin.triggerShellCreateBefore({ command: 'ls', cwd: '/repo', env: {} }) + + const result = { output: 's3cret-value' } + const event: ToolExecuteAfterEvent = { tool: 'shell', status: 'error', result } + + await plugin.triggerToolExecuteAfter(event) + + expect(event.result).toBe(result) + }) + + it('does not reallocate the result when no mask value occurs', async () => { + stubShellEnvFetch({ env: {}, mask: ['s3cret-value'] }) + const plugin = await loadPlugin() + await plugin.triggerShellCreateBefore({ command: 'ls', cwd: '/repo', env: {} }) + + const result = { output: 'nothing sensitive', content: [{ type: 'text', text: 'plain' }] } + const event: ToolExecuteAfterEvent = { tool: 'shell', status: 'completed', result } + + await plugin.triggerToolExecuteAfter(event) + + expect(event.result).toBe(result) + }) + + it('replaces the masks for a cwd instead of keeping values that rotated out', async () => { + const realNow = Date.now + const fetchMock = stubShellEnvFetch({ env: {}, mask: ['first-secret'] }) + const plugin = await loadPlugin() + await plugin.triggerShellCreateBefore({ command: 'ls', cwd: '/repo', env: {} }) + + fetchMock.mockResolvedValue({ ok: true, json: async () => ({ env: {}, mask: ['second-secret'] }) }) + vi.spyOn(Date, 'now').mockReturnValue(realNow() + 6000) + await plugin.triggerShellCreateBefore({ command: 'ls', cwd: '/repo', env: {} }) + + const event: ToolExecuteAfterEvent = { tool: 'shell', status: 'completed', result: { output: 'first-secret and second-secret' } } + + await plugin.triggerToolExecuteAfter(event) + + expect(event.result).toEqual({ output: 'first-secret and ***' }) + expect(maskStateForTests()?.byCwd.get('/repo')).toEqual(['second-secret']) + }) + + it('keeps masks per cwd and masks a result with the masks of the cwd its shell ran in', async () => { + const fetchMock = vi.fn().mockImplementation((url: URL) => { + const mask = String(url).includes('%2Frepo-a') ? ['a-secret'] : ['b-secret'] + return Promise.resolve({ ok: true, json: async () => ({ env: {}, mask }) }) + }) + vi.stubGlobal('fetch', fetchMock) + const plugin = await loadPlugin() + await plugin.triggerShellCreateBefore({ command: 'ls', cwd: '/repo-a', env: {} }) + await plugin.triggerShellCreateBefore({ command: 'ls', cwd: '/repo-b', env: {} }) + + const fromA: ToolExecuteAfterEvent = { + tool: 'shell', + status: 'completed', + input: { command: 'ls', workdir: '/repo-a' }, + result: { output: 'a-secret and b-secret' }, + } + await plugin.triggerToolExecuteAfter(fromA) + + expect(fromA.result).toEqual({ output: '*** and b-secret' }) + }) + + it('masks a result without a cwd with the union of the current per-cwd masks', async () => { + const fetchMock = vi.fn().mockImplementation((url: URL) => { + const mask = String(url).includes('%2Frepo-a') ? ['a-secret'] : ['b-secret'] + return Promise.resolve({ ok: true, json: async () => ({ env: {}, mask }) }) + }) + vi.stubGlobal('fetch', fetchMock) + const plugin = await loadPlugin() + await plugin.triggerShellCreateBefore({ command: 'ls', cwd: '/repo-a', env: {} }) + await plugin.triggerShellCreateBefore({ command: 'ls', cwd: '/repo-b', env: {} }) + + const event: ToolExecuteAfterEvent = { + tool: 'read', + status: 'completed', + result: { output: 'a-secret and b-secret' }, + } + await plugin.triggerToolExecuteAfter(event) + + expect(event.result).toEqual({ output: '*** and ***' }) + }) + + it('masks overlapping values longest first without sorting per string', async () => { + stubShellEnvFetch({ env: {}, mask: ['abc', 'abcdef', 'abc'] }) + const plugin = await loadPlugin() + await plugin.triggerShellCreateBefore({ command: 'ls', cwd: '/repo', env: {} }) + + expect(maskStateForTests()?.byCwd.get('/repo')).toEqual(['abcdef', 'abc']) + + const event: ToolExecuteAfterEvent = { tool: 'shell', status: 'completed', result: { output: 'abcdef' } } + await plugin.triggerToolExecuteAfter(event) + + expect(event.result).toEqual({ output: '***' }) + }) + + for (const order of ['gh-env first', 'sandbox first'] as const) { + it(`lets a sandboxed shell register the plan masks and skips the repository env (${order})`, async () => { + process.env.OCM_SANDBOX_ENFORCED = 'true' + const shellEnvCalls: string[] = [] + vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => { + const target = String(url) + if (target.includes('/sandbox/sessions/')) { + return Promise.resolve({ ok: true, status: 200, json: async () => ({ sandboxed: true, source: 'default', since: 1 }) }) + } + if (target.endsWith('/sandbox/shell')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ mode: 'sandbox', workdir: '/repo', name: PLAN_NAME, mask: ['plan-secret'] }), + }) + } + shellEnvCalls.push(target) + return Promise.resolve({ ok: true, json: async () => ({ env: { GH_TOKEN: 'ghp' }, mask: ['repo-secret'] }) }) + })) + const ghEnv = await loadPlugin() + const sandbox = await loadGeneratedPlugin(path.join(getOpenCodePluginDir(configHome), 'ocm-sandbox.js')) + const base = vi.fn().mockImplementation(async (input: { command: string }) => { + const event = shellCreateEvent(input.command, '/repo') + if (order === 'gh-env first') { + await ghEnv.triggerShellCreateBefore(event) + await sandbox.triggerShellCreateBefore(event) + } else { + await sandbox.triggerShellCreateBefore(event) + await ghEnv.triggerShellCreateBefore(event) + } + return { output: 'ok' } + }) + const shell = shellTool(sandbox, base) + + await shell.execute!({ command: 'git push' }, { signal: new AbortController().signal, sessionID: 'ses_test', id: 'call_test' }) + + expect(shellEnvCalls).toHaveLength(1) + expect(shellEnvCalls[0]).toContain('sandboxed=1') + expect(maskStateForTests()?.byCwd.get('/repo')).toEqual(['plan-secret']) + expect(shellSandboxStateForTests()?.inFlightSandboxedCommands.size).toBe(0) + + const event: ToolExecuteAfterEvent = { tool: 'shell', status: 'completed', result: { output: 'plan-secret and repo-secret' } } + await ghEnv.triggerToolExecuteAfter(event) + + expect(event.result).toEqual({ output: '*** and repo-secret' }) + }) + } + it('fails loudly when the generated module does not match the V2 plugin contract', async () => { const modulePath = path.join(configHome, 'not-a-plugin.js') await fs.writeFile(modulePath, 'export default async function () { return {} }') diff --git a/backend/test/services/opencode-manager-tool-plugin.test.ts b/backend/test/services/opencode-manager-tool-plugin.test.ts index 839fd25df..c211573e5 100644 --- a/backend/test/services/opencode-manager-tool-plugin.test.ts +++ b/backend/test/services/opencode-manager-tool-plugin.test.ts @@ -655,7 +655,7 @@ describe('ocm-manager plugin', () => { it('rejects non-allow-listed routes without calling the API', async () => { const tool = await loadTool(configHome) const deniedRoutes = [ - ['GET', '/git-credentials/gh-env'], + ['GET', '/shell-env'], ['POST', '/sandbox/shell'], ['GET', '/repos/0/mirror/head'], ['POST', '/notifications/send'], @@ -707,7 +707,7 @@ describe('ocm-manager plugin', () => { const tool = await loadTool(configHome) await expect( - runTool(tool, { action: 'request', params: { method: 'GET', path: '/repos/x/../../git-credentials/gh-env' } }), + runTool(tool, { action: 'request', params: { method: 'GET', path: '/repos/x/../../shell-env' } }), ).rejects.toThrow(/is not an allowed OpenCode Manager route/) expect(fetchMock).not.toHaveBeenCalled() }) diff --git a/backend/test/services/opencode-sandbox-plugin.test.ts b/backend/test/services/opencode-sandbox-plugin.test.ts index 82e80b58b..eb86cdfeb 100644 --- a/backend/test/services/opencode-sandbox-plugin.test.ts +++ b/backend/test/services/opencode-sandbox-plugin.test.ts @@ -9,11 +9,11 @@ import os from 'os' import { SANDBOX_CLAIM_TIMEOUT_MS, SANDBOX_PLAN_TIMEOUT_MS, - SANDBOX_SHELL_STATE_KEY, } from '../../src/services/opencode-sandbox-plugin' +import { SANDBOX_SHELL_STATE_KEY } from '../../src/services/opencode-plugin-state' import { installManagedPlugins, getOpenCodePluginDir } from '../../src/services/opencode/plugin-registry' import { - SANDBOX_FORWARDED_ENV_NAMES, + SANDBOX_SHELL_ENV_FORWARD, SANDBOX_SHELL_ENV_NAME, SANDBOX_SHELL_ENV_WORKDIR, sandboxShellShimPath, @@ -138,6 +138,7 @@ type ShellSandboxState = { pendingMarkers: Map sandboxedCallIDs: Set foreignOwners: Map + inFlightSandboxedCommands: Map } function shellSandboxState(): ShellSandboxState | undefined { @@ -148,6 +149,18 @@ function clearShellSandboxState(): void { delete (globalThis as Record)[Symbol.for(SANDBOX_SHELL_STATE_KEY)] } +type MaskState = { byCwd: Map; version: number; unionVersion: number; union: string[] } + +const MASK_STATE_KEY = Symbol.for('ocm-secret-masks.v1') + +function maskStateForTests(): MaskState | undefined { + return (globalThis as Record)[MASK_STATE_KEY] as MaskState | undefined +} + +function clearMaskState(): void { + delete (globalThis as Record)[MASK_STATE_KEY] +} + type TestClaim = { tier: 'workload' | 'explicit' | 'default'; since: number } type TestProvider = { id: string; claim: (request: { sessionID?: string; directory?: string }) => Promise } type TestDecision = { owner: string | null } @@ -264,6 +277,7 @@ describe('ocm-sandbox plugin', () => { afterEach(async () => { vi.unstubAllGlobals() clearShellSandboxState() + clearMaskState() clearArbiterRegistry() delete process.env.OCM_INTERNAL_API_URL delete process.env.OCM_INTERNAL_TOKEN @@ -284,6 +298,8 @@ describe('ocm-sandbox plugin', () => { expect(statSync(shimPath).mode & 0o100).not.toBe(0) expect(shim.startsWith('#!/bin/sh')).toBe(true) expect(shim).toContain(`$${SANDBOX_SHELL_ENV_WORKDIR}`) + expect(shim).toContain(`\${${SANDBOX_SHELL_ENV_FORWARD}}`) + expect(pluginSource).toContain(`var ENV_FORWARD = ${JSON.stringify(SANDBOX_SHELL_ENV_FORWARD)}`) expect(pluginSource).toContain(`var SHELL_SHIM_PATH = ${JSON.stringify(shimPath)}`) }) @@ -374,6 +390,7 @@ describe('ocm-sandbox plugin', () => { expect(createEvent.shell).toBe(sandboxShellShimPath(configHome)) expect(createEvent.env[SANDBOX_SHELL_ENV_WORKDIR]).toBe(WORKDIR) expect(createEvent.env[SANDBOX_SHELL_ENV_NAME]).toBe(PLAN_NAME) + expect(createEvent.env[SANDBOX_SHELL_ENV_FORWARD]).toBe('') const planCall = fetchMock.mock.calls.find(([url]) => String(url).endsWith('/sandbox/shell')) expect(planCall).toBeDefined() const [url, init] = planCall as [string, { body: string; headers: Record }] @@ -403,6 +420,9 @@ describe('ocm-sandbox plugin', () => { expect(() => { createEvent.env[SANDBOX_SHELL_ENV_NAME] = 'other' }).toThrow(message) + expect(() => { + createEvent.env[SANDBOX_SHELL_ENV_FORWARD] = 'EVIL' + }).toThrow(message) expect(createEvent.shell).toBe(sandboxShellShimPath(configHome)) expect(createEvent.env[SANDBOX_SHELL_ENV_WORKDIR]).toBe(WORKDIR) @@ -410,9 +430,10 @@ describe('ocm-sandbox plugin', () => { expect(Object.getOwnPropertyDescriptor(createEvent, 'shell')?.configurable).toBe(false) expect(Object.getOwnPropertyDescriptor(createEvent.env, SANDBOX_SHELL_ENV_WORKDIR)?.configurable).toBe(false) expect(Object.getOwnPropertyDescriptor(createEvent.env, SANDBOX_SHELL_ENV_NAME)?.configurable).toBe(false) + expect(Object.getOwnPropertyDescriptor(createEvent.env, SANDBOX_SHELL_ENV_FORWARD)?.configurable).toBe(false) }) - it('forwards only the allow-listed plan env values into the spawn environment', async () => { + it('forwards the plan env names it can represent and pins the forward list', async () => { const plugin = await loadPlugin(configHome) const fetchMock = managerFetch({ session: { sandboxed: true }, @@ -421,7 +442,16 @@ describe('ocm-sandbox plugin', () => { mode: 'sandbox', workdir: WORKDIR, name: PLAN_NAME, - env: { GH_TOKEN: 'gh-secret', OCM_INTERNAL_TOKEN: 'must-not-be-forwarded', GIT_CONFIG_COUNT: '1' }, + env: { + GH_TOKEN: 'gh-secret', + GIT_CONFIG_COUNT: '1', + OPENAI_API_KEY: 'sk-secret', + OCM_INTERNAL_TOKEN: 'must-not-be-forwarded', + MSB_SECRET: 'must-not-be-forwarded', + '1BAD': 'invalid', + 'A-B': 'invalid', + NUMERIC: 5, + }, }, }, }) @@ -431,12 +461,72 @@ describe('ocm-sandbox plugin', () => { expect(createEvent.env.GH_TOKEN).toBe('gh-secret') expect(createEvent.env.GIT_CONFIG_COUNT).toBe('1') + expect(createEvent.env.OPENAI_API_KEY).toBe('sk-secret') expect(createEvent.env.OCM_INTERNAL_TOKEN).toBeUndefined() - for (const name of Object.keys(createEvent.env)) { - if (createEvent.env[name] === undefined) continue - if (name === SANDBOX_SHELL_ENV_WORKDIR || name === SANDBOX_SHELL_ENV_NAME || name === 'TERM' || name === 'OPENCODE_TERMINAL') continue - expect(SANDBOX_FORWARDED_ENV_NAMES).toContain(name) - } + expect(createEvent.env.MSB_SECRET).toBeUndefined() + expect(createEvent.env['1BAD']).toBeUndefined() + expect(createEvent.env['A-B']).toBeUndefined() + expect(createEvent.env.NUMERIC).toBeUndefined() + expect(createEvent.env[SANDBOX_SHELL_ENV_FORWARD]).toBe('GH_TOKEN GIT_CONFIG_COUNT OPENAI_API_KEY') + expect(Object.getOwnPropertyDescriptor(createEvent.env, SANDBOX_SHELL_ENV_FORWARD)?.configurable).toBe(false) + }) + + it('registers the plan mask values for the spawned cwd', async () => { + const plugin = await loadPlugin(configHome) + const fetchMock = managerFetch({ + session: { sandboxed: true }, + plan: { body: { mode: 'sandbox', workdir: WORKDIR, name: PLAN_NAME, mask: ['s3cret-value', 'other-value'] } }, + }) + const { shell } = await runSandboxedShellCall(plugin, fetchMock) + + await shell.execute!({ command: 'echo sentinel' }, toolContext()) + + expect(maskStateForTests()?.byCwd.get(WORKDIR)).toEqual(['s3cret-value', 'other-value']) + }) + + it('clears the registered masks for the cwd when the plan carries none', async () => { + const plugin = await loadPlugin(configHome) + const masked = await runSandboxedShellCall( + plugin, + managerFetch({ + session: { sandboxed: true }, + plan: { body: { mode: 'sandbox', workdir: WORKDIR, name: PLAN_NAME, mask: ['s3cret-value'] } }, + }), + ) + await masked.shell.execute!({ command: 'echo sentinel' }, toolContext()) + expect(maskStateForTests()?.byCwd.get(WORKDIR)).toEqual(['s3cret-value']) + + const unmasked = await runSandboxedShellCall( + plugin, + managerFetch({ session: { sandboxed: true }, plan: { body: { mode: 'sandbox', workdir: WORKDIR, name: PLAN_NAME } } }), + ) + await unmasked.shell.execute!({ command: 'echo other' }, toolContext()) + + expect(maskStateForTests()?.byCwd.get(WORKDIR)).toBeUndefined() + }) + + it('pins the forward list only from the plan instead of a static env list', async () => { + const plugin = await loadPlugin(configHome) + const source = await fs.readFile(path.join(getOpenCodePluginDir(configHome), 'ocm-sandbox.js'), 'utf-8') + let pinned: ShellCreateBeforeEvent | undefined + const { shell } = await runSandboxedShellCall( + plugin, + managerFetch({ session: { sandboxed: true }, plan: { body: { mode: 'sandbox', workdir: WORKDIR, name: PLAN_NAME } } }), + (marked) => { + pinned = shellCreateEvent({ + command: marked, + env: { PATH: '/usr/bin', GH_TOKEN: 'spawn-token', GIT_CONFIG_COUNT: '1' }, + }) + return pinned + }, + ) + + await shell.execute!({ command: 'echo sentinel' }, toolContext()) + + expect(source).not.toContain('FORWARDED_ENV_NAMES') + expect(pinned?.env[SANDBOX_SHELL_ENV_FORWARD]).toBe('') + expect(pinned?.env.GH_TOKEN).toBe('spawn-token') + expect(pinned?.env.GIT_CONFIG_COUNT).toBe('1') }) it('leaves an unmarked spawn on the host without planning', async () => { @@ -692,6 +782,22 @@ describe('ocm-sandbox plugin', () => { expect(marked).toMatch(/^ocm-sandbox-required-[0-9a-f-]{36} && echo sentinel$/) }) + it('records the sandboxed command as in flight for the duration of the spawn', async () => { + const plugin = await loadPlugin(configHome) + const inFlightDuringSpawn: string[][] = [] + vi.stubGlobal('fetch', managerFetch({ session: { sandboxed: true } })) + const shell = shellTool(plugin, vi.fn().mockImplementation(async () => { + inFlightDuringSpawn.push([...(shellSandboxState()?.inFlightSandboxedCommands.keys() ?? [])]) + return { output: 'ok' } + })) + + await shell.execute!({ command: 'echo sentinel' }, toolContext()) + + expect(inFlightDuringSpawn[0]).toContain('echo sentinel') + expect(inFlightDuringSpawn[0]?.some((command) => command.endsWith(' && echo sentinel'))).toBe(true) + expect(shellSandboxState()?.inFlightSandboxedCommands.size).toBe(0) + }) + it('refuses to run a sandboxed shell call without a command', async () => { const plugin = await loadPlugin(configHome) const original = vi.fn() diff --git a/backend/test/services/project-config.test.ts b/backend/test/services/project-config.test.ts index 8d946a052..a5a881c66 100644 --- a/backend/test/services/project-config.test.ts +++ b/backend/test/services/project-config.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest' import { Database } from 'bun:sqlite' +import { createHash } from 'node:crypto' import { execSync } from 'node:child_process' import fs from 'node:fs' import os from 'node:os' @@ -340,7 +341,7 @@ describe('ProjectConfigService repository file', () => { const config = await service.getConfig(repo, directory) - expect(config.repoFile.error).toBe('Does not match the project file schema') + expect(config.repoFile.error).toContain('Does not match the project file schema') expect(config.actions).toEqual([]) }) @@ -352,7 +353,7 @@ describe('ProjectConfigService repository file', () => { const config = await service.getConfig(repo, directory) - expect(config.repoFile.error).toBe('Does not match the project file schema') + expect(config.repoFile.error).toContain('Does not match the project file schema') expect(config.repoFile.executable).toBeNull() }) @@ -529,6 +530,93 @@ describe('ProjectConfigService repository file', () => { ]) }) + it('hashes a file without env exactly like the legacy executable shape', async () => { + writeRepoFile({ version: 1, projectActions: [{ id: 'serve', name: 'Serve', command: 'pnpm dev' }] }) + + const config = await service.getConfig(repo, directory) + const legacyHash = createHash('sha256') + .update( + JSON.stringify({ + actions: [{ id: 'serve', name: 'Serve', command: 'pnpm dev', url: null, autoOpenUrl: false }], + setup: [], + }), + ) + .digest('hex') + + expect(config.repoFile.hash).toBe(legacyHash) + }) + + it('changes the hash and drops trust when env is added or edited', async () => { + writeRepoFile({ version: 1, projectActions: [{ id: 'serve', name: 'Serve', command: 'pnpm dev' }] }) + const base = await service.getConfig(repo, directory) + await service.trustRepoFile(repo, directory, base.repoFile.hash ?? '') + expect((await service.getConfig(repo, directory)).repoFile.trusted).toBe(true) + + writeRepoFile({ + version: 1, + projectActions: [{ id: 'serve', name: 'Serve', command: 'pnpm dev' }], + env: { TOKEN: '${{ secrets.TOKEN }}' }, + }) + const withEnv = await service.getConfig(repo, directory) + expect(withEnv.repoFile.hash).not.toBe(base.repoFile.hash) + expect(withEnv.repoFile.trusted).toBe(false) + + await service.trustRepoFile(repo, directory, withEnv.repoFile.hash ?? '') + expect((await service.getConfig(repo, directory)).repoFile.trusted).toBe(true) + + writeRepoFile({ + version: 1, + projectActions: [{ id: 'serve', name: 'Serve', command: 'pnpm dev' }], + env: { TOKEN: '${{ secrets.OTHER }}' }, + }) + const edited = await service.getConfig(repo, directory) + expect(edited.repoFile.hash).not.toBe(withEnv.repoFile.hash) + expect(edited.repoFile.trusted).toBe(false) + }) + + it('includes environment and env in the executable snapshot only when present', async () => { + writeRepoFile({ version: 1, projectActions: [{ id: 'serve', name: 'Serve', command: 'pnpm dev' }] }) + expect((await service.getConfig(repo, directory)).repoFile.executable).not.toHaveProperty('env') + + writeRepoFile({ + version: 1, + projectActions: [{ id: 'serve', name: 'Serve', command: 'pnpm dev' }], + environment: 'Prod', + env: { TOKEN: '${{ secrets.TOKEN }}' }, + }) + const config = await service.getConfig(repo, directory) + expect(config.repoFile.executable?.environment).toBe('prod') + expect(config.repoFile.executable?.env).toEqual({ TOKEN: '${{ secrets.TOKEN }}' }) + }) + + it('counts an untrusted env mapping as executable content when moving an action', async () => { + service.setPersonalActions(repo, [createAction()]) + writeRepoFile({ version: 1, env: { TOKEN: '${{ secrets.TOKEN }}' } }) + + await service.moveItem(repo, directory, { kind: 'action', id: 'serve', to: 'repo' }) + + expect((await service.getConfig(repo, directory)).repoFile.trusted).toBe(false) + }) + + it('counts an untrusted environment selector as executable content when moving an action', async () => { + service.setPersonalActions(repo, [createAction()]) + writeRepoFile({ version: 1, environment: 'prod' }) + + await service.moveItem(repo, directory, { kind: 'action', id: 'serve', to: 'repo' }) + + expect((await service.getConfig(repo, directory)).repoFile.trusted).toBe(false) + }) + + it('reports the first schema issue with its path and message', async () => { + writeRepoFile({ version: 1, env: { TOKEN: '${{ github.sha }}' } }) + + const config = await service.getConfig(repo, directory) + + expect(config.repoFile.error).toContain('Does not match the project file schema') + expect(config.repoFile.error).toContain('env.TOKEN') + expect(config.repoFile.error).toContain('Unsupported expression') + }) + it('rejects moving an unknown personal action', async () => { await expect(service.moveItem(repo, directory, { kind: 'action', id: 'missing', to: 'repo' })).rejects.toMatchObject({ status: 404, diff --git a/backend/test/services/repo-env.test.ts b/backend/test/services/repo-env.test.ts new file mode 100644 index 000000000..9dca0d74f --- /dev/null +++ b/backend/test/services/repo-env.test.ts @@ -0,0 +1,371 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { Database } from 'bun:sqlite' +import { execSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import { createRepo } from '../../src/db/queries' +import { createGitService } from '../../src/services/git/GitService' +import { ProjectConfigService } from '../../src/services/project-config' +import { SecretsService } from '../../src/services/secrets' +import { RepoEnvService } from '../../src/services/repo-env' +import { deleteRepoFiles } from '../../src/services/repo' +import { invalidateRepoEnvCache } from '../../src/utils/repo-env-cache' +import { executeCommand } from '../../src/utils/process' +import { logger } from '../../src/utils/logger' +import type { GitAuthService } from '../../src/services/git-auth' +import type { Repo } from '@opencode-manager/shared/types' + +vi.mock('../../src/utils/process', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, executeCommand: vi.fn(actual.executeCommand) } +}) + +const gitAuthService = { getGitEnvironment: () => ({}) } as unknown as GitAuthService + +const EMPTY = { env: {}, maskValues: [] } + +function topLevelProbes(): number { + return vi.mocked(executeCommand).mock.calls.filter(([args]) => args.includes('--show-toplevel')).length +} + +describe('RepoEnvService', () => { + let db: Database + let projectConfigService: ProjectConfigService + let secretsService: SecretsService + let repoEnvService: RepoEnvService + let tempDirs: string[] + + function createTempDir(prefix: string): string { + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix))) + tempDirs.push(dir) + return dir + } + + function initGitRepo(dir: string, message = 'Initial commit'): void { + execSync(`git init "${dir}"`) + execSync(`git -C "${dir}" config user.email test@test.com`) + execSync(`git -C "${dir}" config user.name Test`) + execSync(`git -C "${dir}" commit --allow-empty -m "${message}"`) + } + + function writeProjectFile(dir: string, content: unknown): void { + const filePath = path.join(dir, '.ocm', 'project.json') + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, JSON.stringify(content, null, 2)) + } + + function registerRepo(dir: string): Repo { + return createRepo(db, { + localPath: dir, + sourcePath: dir, + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + isLocal: true, + }) + } + + async function trust(dir: string, repo: Repo): Promise { + const hash = projectConfigService.readRepoFile(dir).hash + await projectConfigService.trustRepoFile(repo, dir, hash ?? '') + } + + beforeEach(() => { + vi.mocked(executeCommand).mockClear() + db = new Database(':memory:') + migrate(db, allMigrations) + projectConfigService = new ProjectConfigService(db, createGitService(gitAuthService), gitAuthService) + secretsService = new SecretsService(db) + repoEnvService = new RepoEnvService(db, projectConfigService, secretsService) + tempDirs = [] + }) + + afterEach(() => { + db.close() + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }) + } + }) + + it('resolves a trusted mapping with secret precedence and the selected environment', async () => { + const root = createTempDir('ocm-repo-env-') + initGitRepo(root) + const repo = registerRepo(root) + writeProjectFile(root, { + version: 1, + environment: 'prod', + env: { + API_URL: 'https://${{ vars.HOST }}/${{ secrets.TOKEN }}', + PLAIN: 'literal', + }, + }) + secretsService.set('variable', repo.id, 'prod', 'HOST', 'example.com') + secretsService.set('secret', repo.id, null, 'TOKEN', 'repo-token') + secretsService.set('secret', repo.id, 'prod', 'TOKEN', 'prod-token') + await trust(root, repo) + + const result = await repoEnvService.resolveForDirectory(root) + + expect(result.env).toEqual({ + API_URL: 'https://example.com/prod-token', + PLAIN: 'literal', + }) + expect(result.maskValues).toEqual(['prod-token']) + }) + + it('returns an empty result for an untrusted mapping', async () => { + const root = createTempDir('ocm-repo-env-') + initGitRepo(root) + const repo = registerRepo(root) + writeProjectFile(root, { version: 1, env: { TOKEN: '${{ secrets.TOKEN }}' } }) + secretsService.set('secret', repo.id, null, 'TOKEN', 'prod-token') + + expect(await repoEnvService.resolveForDirectory(root)).toEqual(EMPTY) + }) + + it('returns an empty result when the file has no env mapping', async () => { + const root = createTempDir('ocm-repo-env-') + initGitRepo(root) + const repo = registerRepo(root) + writeProjectFile(root, { version: 1, projectActions: [{ id: 'serve', name: 'Serve', command: 'pnpm dev' }] }) + await trust(root, repo) + + expect(await repoEnvService.resolveForDirectory(root)).toEqual(EMPTY) + }) + + it('resolves from a subdirectory of the checkout', async () => { + const root = createTempDir('ocm-repo-env-') + initGitRepo(root) + const repo = registerRepo(root) + writeProjectFile(root, { version: 1, env: { PLAIN: 'literal' } }) + await trust(root, repo) + const nested = path.join(root, 'packages', 'app') + fs.mkdirSync(nested, { recursive: true }) + + const result = await repoEnvService.resolveForDirectory(nested) + + expect(result.env).toEqual({ PLAIN: 'literal' }) + }) + + it('ignores an unregistered nested git root inside a registered repository', async () => { + const root = createTempDir('ocm-repo-env-') + initGitRepo(root) + const repo = registerRepo(root) + secretsService.set('secret', repo.id, null, 'TOKEN', 'root-token') + const nested = path.join(root, 'packages', 'nested') + fs.mkdirSync(nested, { recursive: true }) + initGitRepo(nested, 'Nested initial commit') + writeProjectFile(nested, { version: 1, env: { TOKEN: '${{ secrets.TOKEN }}' } }) + await trust(nested, repo) + + expect(await repoEnvService.resolveForDirectory(nested)).toEqual(EMPTY) + }) + + it('resolves a git worktree outside the repository folder to the main repo', async () => { + const root = createTempDir('ocm-repo-env-') + initGitRepo(root) + writeProjectFile(root, { version: 1, env: { PLAIN: 'literal' } }) + execSync(`git -C "${root}" add -A`) + execSync(`git -C "${root}" commit -m "Add project config"`) + const repo = registerRepo(root) + await trust(root, repo) + const worktreePath = createTempDir('ocm-repo-env-worktree-') + fs.rmSync(worktreePath, { recursive: true, force: true }) + execSync(`git -C "${root}" worktree add -b feature "${worktreePath}"`) + + const result = await repoEnvService.resolveForDirectory(worktreePath) + + expect(result.env).toEqual({ PLAIN: 'literal' }) + }) + + it('does not resolve a separate clone of the same origin to the registered repo', async () => { + const root = createTempDir('ocm-repo-env-') + initGitRepo(root) + execSync(`git -C "${root}" remote add origin https://github.com/test/shared.git`) + writeProjectFile(root, { version: 1, env: { TOKEN: '${{ secrets.TOKEN }}' } }) + const repo = registerRepo(root) + secretsService.set('secret', repo.id, null, 'TOKEN', 'registered-token') + await trust(root, repo) + + const clone = createTempDir('ocm-repo-env-clone-') + initGitRepo(clone) + execSync(`git -C "${clone}" remote add origin https://github.com/test/shared.git`) + writeProjectFile(clone, { version: 1, env: { TOKEN: '${{ secrets.TOKEN }}' } }) + + expect(await repoEnvService.resolveForDirectory(clone)).toEqual(EMPTY) + expect(await repoEnvService.resolveForDirectory(root)).toEqual({ + env: { TOKEN: 'registered-token' }, + maskValues: ['registered-token'], + }) + }) + + it('returns an empty result for a non-git directory', async () => { + const plain = createTempDir('ocm-repo-env-plain-') + + expect(await repoEnvService.resolveForDirectory(plain)).toEqual(EMPTY) + }) + + it('resolves missing references to an empty string', async () => { + const root = createTempDir('ocm-repo-env-') + initGitRepo(root) + const repo = registerRepo(root) + writeProjectFile(root, { + version: 1, + env: { MISSING: '${{ secrets.NOPE }}-${{ vars.NOPE2 }}' }, + }) + await trust(root, repo) + + const result = await repoEnvService.resolveForDirectory(root) + + expect(result.env).toEqual({ MISSING: '-' }) + }) + + it('excludes vars and short secret values from maskValues', async () => { + const root = createTempDir('ocm-repo-env-') + initGitRepo(root) + const repo = registerRepo(root) + writeProjectFile(root, { + version: 1, + env: { + LONG: '${{ secrets.LONG }}', + SHORT: '${{ secrets.SHORT }}', + FROM_VAR: '${{ vars.VAR }}', + }, + }) + secretsService.set('secret', repo.id, null, 'LONG', 'longvalue') + secretsService.set('secret', repo.id, null, 'SHORT', 'abc') + secretsService.set('variable', repo.id, null, 'VAR', 'variablevalue') + await trust(root, repo) + + const result = await repoEnvService.resolveForDirectory(root) + + expect(result.maskValues).toEqual(['longvalue']) + }) + + it('returns an empty result and warns when the secrets resolver fails', async () => { + const root = createTempDir('ocm-repo-env-') + initGitRepo(root) + const repo = registerRepo(root) + writeProjectFile(root, { version: 1, env: { TOKEN: '${{ secrets.TOKEN }}' } }) + await trust(root, repo) + vi.spyOn(secretsService, 'resolveValues').mockImplementation(() => { + throw new Error('boom') + }) + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => undefined) + + expect(await repoEnvService.resolveForDirectory(root)).toEqual(EMPTY) + expect(warn).toHaveBeenCalledWith(expect.stringContaining(root), expect.anything()) + + warn.mockRestore() + }) + + describe('caching', () => { + async function createTrustedRepo(env: Record): Promise<{ root: string; repo: Repo }> { + const root = createTempDir('ocm-repo-env-') + initGitRepo(root) + const repo = registerRepo(root) + writeProjectFile(root, { version: 1, env }) + await trust(root, repo) + return { root, repo } + } + + it('reuses the directory resolution within the TTL without spawning git', async () => { + const { root } = await createTrustedRepo({ PLAIN: 'literal' }) + + await repoEnvService.resolveForDirectory(root) + const probes = topLevelProbes() + expect(probes).toBeGreaterThan(0) + + const result = await repoEnvService.resolveForDirectory(root) + + expect(result.env).toEqual({ PLAIN: 'literal' }) + expect(topLevelProbes()).toBe(probes) + }) + + it('caches misses for directories without a repository', async () => { + const plain = createTempDir('ocm-repo-env-plain-') + + expect(await repoEnvService.resolveForDirectory(plain)).toEqual(EMPTY) + const probes = topLevelProbes() + + expect(await repoEnvService.resolveForDirectory(plain)).toEqual(EMPTY) + expect(topLevelProbes()).toBe(probes) + }) + + it('re-reads the project file when it changes', async () => { + const { root, repo } = await createTrustedRepo({ PLAIN: 'first' }) + expect((await repoEnvService.resolveForDirectory(root)).env).toEqual({ PLAIN: 'first' }) + const probes = topLevelProbes() + + writeProjectFile(root, { version: 1, env: { PLAIN: 'second-value' } }) + await trust(root, repo) + + expect((await repoEnvService.resolveForDirectory(root)).env).toEqual({ PLAIN: 'second-value' }) + expect(topLevelProbes()).toBe(probes) + }) + + it('applies a trust decision made after the file was cached', async () => { + const root = createTempDir('ocm-repo-env-') + initGitRepo(root) + const repo = registerRepo(root) + writeProjectFile(root, { version: 1, env: { PLAIN: 'literal' } }) + + expect(await repoEnvService.resolveForDirectory(root)).toEqual(EMPTY) + + await trust(root, repo) + + expect((await repoEnvService.resolveForDirectory(root)).env).toEqual({ PLAIN: 'literal' }) + }) + + it('applies secret edits made after the directory was cached', async () => { + const { root, repo } = await createTrustedRepo({ TOKEN: '${{ secrets.TOKEN }}' }) + secretsService.set('secret', repo.id, null, 'TOKEN', 'first-token') + + expect((await repoEnvService.resolveForDirectory(root)).env).toEqual({ TOKEN: 'first-token' }) + + secretsService.set('secret', repo.id, null, 'TOKEN', 'second-token') + + const result = await repoEnvService.resolveForDirectory(root) + expect(result.env).toEqual({ TOKEN: 'second-token' }) + expect(result.maskValues).toEqual(['second-token']) + }) + + it('invalidates the cached miss when the repository is registered', async () => { + const root = createTempDir('ocm-repo-env-') + initGitRepo(root) + writeProjectFile(root, { version: 1, env: { PLAIN: 'literal' } }) + + expect(await repoEnvService.resolveForDirectory(root)).toEqual(EMPTY) + + const repo = registerRepo(root) + await trust(root, repo) + + expect((await repoEnvService.resolveForDirectory(root)).env).toEqual({ PLAIN: 'literal' }) + }) + + it('invalidates the cached resolution when the repository is deleted', async () => { + const { root, repo } = await createTrustedRepo({ PLAIN: 'literal' }) + expect((await repoEnvService.resolveForDirectory(root)).env).toEqual({ PLAIN: 'literal' }) + const probes = topLevelProbes() + + await deleteRepoFiles(db, repo.id) + + expect(await repoEnvService.resolveForDirectory(root)).toEqual(EMPTY) + expect(topLevelProbes()).toBeGreaterThan(probes) + }) + + it('invalidates the cached resolution when the repo environment cache is reset', async () => { + const { root } = await createTrustedRepo({ PLAIN: 'literal' }) + expect((await repoEnvService.resolveForDirectory(root)).env).toEqual({ PLAIN: 'literal' }) + const probes = topLevelProbes() + + invalidateRepoEnvCache() + + expect((await repoEnvService.resolveForDirectory(root)).env).toEqual({ PLAIN: 'literal' }) + expect(topLevelProbes()).toBeGreaterThan(probes) + }) + }) +}) diff --git a/backend/test/services/repo.test.ts b/backend/test/services/repo.test.ts index e0fe94996..e58c80597 100644 --- a/backend/test/services/repo.test.ts +++ b/backend/test/services/repo.test.ts @@ -666,12 +666,34 @@ describe('resolveRepoForDirectory', () => { resolveProjectId.mockImplementation(async (directory: string) => directory === workspaceDirectory || directory === repo.fullPath ? 'commit-A' : null, ) + executeCommand.mockImplementation(async (args: string[]) => + args.includes('--git-common-dir') ? path.join(repo.fullPath, '.git') : '', + ) const result = await resolveRepoForDirectory({} as never, workspaceDirectory) expect(result).toBe(repo) }) + it('returns null for a matching project id that is not the registered checkout', async () => { + const { resolveRepoForDirectory } = await import('../../src/services/repo') + const repo = createReadyRepo(1, 'repo-a') + const cloneDirectory = '/clones/repo-a' + getRepoBySourcePath.mockReturnValue(null) + getRepoByLocalPath.mockReturnValue(null) + listRepos.mockReturnValue([repo]) + resolveProjectId.mockImplementation(async (directory: string) => + directory === cloneDirectory || directory === repo.fullPath ? 'commit-A' : null, + ) + executeCommand.mockImplementation(async (args: string[]) => + args.includes('--git-common-dir') ? path.join(cloneDirectory, '.git') : '', + ) + + const result = await resolveRepoForDirectory({} as never, cloneDirectory) + + expect(result).toBeNull() + }) + it('returns null for a directory that matches no repo', async () => { const { resolveRepoForDirectory } = await import('../../src/services/repo') getRepoBySourcePath.mockReturnValue(null) diff --git a/backend/test/services/sandbox/runtime.test.ts b/backend/test/services/sandbox/runtime.test.ts index 3135c4732..3fcb53535 100644 --- a/backend/test/services/sandbox/runtime.test.ts +++ b/backend/test/services/sandbox/runtime.test.ts @@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node: import { tmpdir } from 'node:os' import path from 'node:path' import { ENV, getOpenCodeAgentTmpPath, getOpenCodeGlobalSkillsPath, getOpenCodeToolOutputPath, getReposPath, getScheduleWorktreesPath } from '@opencode-manager/shared/config/env' +import type { GitCredential } from '@opencode-manager/shared' import { migrate } from '../../../src/db/migration-runner' import { allMigrations } from '../../../src/db/migrations' import { SettingsService } from '../../../src/services/settings' @@ -32,6 +33,7 @@ import { executeCommand } from '../../../src/utils/process' import { detectSandboxCapability } from '../../../src/services/sandbox/capability' import { logger } from '../../../src/utils/logger' import { forceProcessAttestation } from '../../../src/services/opencode/process-identity' +import type { RepoEnvService } from '../../../src/services/repo-env' vi.mock('../../../src/utils/process', () => ({ executeCommand: vi.fn(), @@ -525,6 +527,66 @@ describe('SandboxRuntimeService', () => { }) }) + it('forwards the sandbox git credentials from the plan only when the toggle allows them', async () => { + enableEnforcement() + settingsService.updateSettings({ + gitCredentials: [{ name: 'github', host: 'github.com', type: 'pat', token: 'ghp_plan_token' } as GitCredential], + sandbox: { enabled: true, gitCredentials: true }, + }) + installFakeMsb(new Map([[VM_NAME, vm()]])) + + const withCredentials = await service.planShell(repoADir, shellTarget(repoADir)) + + expect(withCredentials).toMatchObject({ + mode: 'sandbox', + env: { GIT_CONFIG_COUNT: '1', GH_TOKEN: 'ghp_plan_token' }, + }) + + settingsService.updateSettings({ sandbox: { enabled: true, gitCredentials: false } }) + const withoutCredentials = await service.planShell(repoADir, shellTarget(repoADir)) + + expect(withoutCredentials.mode).toBe('sandbox') + expect(withoutCredentials).not.toHaveProperty('env.GH_TOKEN') + expect(withoutCredentials).not.toHaveProperty('env.GIT_CONFIG_COUNT') + }) + + it('includes the repository env in the sandbox plan env after the git env', async () => { + enableEnforcement() + mockExecuteCommand.mockImplementation(async (args: string[]) => { + if (args.includes('inspect')) return { exitCode: 0, stdout: runningInspectOutput(inspectConfig(vm(), VM_NAME), VM_NAME), stderr: '' } + if (args.includes('config') && args.includes('user.name')) { + return { exitCode: 0, stdout: 'local\tfile:/repo/.git/config\tLocal User\n', stderr: '' } + } + if (args.includes('config') && args.includes('user.email')) { + return { exitCode: 0, stdout: 'local\tfile:/repo/.git/config\tlocal@example.com\n', stderr: '' } + } + return { exitCode: 0, stdout: '[]', stderr: '' } + }) + const repoEnvService = { + resolveForDirectory: vi.fn(async () => ({ + env: { REPO_SECRET: 's3cret', GIT_AUTHOR_NAME: 'Repo User' }, + maskValues: ['s3cret'], + })), + } as unknown as RepoEnvService + const withRepoEnv = new SandboxRuntimeService(db, repoEnvService) + + const plan = await withRepoEnv.planShell(repoADir, shellTarget(repoADir)) + + expect(plan).toEqual({ + mode: 'sandbox', + name: VM_NAME, + workdir: repoADir, + env: { + GIT_AUTHOR_NAME: 'Repo User', + GIT_AUTHOR_EMAIL: 'local@example.com', + GIT_COMMITTER_NAME: 'Local User', + GIT_COMMITTER_EMAIL: 'local@example.com', + REPO_SECRET: 's3cret', + }, + mask: ['s3cret'], + }) + }) + it('starts an existing stopped sandbox instead of recreating it', async () => { enableEnforcement() const vms = new Map([[VM_NAME, vm({ running: false })]]) diff --git a/backend/test/services/sandbox/shell-shim.test.ts b/backend/test/services/sandbox/shell-shim.test.ts index baaf30499..11f956ad0 100644 --- a/backend/test/services/sandbox/shell-shim.test.ts +++ b/backend/test/services/sandbox/shell-shim.test.ts @@ -6,7 +6,8 @@ import path from 'node:path' import { getReposPath } from '@opencode-manager/shared/config/env' import { sessionSandboxName } from '../../../src/services/sandbox/command' import { - SANDBOX_FORWARDED_ENV_NAMES, + SANDBOX_MAX_FORWARDED_GIT_CONFIGS, + SANDBOX_SHELL_ENV_FORWARD, SANDBOX_SHELL_ENV_NAME, SANDBOX_SHELL_ENV_WORKDIR, } from '../../../src/services/sandbox/shell-shim' @@ -37,7 +38,22 @@ function writeArgvCapturingFakeMsb(msbPath: string, captureFile: string): void { function sandboxShellEnv(overrides: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const env = { ...process.env } - for (const name of SANDBOX_FORWARDED_ENV_NAMES) delete env[name] + const inherited = [ + 'GIT_TERMINAL_PROMPT', + 'GIT_CONFIG_COUNT', + ...Array.from({ length: SANDBOX_MAX_FORWARDED_GIT_CONFIGS }, (_, index) => [ + `GIT_CONFIG_KEY_${index}`, + `GIT_CONFIG_VALUE_${index}`, + ]).flat(), + 'GIT_AUTHOR_NAME', + 'GIT_AUTHOR_EMAIL', + 'GIT_COMMITTER_NAME', + 'GIT_COMMITTER_EMAIL', + 'GH_TOKEN', + 'GITHUB_TOKEN', + SANDBOX_SHELL_ENV_FORWARD, + ] + for (const name of inherited) delete env[name] return { ...env, ...overrides } } @@ -96,7 +112,7 @@ describe('sandbox shell shim', () => { } }) - it('forwards only the allow-listed credential env into the guest as -e pairs, ahead of the command', async () => { + it('forwards exactly the plan-approved names as -e pairs, ahead of the command', async () => { const fakeBin = mkdtempSync(path.join(tmpdir(), 'ocm-shim-msb-')) const configHome = mkdtempSync(path.join(tmpdir(), 'ocm-shim-config-')) const msbPath = path.join(fakeBin, 'msb') @@ -118,9 +134,12 @@ describe('sandbox shell shim', () => { env: sandboxShellEnv({ [SANDBOX_SHELL_ENV_WORKDIR]: directory, [SANDBOX_SHELL_ENV_NAME]: SANDBOX_NAME, + [SANDBOX_SHELL_ENV_FORWARD]: 'GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0', GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'http.https://github.com/.extraheader', GIT_CONFIG_VALUE_0: extraheaderValue, + GH_TOKEN: 'inherited-must-not-be-forwarded', + GIT_AUTHOR_NAME: 'Inherited Author', OCM_INTERNAL_TOKEN: 'must-not-be-forwarded', }), }) @@ -130,19 +149,171 @@ describe('sandbox shell shim', () => { expect(argv).toContain('-e') expect(argv).toContain('GIT_CONFIG_COUNT=1') + expect(argv).toContain('GIT_CONFIG_KEY_0=http.https://github.com/.extraheader') expect(argv).toContain(`GIT_CONFIG_VALUE_0=${extraheaderValue}`) const separatorIndex = argv.indexOf('--') expect(argv.slice(separatorIndex)).toEqual(['--', 'sh', '-c', 'echo -e sentinel']) expect(argv.slice(0, separatorIndex).filter((element) => element === '-e')).toHaveLength(3) + expect(argv.some((element) => element.startsWith('GH_TOKEN='))).toBe(false) + expect(argv.some((element) => element.startsWith('GIT_AUTHOR_NAME='))).toBe(false) expect(argv.some((element) => element.startsWith('OCM_INTERNAL_TOKEN='))).toBe(false) - for (const element of argv) { - if (!element.includes('=') || element === '--') continue - const name = element.slice(0, element.indexOf('=')) - if (!SANDBOX_FORWARDED_ENV_NAMES.includes(name as never)) continue - expect(SANDBOX_FORWARDED_ENV_NAMES).toContain(name) + } finally { + if (originalMsbPath === undefined) { + delete process.env.MSB_PATH + } else { + process.env.MSB_PATH = originalMsbPath + } + rmSync(fakeBin, { recursive: true, force: true }) + rmSync(configHome, { recursive: true, force: true }) + } + }) + + it('forwards nothing but the plan-approved names when the spawn env carries git and GitHub credentials', async () => { + const fakeBin = mkdtempSync(path.join(tmpdir(), 'ocm-shim-msb-')) + const configHome = mkdtempSync(path.join(tmpdir(), 'ocm-shim-config-')) + const msbPath = path.join(fakeBin, 'msb') + const captureFile = path.join(fakeBin, 'argv.txt') + writeArgvCapturingFakeMsb(msbPath, captureFile) + const originalMsbPath = process.env.MSB_PATH + process.env.MSB_PATH = msbPath + try { + vi.resetModules() + const shimMod = await import('../../../src/services/sandbox/shell-shim') + const commandMod = await import('../../../src/services/sandbox/command') + commandMod.overrideSandboxExecutableTrustValidator(() => true) + const shimPath = await shimMod.ensureSandboxShellShim(configHome) + + const directory = path.join(getReposPath(), 'foo') + const result = spawnSync(shimPath, ['-c', 'echo sentinel'], { + encoding: 'utf8', + env: sandboxShellEnv({ + [SANDBOX_SHELL_ENV_WORKDIR]: directory, + [SANDBOX_SHELL_ENV_NAME]: SANDBOX_NAME, + GH_TOKEN: 'inherited-gh-token', + GITHUB_TOKEN: 'inherited-github-token', + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'http.https://github.com/.extraheader', + GIT_CONFIG_VALUE_0: 'AUTHORIZATION: basic inherited', + GIT_AUTHOR_NAME: 'Inherited Author', + GIT_COMMITTER_EMAIL: 'inherited@example.com', + }), + }) + expect(result.status).toBe(0) + + const argv = readFileSync(captureFile, 'utf8').split('\0').filter((element) => element !== '') + + expect(argv.filter((element) => element === '-e')).toHaveLength(0) + expect(argv).not.toContain('GH_TOKEN=inherited-gh-token') + expect(argv).not.toContain('GITHUB_TOKEN=inherited-github-token') + expect(argv).not.toContain('GIT_CONFIG_COUNT=1') + } finally { + if (originalMsbPath === undefined) { + delete process.env.MSB_PATH + } else { + process.env.MSB_PATH = originalMsbPath + } + rmSync(fakeBin, { recursive: true, force: true }) + rmSync(configHome, { recursive: true, force: true }) + } + }) + + it('skips reserved and invalid forward names without executing anything', async () => { + const fakeBin = mkdtempSync(path.join(tmpdir(), 'ocm-shim-names-')) + const configHome = mkdtempSync(path.join(tmpdir(), 'ocm-shim-config-')) + const msbPath = path.join(fakeBin, 'msb') + const captureFile = path.join(fakeBin, 'argv.txt') + writeArgvCapturingFakeMsb(msbPath, captureFile) + const originalMsbPath = process.env.MSB_PATH + process.env.MSB_PATH = msbPath + try { + vi.resetModules() + const shimMod = await import('../../../src/services/sandbox/shell-shim') + const commandMod = await import('../../../src/services/sandbox/command') + commandMod.overrideSandboxExecutableTrustValidator(() => true) + const shimPath = await shimMod.ensureSandboxShellShim(configHome) + + const directory = path.join(getReposPath(), 'foo') + const marker = path.join(fakeBin, 'injected.marker') + const substitutionName = `$(touch ${marker})` + const result = spawnSync(shimPath, ['-c', 'echo invalid-sentinel'], { + encoding: 'utf8', + env: sandboxShellEnv({ + [SANDBOX_SHELL_ENV_WORKDIR]: directory, + [SANDBOX_SHELL_ENV_NAME]: SANDBOX_NAME, + [SANDBOX_SHELL_ENV_FORWARD]: `1BAD A-B ${substitutionName} OCM_INTERNAL_TOKEN MSB_SECRET GOOD_ONE`, + '1BAD': 'leading-digit', + 'A-B': 'dash', + [substitutionName]: 'substitution', + OCM_INTERNAL_TOKEN: 'reserved-ocm', + MSB_SECRET: 'reserved-msb', + GOOD_ONE: 'forwarded', + }), + }) + expect(result.status).toBe(0) + expect(result.stdout).toBe('invalid-sentinel\n') + expect(existsSync(marker)).toBe(false) + + const argv = readFileSync(captureFile, 'utf8').split('\0').filter((element) => element !== '') + expect(argv.filter((element) => element === '-e')).toHaveLength(1) + expect(argv).toContain('GOOD_ONE=forwarded') + expect(argv.some((element) => element.startsWith('OCM_INTERNAL_TOKEN='))).toBe(false) + expect(argv.some((element) => element.startsWith('MSB_SECRET='))).toBe(false) + expect(argv.some((element) => element.startsWith('1BAD='))).toBe(false) + expect(argv.some((element) => element.startsWith('A-B='))).toBe(false) + } finally { + if (originalMsbPath === undefined) { + delete process.env.MSB_PATH + } else { + process.env.MSB_PATH = originalMsbPath } + rmSync(fakeBin, { recursive: true, force: true }) + rmSync(configHome, { recursive: true, force: true }) + } + }) + + it('passes forwarded values with spaces, quotes, dollars, and newlines verbatim as single arguments', async () => { + const fakeBin = mkdtempSync(path.join(tmpdir(), 'ocm-shim-values-')) + const configHome = mkdtempSync(path.join(tmpdir(), 'ocm-shim-config-')) + const msbPath = path.join(fakeBin, 'msb') + const captureFile = path.join(fakeBin, 'argv.txt') + writeArgvCapturingFakeMsb(msbPath, captureFile) + const originalMsbPath = process.env.MSB_PATH + process.env.MSB_PATH = msbPath + try { + vi.resetModules() + const shimMod = await import('../../../src/services/sandbox/shell-shim') + const commandMod = await import('../../../src/services/sandbox/command') + commandMod.overrideSandboxExecutableTrustValidator(() => true) + const shimPath = await shimMod.ensureSandboxShellShim(configHome) + + const directory = path.join(getReposPath(), 'foo') + const plain = 'a b c' + const quoted = 'it\'s "quoted"' + const dollar = '$HOME ${not_expanded} `backtick`' + const multiline = 'line1\nline2' + const result = spawnSync(shimPath, ['-c', 'echo verbatim-sentinel'], { + encoding: 'utf8', + env: sandboxShellEnv({ + [SANDBOX_SHELL_ENV_WORKDIR]: directory, + [SANDBOX_SHELL_ENV_NAME]: SANDBOX_NAME, + [SANDBOX_SHELL_ENV_FORWARD]: 'PLAIN QUOTED DOLLAR MULTILINE', + PLAIN: plain, + QUOTED: quoted, + DOLLAR: dollar, + MULTILINE: multiline, + }), + }) + expect(result.status).toBe(0) + expect(result.stdout).toBe('verbatim-sentinel\n') + + const argv = readFileSync(captureFile, 'utf8').split('\0').filter((element) => element !== '') + expect(argv.filter((element) => element === '-e')).toHaveLength(4) + expect(argv).toContain(`PLAIN=${plain}`) + expect(argv).toContain(`QUOTED=${quoted}`) + expect(argv).toContain(`DOLLAR=${dollar}`) + expect(argv).toContain(`MULTILINE=${multiline}`) } finally { if (originalMsbPath === undefined) { delete process.env.MSB_PATH diff --git a/backend/test/services/secrets.test.ts b/backend/test/services/secrets.test.ts new file mode 100644 index 000000000..1cc43df2f --- /dev/null +++ b/backend/test/services/secrets.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { Database } from 'bun:sqlite' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import { SecretsService, SecretsServiceError } from '../../src/services/secrets' +import { decryptSecret } from '../../src/utils/crypto' +import { logger } from '../../src/utils/logger' +import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' + +function createTestDb(): Database { + const db = new Database(':memory:') + migrate(db, allMigrations) + return db +} + +function readStoredValue(db: Database, name: string): string { + const row = db.prepare('SELECT value FROM secrets WHERE name = ?').get(name) as { value: string } | undefined + return row?.value ?? '' +} + +describe('SecretsService', () => { + let db: Database + let service: SecretsService + + beforeEach(() => { + db = createTestDb() + service = new SecretsService(db) + }) + + afterEach(() => { + vi.restoreAllMocks() + db.close() + }) + + it('stores secret values encrypted at rest and decrypts them on resolve', () => { + service.set('secret', null, null, 'TOKEN', 'super-secret') + + const stored = readStoredValue(db, 'TOKEN') + expect(stored).not.toBe('super-secret') + expect(decryptSecret(stored)).toBe('super-secret') + + expect(service.resolveValues(1, null).secrets.TOKEN).toBe('super-secret') + }) + + it('stores variable values in plain text', () => { + service.set('variable', null, null, 'LOG_LEVEL', 'debug') + + expect(readStoredValue(db, 'LOG_LEVEL')).toBe('debug') + expect(service.resolveValues(1, null).vars.LOG_LEVEL).toBe('debug') + }) + + it('omits secret values from list but includes variable values', () => { + service.set('secret', null, null, 'TOKEN', 'super-secret') + service.set('variable', null, null, 'LOG_LEVEL', 'debug') + + const secrets = service.list('secret', null) + expect(secrets).toHaveLength(1) + expect(secrets[0]).not.toHaveProperty('value') + + const variables = service.list('variable', null) + expect(variables).toHaveLength(1) + expect(variables[0]?.value).toBe('debug') + }) + + it('upserts an existing entry and refreshes updatedAt', () => { + const nowSpy = vi.spyOn(Date, 'now') + nowSpy.mockReturnValueOnce(1000) + service.set('variable', null, null, 'LOG_LEVEL', 'info') + + nowSpy.mockReturnValueOnce(2000) + service.set('variable', null, null, 'LOG_LEVEL', 'debug') + + const variables = service.list('variable', null) + expect(variables).toHaveLength(1) + expect(variables[0]?.value).toBe('debug') + expect(variables[0]?.updatedAt).toBe(2000) + }) + + it('rejects an environment for a global entry', () => { + expect(() => service.set('secret', null, 'prod', 'TOKEN', 'value')).toThrow(SecretsServiceError) + + try { + service.set('secret', null, 'prod', 'TOKEN', 'value') + } catch (error) { + expect((error as SecretsServiceError).statusCode).toBe(400) + } + }) + + it('deletes an entry and reports whether it existed', () => { + service.set('variable', null, null, 'LOG_LEVEL', 'debug') + + expect(service.delete('variable', null, null, 'LOG_LEVEL')).toBe(true) + expect(service.delete('variable', null, null, 'LOG_LEVEL')).toBe(false) + expect(service.list('variable', null)).toHaveLength(0) + }) + + it('lists a repository entries across all environments with empty environment mapped to null', () => { + service.set('variable', 7, null, 'PLAIN', 'plain') + service.set('variable', 7, 'prod', 'SCOPED', 'scoped') + + const variables = service.list('variable', 7) + expect(variables).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'PLAIN', environment: null }), + expect.objectContaining({ name: 'SCOPED', environment: 'prod' }), + ]), + ) + }) + + it('keeps global and Assistant repository entries with the same name independent', () => { + service.set('variable', null, null, 'LOG_LEVEL', 'global') + service.set('variable', ASSISTANT_REPO_ID, null, 'LOG_LEVEL', 'assistant') + + expect(service.list('variable', null)).toEqual([ + expect.objectContaining({ name: 'LOG_LEVEL', value: 'global' }), + ]) + expect(service.list('variable', ASSISTANT_REPO_ID)).toEqual([ + expect.objectContaining({ name: 'LOG_LEVEL', value: 'assistant' }), + ]) + + service.set('variable', null, null, 'LOG_LEVEL', 'global-updated') + service.set('variable', ASSISTANT_REPO_ID, null, 'LOG_LEVEL', 'assistant-updated') + + expect(service.list('variable', null)[0]?.value).toBe('global-updated') + expect(service.list('variable', ASSISTANT_REPO_ID)[0]?.value).toBe('assistant-updated') + }) + + it('resolves values with global < repository < repository+environment precedence', () => { + service.set('secret', null, null, 'GLOBAL_ONLY', 'g') + service.set('secret', 7, null, 'SHARED', 'repo') + service.set('secret', 7, 'prod', 'SHARED', 'prod') + service.set('variable', null, null, 'VAR_GLOBAL', 'vg') + service.set('variable', 7, null, 'VAR_SHARED', 'vrepo') + service.set('variable', 7, 'prod', 'VAR_SHARED', 'vprod') + + const production = service.resolveValues(7, 'prod') + expect(production.secrets).toEqual({ GLOBAL_ONLY: 'g', SHARED: 'prod' }) + expect(production.vars).toEqual({ VAR_GLOBAL: 'vg', VAR_SHARED: 'vprod' }) + + const withoutEnvironment = service.resolveValues(7, null) + expect(withoutEnvironment.secrets).toEqual({ GLOBAL_ONLY: 'g', SHARED: 'repo' }) + expect(withoutEnvironment.vars).toEqual({ VAR_GLOBAL: 'vg', VAR_SHARED: 'vrepo' }) + + const otherRepo = service.resolveValues(8, 'prod') + expect(otherRepo.secrets).toEqual({ GLOBAL_ONLY: 'g' }) + expect(otherRepo.vars).toEqual({ VAR_GLOBAL: 'vg' }) + }) + + it('skips a secret that cannot be decrypted and logs only its name', () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + const now = Date.now() + db.prepare( + 'INSERT INTO secrets (kind, repo_id, environment, name, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + ).run('secret', null, '', 'BROKEN', 'not-a-valid-encrypted-value', now, now) + + const resolved = service.resolveValues(1, null) + expect(resolved.secrets).not.toHaveProperty('BROKEN') + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0]?.[0]).toContain('BROKEN') + expect(warn.mock.calls[0]?.[0]).not.toContain('not-a-valid-encrypted-value') + }) +}) diff --git a/backend/test/services/terminal.test.ts b/backend/test/services/terminal.test.ts index 3f2d2cfa8..4ce85201a 100644 --- a/backend/test/services/terminal.test.ts +++ b/backend/test/services/terminal.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest' import { TerminalService, TerminalNotFoundError } from '../../src/services/terminal' import type { OpenCodeClient } from '../../src/services/opencode/client' import type { CredentialProvider } from '../../src/services/credential-provider' +import { createStubRepoEnvService } from '../helpers/stub-repo-env-service' interface PtyRecord { id: string @@ -50,11 +51,12 @@ function createTerminalService( provider: CredentialProvider, openSocket: (url: string) => WebSocket = createSocketFactory().openSocket, baseUrl = 'http://127.0.0.1:5551', - includeLocalCredentials = true, + includeLocalEnv = true, ): TerminalService { return new TerminalService( - { client, baseUrl: () => baseUrl, includeLocalCredentials }, + { client, baseUrl: () => baseUrl, includeLocalEnv }, provider, + createStubRepoEnvService(), openSocket, ) } @@ -224,6 +226,27 @@ describe('TerminalService', () => { expect(captured).toMatchObject({ command: '/bin/zsh', args: ['-l'] }) }) + + it('merges the repository env between the GitHub env and the input env', async () => { + const create = vi.fn(async () => ({ + location: { directory: '/repo' }, + data: createPty({ id: 'pty-new', title: 'Terminal' }), + })) + const credentialProvider = createCredentialProvider({ GH_TOKEN: 'gh', SHARED: 'gh' }) + const repoEnvService = createStubRepoEnvService({ REPO_VAR: 'repo', SHARED: 'repo' }) + const service = new TerminalService( + { client: createClient({ create }), baseUrl: () => 'http://127.0.0.1:5551', includeLocalEnv: true }, + credentialProvider, + repoEnvService, + ) + + await service.create('/repo', { kind: 'shell', name: 'Terminal', env: { FOO: 'bar', SHARED: 'input' } }) + + expect(repoEnvService.resolveForDirectory).toHaveBeenCalledWith('/repo') + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ env: { GH_TOKEN: 'gh', REPO_VAR: 'repo', FOO: 'bar', SHARED: 'input' } }), + ) + }) }) describe('hub capture', () => { @@ -416,23 +439,23 @@ describe('TerminalService', () => { }) describe('remote target', () => { - it('does not inject local GitHub credentials when creating a terminal', async () => { + it('does not inject local GitHub credentials or repository environment when creating a terminal', async () => { const create = vi.fn(async () => ({ location: { directory: '/repo' }, data: createPty({ id: 'pty-remote', title: 'Terminal' }), })) const credentialProvider = createCredentialProvider({ GH_TOKEN: 'gh' }) - const service = createTerminalService( - createClient({ create }), + const repoEnvService = createStubRepoEnvService({ REPO_VAR: 'repo' }) + const service = new TerminalService( + { client: createClient({ create }), baseUrl: () => 'https://remote.internal', includeLocalEnv: false }, credentialProvider, - undefined, - 'https://remote.internal', - false, + repoEnvService, ) await service.create('/repo', { kind: 'shell', name: 'Terminal', env: { FOO: 'bar' } }) expect(credentialProvider.getGhCliEnv).not.toHaveBeenCalled() + expect(repoEnvService.resolveForDirectory).not.toHaveBeenCalled() expect(create).toHaveBeenCalledWith(expect.objectContaining({ env: { FOO: 'bar' } })) }) @@ -478,7 +501,7 @@ describe('TerminalService', () => { const remote = service.forTarget({ client: remoteClient, baseUrl: () => 'https://remote.internal', - includeLocalCredentials: false, + includeLocalEnv: false, }) await remote.connect('/repo', 'pty-1', undefined, createPeer()) diff --git a/backend/test/utils/env-expressions.test.ts b/backend/test/utils/env-expressions.test.ts new file mode 100644 index 000000000..30e8fcd31 --- /dev/null +++ b/backend/test/utils/env-expressions.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest' +import { interpolateEnvExpressions, parseEnvExpressionReferences } from '@opencode-manager/shared/utils' + +describe('parseEnvExpressionReferences', () => { + it('accepts a single secrets reference', () => { + expect(parseEnvExpressionReferences('${{ secrets.TOKEN }}')).toEqual({ + ok: true, + references: [{ context: 'secrets', name: 'TOKEN' }], + }) + }) + + it('accepts interpolation inside a larger string and multiple references', () => { + expect(parseEnvExpressionReferences('Bearer ${{ secrets.TOKEN }} on ${{ vars.HOST }}')).toEqual({ + ok: true, + references: [ + { context: 'secrets', name: 'TOKEN' }, + { context: 'vars', name: 'HOST' }, + ], + }) + }) + + it('treats the context case-insensitively and upper-cases the name', () => { + expect(parseEnvExpressionReferences('${{ Secrets.my_token }}')).toEqual({ + ok: true, + references: [{ context: 'secrets', name: 'MY_TOKEN' }], + }) + expect(parseEnvExpressionReferences('${{ VARS.Host }}')).toEqual({ + ok: true, + references: [{ context: 'vars', name: 'HOST' }], + }) + }) + + it('returns no references for a literal value', () => { + expect(parseEnvExpressionReferences('plain value')).toEqual({ ok: true, references: [] }) + }) + + it('rejects an unknown context', () => { + const result = parseEnvExpressionReferences('${{ github.sha }}') + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error).toContain('${{ github.sha }}') + expect(result.error).toContain('secrets.') + } + }) + + it('rejects an operator inside the expression', () => { + expect(parseEnvExpressionReferences("${{ secrets.A || 'x' }}").ok).toBe(false) + }) + + it('rejects a function call inside the expression', () => { + expect(parseEnvExpressionReferences("${{ format('{0}', vars.A) }}").ok).toBe(false) + }) + + it('rejects a reference with an invalid name', () => { + expect(parseEnvExpressionReferences('${{ secrets.1BAD }}').ok).toBe(false) + }) +}) + +describe('interpolateEnvExpressions', () => { + const lookup = (reference: { context: string; name: string }): string | undefined => { + const values: Record = { + 'secrets.TOKEN': 'super-secret', + 'vars.HOST': 'example.com', + } + return values[`${reference.context}.${reference.name}`] + } + + it('replaces references and interpolates inside a larger string', () => { + expect(interpolateEnvExpressions('https://${{ vars.HOST }}/${{ secrets.TOKEN }}', lookup)).toEqual({ + value: 'https://example.com/super-secret', + }) + }) + + it('resolves a missing reference to an empty string', () => { + expect(interpolateEnvExpressions('x-${{ secrets.MISSING }}-y', lookup)).toEqual({ + value: 'x--y', + }) + }) + + it('leaves an unsupported expression untouched', () => { + expect(interpolateEnvExpressions('${{ github.sha }}', lookup)).toEqual({ + value: '${{ github.sha }}', + }) + }) + + it('leaves an expression with an invalid name untouched', () => { + expect(interpolateEnvExpressions('${{ secrets.1BAD }}', lookup)).toEqual({ + value: '${{ secrets.1BAD }}', + }) + }) + + it('leaves a literal value untouched', () => { + expect(interpolateEnvExpressions('literal', lookup)).toEqual({ value: 'literal' }) + }) +}) diff --git a/e2e/mockProvider.ts b/e2e/mockProvider.ts index 7ad5bff9c..d36ee7903 100644 --- a/e2e/mockProvider.ts +++ b/e2e/mockProvider.ts @@ -7,8 +7,20 @@ import { BASE_URL, E2E_TMP, STORAGE_STATE } from './env' const MOCK_MODEL_ID = 'mock-model' +export type MockTurn = + | { content: string } + | { toolCall: { name: string; arguments: Record } } + +export interface MockChatRequest { + stream?: boolean + tools?: unknown[] + messages?: Array<{ role?: string }> +} + export interface MockProvider { + readonly port: number setReply(text: string): void + setResponder(respond: (request: MockChatRequest) => MockTurn): void close(): Promise } @@ -22,10 +34,19 @@ export interface CreateMockRepoOptions { beforeCommit?: (directory: string) => void } +function toolCallPayload(toolCall: { name: string; arguments: Record }): object { + return { + index: 0, + id: 'call_mock', + type: 'function', + function: { name: toolCall.name, arguments: JSON.stringify(toolCall.arguments) }, + } +} + function handleMockRequest( request: http.IncomingMessage, response: http.ServerResponse, - getReply: () => string, + respond: (request: MockChatRequest) => MockTurn, ): void { const chunks: Buffer[] = [] request.on('data', (chunk: Buffer) => chunks.push(chunk)) @@ -43,14 +64,14 @@ function handleMockRequest( } if (request.method === 'POST' && url.startsWith('/v1/chat/completions')) { - let body: { stream?: boolean } = {} + let body: MockChatRequest = {} try { - body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}') + body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}') as MockChatRequest } catch { body = {} } - const mockReply = getReply() + const turn = respond(body) const id = 'chatcmpl-mock' const created = Math.floor(Date.now() / 1000) const usage = { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } @@ -65,25 +86,37 @@ function handleMockRequest( response.write( `data: ${JSON.stringify({ ...base, choices: [{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }] })}\n\n`, ) - response.write( - `data: ${JSON.stringify({ ...base, choices: [{ index: 0, delta: { content: mockReply }, finish_reason: null }] })}\n\n`, - ) - response.write( - `data: ${JSON.stringify({ ...base, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage })}\n\n`, - ) + if ('toolCall' in turn) { + response.write( + `data: ${JSON.stringify({ ...base, choices: [{ index: 0, delta: { tool_calls: [toolCallPayload(turn.toolCall)] }, finish_reason: null }] })}\n\n`, + ) + response.write( + `data: ${JSON.stringify({ ...base, choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], usage })}\n\n`, + ) + } else { + response.write( + `data: ${JSON.stringify({ ...base, choices: [{ index: 0, delta: { content: turn.content }, finish_reason: null }] })}\n\n`, + ) + response.write( + `data: ${JSON.stringify({ ...base, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage })}\n\n`, + ) + } response.write('data: [DONE]\n\n') response.end() return } response.writeHead(200, { 'Content-Type': 'application/json' }) + const message = 'toolCall' in turn + ? { role: 'assistant', content: null, tool_calls: [toolCallPayload(turn.toolCall)] } + : { role: 'assistant', content: turn.content } response.end( JSON.stringify({ id, object: 'chat.completion', created, model: MOCK_MODEL_ID, - choices: [{ index: 0, message: { role: 'assistant', content: mockReply }, finish_reason: 'stop' }], + choices: [{ index: 0, message, finish_reason: 'toolCall' in turn ? 'tool_calls' : 'stop' }], usage, }), ) @@ -95,18 +128,26 @@ function handleMockRequest( }) } -export async function startMockProvider(port: number): Promise { - let mockReply = '' +export async function startMockProvider(port = 0): Promise { + let responder: (request: MockChatRequest) => MockTurn = () => ({ content: '' }) const server = http.createServer((request, response) => { - handleMockRequest(request, response, () => mockReply) + handleMockRequest(request, response, (body) => responder(body)) }) await new Promise((resolve, reject) => { server.once('error', reject) server.listen(port, '127.0.0.1', () => resolve()) }) + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('mock provider failed to bind a TCP port') + } return { + port: address.port, setReply: (text) => { - mockReply = text + responder = () => ({ content: text }) + }, + setResponder: (respond) => { + responder = respond }, close: () => new Promise((resolve) => { @@ -147,7 +188,7 @@ export async function createMockRepo( npm: '@ai-sdk/openai-compatible', name: 'Mock', options: { baseURL: `http://127.0.0.1:${port}/v1`, apiKey: 'mock' }, - models: { [MOCK_MODEL_ID]: { name: 'Mock Model' } }, + models: { [MOCK_MODEL_ID]: { name: 'Mock Model', tool_call: true } }, }, }, model: `mock/${MOCK_MODEL_ID}`, diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 7bfe0352e..315d735a2 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,6 +1,8 @@ import { defineConfig, devices } from '@playwright/test' import { BASE_URL, E2E_ROOT, REPORT_DIR, REPO_ROOT, STORAGE_STATE, TEST_RESULTS_DIR, createServerEnv } from './env' +const serverEnv = createServerEnv() + export default defineConfig({ testDir: E2E_ROOT, outputDir: TEST_RESULTS_DIR, @@ -43,6 +45,6 @@ export default defineConfig({ timeout: 180_000, reuseExistingServer: false, gracefulShutdown: { signal: 'SIGTERM', timeout: 15_000 }, - env: createServerEnv(), + env: { ...serverEnv, E2E_SERVER_ENV_KEYS: Object.keys(serverEnv).join(' ') }, }, }) diff --git a/e2e/repo-secrets.spec.ts b/e2e/repo-secrets.spec.ts new file mode 100644 index 000000000..d720b9816 --- /dev/null +++ b/e2e/repo-secrets.spec.ts @@ -0,0 +1,221 @@ +import { randomBytes } from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' +import { request as playwrightRequest, type APIRequestContext, type APIResponse, type Page } from '@playwright/test' +import { test, expect } from './fixtures' +import { createMockRepo, createSession, startMockProvider, type MockProvider } from './mockProvider' +import { BASE_URL, STORAGE_STATE } from './env' + +const SECRET_NAME = 'E2E_SECRET_TOKEN' +const VARIABLE_NAME = 'E2E_BASE_URL' +const SECRET_VALUE = `e2e-secret-${randomBytes(12).toString('hex')}` +const VARIABLE_VALUE = 'https://e2e.example.test' +const PRINT_ENV = `printf 'secret=[%s] var=[%s]\\n' "$${SECRET_NAME}" "$${VARIABLE_NAME}"` +const AGENT_COMMAND = `printf 'token=%s\\n' "$${SECRET_NAME}"` + +interface SecretsRepo { + id: number + fullPath: string +} + +let provider: MockProvider +let secretsRepo: SecretsRepo + +function locationQuery(directory: string): string { + return new URLSearchParams({ 'location[directory]': directory }).toString() +} + +async function requireOk(response: APIResponse, label: string): Promise { + if (!response.ok()) { + throw new Error(`${label} failed: ${response.status()} ${await response.text()}`) + } + return response.status() === 204 ? null : response.json() +} + +async function runShell(request: APIRequestContext, directory: string, command: string): Promise { + const sessionID = await createSession(request, directory) + const started = await requireOk( + await request.post(`/api/opencode/api/shell?${locationQuery(directory)}`, { + data: { command, timeout: 30_000, metadata: { sessionID } }, + }), + 'start shell', + ) + const shellID = (started as { data: { id: string } }).data.id + try { + let output = '' + await expect + .poll(async () => { + const read = await requireOk( + await request.get(`/api/opencode/api/shell/${shellID}/output?${locationQuery(directory)}&cursor=0&limit=65536`), + 'read shell output', + ) + output = JSON.stringify(read) + return output.includes('secret=[') + }) + .toBe(true) + return output + } finally { + await request.delete(`/api/opencode/api/shell/${shellID}?${locationQuery(directory)}`) + } +} + +async function readTerminalOutput(page: Page, repoId: number, ptyID: string, command: string, expected: string): Promise { + return page.evaluate( + async ({ url, input, needle }) => { + const socket = new WebSocket(url) + socket.binaryType = 'arraybuffer' + const decoder = new TextDecoder() + let text = '' + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + socket.close() + reject(new Error(`terminal output did not contain ${needle}: ${text}`)) + }, 20_000) + socket.onopen = () => socket.send(input) + socket.onerror = () => reject(new Error('terminal socket failed')) + socket.onmessage = (event: MessageEvent) => { + text += typeof event.data === 'string' ? event.data : decoder.decode(event.data, { stream: true }) + if (!text.includes(needle)) return + clearTimeout(timer) + socket.close() + resolve(text) + } + }) + }, + { + url: `${BASE_URL.replace(/^http/, 'ws')}/api/repos/${repoId}/terminals/${encodeURIComponent(ptyID)}/connect`, + input: `${command}\r`, + needle: expected, + }, + ) +} + +test.describe.configure({ mode: 'serial' }) + +test.beforeAll(async () => { + provider = await startMockProvider() + provider.setResponder((request) => { + const lastRole = request.messages?.at(-1)?.role + const wantsShell = Array.isArray(request.tools) && request.tools.length > 0 && lastRole !== 'tool' + if (!wantsShell) { + return { content: 'Done.' } + } + return { toolCall: { name: 'shell', arguments: { command: AGENT_COMMAND, description: 'Print the token' } } } + }) + + const repo = await createMockRepo('e2e-secrets-repo', provider.port, { + beforeCommit: (directory) => { + fs.mkdirSync(path.join(directory, '.ocm'), { recursive: true }) + fs.writeFileSync( + path.join(directory, '.ocm', 'project.json'), + JSON.stringify({ + version: 1, + environment: 'e2e', + env: { + [SECRET_NAME]: `\${{ secrets.${SECRET_NAME} }}`, + [VARIABLE_NAME]: `\${{ vars.${VARIABLE_NAME} }}`, + }, + }), + ) + }, + }) + secretsRepo = { id: repo.id, fullPath: repo.fullPath } + + const api = await playwrightRequest.newContext({ baseURL: BASE_URL, storageState: STORAGE_STATE }) + try { + await requireOk( + await api.put(`/api/repos/${repo.id}/secrets/${SECRET_NAME}`, { data: { value: SECRET_VALUE, environment: 'e2e' } }), + 'store secret', + ) + await requireOk( + await api.put(`/api/repos/${repo.id}/variables/${VARIABLE_NAME}`, { data: { value: VARIABLE_VALUE } }), + 'store variable', + ) + } finally { + await api.dispose() + } +}) + +test.afterAll(async () => { + await provider.close() +}) + +test.describe('repository secrets', () => { + test('injects mapped values into agent shells only once the project file is trusted', async ({ request }) => { + const config = (await requireOk( + await request.get(`/api/repos/${secretsRepo.id}/project-config`), + 'read project config', + )) as { repoFile: { trusted: boolean; hash: string | null; executable: { env?: Record } | null } } + expect(config.repoFile.trusted).toBe(false) + expect(config.repoFile.executable?.env).toEqual({ + [SECRET_NAME]: `\${{ secrets.${SECRET_NAME} }}`, + [VARIABLE_NAME]: `\${{ vars.${VARIABLE_NAME} }}`, + }) + + const untrusted = await runShell(request, secretsRepo.fullPath, PRINT_ENV) + expect(untrusted).toContain('secret=[] var=[]') + + expect(config.repoFile.hash).not.toBeNull() + await requireOk( + await request.post(`/api/repos/${secretsRepo.id}/project-config/trust`, { data: { hash: config.repoFile.hash } }), + 'trust project file', + ) + + await expect + .poll(async () => runShell(request, secretsRepo.fullPath, PRINT_ENV), { timeout: 30_000, intervals: [1_000, 2_000] }) + .toContain(`secret=[${SECRET_VALUE}] var=[${VARIABLE_VALUE}]`) + }) + + test('injects mapped values into repo terminals', async ({ page, request }) => { + await page.goto(`/repos/${secretsRepo.id}`) + const terminal = (await requireOk( + await request.post(`/api/repos/${secretsRepo.id}/terminals`, { data: { title: 'secrets' } }), + 'create terminal', + )) as { id: string } + try { + const output = await readTerminalOutput( + page, + secretsRepo.id, + terminal.id, + PRINT_ENV, + `secret=[${SECRET_VALUE}] var=[${VARIABLE_VALUE}]`, + ) + expect(output).toContain(`secret=[${SECRET_VALUE}] var=[${VARIABLE_VALUE}]`) + } finally { + await request.delete(`/api/repos/${secretsRepo.id}/terminals/${encodeURIComponent(terminal.id)}`) + } + }) + + test('masks secret values in recorded agent tool output', async ({ request }) => { + const sessionId = await createSession(request, secretsRepo.fullPath) + await requireOk( + await request.post(`/api/opencode/api/session/${sessionId}/prompt`, { data: { text: 'print the token' } }), + 'prompt session', + ) + + let transcript = '' + await expect + .poll( + async () => { + const messages = await requireOk( + await request.get(`/api/opencode/api/session/${sessionId}/message`), + 'list messages', + ) + transcript = JSON.stringify(messages) + return transcript.includes('token=***') + }, + { timeout: 60_000 }, + ) + .toBe(true) + expect(transcript).not.toContain(SECRET_VALUE) + }) + + test('lists secrets by name without revealing their values', async ({ page }) => { + await page.goto(`/repos/${secretsRepo.id}`) + await page.getByRole('navigation', { name: 'Tools' }).getByRole('button', { name: 'Secrets' }).click() + const panel = page.getByRole('complementary', { name: 'Secrets' }) + await expect(panel.getByText(SECRET_NAME, { exact: true })).toBeVisible() + await expect(panel.getByRole('button', { name: `Update ${SECRET_NAME}` })).toBeVisible() + await expect(page.locator('body')).not.toContainText(SECRET_VALUE) + }) +}) diff --git a/e2e/server.sh b/e2e/server.sh index cfb18f113..44433c59e 100644 --- a/e2e/server.sh +++ b/e2e/server.sh @@ -5,11 +5,22 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TMP="$ROOT/e2e/.tmp" rm -rf "$TMP" -mkdir -p "$TMP/home" "$TMP/data" "$TMP/workspace" "$TMP/tmp" "$TMP/run" +mkdir -p "$TMP/home" "$TMP/data" "$TMP/workspace" "$TMP/tmp" "$TMP/run" "$TMP/app" +ln -s "$ROOT/frontend" "$TMP/app/frontend" trap 'rm -rf "$TMP"' EXIT -cd "$ROOT" -bun run backend/dist/index.js & +if [[ -z "${E2E_SERVER_ENV_KEYS:-}" ]]; then + echo "E2E_SERVER_ENV_KEYS is empty; refusing to start the E2E backend" >&2 + exit 1 +fi + +args=() +for key in $E2E_SERVER_ENV_KEYS; do + args+=("$key=${!key}") +done + +cd "$TMP/app" +env -i PATH="$PATH" "${args[@]}" bun --no-env-file run "$ROOT/backend/dist/index.js" & BACKEND_PID=$! trap 'kill -TERM "$BACKEND_PID" 2>/dev/null || true' TERM INT wait "$BACKEND_PID" || wait "$BACKEND_PID" diff --git a/frontend/src/api/secrets.ts b/frontend/src/api/secrets.ts new file mode 100644 index 000000000..241f6c529 --- /dev/null +++ b/frontend/src/api/secrets.ts @@ -0,0 +1,46 @@ +import { API_BASE_URL } from '@/config' +import type { SecretKind, SecretListResponse, SetSecretRequest } from '@opencode-manager/shared/schemas' +import { fetchWrapper, fetchWrapperVoid } from './fetchWrapper' + +export type SecretScope = { scope: 'global' } | { scope: 'repo'; repoId: number } + +const KIND_SEGMENT: Record = { + secret: 'secrets', + variable: 'variables', +} + +function secretsPath(scope: SecretScope, kind: SecretKind): string { + const segment = KIND_SEGMENT[kind] + return scope.scope === 'global' + ? `${API_BASE_URL}/api/${segment}` + : `${API_BASE_URL}/api/repos/${scope.repoId}/${segment}` +} + +export async function listSecrets(scope: SecretScope, kind: SecretKind): Promise { + return fetchWrapper(secretsPath(scope, kind)) +} + +export async function setSecret( + scope: SecretScope, + kind: SecretKind, + name: string, + request: SetSecretRequest, +): Promise { + return fetchWrapperVoid(`${secretsPath(scope, kind)}/${encodeURIComponent(name)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }) +} + +export async function deleteSecret( + scope: SecretScope, + kind: SecretKind, + name: string, + environment?: string, +): Promise { + return fetchWrapperVoid(`${secretsPath(scope, kind)}/${encodeURIComponent(name)}`, { + method: 'DELETE', + ...(environment ? { params: { environment } } : {}), + }) +} diff --git a/frontend/src/components/navigation/ToolSidePanel.test.tsx b/frontend/src/components/navigation/ToolSidePanel.test.tsx index 60006f21d..d7df4d985 100644 --- a/frontend/src/components/navigation/ToolSidePanel.test.tsx +++ b/frontend/src/components/navigation/ToolSidePanel.test.tsx @@ -50,6 +50,9 @@ vi.mock('@/components/schedules/RepoSchedulesContent', () => ({ vi.mock('@/components/repo/RepoMcpDialog', () => ({ RepoMcpContent: () =>
mcp-tool
})) vi.mock('@/components/repo/RepoActionsDialog', () => ({ RepoActionsContent: () =>
actions-tool
})) vi.mock('@/components/repo/RepoSkillsDialog', () => ({ RepoSkillsContent: () =>
skills-tool
})) +vi.mock('@/components/secrets/RepoSecretsDialog', () => ({ + RepoSecretsContent: () =>
secrets-tool
, +})) vi.mock('@/components/session/ChangesWalkthroughSheet', () => ({ ChangesWalkthroughProvider: ({ children }: { children: ReactNode }) => <>{children}, ChangesWalkthroughRegenerate: () => null, @@ -187,6 +190,23 @@ function RemoteHarness() { ) } +function RepoHarness() { + const panel = useToolPanel(true) + return ( + <> + + + + ) +} + beforeEach(() => { vi.clearAllMocks() activityMocks.useActiveWorkCounts.mockReturnValue({ needsInput: 0, running: 0 }) @@ -418,6 +438,21 @@ describe('ToolSidePanel', () => { expect(panel).toHaveStyle({ width: '320px' }) }) + it('opens Secrets inside the docked repo panel', async () => { + const user = userEvent.setup() + render( + + + , + ) + + await user.click(screen.getByRole('button', { name: 'Secrets' })) + + expect(await screen.findByText('secrets-tool')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Secrets' })).toBeInTheDocument() + expect(search().has('dialog')).toBe(false) + }) + it('opens non-panel session items from the rail as dialogs', async () => { const user = userEvent.setup() renderAt('?panel=files') diff --git a/frontend/src/components/navigation/ToolSidePanel.tsx b/frontend/src/components/navigation/ToolSidePanel.tsx index f273c53eb..7e0c0aebb 100644 --- a/frontend/src/components/navigation/ToolSidePanel.tsx +++ b/frontend/src/components/navigation/ToolSidePanel.tsx @@ -19,6 +19,7 @@ import { PreviewWorkspace } from '@/components/preview/PreviewPanel' import { RepoMcpContent } from '@/components/repo/RepoMcpDialog' import { RepoActionsContent } from '@/components/repo/RepoActionsDialog' import { RepoSkillsContent } from '@/components/repo/RepoSkillsDialog' +import { RepoSecretsContent } from '@/components/secrets/RepoSecretsDialog' import { RepoSchedulesContent } from '@/components/schedules/RepoSchedulesContent' import type { SkillFileInfo } from '@opencode-manager/shared' import type { ModelRef } from '@opencode-manager/shared/opencode' @@ -384,6 +385,8 @@ export function ToolSidePanel({ return case 'schedules': return + case 'secrets': + return } } diff --git a/frontend/src/components/navigation/moreDrawerItems.test.ts b/frontend/src/components/navigation/moreDrawerItems.test.ts index 75a33657f..bb31199fc 100644 --- a/frontend/src/components/navigation/moreDrawerItems.test.ts +++ b/frontend/src/components/navigation/moreDrawerItems.test.ts @@ -14,7 +14,7 @@ describe('buildMoreItems', () => { it('returns repo-specific items for /repos/:id', () => { const items = buildMoreItems('/repos/42') - expect(items).toHaveLength(12) + expect(items).toHaveLength(13) expect(items[0].key).toBe('home') expect(items[1].key).toBe('files') expect(items[1].dialog).toBe('files') @@ -22,21 +22,23 @@ describe('buildMoreItems', () => { expect(items[2].dialog).toBe('mcp') expect(items[3].key).toBe('skills') expect(items[3].dialog).toBe('skills') - expect(items[4].key).toBe('reset-permissions') - expect(items[4].dialog).toBe('resetPermissions') - expect(items[4].danger).toBe(true) - expect(items[5].key).toBe('schedules') - expect(items[5].to).toBe('/repos/42/schedules') - expect(items[6].key).toBe('source-control') - expect(items[6].dialog).toBe('sourceControl') - expect(items[7].key).toBe('terminal') - expect(items[7].dialog).toBe('terminal') - expect(items[8].key).toBe('actions') - expect(items[8].dialog).toBe('actions') - expect(items[9].key).toBe('preview') - expect(items[9].dialog).toBe('preview') - expect(items[10].key).toBe('settings') - expect(items[11].key).toBe('logout') + expect(items[4].key).toBe('secrets') + expect(items[4].dialog).toBe('secrets') + expect(items[5].key).toBe('reset-permissions') + expect(items[5].dialog).toBe('resetPermissions') + expect(items[5].danger).toBe(true) + expect(items[6].key).toBe('schedules') + expect(items[6].to).toBe('/repos/42/schedules') + expect(items[7].key).toBe('source-control') + expect(items[7].dialog).toBe('sourceControl') + expect(items[8].key).toBe('terminal') + expect(items[8].dialog).toBe('terminal') + expect(items[9].key).toBe('actions') + expect(items[9].dialog).toBe('actions') + expect(items[10].key).toBe('preview') + expect(items[10].dialog).toBe('preview') + expect(items[11].key).toBe('settings') + expect(items[12].key).toBe('logout') }) it('returns session-specific items for /repos/:id/sessions/:sid', () => { @@ -219,6 +221,12 @@ describe('buildToolItems', () => { expect(buildToolItems(path).find((item) => item.key === 'schedules')?.panelTool).toBe('schedules') }) + it('exposes the repo Secrets tool through the shared dialog param', () => { + const secrets = buildToolItems('/repos/42').find((item) => item.key === 'secrets') + expect(secrets?.dialog).toBe('secrets') + expect(secrets?.panelTool).toBeUndefined() + }) + it.each(['/schedules', '/repos/42/schedules'])('returns only the Activity tool on %s', (path) => { expect(buildToolItems(path).map((item) => item.key)).toEqual(['activity']) }) diff --git a/frontend/src/components/navigation/moreDrawerItems.ts b/frontend/src/components/navigation/moreDrawerItems.ts index 270df0fe1..caff8e55c 100644 --- a/frontend/src/components/navigation/moreDrawerItems.ts +++ b/frontend/src/components/navigation/moreDrawerItems.ts @@ -1,5 +1,5 @@ import type { LucideIcon } from 'lucide-react' -import { Activity, BookOpen, Plug, Sparkles, ShieldOff, CalendarClock, GitCommitHorizontal, SquareTerminal, Settings, LogOut, Bot, Folder, Home, Play, Globe } from 'lucide-react' +import { Activity, BookOpen, Plug, Sparkles, ShieldOff, CalendarClock, GitCommitHorizontal, SquareTerminal, Settings, LogOut, Bot, Folder, Home, Play, Globe, KeyRound } from 'lucide-react' import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' import { getAssistantPath, isAssistantPath } from '@/lib/navigation' @@ -106,6 +106,7 @@ function buildRouteNavModel(pathname: string): NavModel { { key: 'files', label: 'Files', icon: Folder, dialog: 'files' }, { key: 'mcp', label: 'MCP', icon: Plug, dialog: 'mcp' }, { key: 'skills', label: 'Skills', icon: Sparkles, dialog: 'skills' }, + { key: 'secrets', label: 'Secrets', icon: KeyRound, dialog: 'secrets' }, { key: 'reset-permissions', label: 'Reset Permissions', icon: ShieldOff, dialog: 'resetPermissions', danger: true }, { key: 'schedules', label: 'Schedules', icon: CalendarClock, to: `/repos/${id}/schedules`, panelTool: 'schedules' }, { key: 'source-control', label: 'Source Control', icon: GitCommitHorizontal, dialog: 'sourceControl' }, diff --git a/frontend/src/components/repo/ProjectActionsMenu.test.tsx b/frontend/src/components/repo/ProjectActionsMenu.test.tsx index c0acf0d62..d0cc51859 100644 --- a/frontend/src/components/repo/ProjectActionsMenu.test.tsx +++ b/frontend/src/components/repo/ProjectActionsMenu.test.tsx @@ -191,6 +191,8 @@ describe('ProjectActionsMenu', () => { { id: 'shadowed', name: 'Shadowed action', command: 'pnpm shadowed', url: null, autoOpenUrl: false }, ], setup: ['pnpm install'], + environment: 'e2e', + env: { OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}' }, } mocks.useProjectConfig.mockReturnValue({ data: makeConfig([repoAction], HASH, executable), isLoading: false, error: null }) mocks.getProjectConfig.mockResolvedValue(makeConfig([repoAction], HASH, executable)) @@ -207,6 +209,9 @@ describe('ProjectActionsMenu', () => { expect(screen.getByText('pnpm shadowed')).toBeInTheDocument() expect(screen.getByText('http://localhost:3000')).toBeInTheDocument() expect(screen.getByText('Opens URL automatically')).toBeInTheDocument() + expect(screen.getByText('Environment variables')).toBeInTheDocument() + expect(screen.getByText('e2e')).toBeInTheDocument() + expect(screen.getByText('OPENAI_API_KEY = ${{ secrets.OPENAI_API_KEY }}')).toBeInTheDocument() expect(screen.getByText('Worktree setup commands')).toBeInTheDocument() expect(screen.getByText('pnpm install')).toBeInTheDocument() }) diff --git a/frontend/src/components/repo/RepoActionsDialog.test.tsx b/frontend/src/components/repo/RepoActionsDialog.test.tsx index 4cffc6f6e..d054562fd 100644 --- a/frontend/src/components/repo/RepoActionsDialog.test.tsx +++ b/frontend/src/components/repo/RepoActionsDialog.test.tsx @@ -235,6 +235,8 @@ describe('RepoActionsDialog', () => { { id: 'shared', name: 'Repo action', command: 'echo repo', url: 'https://example.com', autoOpenUrl: true }, ], setup: ['pnpm install'], + environment: 'e2e', + env: { OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}' }, }, }, }), @@ -248,10 +250,76 @@ describe('RepoActionsDialog', () => { expect(within(review).getByText('echo repo')).toBeInTheDocument() expect(within(review).getByText('https://example.com')).toBeInTheDocument() expect(within(review).getByText('Opens URL automatically')).toBeInTheDocument() + expect(within(review).getByText('Environment variables')).toBeInTheDocument() + expect(within(review).getByText('e2e')).toBeInTheDocument() + expect(within(review).getByText('OPENAI_API_KEY = ${{ secrets.OPENAI_API_KEY }}')).toBeInTheDocument() expect(within(review).getByText('Worktree setup commands')).toBeInTheDocument() expect(within(review).getByText('pnpm install')).toBeInTheDocument() }) + it('offers trust when the only executable content is an environment mapping', async () => { + mockConfig( + baseConfig({ + repoFile: { + path: '.ocm/project.json', + exists: true, + trusted: false, + hash: HASH, + warnings: [], + executable: { actions: [], setup: [], env: { OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}' } }, + }, + }), + ) + const user = userEvent.setup() + renderDialog() + + expect(screen.getByText(/not trusted/i)).toBeInTheDocument() + const review = await openTrustReview(user) + expect(within(review).getByText('Environment variables')).toBeInTheDocument() + expect(within(review).getByText('OPENAI_API_KEY = ${{ secrets.OPENAI_API_KEY }}')).toBeInTheDocument() + }) + + it('offers trust when the only executable content is an environment name', async () => { + mockConfig( + baseConfig({ + repoFile: { + path: '.ocm/project.json', + exists: true, + trusted: false, + hash: HASH, + warnings: [], + executable: { actions: [], setup: [], environment: 'e2e' }, + }, + }), + ) + const user = userEvent.setup() + renderDialog() + + expect(screen.getByText(/not trusted/i)).toBeInTheDocument() + const review = await openTrustReview(user) + expect(within(review).getByText('Environment variables')).toBeInTheDocument() + expect(within(review).getByText('e2e')).toBeInTheDocument() + }) + + it('does not offer trust when the executable snapshot has no content', () => { + mockConfig( + baseConfig({ + repoFile: { + path: '.ocm/project.json', + exists: true, + trusted: false, + hash: HASH, + warnings: [], + executable: { actions: [], setup: [] }, + }, + }), + ) + renderDialog() + + expect(screen.queryByText(/not trusted/i)).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /^review$/i })).not.toBeInTheDocument() + }) + it('does not offer trust when the repo file has no executable commands', () => { mockConfig( baseConfig({ diff --git a/frontend/src/components/repo/RepoActionsDialog.tsx b/frontend/src/components/repo/RepoActionsDialog.tsx index 18000fa57..1fd4eab38 100644 --- a/frontend/src/components/repo/RepoActionsDialog.tsx +++ b/frontend/src/components/repo/RepoActionsDialog.tsx @@ -24,11 +24,11 @@ import { randomId } from '@/lib/utils' import type { ProjectAction, ProjectActionIcon, - ProjectConfigResponse, ProjectItemSource, } from '@opencode-manager/shared/types' import { ACTION_ICON_OPTIONS, actionIcon } from './projectActionIcons' import { TrustExecutableList } from './TrustExecutableList' +import { hasExecutableContent, type RepoFileExecutable } from './trustExecutable' interface ActionDraft { id: string @@ -162,8 +162,6 @@ function ActionForm({ draft, isSaving, onChange, onSave, onCancel }: ActionFormP ) } -type RepoFileExecutable = NonNullable - interface TrustReview { hash: string directory: string | undefined @@ -281,7 +279,9 @@ export function RepoActionsContent({ repoId, directory, open }: RepoActionsConte setSetupCommands((current) => [...current, '']) } - const needsTrust = Boolean(repoFile?.exists && !repoFile.trusted && repoFile.hash && repoFile.executable) + const needsTrust = Boolean( + repoFile?.exists && !repoFile.trusted && repoFile.hash && hasExecutableContent(repoFile.executable), + ) const openTrustReview = () => { if (!repoFile?.hash || !repoFile.executable) return diff --git a/frontend/src/components/repo/TrustExecutableList.tsx b/frontend/src/components/repo/TrustExecutableList.tsx index fb7163800..19f0128ae 100644 --- a/frontend/src/components/repo/TrustExecutableList.tsx +++ b/frontend/src/components/repo/TrustExecutableList.tsx @@ -1,8 +1,7 @@ -import type { ProjectConfigResponse } from '@opencode-manager/shared/types' - -type RepoFileExecutable = NonNullable +import type { RepoFileExecutable } from './trustExecutable' export function TrustExecutableList({ executable }: { executable: RepoFileExecutable }) { + const envEntries = Object.entries(executable.env ?? {}) return (
{executable.actions.length > 0 && ( @@ -20,6 +19,25 @@ export function TrustExecutableList({ executable }: { executable: RepoFileExecut
)} + {(executable.environment !== undefined || envEntries.length > 0) && ( +
+

Environment variables

+ {executable.environment !== undefined && ( +

+ Environment: {executable.environment} +

+ )} + {envEntries.length > 0 && ( +
    + {envEntries.map(([key, value]) => ( +
  • + {key} = {value} +
  • + ))} +
+ )} +
+ )} {executable.setup.length > 0 && (

Worktree setup commands

diff --git a/frontend/src/components/repo/trustExecutable.ts b/frontend/src/components/repo/trustExecutable.ts new file mode 100644 index 000000000..76a4e03f5 --- /dev/null +++ b/frontend/src/components/repo/trustExecutable.ts @@ -0,0 +1,13 @@ +import type { ProjectConfigResponse } from '@opencode-manager/shared/types' + +export type RepoFileExecutable = NonNullable + +export function hasExecutableContent(executable: RepoFileExecutable | null | undefined): boolean { + if (!executable) return false + return ( + executable.actions.length > 0 || + executable.setup.length > 0 || + Object.keys(executable.env ?? {}).length > 0 || + executable.environment !== undefined + ) +} diff --git a/frontend/src/components/secrets/GlobalSecretsSettings.tsx b/frontend/src/components/secrets/GlobalSecretsSettings.tsx new file mode 100644 index 000000000..c1789462c --- /dev/null +++ b/frontend/src/components/secrets/GlobalSecretsSettings.tsx @@ -0,0 +1,18 @@ +import { useState } from 'react' +import { SettingsDisclosure } from '@/components/settings/SettingsDisclosure' +import { SecretsManager } from './SecretsManager' + +export function GlobalSecretsSettings() { + const [isOpen, setIsOpen] = useState(false) + + return ( + setIsOpen((value) => !value)} + contentClassName="space-y-3" + > + + + ) +} diff --git a/frontend/src/components/secrets/RepoSecretsDialog.tsx b/frontend/src/components/secrets/RepoSecretsDialog.tsx new file mode 100644 index 000000000..39cb57541 --- /dev/null +++ b/frontend/src/components/secrets/RepoSecretsDialog.tsx @@ -0,0 +1,35 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { SecretsManager } from './SecretsManager' + +interface RepoSecretsContentProps { + repoId: number +} + +export function RepoSecretsContent({ repoId }: RepoSecretsContentProps) { + return ( +
+ +
+ ) +} + +interface RepoSecretsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + repoId: number +} + +export function RepoSecretsDialog({ open, onOpenChange, repoId }: RepoSecretsDialogProps) { + if (!repoId) return null + + return ( + + + + Secrets + + + + + ) +} diff --git a/frontend/src/components/secrets/SecretsManager.test.tsx b/frontend/src/components/secrets/SecretsManager.test.tsx new file mode 100644 index 000000000..f52f39d2e --- /dev/null +++ b/frontend/src/components/secrets/SecretsManager.test.tsx @@ -0,0 +1,245 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactNode } from 'react' +import { SecretsManager } from './SecretsManager' +import type { SecretScope } from '@/api/secrets' + +const mocks = vi.hoisted(() => ({ + listSecrets: vi.fn(), + setSecret: vi.fn(), + deleteSecret: vi.fn(), + copyTextToClipboard: vi.fn(), + showToast: { success: vi.fn(), error: vi.fn() }, +})) + +vi.mock('@/api/secrets', () => ({ + listSecrets: mocks.listSecrets, + setSecret: mocks.setSecret, + deleteSecret: mocks.deleteSecret, +})) + +vi.mock('@/lib/clipboard', () => ({ copyTextToClipboard: mocks.copyTextToClipboard })) + +vi.mock('@/lib/toast', () => ({ showToast: mocks.showToast })) + +const repoScope: SecretScope = { scope: 'repo', repoId: 7 } + +function renderManager(scope: SecretScope = repoScope) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + return render(, { wrapper }) +} + +describe('SecretsManager', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.setSecret.mockResolvedValue(undefined) + mocks.deleteSecret.mockResolvedValue(undefined) + mocks.copyTextToClipboard.mockResolvedValue(true) + }) + + it('renders secret names without ever rendering secret values', async () => { + mocks.listSecrets.mockResolvedValue({ + items: [{ name: 'OPENAI_API_KEY', environment: null, updatedAt: Date.now(), value: 'sk-super-secret' }], + }) + + renderManager() + + expect(await screen.findByText('OPENAI_API_KEY')).toBeInTheDocument() + expect(screen.queryByText(/sk-super-secret/)).not.toBeInTheDocument() + }) + + it('renders variable values', async () => { + const user = userEvent.setup() + mocks.listSecrets.mockImplementation((_scope: SecretScope, kind: string) => + Promise.resolve( + kind === 'variable' + ? { items: [{ name: 'BASE_URL', environment: null, updatedAt: Date.now(), value: 'https://example.com' }] } + : { items: [] }, + ), + ) + + renderManager() + await user.click(screen.getByRole('tab', { name: 'Variables' })) + + expect(await screen.findByText('BASE_URL')).toBeInTheDocument() + expect(screen.getByText(/https:\/\/example\.com/)).toBeInTheDocument() + }) + + it('upper-cases the name and saves a secret', async () => { + const user = userEvent.setup() + mocks.listSecrets.mockResolvedValue({ items: [] }) + + renderManager() + await user.click(await screen.findByRole('button', { name: /Add secret/i })) + + const nameInput = screen.getByLabelText('Secret name') + await user.type(nameInput, 'my_key') + expect(nameInput).toHaveValue('MY_KEY') + + await user.type(screen.getByLabelText('Secret value'), 's3cret') + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => + expect(mocks.setSecret).toHaveBeenCalledWith(repoScope, 'secret', 'MY_KEY', { value: 's3cret' }), + ) + }) + + it('saves a multi-line secret value through the textarea', async () => { + const user = userEvent.setup() + mocks.listSecrets.mockResolvedValue({ items: [] }) + + renderManager() + await user.click(await screen.findByRole('button', { name: /Add secret/i })) + await user.type(screen.getByLabelText('Secret name'), 'PRIVATE_KEY') + + const valueField = screen.getByLabelText('Secret value') + expect(valueField.tagName).toBe('TEXTAREA') + expect(valueField).toHaveAttribute('autocomplete', 'off') + expect(valueField).toHaveAttribute('spellcheck', 'false') + + const pem = '-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBg\n-----END PRIVATE KEY-----' + await user.click(valueField) + await user.paste(pem) + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => + expect(mocks.setSecret).toHaveBeenCalledWith(repoScope, 'secret', 'PRIVATE_KEY', { value: pem }), + ) + }) + + it('sends the lower-cased environment for repo scope', async () => { + const user = userEvent.setup() + mocks.listSecrets.mockResolvedValue({ items: [] }) + + renderManager() + await user.click(await screen.findByRole('button', { name: /Add secret/i })) + await user.type(screen.getByLabelText('Secret name'), 'API_URL') + await user.type(screen.getByLabelText('Environment (optional)'), 'E2E') + await user.type(screen.getByLabelText('Secret value'), 'value') + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => + expect(mocks.setSecret).toHaveBeenCalledWith(repoScope, 'secret', 'API_URL', { + value: 'value', + environment: 'e2e', + }), + ) + }) + + it('omits the environment field for global scope', async () => { + const user = userEvent.setup() + mocks.listSecrets.mockResolvedValue({ items: [] }) + + renderManager({ scope: 'global' }) + await user.click(await screen.findByRole('button', { name: /Add secret/i })) + + expect(screen.queryByLabelText('Environment (optional)')).not.toBeInTheDocument() + + await user.type(screen.getByLabelText('Secret name'), 'TOKEN') + await user.type(screen.getByLabelText('Secret value'), 'value') + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => + expect(mocks.setSecret).toHaveBeenCalledWith({ scope: 'global' }, 'secret', 'TOKEN', { value: 'value' }), + ) + }) + + it('updates an existing secret without letting the name change', async () => { + const user = userEvent.setup() + mocks.listSecrets.mockResolvedValue({ + items: [{ name: 'FOO', environment: 'e2e', updatedAt: Date.now() }], + }) + + renderManager() + await user.click(await screen.findByRole('button', { name: 'Update FOO' })) + + expect(screen.getByLabelText('Secret name')).toBeDisabled() + + await user.type(screen.getByLabelText('Secret value'), 'new-value') + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => + expect(mocks.setSecret).toHaveBeenCalledWith(repoScope, 'secret', 'FOO', { + value: 'new-value', + environment: 'e2e', + }), + ) + }) + + it('removes a secret after confirmation', async () => { + const user = userEvent.setup() + mocks.listSecrets.mockResolvedValue({ + items: [{ name: 'FOO', environment: null, updatedAt: Date.now() }], + }) + + renderManager() + await user.click(await screen.findByRole('button', { name: 'Remove FOO' })) + await user.click(await screen.findByRole('button', { name: 'Delete' })) + + await waitFor(() => expect(mocks.deleteSecret).toHaveBeenCalledWith(repoScope, 'secret', 'FOO', undefined)) + }) + + it('copies the secret reference text', async () => { + const user = userEvent.setup() + mocks.listSecrets.mockResolvedValue({ + items: [{ name: 'FOO', environment: null, updatedAt: Date.now() }], + }) + + renderManager() + await user.click(await screen.findByRole('button', { name: 'Copy reference for FOO' })) + + expect(mocks.copyTextToClipboard).toHaveBeenCalledWith('${{ secrets.FOO }}') + }) + + it('copies the variable reference text', async () => { + const user = userEvent.setup() + mocks.listSecrets.mockImplementation((_scope: SecretScope, kind: string) => + Promise.resolve( + kind === 'variable' + ? { items: [{ name: 'BASE_URL', environment: null, updatedAt: Date.now(), value: 'x' }] } + : { items: [] }, + ), + ) + + renderManager() + await user.click(screen.getByRole('tab', { name: 'Variables' })) + await user.click(await screen.findByRole('button', { name: 'Copy reference for BASE_URL' })) + + expect(mocks.copyTextToClipboard).toHaveBeenCalledWith('${{ vars.BASE_URL }}') + }) + + it('shows a validation error and does not call the API for an invalid name', async () => { + const user = userEvent.setup() + mocks.listSecrets.mockResolvedValue({ items: [] }) + + renderManager() + await user.click(await screen.findByRole('button', { name: /Add secret/i })) + await user.type(screen.getByLabelText('Secret name'), 'BAD-NAME') + await user.type(screen.getByLabelText('Secret value'), 'x') + await user.click(screen.getByRole('button', { name: 'Save' })) + + expect(await screen.findByRole('alert')).toHaveTextContent(/letters, numbers, and underscores/i) + expect(mocks.setSecret).not.toHaveBeenCalled() + }) + + it('shows the server error from a failed save', async () => { + const user = userEvent.setup() + mocks.listSecrets.mockResolvedValue({ items: [] }) + mocks.setSecret.mockRejectedValue(new Error('Name is reserved for Manager or the runtime')) + + renderManager() + await user.click(await screen.findByRole('button', { name: /Add secret/i })) + await user.type(screen.getByLabelText('Secret name'), 'PATH') + await user.type(screen.getByLabelText('Secret value'), 'x') + await user.click(screen.getByRole('button', { name: 'Save' })) + + expect(await screen.findByRole('alert')).toHaveTextContent('Name is reserved for Manager or the runtime') + }) +}) diff --git a/frontend/src/components/secrets/SecretsManager.tsx b/frontend/src/components/secrets/SecretsManager.tsx new file mode 100644 index 000000000..f3e17ec3f --- /dev/null +++ b/frontend/src/components/secrets/SecretsManager.tsx @@ -0,0 +1,326 @@ +import { useId, useState, type FormEvent } from 'react' +import { Copy, Pencil, Plus, Trash2 } from 'lucide-react' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Badge } from '@/components/ui/badge' +import { SettingsList, SettingsListRow } from '@/components/ui/settings-list' +import { DeleteDialog } from '@/components/ui/delete-dialog' +import { useDeleteSecret, useSecretEntries, useSetSecret } from '@/hooks/useSecrets' +import { copyTextToClipboard } from '@/lib/clipboard' +import { showToast } from '@/lib/toast' +import { formatShortRelativeTime } from '@/lib/utils' +import { + EnvironmentNameSchema, + SecretNameSchema, + SecretValueSchema, + type SecretEntry, + type SecretKind, +} from '@opencode-manager/shared/schemas' +import type { SecretScope } from '@/api/secrets' + +const KIND_META: Record = { + secret: { + label: 'Secret', + namespace: 'secrets', + emptyHint: 'Secret values are encrypted and never shown again once saved.', + }, + variable: { + label: 'Variable', + namespace: 'vars', + emptyHint: 'Variable values are stored in plain text and shown here.', + }, +} + +function secretReference(kind: SecretKind, name: string): string { + return `\${{ ${KIND_META[kind].namespace}.${name} }}` +} + +const PROJECT_CONFIG_EXAMPLE = `{ + "version": 1, + "environment": "e2e", + "env": { + "OPENAI_API_KEY": "\${{ secrets.OPENAI_API_KEY }}", + "BASE_URL": "\${{ vars.BASE_URL }}" + } +}` + +interface SecretsManagerProps { + scope: SecretScope +} + +export function SecretsManager({ scope }: SecretsManagerProps) { + return ( +
+

+ Values are injected into sessions only when the repository maps them in{' '} + .ocm/project.json, for example: +

+
+        {PROJECT_CONFIG_EXAMPLE}
+      
+

+ Precedence: environment > repository > global. The same reference text works in a GitHub workflow. +

+ + + + Secrets + Variables + + + + + + + + +
+ ) +} + +interface SecretFormState { + name: string + value: string + environment: string +} + +interface SecretKindPanelProps { + scope: SecretScope + kind: SecretKind +} + +function SecretKindPanel({ scope, kind }: SecretKindPanelProps) { + const meta = KIND_META[kind] + const isRepoScope = scope.scope === 'repo' + const nameId = useId() + const environmentId = useId() + const valueId = useId() + + const entriesQuery = useSecretEntries(scope, kind) + const setSecret = useSetSecret(scope) + const deleteSecret = useDeleteSecret(scope) + + const [form, setForm] = useState(null) + const [isEditing, setIsEditing] = useState(false) + const [formError, setFormError] = useState(null) + const [removeTarget, setRemoveTarget] = useState(null) + + const entries = entriesQuery.data ?? [] + + const startAdd = () => { + setIsEditing(false) + setFormError(null) + setForm({ name: '', value: '', environment: '' }) + } + + const startEdit = (entry: SecretEntry) => { + setIsEditing(true) + setFormError(null) + setForm({ name: entry.name, value: '', environment: entry.environment ?? '' }) + } + + const cancelForm = () => { + setForm(null) + setIsEditing(false) + setFormError(null) + } + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault() + if (!form) return + + const nameResult = SecretNameSchema.safeParse(form.name) + if (!nameResult.success) { + setFormError(nameResult.error.issues[0]?.message ?? 'Invalid name') + return + } + + const valueResult = SecretValueSchema.safeParse(form.value) + if (!valueResult.success) { + setFormError(valueResult.error.issues[0]?.message ?? 'Invalid value') + return + } + + let environment: string | undefined + const trimmedEnvironment = form.environment.trim() + if (isRepoScope && trimmedEnvironment) { + const environmentResult = EnvironmentNameSchema.safeParse(trimmedEnvironment) + if (!environmentResult.success) { + setFormError(environmentResult.error.issues[0]?.message ?? 'Invalid environment') + return + } + environment = environmentResult.data + } + + setFormError(null) + try { + await setSecret.mutateAsync({ kind, name: nameResult.data, value: valueResult.data, environment }) + showToast.success(`${meta.label} ${isEditing ? 'updated' : 'saved'}`) + cancelForm() + } catch (error) { + setFormError(error instanceof Error ? error.message : `Failed to save ${meta.label.toLowerCase()}`) + } + } + + const handleRemove = async () => { + if (!removeTarget) return + try { + await deleteSecret.mutateAsync({ + kind, + name: removeTarget.name, + environment: removeTarget.environment ?? undefined, + }) + showToast.success(`${meta.label} removed`) + setRemoveTarget(null) + } catch (error) { + showToast.error(error instanceof Error ? error.message : `Failed to remove ${meta.label.toLowerCase()}`) + } + } + + const handleCopyReference = async (entry: SecretEntry) => { + const copied = await copyTextToClipboard(secretReference(kind, entry.name)) + if (copied) showToast.success('Reference copied') + else showToast.error('Failed to copy reference') + } + + return ( +
+ {form ? ( +
+
+ + setForm({ ...form, name: event.target.value.toUpperCase() })} + placeholder="MY_NAME" + className="font-mono" + autoComplete="off" + disabled={isEditing} + /> +
+ + {isRepoScope && ( +
+ + setForm({ ...form, environment: event.target.value })} + placeholder="e.g. e2e" + className="font-mono" + autoComplete="off" + disabled={isEditing} + /> +
+ )} + +
+ +