sync: update from private repo (e7c4a56)
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
This commit is contained in:
parent
483464597a
commit
2bab882d08
@ -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) {
|
||||
|
||||
51
src/db/ensure-local-user.test.ts
Normal file
51
src/db/ensure-local-user.test.ts
Normal file
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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 = ?')
|
||||
|
||||
@ -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 && (
|
||||
<>
|
||||
<div className="mb-2 flex items-center gap-2 text-2xs text-slate-500 flex-wrap">
|
||||
<span>作成者: <b>{task.ownerName ?? 'system'}</b></span>
|
||||
<span>作成者: <b>{ownerDisplayName(task.ownerId, task.ownerName)}</b></span>
|
||||
<span>·</span>
|
||||
<span>{relativeTime(task.createdAt)}</span>
|
||||
{task.visibility === 'private' && <span>· 🔒 非公開</span>}
|
||||
|
||||
@ -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,
|
||||
<div className="mt-1.5 flex items-center gap-1.5 text-[10px]">
|
||||
<span className="font-mono text-slate-400 tabular-nums">{relativeTime(task.updatedAt)}</span>
|
||||
<span className="text-slate-300">·</span>
|
||||
{task.ownerId ? (
|
||||
<span className="text-slate-600">{task.ownerName ?? 'user'}</span>
|
||||
) : (
|
||||
<span className="text-slate-400">system</span>
|
||||
)}
|
||||
<span className="text-slate-600">{ownerDisplayName(task.ownerId, task.ownerName)}</span>
|
||||
{task.visibility === 'private' && (
|
||||
<span className="px-1 rounded text-[10px] font-medium bg-amber-50 dark:bg-amber-500/15 text-amber-700 dark:text-amber-300 border border-amber-100 dark:border-amber-500/30" title="Private">private</span>
|
||||
)}
|
||||
|
||||
21
ui/src/lib/owner.test.ts
Normal file
21
ui/src/lib/owner.test.ts
Normal file
@ -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');
|
||||
});
|
||||
});
|
||||
19
ui/src/lib/owner.ts
Normal file
19
ui/src/lib/owner.ts
Normal file
@ -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';
|
||||
}
|
||||
@ -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
|
||||
</div>
|
||||
{showOwnership && (
|
||||
<div className="mt-1.5 flex items-center gap-1.5 text-[10px] flex-wrap">
|
||||
{task.ownerId ? (
|
||||
<span className="text-slate-600">{task.ownerName ?? 'user'}</span>
|
||||
) : (
|
||||
<span className="text-slate-400">system</span>
|
||||
)}
|
||||
<span className="text-slate-600">{ownerDisplayName(task.ownerId, task.ownerName)}</span>
|
||||
{task.visibility === 'private' && (
|
||||
<span className="px-1 rounded text-[10px] font-medium bg-amber-50 dark:bg-amber-500/15 text-amber-700 dark:text-amber-300 border border-amber-100 dark:border-amber-500/30" title="非公開">🔒 非公開</span>
|
||||
)}
|
||||
@ -715,7 +712,7 @@ function ScheduleDetailPane({
|
||||
<div>
|
||||
<dt className="text-2xs font-medium text-slate-500 mb-1">所有者</dt>
|
||||
<dd className="text-slate-900">
|
||||
{task.ownerId ? (task.ownerName ?? 'user') : <span className="text-slate-400">system</span>}
|
||||
{ownerDisplayName(task.ownerId, task.ownerName)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user