diff --git a/backend/src/db/migrations/202610141200-secrets.ts b/backend/src/db/migrations/202610141200-secrets.ts new file mode 100644 index 000000000..1c51b3adb --- /dev/null +++ b/backend/src/db/migrations/202610141200-secrets.ts @@ -0,0 +1,26 @@ +import type { Migration } from '../migration-runner' + +const migration: Migration = { + id: '202610141200-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/index.ts b/backend/src/db/migrations/index.ts index 20084f541..78df19af5 100644 --- a/backend/src/db/migrations/index.ts +++ b/backend/src/db/migrations/index.ts @@ -30,6 +30,7 @@ import migration202610061345 from './202610061345-schedule-workspace-mode' import migration202610061700 from './202610061700-multi-run-fusions' import migration202610061701 from './202610061701-change-walkthroughs' import migration202610081200 from './202610081200-change-walkthrough-sources' +import migration202610141200 from './202610141200-secrets' export const allMigrations: Migration[] = [ migration001, @@ -63,4 +64,5 @@ export const allMigrations: Migration[] = [ migration202610061700, migration202610061701, migration202610081200, + migration202610141200, ] diff --git a/backend/src/db/queries.ts b/backend/src/db/queries.ts index 9b045c215..28bb202c9 100644 --- a/backend/src/db/queries.ts +++ b/backend/src/db/queries.ts @@ -351,6 +351,7 @@ 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) } diff --git a/backend/src/index.ts b/backend/src/index.ts index fc34df66d..64b389b66 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -44,6 +44,7 @@ import { createMultiRunRoutes } from './routes/multi-runs' import { createChangeWalkthroughRoutes } from './routes/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' @@ -64,6 +65,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' @@ -127,6 +130,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) @@ -225,7 +232,7 @@ try { opencodeServerManager.setDatabase(db) detectSandboxCapability() - void new SandboxRuntimeService(db).prepareWorkspaceSandboxOnBoot().catch((error) => { + void new SandboxRuntimeService(db, repoEnvService).prepareWorkspaceSandboxOnBoot().catch((error) => { logger.warn('Workspace sandbox preparation failed:', error) }) const openCodeStatus = await openCodeSupervisor.start() @@ -242,10 +249,9 @@ try { const terminalService = new TerminalService( openCodeClient, new CredentialProvider(db), + repoEnvService, () => getOpenCodeUpstreamBaseUrl(opencodeServerManager.getEffectiveServerHost()), ) -const gitService = createGitService(gitAuthService) -const projectConfigService = new ProjectConfigService(db, gitService, gitAuthService) const scheduleWorktreeManager = new ScheduleWorktreeManager(gitAuthService, db) const scheduleService = new ScheduleService(db, openCodeClient, scheduleWorktreeManager) scheduleService.setWorktreeRemovedHandler(async (directory) => { @@ -341,14 +347,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)) +app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, sessionPermissionModeService, repoWorkspaces, gitAuthService, sessionGoalService, multiRunService, changeWalkthroughService, 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()) @@ -360,6 +366,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('/session-permission-modes', createSessionPermissionModeRoutes(sessionPermissionModeService)) protectedApi.route('/session-goals', createSessionGoalRoutes(sessionGoalService)) diff --git a/backend/src/routes/internal/git-credentials.ts b/backend/src/routes/internal/git-credentials.ts deleted file mode 100644 index 830e4aca5..000000000 --- a/backend/src/routes/internal/git-credentials.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Hono } from 'hono' -import type { Database } from 'bun:sqlite' -import { CredentialProvider } from '../../services/credential-provider' - -export function createInternalGitCredentialsRoutes(db: Database) { - const app = new Hono() - - app.get('/gh-env', (c) => { - const provider = new CredentialProvider(db) - const cwd = c.req.query('cwd') - return c.json(provider.getGhCliEnv({ cwd })) - }) - - return app -} diff --git a/backend/src/routes/internal/index.ts b/backend/src/routes/internal/index.ts index 126579b48..a925b1293 100644 --- a/backend/src/routes/internal/index.ts +++ b/backend/src/routes/internal/index.ts @@ -15,7 +15,7 @@ import { createInternalRepoMirrorRoutes as mirrorRoutes } from './repo-mirror' import { createInternalOpenCodeWorkspacesRoutes } from './opencode-workspaces' import { createInternalSessionRoutes } from './sessions' import { createInternalAssistantRoutes } from './assistant' -import { createInternalGitCredentialsRoutes } from './git-credentials' +import { createInternalShellEnvRoutes } from './shell-env' import { createInternalSandboxRoutes } from './sandbox' import { createInternalSessionArtifactRoutes } from './session-artifacts' import { createSessionGoalRoutes } from '../session-goals' @@ -27,6 +27,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, @@ -40,6 +41,7 @@ export function createInternalRoutes( sessionGoals: SessionGoalService, multiRuns: MultiRunService, changeWalkthroughService: ChangeWalkthroughService, + repoEnvService: RepoEnvService, ) { const app = new Hono() app.use('/*', createInternalTokenMiddleware(db)) @@ -60,7 +62,7 @@ export function createInternalRoutes( app.route('/assistant', createInternalAssistantRoutes(openCodeClient)) app.route('/change-walkthroughs', createChangeWalkthroughRoutes(changeWalkthroughService)) app.route('/session-artifacts', createInternalSessionArtifactRoutes(openCodeClient)) - app.route('/git-credentials', createInternalGitCredentialsRoutes(db)) - app.route('/sandbox', createInternalSandboxRoutes(db)) + app.route('/shell-env', createInternalShellEnvRoutes(db, repoEnvService)) + app.route('/sandbox', createInternalSandboxRoutes(db, repoEnvService)) return app } diff --git a/backend/src/routes/internal/sandbox.ts b/backend/src/routes/internal/sandbox.ts index 7e9e13191..5a1e49152 100644 --- a/backend/src/routes/internal/sandbox.ts +++ b/backend/src/routes/internal/sandbox.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono' import { z } from 'zod' import type { Database } from 'bun:sqlite' import { SandboxRuntimeService } from '../../services/sandbox/runtime' +import type { RepoEnvService } from '../../services/repo-env' import { logger } from '../../utils/logger' const SandboxShellRequestSchema = z.object({ @@ -9,7 +10,7 @@ const SandboxShellRequestSchema = z.object({ enforced: z.boolean().optional(), }) -export function createInternalSandboxRoutes(db: Database) { +export function createInternalSandboxRoutes(db: Database, repoEnvService: RepoEnvService) { const app = new Hono() app.post('/shell', async (c) => { @@ -27,7 +28,7 @@ export function createInternalSandboxRoutes(db: Database) { try { return c.json( - await new SandboxRuntimeService(db).planShell(parsed.data.directory, parsed.data.enforced === true), + await new SandboxRuntimeService(db, repoEnvService).planShell(parsed.data.directory, parsed.data.enforced === true), ) } catch (error) { logger.error('Failed to plan the sandbox shell', error) diff --git a/backend/src/routes/internal/shell-env.ts b/backend/src/routes/internal/shell-env.ts new file mode 100644 index 000000000..48512dfb8 --- /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) { + 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 28b996191..8dadb38c3 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 96f295a42..2bfb99ad8 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..87625c879 --- /dev/null +++ b/backend/src/routes/secrets.ts @@ -0,0 +1,139 @@ +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 } 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 } +} + +export function createGlobalSecretRoutes(secretsService: SecretsService, kind: SecretKind) { + const app = new Hono() + + app.get('/', (c) => c.json({ items: secretsService.list(kind, null) })) + + app.put('/:name', async (c) => { + const name = readName(c) + if (name instanceof Response) return name + + const body = await c.req.json().catch(() => ({})) + const parsed = SetSecretRequestSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: parsed.error.issues[0]?.message ?? 'Invalid request' }, 400) + } + + try { + secretsService.set(kind, null, parsed.data.environment ?? null, name.name, parsed.data.value) + return c.body(null, 204) + } catch (error: unknown) { + return handleServiceError(c, error, 'Failed to store secret', SecretsServiceError) + } + }) + + 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 name = readName(c) + if (name instanceof Response) return name + + const body = await c.req.json().catch(() => ({})) + const parsed = SetSecretRequestSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: parsed.error.issues[0]?.message ?? 'Invalid request' }, 400) + } + + const repo = await resolveProjectRepo(c) + if (repo instanceof Response) return repo + + try { + secretsService.set(kind, repo.id, parsed.data.environment ?? null, name.name, parsed.data.value) + return c.body(null, 204) + } catch (error: unknown) { + return handleServiceError(c, error, 'Failed to store secret', SecretsServiceError) + } + }) + + 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..94357cd61 100644 --- a/backend/src/services/opencode-gh-env-plugin.ts +++ b/backend/src/services/opencode-gh-env-plugin.ts @@ -1,36 +1,111 @@ export function buildGhEnvPluginSource(id: string): string { return `const TTL_MS = 5000 let cache = new Map() +const maskValues = new Set() -async function fetchGhEnv(cwd) { +function rememberMasks(values) { + if (!Array.isArray(values)) return + for (const value of values) { + if (typeof value === 'string' && value.length > 0) maskValues.add(value) + } +} + +function emptyBody() { + return { env: {}, mask: [] } +} + +async function fetchShellEnv(cwd) { 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 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) 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 containsMask(text) { + for (const value of maskValues) { + if (text.indexOf(value) !== -1) return true } + return false +} + +function applyMasks(text) { + let result = text + const ordered = Array.from(maskValues).sort(function (a, b) { return b.length - a.length }) + for (const value of ordered) { + if (result.indexOf(value) !== -1) result = result.split(value).join('***') + } + return result +} + +function maskValue(value) { + if (typeof value === 'string') { + return containsMask(value) ? applyMasks(value) : value + } + if (Array.isArray(value)) { + let copy = null + for (let index = 0; index < value.length; index += 1) { + const masked = maskValue(value[index]) + 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]) + 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 body = await fetchShellEnv(event.cwd) + rememberMasks(body.mask) + Object.assign(event.env, body.env) + }) + await ctx.tool.hook('execute.after', async (event) => { + if (event.status !== 'completed') return + if (maskValues.size === 0) return + if (event.result === null || typeof event.result !== 'object') return + const source = { ...event.result } + const masked = maskValue(source) + if (masked !== source) event.result = masked }) }, } diff --git a/backend/src/services/opencode-sandbox-plugin.ts b/backend/src/services/opencode-sandbox-plugin.ts index 7a3f2d63b..f4a4af7f3 100644 --- a/backend/src/services/opencode-sandbox-plugin.ts +++ b/backend/src/services/opencode-sandbox-plugin.ts @@ -1,6 +1,6 @@ import { sandboxPlanTimeoutMs, SANDBOX_UNAVAILABLE_PREFIX } from './sandbox/command' import { - SANDBOX_FORWARDED_ENV_NAMES, + SANDBOX_SHELL_ENV_FORWARD, SANDBOX_SHELL_ENV_WORKDIR, } from './sandbox/shell-shim' @@ -14,7 +14,7 @@ var SANDBOX_UNAVAILABLE_PREFIX = ${JSON.stringify(SANDBOX_UNAVAILABLE_PREFIX)} var PLAN_TIMEOUT_MS = ${SANDBOX_PLAN_TIMEOUT_MS} var SHELL_SHIM_PATH = ${JSON.stringify(shellShimPath)} var ENV_WORKDIR = ${JSON.stringify(SANDBOX_SHELL_ENV_WORKDIR)} -var FORWARDED_ENV_NAMES = ${JSON.stringify(SANDBOX_FORWARDED_ENV_NAMES)} +var ENV_FORWARD = ${JSON.stringify(SANDBOX_SHELL_ENV_FORWARD)} 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.' @@ -146,11 +146,15 @@ async function pinSandboxShell(event) { throw unavailable('the sandbox shell shim is missing at ' + SHELL_SHIM_PATH) } var plan = await planSandboxShell(event.cwd) + 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 (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) continue + if (name.startsWith('OCM_') || name.startsWith('MSB_')) continue + event.env[name] = value + forwardNames.push(name) } } if (!lockAccessor(event, 'shell', SHELL_SHIM_PATH)) { @@ -159,6 +163,9 @@ async function pinSandboxShell(event) { if (!lockAccessor(event.env, ENV_WORKDIR, plan.workdir)) { throw unavailable('sandbox enforcement could not pin the sandbox working directory; 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') + } } export default { 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..656bd90a1 --- /dev/null +++ b/backend/src/services/repo-env.ts @@ -0,0 +1,116 @@ +import type { Database } from 'bun:sqlite' +import type { Repo } from '@opencode-manager/shared/types' +import { interpolateEnvExpressions, type EnvExpressionReference } from '@opencode-manager/shared/utils' +import { getRepoByDirectory } from '../db/queries' +import { executeCommand } from '../utils/process' +import { logger } from '../utils/logger' +import { resolveMainCheckoutPath, resolveRepoForDirectory } from './repo' +import type { ProjectConfigService } from './project-config' +import type { SecretsService } from './secrets' + +export interface ResolvedRepoEnv { + env: Record + maskValues: string[] + missing: string[] +} + +function emptyResult(): ResolvedRepoEnv { + return { env: {}, maskValues: [], missing: [] } +} + +export class RepoEnvService { + 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 { + const topLevel = await this.findTopLevel(cwd) + if (!topLevel) { + return emptyResult() + } + + const repo = await this.findRepo(topLevel) + if (!repo) { + return emptyResult() + } + + const projectRepo = await this.projectConfigService.resolveProjectRepo(repo) + const repoFile = this.projectConfigService.readRepoFile(topLevel) + if (!repoFile.file || !this.projectConfigService.isRepoFileTrusted(projectRepo, repoFile)) { + return emptyResult() + } + + const mapping = repoFile.file.env + if (!mapping || Object.keys(mapping).length === 0) { + return emptyResult() + } + + const resolved = this.secretsService.resolveValues(projectRepo.id, repoFile.file.environment ?? null) + const maskValues = new Set() + const missing = 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) { + missing.add(`${reference.context}.${reference.name}`) + 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), + missing: [...missing], + } + } catch (error) { + logger.warn(`Failed to resolve repository environment for ${cwd}`, error) + return emptyResult() + } + } + + private async findTopLevel(cwd: string): Promise { + try { + const output = await executeCommand(['git', '-C', cwd, 'rev-parse', '--show-toplevel'], { silent: true }) + const topLevel = output.trim() + return topLevel.length > 0 ? topLevel : null + } catch { + return null + } + } + + private async findRepo(topLevel: string): Promise { + const direct = getRepoByDirectory(this.db, topLevel) + if (direct) { + return direct + } + + const mainCheckout = await resolveMainCheckoutPath(topLevel) + if (mainCheckout) { + const mainRepo = getRepoByDirectory(this.db, mainCheckout) + if (mainRepo) { + return mainRepo + } + } + + return resolveRepoForDirectory(this.db, topLevel) + } +} diff --git a/backend/src/services/sandbox/runtime.ts b/backend/src/services/sandbox/runtime.ts index b7ce75b71..7818820c7 100644 --- a/backend/src/services/sandbox/runtime.ts +++ b/backend/src/services/sandbox/runtime.ts @@ -7,6 +7,7 @@ import { 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' @@ -823,7 +824,10 @@ async function startWorkspaceSandbox(): Promise { } export class SandboxRuntimeService { - constructor(private readonly db: Database) {} + constructor( + private readonly db: Database, + private readonly repoEnvService?: RepoEnvService, + ) {} async prepareWorkspaceSandboxOnBoot(): Promise { if (!this.isSandboxEnabled()) return @@ -884,6 +888,10 @@ export class SandboxRuntimeService { ...new CredentialProvider(this.db).getSandboxGitEnv({ cwd: directory }), ...await getGitIdentityEnvForDirectory(directory, this.db), } + if (this.repoEnvService) { + const repoEnv = await this.repoEnvService.resolveForDirectory(directory) + Object.assign(env, repoEnv.env) + } return Object.keys(env).length > 0 ? { mode: 'sandbox', workdir: workDirectory, env } : { mode: 'sandbox', workdir: workDirectory } diff --git a/backend/src/services/sandbox/shell-shim.ts b/backend/src/services/sandbox/shell-shim.ts index afa3a01bc..8d45881d0 100644 --- a/backend/src/services/sandbox/shell-shim.ts +++ b/backend/src/services/sandbox/shell-shim.ts @@ -14,38 +14,25 @@ export const SANDBOX_SHELL_FILENAME = 'ocm-sandbox-shell' export const SANDBOX_SHELL_ENV_WORKDIR = 'OCM_SANDBOX_WORKDIR' /** - * 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 /** - * 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 @@ -84,24 +71,34 @@ 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_]*|OCM_*|MSB_*) 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 exit 126 fi -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..fc3e72e27 --- /dev/null +++ b/backend/src/services/secrets.ts @@ -0,0 +1,123 @@ +import type { Database } from 'bun:sqlite' +import type { SecretEntry, SecretKind } from '@opencode-manager/shared/schemas' +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 +} + +interface SecretValueRow { + kind: SecretKind + repo_id: number | null + environment: string + name: string + value: string +} + +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 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[] { + if (repoId === null) { + return this.db + .prepare( + 'SELECT kind, repo_id, environment, name, value, updated_at FROM secrets WHERE kind = ? AND repo_id IS NULL ORDER BY name', + ) + .all(kind) as SecretRow[] + } + + return this.db + .prepare( + 'SELECT kind, repo_id, environment, name, value, updated_at FROM secrets WHERE kind = ? AND repo_id = ? 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 d32f42fdd..ef51167f6 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' @@ -34,6 +35,7 @@ export class TerminalService { constructor( private readonly openCodeClient: OpenCodeClient, private readonly credentialProvider: CredentialProvider, + private readonly repoEnvService: RepoEnvService, private readonly upstreamBaseUrl: () => string, private readonly openSocket: (url: string) => WebSocket = (url) => new WebSocket(url), ) {} @@ -44,12 +46,13 @@ export class TerminalService { } async create(directory: string, input: CreateTerminalInput): Promise { + const repoEnv = (await this.repoEnvService.resolveForDirectory(directory)).env const result = await this.openCodeClient.api.pty.create({ location: { directory }, title: formatTerminalTitle({ kind: input.kind, name: input.name, actionId: input.actionId }), ...(input.command !== undefined ? { command: input.command } : {}), ...(input.args !== undefined ? { args: input.args } : {}), - env: { ...this.credentialProvider.getGhCliEnv({ cwd: directory }), ...input.env }, + env: { ...this.credentialProvider.getGhCliEnv({ cwd: directory }), ...repoEnv, ...input.env }, }) return this.toTerminalInfo(result.data) } 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/helpers/internal-test-app.ts b/backend/test/helpers/internal-test-app.ts index 21150fe15..9c4584c1f 100644 --- a/backend/test/helpers/internal-test-app.ts +++ b/backend/test/helpers/internal-test-app.ts @@ -14,8 +14,15 @@ type InternalTestAppOverrides = Partial<{ sessionGoals: CreateInternalRoutesParams[8] multiRuns: CreateInternalRoutesParams[9] changeWalkthroughService: CreateInternalRoutesParams[10] + repoEnvService: CreateInternalRoutesParams[11] }> +function createStubRepoEnvService(): CreateInternalRoutesParams[11] { + return { + resolveForDirectory: async () => ({ env: {}, maskValues: [], missing: [] }), + } as unknown as CreateInternalRoutesParams[11] +} + export function createInternalTestApp( db: CreateInternalRoutesParams[0], overrides: InternalTestAppOverrides = {}, @@ -32,5 +39,6 @@ export function createInternalTestApp( overrides.sessionGoals ?? ({} as CreateInternalRoutesParams[8]), overrides.multiRuns ?? ({} as CreateInternalRoutesParams[9]), overrides.changeWalkthroughService ?? ({} as CreateInternalRoutesParams[10]), + overrides.repoEnvService ?? createStubRepoEnvService(), ) } 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 deleted file mode 100644 index 8b8a2a053..000000000 --- a/backend/test/routes/internal/git-credentials.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, it, expect, beforeEach } 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 { createInternalGitCredentialsRoutes } from '../../../src/routes/internal/git-credentials' -import type { GitCredential } from '@opencode-manager/shared' - -describe('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 () => { - settingsService.updateSettings({ - gitCredentials: [ - { name: 'github', host: 'github.com', type: 'pat', token: 'ghp_test_token' } as GitCredential, - ], - }) - - 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' }) - }) - - 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' }], - }) - - 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' }) - }) -}) 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..dc402dc55 --- /dev/null +++ b/backend/test/routes/internal/shell-env.test.ts @@ -0,0 +1,76 @@ +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: [], missing: [] })) + 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: [], missing: [] }))) + + 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'], + missing: [], + })) + 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') + }) +}) diff --git a/backend/test/routes/repo-terminal-socket.test.ts b/backend/test/routes/repo-terminal-socket.test.ts index 3531c55b4..c31dfa387 100644 --- a/backend/test/routes/repo-terminal-socket.test.ts +++ b/backend/test/routes/repo-terminal-socket.test.ts @@ -11,6 +11,7 @@ import { TerminalService } from '../../src/services/terminal' import type { GitAuthService } from '../../src/services/git-auth' import type { OpenCodeClient } from '../../src/services/opencode/client' import type { CredentialProvider } from '../../src/services/credential-provider' +import type { RepoEnvService } from '../../src/services/repo-env' import { startWebSocketServer, type WebSocketTestServer } from '../helpers/websocket-server' vi.mock('../../src/services/repo', () => ({ @@ -79,7 +80,12 @@ upstreamApp.get( }), ) -const terminalService = new TerminalService(openCodeClient, credentialProvider, () => upstreamBase) +const terminalService = new TerminalService( + openCodeClient, + credentialProvider, + { resolveForDirectory: async () => ({ env: {}, maskValues: [], missing: [] }) } as unknown as RepoEnvService, + () => upstreamBase, +) const managerApp = new Hono() const managerWs = createNodeWebSocket({ app: managerApp }) 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 f2a56ce4e..f31902029 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..b01e0c01c --- /dev/null +++ b/backend/test/routes/secrets.test.ts @@ -0,0 +1,197 @@ +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('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..1fe706890 --- /dev/null +++ b/backend/test/schemas/project-config.test.ts @@ -0,0 +1,77 @@ +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 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 bc63233f1..9a38b1a6a 100644 --- a/backend/test/services/git/GitService.real.test.ts +++ b/backend/test/services/git/GitService.real.test.ts @@ -19,6 +19,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-')) @@ -1082,7 +1083,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..acfdb31c8 100644 --- a/backend/test/services/opencode-gh-env-plugin.test.ts +++ b/backend/test/services/opencode-gh-env-plugin.test.ts @@ -3,7 +3,7 @@ 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 { loadGeneratedPlugin, type ToolExecuteAfterEvent } from '../helpers/opencode-plugin-context' const INTERNAL_API_URL = 'http://localhost:5003/api/internal' @@ -22,10 +22,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 @@ -97,8 +97,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 +106,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 +135,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 +148,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 +161,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 +175,101 @@ 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('replaces overlapping mask values longest first', async () => { + stubShellEnvFetch({ env: {}, mask: ['abc', 'abcdef'] }) + const plugin = await loadPlugin() + await plugin.triggerShellCreateBefore({ command: 'ls', cwd: '/repo', env: {} }) + + const event: ToolExecuteAfterEvent = { tool: 'shell', status: 'completed', result: { output: 'abcdef' } } + + await plugin.triggerToolExecuteAfter(event) + + expect(event.result).toEqual({ output: '***' }) + }) + + 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('keeps masking values learned earlier even after they rotate out of the response', 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: '*** and ***' }) + }) + 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 c091f64d2..9760ce2e4 100644 --- a/backend/test/services/opencode-manager-tool-plugin.test.ts +++ b/backend/test/services/opencode-manager-tool-plugin.test.ts @@ -423,7 +423,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'], @@ -473,7 +473,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 1e5da463d..f7c87b9d7 100644 --- a/backend/test/services/opencode-sandbox-plugin.test.ts +++ b/backend/test/services/opencode-sandbox-plugin.test.ts @@ -8,7 +8,7 @@ import os from 'os' import { SANDBOX_PLAN_TIMEOUT_MS } from '../../src/services/opencode-sandbox-plugin' import { installManagedPlugins, getOpenCodePluginDir } from '../../src/services/opencode/plugin-registry' import { - SANDBOX_FORWARDED_ENV_NAMES, + SANDBOX_SHELL_ENV_FORWARD, SANDBOX_SHELL_ENV_WORKDIR, sandboxShellShimPath, } from '../../src/services/sandbox/shell-shim' @@ -88,6 +88,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)}`) }) @@ -158,42 +160,57 @@ describe('ocm-sandbox plugin', () => { expect(event.shell).toBe(sandboxShellShimPath(configHome)) expect(event.env[SANDBOX_SHELL_ENV_WORKDIR]).toBe(WORKDIR) + expect(event.env[SANDBOX_SHELL_ENV_FORWARD]).toBe('') const [url, init] = fetchMock.mock.calls[0] as [string, { body: string; headers: Record }] expect(url).toBe('http://localhost:5003/api/internal/sandbox/shell') expect(JSON.parse(init.body)).toEqual({ directory: WORKDIR, enforced: true }) expect(init.headers.Authorization).toBe('Bearer secret-token') }) - it('ignores a later hook that tries to redirect the pinned shell or working directory', async () => { + it('ignores a later hook that tries to redirect the pinned shell, working directory, or forward list', async () => { vi.stubGlobal('fetch', planResponse({ mode: 'sandbox', workdir: WORKDIR })) const event = await triggerShellCreate(configHome) event.shell = '/bin/sh' event.env[SANDBOX_SHELL_ENV_WORKDIR] = '/tmp' + event.env[SANDBOX_SHELL_ENV_FORWARD] = 'EVIL' expect(event.shell).toBe(sandboxShellShimPath(configHome)) expect(event.env[SANDBOX_SHELL_ENV_WORKDIR]).toBe(WORKDIR) + expect(event.env[SANDBOX_SHELL_ENV_FORWARD]).toBe('') expect(Object.getOwnPropertyDescriptor(event, 'shell')?.configurable).toBe(false) expect(Object.getOwnPropertyDescriptor(event.env, SANDBOX_SHELL_ENV_WORKDIR)?.configurable).toBe(false) + expect(Object.getOwnPropertyDescriptor(event.env, SANDBOX_SHELL_ENV_FORWARD)?.configurable).toBe(false) }) - it('forwards only the allow-listed plan env values into the spawn environment', async () => { + it('forwards arbitrary plan env names and pins the forward list', async () => { vi.stubGlobal('fetch', planResponse({ mode: 'sandbox', workdir: WORKDIR, - 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, + }, })) const event = await triggerShellCreate(configHome) expect(event.env.GH_TOKEN).toBe('gh-secret') expect(event.env.GIT_CONFIG_COUNT).toBe('1') + expect(event.env.OPENAI_API_KEY).toBe('sk-secret') expect(event.env.OCM_INTERNAL_TOKEN).toBeUndefined() - for (const name of Object.keys(event.env)) { - if (event.env[name] === undefined) continue - if (name === SANDBOX_SHELL_ENV_WORKDIR || name === 'TERM' || name === 'OPENCODE_TERMINAL') continue - expect(SANDBOX_FORWARDED_ENV_NAMES).toContain(name) - } + expect(event.env.MSB_SECRET).toBeUndefined() + expect(event.env['1BAD']).toBeUndefined() + expect(event.env['A-B']).toBeUndefined() + expect(event.env.NUMERIC).toBeUndefined() + expect(event.env[SANDBOX_SHELL_ENV_FORWARD]).toBe('GH_TOKEN GIT_CONFIG_COUNT OPENAI_API_KEY') + expect(Object.getOwnPropertyDescriptor(event.env, SANDBOX_SHELL_ENV_FORWARD)?.configurable).toBe(false) }) it('does not plan and leaves the event untouched when enforcement is off', async () => { 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..39905702c --- /dev/null +++ b/backend/test/services/repo-env.test.ts @@ -0,0 +1,218 @@ +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 { logger } from '../../src/utils/logger' +import type { GitAuthService } from '../../src/services/git-auth' +import type { Repo } from '@opencode-manager/shared/types' + +const gitAuthService = { getGitEnvironment: () => ({}) } as unknown as GitAuthService + +const EMPTY = { env: {}, maskValues: [], missing: [] } + +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): 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 "Initial commit"`) + } + + 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(() => { + 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']) + expect(result.missing).toEqual([]) + }) + + 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('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('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('reports missing references and resolves them 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: '-' }) + expect(result.missing).toEqual(['secrets.NOPE', 'vars.NOPE2']) + }) + + 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() + }) +}) diff --git a/backend/test/services/sandbox/runtime.test.ts b/backend/test/services/sandbox/runtime.test.ts index 64a9f9c68..553a87e72 100644 --- a/backend/test/services/sandbox/runtime.test.ts +++ b/backend/test/services/sandbox/runtime.test.ts @@ -13,6 +13,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(), @@ -345,6 +346,42 @@ describe('SandboxRuntimeService', () => { }) }) + 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(realInspectConfig()), 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'], + missing: [], + })), + } as unknown as RepoEnvService + const withRepoEnv = new SandboxRuntimeService(db, repoEnvService) + + const plan = await withRepoEnv.planShell(repoADir) + + expect(plan).toEqual({ + mode: 'sandbox', + 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', + }, + }) + }) + it('starts an existing stopped sandbox instead of recreating it', async () => { enableEnforcement() let inspectCalls = 0 diff --git a/backend/test/services/sandbox/shell-shim.test.ts b/backend/test/services/sandbox/shell-shim.test.ts index 8528eb839..b712419fa 100644 --- a/backend/test/services/sandbox/shell-shim.test.ts +++ b/backend/test/services/sandbox/shell-shim.test.ts @@ -6,7 +6,7 @@ import path from 'node:path' import { getReposPath } from '@opencode-manager/shared/config/env' import { WORKSPACE_SANDBOX_NAME } from '../../../src/services/sandbox/command' import { - SANDBOX_FORWARDED_ENV_NAMES, + SANDBOX_SHELL_ENV_FORWARD, SANDBOX_SHELL_ENV_WORKDIR, } from '../../../src/services/sandbox/shell-shim' @@ -34,7 +34,7 @@ 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] + delete env[SANDBOX_SHELL_ENV_FORWARD] return { ...env, ...overrides } } @@ -93,7 +93,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 only the names listed in OCM_SANDBOX_FORWARD_ENV 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') @@ -114,9 +114,11 @@ describe('sandbox shell shim', () => { encoding: 'utf8', env: sandboxShellEnv({ [SANDBOX_SHELL_ENV_WORKDIR]: directory, + [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', OCM_INTERNAL_TOKEN: 'must-not-be-forwarded', }), }) @@ -126,19 +128,119 @@ 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('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('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_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_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..be70ee4cb --- /dev/null +++ b/backend/test/services/secrets.test.ts @@ -0,0 +1,143 @@ +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' + +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('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 d9a1d71b1..ed7acde91 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 type { RepoEnvService } from '../../src/services/repo-env' interface PtyRecord { id: string @@ -26,13 +27,19 @@ function createCredentialProvider(shellEnv: Record = {}): Creden return { getGhCliEnv: vi.fn(() => shellEnv) } as unknown as CredentialProvider } +function createRepoEnvService(env: Record = {}): RepoEnvService { + return { + resolveForDirectory: vi.fn(async () => ({ env, maskValues: [], missing: [] })), + } as unknown as RepoEnvService +} + function createTerminalService( client: OpenCodeClient, provider: CredentialProvider, openSocket?: (url: string) => WebSocket, baseUrl = 'http://127.0.0.1:5551', ): TerminalService { - return new TerminalService(client, provider, () => baseUrl, openSocket) + return new TerminalService(client, provider, createRepoEnvService(), () => baseUrl, openSocket) } class FakeUpstreamSocket { @@ -165,6 +172,28 @@ describe('TerminalService', () => { expect(captured).not.toHaveProperty('args') expect(captured?.env).toEqual({}) }) + + 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 = createRepoEnvService({ REPO_VAR: 'repo', SHARED: 'repo' }) + const service = new TerminalService( + createClient({ create }), + credentialProvider, + repoEnvService, + () => 'http://127.0.0.1:5551', + ) + + 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('requireTerminal', () => { diff --git a/backend/test/utils/env-expressions.test.ts b/backend/test/utils/env-expressions.test.ts new file mode 100644 index 000000000..b03dca002 --- /dev/null +++ b/backend/test/utils/env-expressions.test.ts @@ -0,0 +1,92 @@ +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', + missing: [], + }) + }) + + it('resolves a missing reference to an empty string and reports it', () => { + expect(interpolateEnvExpressions('x-${{ secrets.MISSING }}-y', lookup)).toEqual({ + value: 'x--y', + missing: [{ context: 'secrets', name: 'MISSING' }], + }) + }) + + it('leaves an unsupported expression untouched', () => { + expect(interpolateEnvExpressions('${{ github.sha }}', lookup)).toEqual({ + value: '${{ github.sha }}', + missing: [], + }) + }) + + it('leaves a literal value untouched', () => { + expect(interpolateEnvExpressions('literal', lookup)).toEqual({ value: 'literal', missing: [] }) + }) +}) 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 78873319f..2de79c6a8 100644 --- a/frontend/src/components/navigation/ToolSidePanel.test.tsx +++ b/frontend/src/components/navigation/ToolSidePanel.test.tsx @@ -23,6 +23,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}, ChangesWalkthroughNav: () => null, @@ -87,6 +90,23 @@ function HomeHarness() { ) } +function RepoHarness() { + const panel = useToolPanel(true) + return ( + <> + + + + ) +} + afterEach(() => { Reflect.deleteProperty(window, 'matchMedia') }) @@ -180,6 +200,21 @@ describe('ToolSidePanel', () => { await waitFor(() => expect(search().has('panel')).toBe(false)) }) + 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 0a3697a88..c2cd63261 100644 --- a/frontend/src/components/navigation/ToolSidePanel.tsx +++ b/frontend/src/components/navigation/ToolSidePanel.tsx @@ -17,6 +17,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' @@ -162,6 +163,8 @@ export function ToolSidePanel({ ) 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 611608c6c..d5673d4ec 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', () => { @@ -162,6 +164,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 nothing on %s', (path) => { expect(buildToolItems(path)).toEqual([]) }) diff --git a/frontend/src/components/navigation/moreDrawerItems.ts b/frontend/src/components/navigation/moreDrawerItems.ts index bbed2aa43..00d583c13 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 { BookOpen, Plug, Sparkles, ShieldOff, CalendarClock, GitCommitHorizontal, SquareTerminal, Settings, LogOut, Bot, Folder, Home, Play, Globe } from 'lucide-react' +import { 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' @@ -75,6 +75,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} + /> +
+ )} + +
+ +