diff --git a/src/bridge/server.ts b/src/bridge/server.ts index 7b71884..72d344c 100644 --- a/src/bridge/server.ts +++ b/src/bridge/server.ts @@ -246,6 +246,13 @@ export function createCoreServer(opts: CoreServerOptions): { ); } const authActive = authUsable; + if (!authActive) { + // No-auth single-user mode: per-user rows are owned by the synthetic + // 'local' id, and several tables FK to users(id) (ssh_user_deks, + // browser_session_profiles, …). Seed the 'local' user row so those inserts + // succeed — without it, creating an SSH connection failed with create_failed. + repo.ensureLocalUser(); + } let authenticateUpgrade: import('./auth.js').UpgradeAuthChecker | undefined; if (authActive) { diff --git a/src/db/ensure-local-user.test.ts b/src/db/ensure-local-user.test.ts new file mode 100644 index 0000000..d2fbadc --- /dev/null +++ b/src/db/ensure-local-user.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { Repository } from './repository.js'; +import { bootstrapSystemDek, getOrCreateUserDek } from '../ssh/crypto.js'; + +// Regression: SSH connection creation in no-auth mode returned `create_failed`. +// Root cause — `ssh_user_deks.user_id REFERENCES users(id)` with foreign keys +// ON (Repository enables the pragma). No-auth owns rows under the synthetic +// 'local' id, but no `users` row with id='local' exists, so the lazy DEK insert +// hit a FK violation and getOrCreateUserDek threw → create_failed. Seeding the +// 'local' user row satisfies the FK (and the whole class of users(id) FKs). +const VALID_KEY = 'a'.repeat(64); + +describe('Repository.ensureLocalUser (no-auth SSH create_failed fix)', () => { + let tmpDir = ''; + let repo: Repository; + + beforeEach(() => { + process.env.MCP_ENCRYPTION_KEY = VALID_KEY; + tmpDir = mkdtempSync(join(tmpdir(), 'ensure-local-')); + repo = new Repository(join(tmpDir, 'db.sqlite')); // constructor enables foreign_keys=ON + bootstrapSystemDek(repo.getDb()); + }); + + afterEach(() => { + repo.close(); + rmSync(tmpDir, { recursive: true, force: true }); + delete process.env.MCP_ENCRYPTION_KEY; + }); + + it('reproduces the bug: creating a DEK for owner "local" throws when the user row is absent', () => { + expect(() => getOrCreateUserDek(repo.getDb(), 'local')).toThrow(); + }); + + it('after ensureLocalUser the per-user DEK insert (and thus SSH create) succeeds', () => { + repo.ensureLocalUser(); + const dek = getOrCreateUserDek(repo.getDb(), 'local'); + expect(Buffer.isBuffer(dek)).toBe(true); + expect(dek.length).toBe(32); + }); + + it('is idempotent — repeated calls keep a single "local" user', () => { + repo.ensureLocalUser(); + repo.ensureLocalUser(); + const u = repo.getUserById('local'); + expect(u?.id).toBe('local'); + expect(u?.name).toBe('local'); + }); +}); diff --git a/src/db/repository.ts b/src/db/repository.ts index fed406c..f5740d5 100644 --- a/src/db/repository.ts +++ b/src/db/repository.ts @@ -2457,6 +2457,26 @@ export class Repository { return user; } + /** + * Ensure the synthetic 'local' user row exists. No-auth single-user + * deployments own per-user rows under the id 'local' (tasks, jobs, SSH + * connections, DEKs, …). Many of those tables FK to users(id) with + * foreign_keys ON, so the row must exist or the inserts fail — e.g. + * ssh_user_deks → SSH connection creation returned create_failed. + * Idempotent (INSERT OR IGNORE), so it is safe to call on every startup. + * role='admin' mirrors the synthetic 'local' user the HTTP layer injects + * for task-visibility routes in no-auth mode. + */ + ensureLocalUser(): void { + const now = new Date().toISOString(); + this.db + .prepare( + `INSERT OR IGNORE INTO users (id, email, name, avatar_url, role, status, created_at, updated_at) + VALUES ('local', 'local@localhost', 'local', NULL, 'admin', 'active', @now, @now)` + ) + .run({ now }); + } + getUserById(id: string): User | null { const row = this.db .prepare('SELECT * FROM users WHERE id = ?') diff --git a/ui/src/components/detail/DetailPanel.tsx b/ui/src/components/detail/DetailPanel.tsx index af3e8f2..96a5575 100644 --- a/ui/src/components/detail/DetailPanel.tsx +++ b/ui/src/components/detail/DetailPanel.tsx @@ -2,6 +2,7 @@ import { useState, useDeferredValue } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { LocalTask, LocalFileEntry, SubtaskActivity, Visibility, fetchMyOrgs, updateLocalTask } from '../../api'; import { relativeTime } from '../../lib/utils'; +import { ownerDisplayName } from '../../lib/owner'; import { DetailTabId } from '../../lib/urlState'; import { DetailHeader } from './DetailHeader'; import { ContinueWithPieceDialog } from './ContinueWithPieceDialog'; @@ -179,7 +180,7 @@ export function LocalDetailPanel({ {task && ( <>