From 2bab882d08a30519c58f635e0243dfa61b40850f Mon Sep 17 00:00:00 2001 From: oss-sync Date: Tue, 9 Jun 2026 09:43:05 +0000 Subject: [PATCH] sync: update from private repo (e7c4a56) --- src/bridge/server.ts | 7 ++++ src/db/ensure-local-user.test.ts | 51 ++++++++++++++++++++++++ src/db/repository.ts | 20 ++++++++++ ui/src/components/detail/DetailPanel.tsx | 3 +- ui/src/components/list/TaskListItem.tsx | 7 +--- ui/src/lib/owner.test.ts | 21 ++++++++++ ui/src/lib/owner.ts | 19 +++++++++ ui/src/pages/SchedulesPage.tsx | 9 ++--- 8 files changed, 125 insertions(+), 12 deletions(-) create mode 100644 src/db/ensure-local-user.test.ts create mode 100644 ui/src/lib/owner.test.ts create mode 100644 ui/src/lib/owner.ts 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 && ( <>
- 作成者: {task.ownerName ?? 'system'} + 作成者: {ownerDisplayName(task.ownerId, task.ownerName)} · {relativeTime(task.createdAt)} {task.visibility === 'private' && · 🔒 非公開} diff --git a/ui/src/components/list/TaskListItem.tsx b/ui/src/components/list/TaskListItem.tsx index eb2d0b3..15309d1 100644 --- a/ui/src/components/list/TaskListItem.tsx +++ b/ui/src/components/list/TaskListItem.tsx @@ -1,6 +1,7 @@ import { memo } from 'react'; import { LocalTask } from '../../api'; import { relativeTime, statusTone, formatStatusLabel } from '../../lib/utils'; +import { ownerDisplayName } from '../../lib/owner'; interface LocalTaskListItemProps { task: LocalTask; @@ -46,11 +47,7 @@ export const LocalTaskListItem = memo(function LocalTaskListItem({ task, active,
{relativeTime(task.updatedAt)} · - {task.ownerId ? ( - {task.ownerName ?? 'user'} - ) : ( - system - )} + {ownerDisplayName(task.ownerId, task.ownerName)} {task.visibility === 'private' && ( private )} diff --git a/ui/src/lib/owner.test.ts b/ui/src/lib/owner.test.ts new file mode 100644 index 0000000..68ff101 --- /dev/null +++ b/ui/src/lib/owner.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; +import { ownerDisplayName } from './owner'; + +describe('ownerDisplayName', () => { + it('shows the real user name when present', () => { + expect(ownerDisplayName('u123', 'Alice')).toBe('Alice'); + }); + + it('shows "local" for the no-auth local owner', () => { + expect(ownerDisplayName('local', null)).toBe('local'); + }); + + it('shows "local" for legacy NULL owner (no-auth rows created before the owner fix)', () => { + expect(ownerDisplayName(null, null)).toBe('local'); + expect(ownerDisplayName(undefined, undefined)).toBe('local'); + }); + + it('falls back to "system" for an owner id with no matching user (e.g. deleted account)', () => { + expect(ownerDisplayName('u-deleted', null)).toBe('system'); + }); +}); diff --git a/ui/src/lib/owner.ts b/ui/src/lib/owner.ts new file mode 100644 index 0000000..57d23ab --- /dev/null +++ b/ui/src/lib/owner.ts @@ -0,0 +1,19 @@ +/** + * Resolve the display name for a task / scheduled-task owner. + * + * - A real user name (resolved from the users JOIN) is shown as-is. + * - No-auth single-user deployments own rows under 'local' (new rows) or NULL + * (legacy rows created before owner registration was fixed). Both render as + * 'local' so the lone local operator is shown consistently — instead of the + * misleading 'system' / 'user' labels the views used to fall back to. + * - An owner id with no matching user record (e.g. a deleted account in an auth + * deployment) falls back to 'system'. + */ +export function ownerDisplayName( + ownerId: string | null | undefined, + ownerName: string | null | undefined, +): string { + if (ownerName) return ownerName; + if (ownerId == null || ownerId === 'local') return 'local'; + return 'system'; +} diff --git a/ui/src/pages/SchedulesPage.tsx b/ui/src/pages/SchedulesPage.tsx index 9d74c13..66c971e 100644 --- a/ui/src/pages/SchedulesPage.tsx +++ b/ui/src/pages/SchedulesPage.tsx @@ -5,6 +5,7 @@ import { EmptyState } from '../components/shared/EmptyState'; import { StatChip } from '../components/shared/StatChip'; import { usePieceList } from '../hooks/usePieces'; import { resolvePieceOptions } from '../lib/splitPieces'; +import { ownerDisplayName } from '../lib/owner'; import { fetchMyOrgs, listBrowserSessionProfiles, type Visibility } from '../api'; import { useAuthState } from '../App'; @@ -515,11 +516,7 @@ function ScheduleListItem({ task, active, onClick }: { task: ScheduledTask; acti
{showOwnership && (
- {task.ownerId ? ( - {task.ownerName ?? 'user'} - ) : ( - system - )} + {ownerDisplayName(task.ownerId, task.ownerName)} {task.visibility === 'private' && ( 🔒 非公開 )} @@ -715,7 +712,7 @@ function ScheduleDetailPane({
所有者
- {task.ownerId ? (task.ownerName ?? 'user') : system} + {ownerDisplayName(task.ownerId, task.ownerName)}