diff --git a/apps/desktop/src/main/__tests__/sidebarSettingsStore.test.ts b/apps/desktop/src/main/__tests__/sidebarSettingsStore.test.ts index 2b7783d7cb3..f2198672e32 100644 --- a/apps/desktop/src/main/__tests__/sidebarSettingsStore.test.ts +++ b/apps/desktop/src/main/__tests__/sidebarSettingsStore.test.ts @@ -200,6 +200,7 @@ describe('sidebarSettingsStore', () => { }); afterEach(() => { + vi.restoreAllMocks(); fs.rmSync(harness.root, { recursive: true, force: true }); }); diff --git a/apps/desktop/src/main/__tests__/webview-security.test.ts b/apps/desktop/src/main/__tests__/webview-security.test.ts index d28ded90cc2..5630860f42c 100644 --- a/apps/desktop/src/main/__tests__/webview-security.test.ts +++ b/apps/desktop/src/main/__tests__/webview-security.test.ts @@ -38,6 +38,8 @@ import { LOGIN_CAPTCHA_PARTITION, } from '../../shared/webviewPartition'; import { getEffectiveAppShortcuts, type AppShortcutId } from '../../shared/appShortcuts'; +import * as appSessionState from '../appSessionState'; +import { resolveGhostMediaHandoverTarget } from '../cindy-brain/ghostMediaHandoverTargetTracker'; import { BLANK_POPUP_WINDOW_WEB_PREFERENCES, DEFERRED_POPUP_ROUTE_TIMEOUT_MS, @@ -52,6 +54,7 @@ import { authorizeGhostWebviewAttach, hardenLoginCaptchaSession, installGhostGuestNavigationHandlers, + installGhostMediaHandoverSource, installBrowserGuestHandlers, installDeferredPopupRouter, installLoginCaptchaGuestHandlers, @@ -338,7 +341,9 @@ describe('applyGhostWebviewHardening(意识面板 webview)', () => { expect(authorizeGhostWebviewAttach(webPreferences, params, resolver)).toEqual({ id: 'same-ghost', + instanceId: 'same-ghost', owner: { mode: 'cloud', dataOwnerId: 'owner-a' }, + isCurrent: expect.any(Function), }); expect(resolver).toHaveBeenCalledWith( 'cindy-ghost-same-ghost', @@ -352,6 +357,39 @@ describe('applyGhostWebviewHardening(意识面板 webview)', () => { expect('allowpopups' in params).toBe(false); }); + it('retains the verified physical instance separately from the URL host', () => { + const resolver = vi.fn(() => ({ + ghost: { + manifest: { id: 'helper' }, namespace: 'acme', + dir: '/plugins/_ns/acme/helper', + }, + partition: 'cindy-ghost-owner:cloud:opaque-owner-a:_ns__acme__helper', + owner: { mode: 'cloud', dataOwnerId: 'owner-a' }, + })) as never; + expect(authorizeGhostWebviewAttach({}, { + partition: 'cindy-ghost-_ns__acme__helper', + src: 'cindy-ghost://helper/panel.html', + }, resolver)).toEqual({ + id: 'helper', instanceId: '_ns__acme__helper', + owner: { mode: 'cloud', dataOwnerId: 'owner-a' }, + isCurrent: expect.any(Function), + }); + }); + + it('keeps a stamped installation on its verified physical root key', () => { + const resolver = vi.fn(() => ({ + ghost: { manifest: { id: 'helper' }, namespace: 'acme', dir: '/plugins/helper' }, + partition: 'cindy-ghost-owner:cloud:opaque-owner-a:_ns__acme__helper', + owner: { mode: 'cloud', dataOwnerId: 'owner-a' }, + })) as never; + expect(authorizeGhostWebviewAttach({}, { + partition: 'cindy-ghost-_ns__acme__helper', src: 'cindy-ghost://helper/panel.html', + }, resolver)).toEqual({ + id: 'helper', instanceId: 'helper', owner: { mode: 'cloud', dataOwnerId: 'owner-a' }, + isCurrent: expect.any(Function), + }); + }); + it('Main 解析或协议注册异常时不核准 attach', () => { const params: Record = { src: 'cindy-ghost://same-ghost/panel.html', @@ -529,8 +567,113 @@ describe('installLoginCaptchaGuestHandlers(captcha guest 导航闸)', () => { }); }); +describe('Ghost handover attach lifetime', () => { + const uri = 'cindy-ghost://helper/preview/' + 'a'.repeat(64) + '.png'; + + afterEach(() => vi.restoreAllMocks()); + + function makeSource(instanceId = '_ns__acme__helper') { + const host = Object.assign(new EventEmitter(), { id: 10, isDestroyed: vi.fn(() => false) }); + const guest = Object.assign(new EventEmitter(), { + id: 20, isDestroyed: vi.fn(() => false), + executeJavaScript: vi.fn().mockResolvedValue(undefined), + setWindowOpenHandler: vi.fn(), + }); + let current = true; + installGhostGuestNavigationHandlers( + host as unknown as WebContents, guest as unknown as WebContents, 'helper', () => true, + { preview: vi.fn(), external: vi.fn() }, instanceId, () => current, + ); + const ready = () => { + guest.emit('dom-ready'); + const script = guest.executeJavaScript.mock.lastCall?.[0] as string; + return JSON.parse(script.slice(script.lastIndexOf(',') + 1, -1)) as string; + }; + return { host, guest, ready, expire: () => { current = false; } }; + } + + it('wires registration into the real guest handlers and retires a reloaded guest token', () => { + const source = makeSource(); + const token = source.ready(); + expect(resolveGhostMediaHandoverTarget(token, uri)).toEqual({ ghostId: 'helper', instanceId: '_ns__acme__helper' }); + const replacement = source.ready(); + expect(replacement).not.toBe(token); + expect(resolveGhostMediaHandoverTarget(token, uri)).toBeNull(); + expect(resolveGhostMediaHandoverTarget(replacement, uri)?.instanceId).toBe('_ns__acme__helper'); + source.guest.emit('destroyed'); + expect(resolveGhostMediaHandoverTarget(replacement, uri)).toBeNull(); + expect(source.host.listenerCount('destroyed')).toBe(0); + }); + + it.each(['destroyed', 'render-process-gone', 'did-navigate'])('revokes the source on guest %s', (event) => { + const source = makeSource(); + const token = source.ready(); + source.guest.emit(event, {}, 'cindy-ghost://helper/panel.html', false, true); + expect(resolveGhostMediaHandoverTarget(token, uri)).toBeNull(); + source.guest.emit('destroyed'); + }); + + it('does not retire a source on subframe or same-document navigation', () => { + const source = makeSource(); + const token = source.ready(); + source.guest.emit('did-start-navigation', {}, 'cindy-ghost://helper/panel.html', true, true); + source.guest.emit('did-start-navigation', {}, 'cindy-ghost://helper/frame.html', false, false); + const navigation = { preventDefault: vi.fn() }; + source.guest.emit('will-navigate', navigation, uri); + expect(navigation.preventDefault).toHaveBeenCalledOnce(); + expect(resolveGhostMediaHandoverTarget(token, uri)?.instanceId).toBe('_ns__acme__helper'); + source.guest.emit('destroyed'); + }); + + it('revokes a source when its host closes or receipt expires', () => { + const source = makeSource(); + const token = source.ready(); + source.expire(); + expect(resolveGhostMediaHandoverTarget(token, uri)).toBeNull(); + source.guest.emit('dom-ready'); + expect(source.guest.executeJavaScript).toHaveBeenCalledOnce(); + source.guest.emit('destroyed'); + const other = makeSource('helper'); + const rootToken = other.ready(); + other.host.emit('destroyed'); + expect(resolveGhostMediaHandoverTarget(rootToken, uri)).toBeNull(); + other.guest.emit('destroyed'); + }); + + it('rechecks the Main attach receipt and owner generation, including A to B to A', () => { + const session = vi.spyOn(appSessionState, 'getActiveAppSession').mockReturnValue({ mode: 'cloud', dataOwnerId: 'owner-a', generation: 1 }); + const approved = { + ghost: { manifest: { id: 'helper', version: '1.0.0' }, dir: '/plugins/_ns/acme/helper', namespace: 'acme', approval: { state: 'approved', revision: 'receipt-a' } }, + partition: 'cindy-ghost-owner:cloud:owner-a:_ns__acme__helper', + owner: { mode: 'cloud', dataOwnerId: 'owner-a' }, + }; + const resolver = vi.fn(() => approved); + const attach = authorizeGhostWebviewAttach({}, { partition: 'cindy-ghost-_ns__acme__helper', src: 'cindy-ghost://helper/panel.html' }, resolver as never); + expect(attach?.isCurrent()).toBe(true); + resolver.mockReturnValue({ ...approved, ghost: { ...approved.ghost, approval: { state: 'approved', revision: 'receipt-b' } } }); + expect(attach?.isCurrent()).toBe(false); + resolver.mockReturnValue(approved); + session.mockReturnValue({ mode: 'cloud', dataOwnerId: 'owner-b', generation: 2 }); + expect(attach?.isCurrent()).toBe(false); + session.mockReturnValue({ mode: 'cloud', dataOwnerId: 'owner-a', generation: 3 }); + expect(attach?.isCurrent()).toBe(false); + }); + + it('revokes tokens when script injection fails', async () => { + const host = Object.assign(new EventEmitter(), { isDestroyed: () => false }); + const guest = Object.assign(new EventEmitter(), { isDestroyed: () => false, executeJavaScript: vi.fn().mockRejectedValue(new Error('gone')) }); + installGhostMediaHandoverSource(host as unknown as WebContents, guest as unknown as WebContents, { ghostId: 'helper', instanceId: 'helper' }, () => true); + guest.emit('dom-ready'); + const script = guest.executeJavaScript.mock.lastCall?.[0] as string; + const token = JSON.parse(script.slice(script.lastIndexOf(',') + 1, -1)) as string; + await Promise.resolve(); + expect(resolveGhostMediaHandoverTarget(token, uri)).toBeNull(); + guest.emit('destroyed'); + }); +}); + describe('installGhostGuestNavigationHandlers(Ghost settingsHtml / panel 共用导航链)', () => { - function makeHarness() { + function makeHarness(instanceId?: string) { let openHandler: (() => { action: 'deny' }) | null = null; const guest = new EventEmitter() as EventEmitter & { setWindowOpenHandler: ReturnType; @@ -538,8 +681,9 @@ describe('installGhostGuestNavigationHandlers(Ghost settingsHtml / panel 共用 guest.setWindowOpenHandler = vi.fn((handler) => { openHandler = handler; }); - const host = { id: 10 } as unknown as WebContents; + const host = Object.assign(new EventEmitter(), { id: 10 }) as unknown as WebContents; let ownerActive = true; + let attachCurrent = true; const gesture = vi.fn(); const preview = vi.fn(); const external = vi.fn(); @@ -549,6 +693,8 @@ describe('installGhostGuestNavigationHandlers(Ghost settingsHtml / panel 共用 'xd-sites', () => ownerActive, { gesture, preview, external }, + instanceId, + () => attachCurrent, ); return { guest, @@ -559,10 +705,26 @@ describe('installGhostGuestNavigationHandlers(Ghost settingsHtml / panel 共用 setOwnerActive: (active: boolean) => { ownerActive = active; }, + setAttachCurrent: (current: boolean) => { attachCurrent = current; }, getOpenHandler: () => openHandler, }; } + it('does not navigate or record a gesture after its attached instance is replaced', () => { + const harness = makeHarness('_ns__acme__xd-sites'); + const url = 'https://example.invalid/control'; + harness.guest.emit('will-navigate', { preventDefault: vi.fn() }, url); + expect(harness.external).toHaveBeenCalledTimes(1); + const isCurrent = harness.external.mock.calls[0]![4] as () => boolean; + expect(isCurrent()).toBe(true); + harness.setAttachCurrent(false); + expect(isCurrent()).toBe(false); + harness.guest.emit('will-navigate', { preventDefault: vi.fn() }, url); + harness.guest.emit('before-input-event', {}, { type: 'mouseDown' }); + expect(harness.external).toHaveBeenCalledTimes(1); + expect(harness.gesture).not.toHaveBeenCalled(); + }); + it('普通 HTTPS 的 will-navigate 被拦下并带真实 host/guest 交给外链处理', () => { const harness = makeHarness(); const event = { preventDefault: vi.fn() }; @@ -576,10 +738,22 @@ describe('installGhostGuestNavigationHandlers(Ghost settingsHtml / panel 共用 harness.host, harness.guest, expect.any(Function), + undefined, ); expect(harness.preview).not.toHaveBeenCalled(); }); + it('carries the attached organization instance through external navigation and gestures', () => { + const harness = makeHarness('_ns__acme__xd-sites'); + harness.guest.emit('will-navigate', { preventDefault: vi.fn() }, 'https://example.com/'); + expect(harness.external).toHaveBeenCalledWith( + 'xd-sites', 'https://example.com/', harness.host, harness.guest, + expect.any(Function), '_ns__acme__xd-sites', + ); + harness.guest.emit('before-mouse-event', {}, { type: 'mouseDown' }); + expect(harness.gesture).toHaveBeenCalledWith('_ns__acme__xd-sites'); + }); + it('预览仍走既有处理,同 Ghost 协议普通页面仍允许原位导航', () => { const harness = makeHarness(); const previewEvent = { preventDefault: vi.fn() }; @@ -594,6 +768,17 @@ describe('installGhostGuestNavigationHandlers(Ghost settingsHtml / panel 共用 expect(allowEvent.preventDefault).not.toHaveBeenCalled(); }); + it('passes the verified instance to preview without changing the manifest URL host', () => { + const harness = makeHarness('_ns__xd__xd-sites'); + const url = 'cindy-ghost://xd-sites/preview/' + 'a'.repeat(64) + '.png'; + const event = { preventDefault: vi.fn() }; + harness.guest.emit('will-navigate', event, url); + expect(harness.preview).toHaveBeenCalledExactlyOnceWith( + 'xd-sites', url, harness.host, harness.guest, expect.any(Function), '_ns__xd__xd-sites', + ); + expect(event.preventDefault).toHaveBeenCalledOnce(); + }); + it('HTTP/自定义协议被静默拦下,不进入外链处理', () => { const harness = makeHarness(); for (const url of ['http://workers.xd.team/', 'custom://workers.xd.team/']) { diff --git a/apps/desktop/src/main/bootstrap-electron.ts b/apps/desktop/src/main/bootstrap-electron.ts index f98c8e3e4bb..7496f355456 100644 --- a/apps/desktop/src/main/bootstrap-electron.ts +++ b/apps/desktop/src/main/bootstrap-electron.ts @@ -1105,6 +1105,7 @@ import { suspendAllGhosts, waitForGhostMutations, } from './cindy-brain/index.js'; +import { installedGhostStoragePart } from '../shared/pluginIdentity.js'; import { setCodexImageAuthBinding } from './cindy-brain/codexImageAuthBinding.js'; import { listActiveClaudeBackgroundActivitySessions } from './maker-host/claude-session-background-activity.js'; import { registerRelaunchBusyActivityIpc } from './relaunchBusyActivityIpc.js'; @@ -2432,14 +2433,14 @@ const ghostPanelWindowsController = new GhostPanelWindowsController({ createWindow: (ghostId) => { const ghost = getGhostManager() .list() - .find((g) => g.manifest.id === ghostId); + .find((g) => installedGhostStoragePart(g) === ghostId); const title = ghost?.manifest.panel?.title ?? ghost?.manifest.name ?? ghostId; return createGhostPanelWindow(ghostId, title); }, isGhostDetachable: (ghostId) => { const ghost = getGhostManager() .list() - .find((g) => g.manifest.id === ghostId); + .find((g) => installedGhostStoragePart(g) === ghostId); return ( ghost !== undefined && ghost.enabled !== false && @@ -9237,6 +9238,14 @@ app.on('ready', async () => { } return; } + if (getActiveAppSession().dataOwnerId === userId) { + void getGhostManager().retryInterruptedMutationsAfterDbReady().catch((error) => { + dbClientLog.warn('ghost mutation recovery after DB readiness failed', { + userId, + error: error instanceof Error ? error.message : String(error), + }); + }); + } checkDatabaseSizeWarningAtStartup(); // Bot recovery is owner-scoped and must start only after DbClient // takeover. registerMakerIpc also invokes this once its services exist, diff --git a/apps/desktop/src/main/cindy-brain/GhostManager.ts b/apps/desktop/src/main/cindy-brain/GhostManager.ts index 3d3ea7e9986..ef37da4b69d 100644 --- a/apps/desktop/src/main/cindy-brain/GhostManager.ts +++ b/apps/desktop/src/main/cindy-brain/GhostManager.ts @@ -10,12 +10,32 @@ import { PLUGIN_MEMBER_UPLOAD_MAX_ZIP_ENTRIES, } from '@cindy/plugin-protocol'; +import { authorDeclaredNamespaceReason, isValidPluginNamespace } from '@cindy/plugin-protocol'; +import { + createPluginLogicalIdentity, + findConflictingGhostCommand, + findInstalledGhostByIdentity, + findInstalledGhostByInstanceId, + hasDeliveryNamespace, + installedGhostStoragePart, + isValidPluginInstallRelId, + parsePluginInstallRelId, + parsePluginStoragePart, + PLUGIN_NS_INSTALL_ROOT, + PLUGIN_ROOT_INSTALL_ROOT, + pluginNewInstallRelId, + pluginInstanceInstallRelId, + pluginInstallStoragePart, + pluginInstallRelId, + pluginStoragePart, +} from '../../shared/pluginIdentity.js'; + + import { GHOST_MANIFEST_FILE, GHOST_MANIFEST_MAX_BYTES, GHOST_LOCALE_MAX_BYTES, GHOST_ICON_MAX_BYTES, - GHOST_INSTALL_MANIFEST_MAX_BYTES, GHOST_MANUAL_ENTRY_FILE, GHOST_MANUAL_MD_MAX_BYTES, GHOST_SKILL_MD_MAX_BYTES, @@ -48,10 +68,30 @@ import { import { readBoundedFileNoFollowSync } from '../utils/readBoundedFile.js'; import { checkSkillMdConsistency } from './skillSlot.js'; import { + captureRecoveredNamespaceEntry, + createNamespaceMigrationStore, + censusNamespaceMigration, + dropNamespaceMigrationEntry, + type NamespaceCensusCandidate, + isPendingNamespaceGhost, + isCensusCandidate, + pendingNamespaceGhostIds, + planNamespaceCommit, + namespaceMigrationFilePath, + resolveInstallAgainstPending, + type NamespaceClassification, + type NamespaceMigrationBasis, + type NamespaceMigrationLedger, + type NamespaceMigrationStore, +} from './ghostNamespaceMigration.js'; +import { + assertManagedPluginParentSync, createGhostInstallReceipt, effectiveInstallOrigin, + legacyFirstPartyEligibilityAfterUpdate, GhostInstallReceiptStore, hashApprovedSkillContent, + isValidGhostSourceStateArchiveId, readLegacyInstallTrust, type GhostInstallReceipt, type GhostInstallReceiptReadResult, @@ -62,6 +102,7 @@ import { ghostManualLogicalPathForEntry, } from './ghostManualValidation.js'; import { installedFileModeFromZip, isZipSymbolicLinkMode } from './ghostZipPermissions.js'; +import { captureLegacyFirstPartyEligibility } from './ghostFirstPartyPrivilege.js'; /** 普通沙箱插件维持小包上限;随包 Node/CLI 允许更大的预打包产物。 */ export const MAX_BASIC_CINDY_FILE_BYTES = 8 * 1024 * 1024; @@ -121,6 +162,12 @@ function isZipSymbolicLink(entry: JSZip.JSZipObject): boolean { return isZipSymbolicLinkMode(entry.unixPermissions); } +function relIdFromUpdatingBackupName(name: string): string | null { + const match = /^\.cindy-updating-(.+)-[0-9a-f]{8}$/.exec(name); + if (!match) return null; + return pluginInstanceInstallRelId(match[1]); +} + /** 只有宿主安装/播种路径可以写入的 Cindy 官方身份。 */ export const CINDY_OFFICIAL_GHOST_TRUST: GhostTrustInfo = Object.freeze({ level: 'cindy-official', @@ -201,10 +248,28 @@ export interface GhostManagerOptions { */ isTrustedBundledId?: (id: string) => boolean; /** - * tokenBroker 装入闸。缺省只认静态官方前缀(测试夹具)。生产接线问 - * first-party 判据,官方前缀命中仍走静态表。 + * tokenBroker 装入闸。缺省拒绝(测试夹具须显式注入)。生产接线问 + * first-party 判据:名称前缀不构成资格。 */ isTokenBrokerAuthorized?: (manifest: GhostManifest) => boolean; + /** Classify a pending pre-namespace install using market/org facts. */ + classifyPendingNamespace?: ( + ghostId: string, + marketSyncCompleted?: boolean, + ) => NamespaceClassification; + /** Only return an organization namespace after matching approved package and market provenance. */ + recoverUnstampedOrganizationNamespace?: (ghostId: string) => string | null; + captureLegacyFirstPartyEligibility?: (ghostId: string, approvedPackageSha256: string) => boolean; + /** True when runtime/OAuth/install work should delay a first-time namespace stamp. */ + isNamespaceMigrationBusy?: (ghostId: string) => boolean; + canResumePendingResidentOffline?: (ghostId: string) => boolean; + onResumePendingResidentOffline?: (ghost: InstalledGhost) => void; + preparePendingResidentForMigration?: (ghostId: string) => Promise; + onPendingResidentMigrationDeferred?: (ghostId: string) => void; + /** Best-effort side effect after receipt + census ledger commit. */ + onNamespaceCommitted?: (ghostId: string, namespace: string | null) => void; + beforeNamespaceCommit?: (ghostId: string, namespace: string | null) => void; + onArchiveSourceState?: (fromPart: string, archivePart: string) => Promise; /** sourceDir 是否就是该 id 的随包只读种子目录,而非任意本机可变目录。 */ isTrustedBundledSource?: (id: string, sourceDir: string) => boolean; /** Persist the user's builtin-uninstall intent before approval/content removal. */ @@ -227,6 +292,7 @@ export type InstallRejection = | { code: 'not-installed'; reason: string } | { code: 'command-conflict'; reason: string } | { code: 'state-changed'; reason: string } + | { code: 'namespace-migration-pending'; reason: string } | { code: 'io'; reason: string; @@ -521,6 +587,9 @@ export class GhostManager { private readonly untrustedApprovals = new Set(); /** Owner namespaces whose mutation journal could not be authoritatively scanned. */ private readonly recoveryBlockedApprovalNamespaces = new Set(); + private readonly pendingRecoverySideEffects = new Set>(); + private readonly pendingMutationRecoveries = new Set(); + private recoveryRetry: Promise | null = null; /** * 进程内隔离集合的键:以**当前 owner 的状态根**为命名空间。集合是 manager 级 @@ -573,6 +642,362 @@ export class GhostManager { return this.activeMutationContext?.contentRoot ?? this.resolveContentRoot(); } + private contentPath(relId: string): string { + const root = this.contentRootDir(); + const parts = relId.split('/'); + if (parts.length > 1) { + let parent = root; + for (const part of parts.slice(0, -1)) { + parent = path.join(parent, part); + try { + if (classifyGhostDirEntrySync(parent) !== 'directory') { + throw new Error('plugin namespace parent is not a real directory'); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } + } + return path.join(root, ...parts); + } + + private namespaceMigrationStore(): NamespaceMigrationStore { + return createNamespaceMigrationStore(namespaceMigrationFilePath(this.stateRootDir())); + } + + private persistedNamespaceFields( + opts: { namespace?: string | null }, + existing: { namespace?: string | null } | null, + ): { namespace: string | null } | Record { + if (Object.prototype.hasOwnProperty.call(opts, 'namespace')) { + return { namespace: opts.namespace ?? null }; + } + if (existing && hasDeliveryNamespace(existing)) { + return { namespace: existing.namespace }; + } + return existing === null ? { namespace: null } : {}; + } + + private rootInstallCensusCandidates(): NamespaceCensusCandidate[] { + const root = this.contentRootDir(); + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(root, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } + const candidates: NamespaceCensusCandidate[] = []; + for (const entry of entries) { + if (entry.name.startsWith('.') || entry.name === PLUGIN_NS_INSTALL_ROOT || + entry.name === PLUGIN_ROOT_INSTALL_ROOT) continue; + if (!isValidGhostId(entry.name)) continue; + const dir = path.join(root, entry.name); + if (classifyGhostDirEntrySync(dir) !== 'directory') continue; + const approval = this.readApproval(entry.name); + candidates.push({ + ghostId: entry.name, + relId: entry.name, + ...(approval.state === 'approved' ? { identitySource: approval.receipt } : {}), + }); + } + const pending = this.receiptStore.listPendingMutationIdsSync(); + if (pending.state === 'ok' && !pending.blocked) { + const seen = new Set(candidates.map((candidate) => candidate.ghostId)); + for (const id of pending.ids) { + if (!isValidGhostId(id) || seen.has(id)) continue; + const marker = this.receiptStore.readPendingMutationSync(id); + if (marker.state !== 'valid' || marker.mutation.kind !== 'update') continue; + const backup = path.join(root, marker.mutation.backupDirName); + if (this.recoveryEntryKind(backup) !== 'directory') continue; + const approval = this.receiptStore.readForRecovery(id); + if (approval.state !== 'approved') continue; + candidates.push({ ghostId: id, relId: id, identitySource: approval.receipt }); + } + } + return candidates; + } + + private loadNamespaceMigrationLedger(): NamespaceMigrationLedger | null { + const existing = this.namespaceMigrationStore().read(); + if (existing.kind === 'ok') return existing.ledger; + let candidates: NamespaceCensusCandidate[]; + try { + candidates = this.rootInstallCensusCandidates(); + } catch (error) { + this.options.log?.warn('namespace migration census scan failed', { + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + const outcome = censusNamespaceMigration(existing, candidates, new Date().toISOString()); + if (outcome.kind === 'blocked') { + this.options.log?.warn('namespace migration census blocked', { reason: outcome.reason }); + return null; + } + if (outcome.kind === 'created') { + try { + for (const entry of Object.values(outcome.ledger.entries)) { + const approval = this.readApproval(entry.relId); + if (approval.state === 'approved' && approval.receipt.packageSha256 && + (captureLegacyFirstPartyEligibility({ + legacyExistingInstall: true, ghostId: entry.ghostId, namespace: null, approved: true, + approvedPackageSha256: approval.receipt.packageSha256, marketRecord: null, + approvedOfficialTrust: approval.receipt.trust.level === 'cindy-official', + }) || this.options.captureLegacyFirstPartyEligibility?.(entry.ghostId, approval.receipt.packageSha256) === true)) { + this.receiptStore.captureLegacyFirstPartyEligibilitySync(entry.relId, approval.receipt.revision); + } + } + this.namespaceMigrationStore().write(outcome.ledger); + } catch (error) { + this.options.log?.warn('namespace migration census write failed', { + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + } + return outcome.ledger; + } + + ensureNamespaceMigrationCensus(): NamespaceMigrationLedger | null { + this.ensureCurrentOwnerContextSync(); + return this.loadNamespaceMigrationLedger(); + } + + captureRecoveredLegacyNamespace(ids: readonly string[]): void { + this.ensureCurrentOwnerContextSync(); + const ledger = this.loadNamespaceMigrationLedger(); + if (!ledger) throw new Error('namespace migration census unavailable during legacy recovery'); + let next = ledger; + for (const id of ids) { + if (!isValidGhostId(id)) continue; + const approval = this.receiptStore.readForRecovery(id); + if (approval.state !== 'approved') continue; + const dir = path.join(this.contentRootDir(), id); + if (this.recoveryEntryKind(dir) !== 'directory') continue; + next = captureRecoveredNamespaceEntry(next, { + ghostId: id, + relId: id, + identitySource: approval.receipt, + }, new Date().toISOString()); + } + if (next !== ledger) this.namespaceMigrationStore().write(next); + } + + private async resolvePendingNamespaceInstall( + ghostId: string, + requestedNamespace: string | null, + ): Promise> { + const ledger = this.loadNamespaceMigrationLedger(); + const pending = ledger ? isPendingNamespaceGhost(ledger, ghostId) : false; + if (!pending && ledger) { + const approval = this.readApproval(ghostId); + if (approval.state !== 'approved' || hasDeliveryNamespace(approval.receipt)) { + return { kind: 'proceed' }; + } + return resolveInstallAgainstPending({ + ghostId, requestedNamespace, pending: true, classification: null, + }); + } + if (!ledger) { + const approval = this.readApproval(ghostId); + const legacy = + this.rootInstallCensusCandidates().some((candidate) => candidate.ghostId === ghostId) && + (approval.state !== 'approved' || !hasDeliveryNamespace(approval.receipt)); + if (!legacy) return { kind: 'proceed' }; + return resolveInstallAgainstPending({ + ghostId, + requestedNamespace, + pending: true, + classification: this.options.classifyPendingNamespace?.(ghostId) ?? null, + }); + } + const classification = this.options.classifyPendingNamespace?.(ghostId) ?? null; + if (pending && requestedNamespace !== null && classification?.kind === 'commit' && + classification.namespace !== requestedNamespace) { + const committed = await this.commitPendingNamespaceUnlocked( + ghostId, classification.namespace, classification.basis, + ); + if (committed.ok) return { kind: 'proceed' }; + } + return resolveInstallAgainstPending({ + ghostId, + requestedNamespace, + pending, + classification, + }); + } + + async reconcilePendingRootNamespaces(marketSyncCompleted: boolean): Promise { + const ledger = this.ensureNamespaceMigrationCensus(); + if (!ledger) return; + const ownerContextKey = this.currentOwnerContextKey(); + if (marketSyncCompleted && this.options.recoverUnstampedOrganizationNamespace) { + for (const candidate of this.rootInstallCensusCandidates()) { + if (this.currentOwnerContextKey() !== ownerContextKey) return; + if (isPendingNamespaceGhost(ledger, candidate.ghostId) || + !isCensusCandidate(candidate) || + this.hasPendingMutationJournal(candidate.ghostId)) continue; + const namespace = this.options.recoverUnstampedOrganizationNamespace(candidate.ghostId); + if (!namespace) continue; + const latest = this.loadNamespaceMigrationLedger(); + if (!latest) return; + const captured = captureRecoveredNamespaceEntry(latest, candidate, new Date().toISOString()); + if (captured !== latest) this.namespaceMigrationStore().write(captured); + } + } + for (const ghostId of pendingNamespaceGhostIds(this.loadNamespaceMigrationLedger() ?? ledger)) { + if (this.currentOwnerContextKey() !== ownerContextKey) return; + try { + if (marketSyncCompleted) { + const ready = await this.options.preparePendingResidentForMigration?.(ghostId); + if (this.currentOwnerContextKey() !== ownerContextKey) return; + if (ready === false) { + this.options.onPendingResidentMigrationDeferred?.(ghostId); + continue; + } + } + const classification = this.options.classifyPendingNamespace?.(ghostId, marketSyncCompleted) ?? { + kind: 'pending' as const, + reason: 'awaiting-market-facts', + }; + if (classification.kind === 'commit') { + const result = await this.commitPendingNamespace( + ghostId, classification.namespace, classification.basis, + ); + if (!result.ok && result.reason === 'busy' && marketSyncCompleted) { + this.options.onPendingResidentMigrationDeferred?.(ghostId); + } + } + } catch (error) { + if (marketSyncCompleted && this.currentOwnerContextKey() === ownerContextKey) { + this.options.onPendingResidentMigrationDeferred?.(ghostId); + } + throw error; + } + } + } + + resumePendingResidentsOffline(): void { + const ledger = this.ensureNamespaceMigrationCensus(); + if (!ledger || !this.options.canResumePendingResidentOffline || + !this.options.onResumePendingResidentOffline) return; + const ghosts = this.list(); + for (const ghost of ghosts) { + const ghostId = ghost.manifest.id; + if (ghost.dir !== this.contentPath(ghostId) || + ghost.namespaceMigration !== 'pending' || !ghost.enabled || + ghost.approval.state !== 'approved' || this.hasPendingMutationJournal(ghostId)) continue; + const approval = this.readApproval(ghostId); + if (approval.state !== 'approved' || hasDeliveryNamespace(approval.receipt)) continue; + if (this.options.canResumePendingResidentOffline(ghostId)) { + this.options.onResumePendingResidentOffline(ghost); + } + } + } + + async commitPendingRootNamespace( + ghostId: string, + basis: NamespaceMigrationBasis, + ): Promise<{ ok: true } | { ok: false; reason: string }> { + return this.commitPendingNamespace(ghostId, null, basis); + } + + async commitPendingNamespace( + ghostId: string, + namespace: string | null, + basis: NamespaceMigrationBasis, + ): Promise<{ ok: true } | { ok: false; reason: string }> { + return this.runExclusiveMutation(() => + this.commitPendingNamespaceUnlocked(ghostId, namespace, basis), + ); + } + + private async commitPendingNamespaceUnlocked( + ghostId: string, + namespace: string | null, + basis: NamespaceMigrationBasis, + ): Promise<{ ok: true } | { ok: false; reason: string }> { + const ownerContextKey = this.currentOwnerContextKey(); + if (!isValidGhostId(ghostId)) return { ok: false, reason: 'invalid ghost id' }; + if (namespace !== null && !isValidPluginNamespace(namespace)) { + return { ok: false, reason: 'invalid namespace' }; + } + const ledger = this.loadNamespaceMigrationLedger(); + if (!ledger || !isPendingNamespaceGhost(ledger, ghostId)) { + return { ok: false, reason: 'not pending' }; + } + const approval = this.readApproval(ghostId); + const receiptNamespace = + approval.state === 'approved' && hasDeliveryNamespace(approval.receipt) + ? approval.receipt.namespace ?? null + : undefined; + const plan = planNamespaceCommit({ + pending: true, + busy: + this.hasPendingMutationJournal(ghostId) || + this.options.isNamespaceMigrationBusy?.(ghostId) === true, + ...(receiptNamespace !== undefined ? { receiptNamespace } : {}), + requested: { namespace, basis }, + }); + if (plan.kind === 'skip') return { ok: false, reason: plan.reason }; + this.options.beforeNamespaceCommit?.(ghostId, plan.namespace); + if (plan.kind === 'write-receipt-and-ledger') { + if (approval.state !== 'approved') return { ok: false, reason: 'busy' }; + await this.receiptStore.write( + { ...approval.receipt, namespace: plan.namespace }, + { skillSourceDir: this.contentPath(ghostId), requireSkillSnapshot: false, relId: ghostId }, + ); + } + if (this.currentOwnerContextKey() !== ownerContextKey) { + throw new Error('ghost owner changed while committing a namespace migration'); + } + this.options.onNamespaceCommitted?.(ghostId, plan.namespace); + this.namespaceMigrationStore().write( + dropNamespaceMigrationEntry(ledger, ghostId), + ); + this.options.onChanged?.(this.list()); + return { ok: true }; + } + + private listManagedInstallDirs(root: string): Array<{ relId: string; dir: string }> { + const listed: Array<{ relId: string; dir: string }> = []; + for (const reservedRoot of [PLUGIN_ROOT_INSTALL_ROOT, PLUGIN_NS_INSTALL_ROOT]) { + const managedRoot = path.join(root, reservedRoot); + let parents = [reservedRoot]; + try { + if (classifyGhostDirEntrySync(managedRoot) !== 'directory') continue; + if (reservedRoot === PLUGIN_NS_INSTALL_ROOT) { + parents = fs.readdirSync(managedRoot).filter(isValidPluginNamespace) + .map((namespace) => reservedRoot + '/' + namespace); + } + } catch { + continue; + } + for (const parent of parents) { + const parentDir = path.join(root, ...parent.split('/')); + let entries: string[]; + try { + if (classifyGhostDirEntrySync(parentDir) !== 'directory') continue; + entries = fs.readdirSync(parentDir); + } catch { + continue; + } + for (const entry of entries) { + const relId = parent + '/' + entry; + if (!parsePluginInstallRelId(relId)) continue; + const dir = path.join(parentDir, entry); + try { + if (classifyGhostDirEntrySync(dir) === 'directory') listed.push({ relId, dir }); + } catch { + continue; + } + } + } + } + return listed; + } + private stateRootDir(): string { return this.activeMutationContext?.stateRoot ?? this.resolveStateRoot(); } @@ -637,6 +1062,29 @@ export class GhostManager { this.recoverInterruptedMutationsSync(); } + retryInterruptedMutationsAfterDbReady(): Promise { + if (this.recoveryRetry) return this.recoveryRetry; + const ownerContextKey = this.currentOwnerContextKey(); + const retry = (async () => { + await Promise.allSettled([...this.pendingRecoverySideEffects]); + if (this.currentOwnerContextKey() !== ownerContextKey) return; + this.ensureCurrentOwnerContextSync(); + await Promise.allSettled([...this.pendingRecoverySideEffects]); + if (this.currentOwnerContextKey() !== ownerContextKey) return; + await this.runExclusiveMutation(async () => { + if (this.currentOwnerContextKey() !== ownerContextKey) return; + this.recoverInterruptedMutationsSync(); + }); + await Promise.allSettled([...this.pendingRecoverySideEffects]); + })(); + this.recoveryRetry = retry; + const clearRetry = () => { + if (this.recoveryRetry === retry) this.recoveryRetry = null; + }; + void retry.then(clearRetry, clearRetry); + return retry; + } + private currentOwnerContextKey(): string { return `${this.options.getOwnerContextKey?.() ?? ''}\u0000${this.resolveContentRoot()}\u0000${this.resolveStateRoot()}`; } @@ -719,7 +1167,7 @@ export class GhostManager { } for (const id of pendingScan.ids) { const markerResult = this.receiptStore.readPendingMutationSync(id); - const finalDir = path.join(root, id); + const finalDir = path.join(root, ...id.split('/')); if (markerResult.state === 'missing') continue; // A pending transaction means the receipt cannot authorize finalDir until // recovery proves commit/rollback and clears the marker. @@ -744,7 +1192,7 @@ export class GhostManager { if (!this.options.recordBuiltinTombstone) { throw new Error('builtin uninstall recovery has no tombstone writer'); } - this.options.recordBuiltinTombstone(id); + this.options.recordBuiltinTombstone(parsePluginInstallRelId(id)!.ghostId); } this.receiptStore.removeSync(id); if (this.recoveryEntryKind(finalDir) === 'directory') { @@ -798,6 +1246,77 @@ export class GhostManager { (marker.oldPackageSha256 !== undefined ? marker.oldPackageSha256 !== marker.packageSha256 : marker.phase === 'published')); + if (marker.sourceStateArchiveId !== undefined) { + if (!this.options.onArchiveSourceState) throw new Error('source archive recovery callback unavailable'); + if (backupKind !== 'directory' && backupKind !== 'missing') { + throw new Error('managed update backup is not a real directory'); + } + if (finalKind !== 'directory' && finalKind !== 'missing') { + throw new Error('managed update final is not a real directory'); + } + if (!committed && approval.state === 'approved' && + approval.receipt.revision === marker.receiptRevision) { + throw new Error('committed update receipt has no published directory'); + } + if (!committed && backupKind === 'missing' && + (finalKind !== 'directory' || marker.phase !== 'prepared')) { + throw new Error('source archive update rollback has no verified backup'); + } + const recoveryKey = this.isolationKey(id); + if (!this.pendingMutationRecoveries.has(recoveryKey)) { + const recoveryOwner = this.currentOwnerContextKey(); + const fromPart = pluginInstallStoragePart(id); + const archivePart = marker.sourceStateArchiveId; + this.pendingMutationRecoveries.add(recoveryKey); + const recovery = this.runExclusiveMutation(async () => { + let expectedMarker = marker; + const assertRecoveryCurrent = () => { + if (this.currentOwnerContextKey() !== recoveryOwner) { + throw new Error('source archive recovery owner changed'); + } + const currentMarker = this.receiptStore.readPendingMutationSync(id); + const currentApproval = this.receiptStore.readForRecovery(id); + if (currentMarker.state !== 'valid' || + JSON.stringify(currentMarker.mutation) !== JSON.stringify(expectedMarker) || + JSON.stringify(currentApproval) !== JSON.stringify(approval)) { + throw new Error('source archive recovery transaction superseded'); + } + }; + assertRecoveryCurrent(); + await this.options.onArchiveSourceState!( + committed ? fromPart : archivePart, committed ? archivePart : fromPart, + ); + assertRecoveryCurrent(); + if (committed) { + if (this.recoveryEntryKind(backupPath) === 'directory') { + fs.rmSync(backupPath, { recursive: true, force: true }); + } + } else if (backupKind === 'directory') { + expectedMarker = { ...marker, phase: 'prepared' }; + await this.receiptStore.writePendingMutation(id, expectedMarker); + assertRecoveryCurrent(); + if (this.recoveryEntryKind(finalDir) === 'directory') { + fs.rmSync(finalDir, { recursive: true, force: true }); + } + fs.renameSync(backupPath, finalDir); + } + assertRecoveryCurrent(); + this.receiptStore.clearPendingMutationSync(id); + this.untrustedApprovals.delete(recoveryKey); + }).catch((error) => { + this.options.log?.warn('ghost source archive recovery failed', { + id, error: error instanceof Error ? error.message : String(error), + }); + }); + this.pendingRecoverySideEffects.add(recovery); + const clearRecovery = () => { + this.pendingRecoverySideEffects.delete(recovery); + this.pendingMutationRecoveries.delete(recoveryKey); + }; + void recovery.then(clearRecovery, clearRecovery); + } + continue; + } if (committed) { if (backupKind === 'directory') { fs.rmSync(backupPath, { recursive: true, force: true }); // 陈旧旧字节 @@ -878,9 +1397,8 @@ export class GhostManager { (entry) => entry.name.startsWith('.cindy-updating-') && !handledBackupNames.has(entry.name), ); for (const entry of backups) { - const match = /^\.cindy-updating-(.+)-[0-9a-f]{8}$/.exec(entry.name); - if (!match || !isValidGhostId(match[1])) continue; - const id = match[1]; + const id = relIdFromUpdatingBackupName(entry.name); + if (!id) continue; if (blockedMutationIds.has(id)) continue; const backupPath = path.join(root, entry.name); try { @@ -888,18 +1406,15 @@ export class GhostManager { } catch { continue; } - const finalDir = path.join(root, id); + const finalDir = path.join(root, ...id.split('/')); // 按解析后的 id 精确比对,不能用前缀 startsWith:合法 id 允许带 `-`, // `.cindy-updating-foo-` 是 `.cindy-updating-foo-bar-` 的前缀, // 若用前缀匹配,foo 与 foo-bar 同时留有 backup 时,foo 的唯一 backup 会被 // 误统计成多个而判为"多备份,留待人工",崩溃后 foo 持续消失(评审 P1)。 - const siblings = backups.filter((other) => { - const otherMatch = /^\.cindy-updating-(.+)-[0-9a-f]{8}$/.exec(other.name); - return otherMatch !== null && otherMatch[1] === id; - }); + const siblings = backups.filter((other) => relIdFromUpdatingBackupName(other.name) === id); let finalKind: GhostDirEntryKind | 'missing'; try { - finalKind = classifyGhostDirEntrySync(finalDir); + finalKind = this.recoveryEntryKind(finalDir); } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { finalKind = 'missing'; @@ -969,9 +1484,42 @@ export class GhostManager { } /** `/` 是否真目录(非链接/junction;判据同 ghostContentTree,避免穿透删除)。 */ + private resolvePhysicalRelId(id: string, allowLegacyRoot = false): string | null { + const identity = parsePluginInstallRelId(id) ?? parsePluginStoragePart(id); + if (!identity) return null; + if (isValidGhostId(id) && this.isRealDirChildRel(id)) return id; + const logicalRel = pluginNewInstallRelId(identity); + if (this.isRealDirChildRel(logicalRel)) return logicalRel; + if (!allowLegacyRoot && (id.startsWith(PLUGIN_ROOT_INSTALL_ROOT + '/') || + id.startsWith(PLUGIN_ROOT_INSTALL_ROOT + '__'))) return logicalRel; + if (this.isRealDirChildRel(identity.ghostId)) { + const approval = this.receiptStore.read(identity.ghostId); + if (identity.namespace === null) { + if (approval.state !== 'approved' || !hasDeliveryNamespace(approval.receipt) || + approval.receipt.namespace === null) return identity.ghostId; + } else if (approval.state === 'approved' && hasDeliveryNamespace(approval.receipt) && + approval.receipt.namespace === identity.namespace) { + return identity.ghostId; + } + } + return logicalRel; + } + + /** Receipts live under install rel id (helper or _ns/acme/helper), including in-place org dirs. */ + private approvalRelIdFor(id: string): string | null { + const identity = parsePluginStoragePart(id) ?? parsePluginInstallRelId(id); + if (!identity) return null; + return this.resolvePhysicalRelId(id) ?? pluginInstallRelId(identity); + } + + private isRealDirChildRel(relId: string): boolean { + return this.isRealDirChild(this.contentRootDir(), relId); + } + private isRealDirChild(root: string, id: string): boolean { try { - return classifyGhostDirEntrySync(path.join(root, id)) === 'directory'; + if (!assertManagedPluginParentSync(root, id)) return false; + return classifyGhostDirEntrySync(path.join(root, ...id.split('/'))) === 'directory'; } catch { return false; } @@ -980,6 +1528,9 @@ export class GhostManager { /** Recovery distinguishes a missing path from transiently unreadable state. */ private recoveryEntryKind(absPath: string): GhostDirEntryKind | 'missing' { try { + const root = this.contentRootDir(); + const relPath = path.relative(root, absPath).split(path.sep).join('/'); + if (!assertManagedPluginParentSync(root, relPath)) return 'missing'; return classifyGhostDirEntrySync(absPath); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 'missing'; @@ -1032,8 +1583,10 @@ export class GhostManager { /** 只认显式 Forge 安装写入的来源;未知/手动来源一律按 manual。 */ readEffectiveInstallOrigin(id: string): 'manual' | 'agent-forge' { this.ensureCurrentOwnerContextSync(); + const relId = this.approvalRelIdFor(id); + if (!relId) return 'manual'; try { - const approval = this.readApproval(id); + const approval = this.readApproval(relId); if (approval.state !== 'approved') return 'manual'; return effectiveInstallOrigin(approval.receipt); } catch { @@ -1041,19 +1594,79 @@ export class GhostManager { } } + /** Trusted receipt namespace. undefined means the delivery field is absent. */ + readDeliveryNamespace(id: string): string | null | undefined { + this.ensureCurrentOwnerContextSync(); + const relId = this.approvalRelIdFor(id); + if (!relId) return undefined; + try { + const approval = this.readApproval(relId); + if (approval.state !== 'approved' || !hasDeliveryNamespace(approval.receipt)) { + return undefined; + } + return approval.receipt.namespace ?? null; + } catch { + return undefined; + } + } + + readLegacyFirstPartyEligible(id: string): boolean { + this.ensureCurrentOwnerContextSync(); + const relId = this.approvalRelIdFor(id); + if (!relId) return false; + try { + const approval = this.readApproval(relId); + return approval.state === 'approved' && approval.receipt.legacyFirstPartyEligible === true; + } catch { + return false; + } + } + + isPendingLegacyForge(id: string): boolean { + return this.isPendingLegacyNamespace(id) && this.readEffectiveInstallOrigin(id) === 'agent-forge'; + } + + isPendingLegacyNamespace(id: string): boolean { + this.ensureCurrentOwnerContextSync(); + const identity = parsePluginInstallRelId(id); + if (!identity || identity.namespace !== null) return false; + const ledger = this.loadNamespaceMigrationLedger(); + return isPendingNamespaceGhost(ledger, id) && this.readApproval(id).state === 'approved' && + this.readDeliveryNamespace(id) === undefined; + } + /** * 自动接管只能把一份成功读取且仍为 approved 的 receipt 当作来源证据。 * 与授权链的宽松投影不同,这里任何缺失、损坏或 I/O 异常都必须上抛。 */ readApprovedInstallOriginStrict(id: string): 'manual' | 'agent-forge' { this.ensureCurrentOwnerContextSync(); - const approval = this.readApproval(id); + const relId = this.approvalRelIdFor(id); + if (!relId) { + throw new Error('approved Plugin receipt is unavailable: invalid-id'); + } + const approval = this.readApproval(relId); if (approval.state !== 'approved') { throw new Error(`approved Plugin receipt is unavailable: ${approval.state}`); } return effectiveInstallOrigin(approval.receipt); } + readApprovedInstallReceipt(id: string, expectedRevision?: string): GhostInstallReceipt | null { + this.ensureCurrentOwnerContextSync(); + const relId = this.approvalRelIdFor(id); + if (!relId) return null; + try { + if (this.hasPendingMutationJournal(relId)) return null; + const approval = this.readApproval(relId); + if (approval.state !== 'approved' || + (expectedRevision !== undefined && approval.receipt.revision !== expectedRevision)) return null; + return approval.receipt; + } catch { + return null; + } + } + /** * Host-owned evidence for reconnecting an installation to retained source metadata. * A pending package mutation or an invalid approval fails closed. Legacy provenance @@ -1061,8 +1674,11 @@ export class GhostManager { */ approvedInstallEvidence(id: string): ApprovedGhostInstallEvidence | null { this.ensureCurrentOwnerContextSync(); - if (!isValidGhostId(id) || this.hasPendingMutationJournal(id)) return null; - const approval = this.readApproval(id); + const identity = parsePluginStoragePart(id) ?? parsePluginInstallRelId(id); + if (!identity) return null; + const relId = this.approvalRelIdFor(id) ?? pluginInstallRelId(identity); + if (this.hasPendingMutationJournal(relId)) return null; + const approval = this.readApproval(relId); if (approval.state !== 'approved') return null; const packageSha256 = approval.receipt.packageSha256; const migration = this.receiptStore.readMigrationLedger(); @@ -1073,7 +1689,7 @@ export class GhostManager { legacyMigrated: Boolean( migration && migration.state !== 'in-progress' - && migration.migratedIds.includes(id) + && migration.migratedIds.includes(identity.ghostId) ), }; } @@ -1151,12 +1767,14 @@ export class GhostManager { ) { return false; } - const current = this.readApproval(ghost.manifest.id); + const relId = path.relative(this.contentRootDir(), ghost.dir).split(path.sep).join('/'); + if (!isValidPluginInstallRelId(relId)) return false; + const current = this.readApproval(relId); if (current.state !== 'approved' || current.receipt.revision !== ghost.approval.revision) { return false; } const expectedRoot = this.receiptStore.skillSnapshotRoot( - current.receipt.id, + relId, current.receipt.revision, ); if (path.resolve(ghost.approvedSkillRoot) !== path.resolve(expectedRoot)) { @@ -1226,7 +1844,7 @@ export class GhostManager { }, removeInstallApproval: async (id) => { assertCapabilityActive(); - return this.removeInstallApprovalUnlocked(id); + return this.removeInstallApprovalUnlocked(this.resolvePhysicalRelId(id) ?? id); }, uninstall: async (id, options = {}) => { assertCapabilityActive(); @@ -1347,7 +1965,7 @@ export class GhostManager { if (id.startsWith('.') || !isValidGhostId(id)) continue; // 判据走 ghostContentTree(lstat 分类),不信 Dirent 类型位:根子项是 junction/ // 链接时一律不进迁移,也与 §3「只有真目录才算安装」同向。 - if (classifyGhostDirEntrySync(path.join(root, id)) !== 'directory') continue; + if (classifyGhostDirEntrySync(path.join(root, ...id.split('/'))) !== 'directory') continue; // 随包种子交给 provisioning,不在迁移范围。 if (this.options.isTrustedBundledId?.(id)) { result.skipped.push(id); @@ -1449,7 +2067,7 @@ export class GhostManager { for (const id of candidates) { try { - const migrated = await this.backfillLegacyApproval(path.join(root, id), id, { + const migrated = await this.backfillLegacyApproval(path.join(root, ...id.split('/')), id, { expectedApprovalProjectionSha256: resumeApprovalProjectionDigest?.[id], }); @@ -1634,7 +2252,7 @@ export class GhostManager { continue; } try { - const targetDir = path.join(root, id); + const targetDir = path.join(root, ...id.split('/')); const expectedApprovalProjectionSha256 = options.expectedApprovalProjectionSha256ById[id]; if (expectedApprovalProjectionSha256 === undefined) { @@ -1759,7 +2377,7 @@ export class GhostManager { skillContentSha256: projection.skillContentSha256, ...(projection.iconDataUrl !== undefined ? { iconDataUrl: projection.iconDataUrl } : {}), }), - { skillSourceDir: dir }, + { skillSourceDir: dir, relId: id }, ); this.options.log?.info('legacy ghost approval migrated', { id, @@ -1781,9 +2399,11 @@ export class GhostManager { return []; // 根目录还不存在 = 没装过任何意识 } - const result: InstalledGhost[] = []; + const namespaceLedger = this.loadNamespaceMigrationLedger(); + const listedDirs = this.listManagedInstallDirs(root); for (const entry of entries) { if (entry.name.startsWith('.')) continue; // staging / 系统目录 + if (entry.name === PLUGIN_NS_INSTALL_ROOT || entry.name === PLUGIN_ROOT_INSTALL_ROOT) continue; const dir = path.join(root, entry.name); // 判据走 ghostContentTree(lstat 分类),不信 Dirent 类型位:根子项是 junction/ // 链接时不算已装插件 —— 与迁移扫描、内容树遍历同一份判据(§3)。 @@ -1796,32 +2416,45 @@ export class GhostManager { this.options.log?.warn('ghost dir skipped: invalid directory id', { dir }); continue; } - const approvalResult = this.readApproval(entry.name); + listedDirs.push({ relId: entry.name, dir }); + } + const result: InstalledGhost[] = []; + for (const listed of listedDirs) { + const { relId, dir } = listed; + const identity = parsePluginInstallRelId(relId); + if (!identity) continue; + const approvalResult = this.readApproval(relId); if (approvalResult.state === 'approved') { const receipt = approvalResult.receipt; const localizedManifest = this.localizeApprovedManifest(receipt); result.push({ manifest: localizedManifest, dir, + ...(identity.namespace !== null || (approvalResult.state === 'approved' && hasDeliveryNamespace(approvalResult.receipt)) + ? { namespace: identity.namespace !== null ? identity.namespace : approvalResult.receipt.namespace } + : {}), + ...(isValidGhostId(relId) && (isPendingNamespaceGhost(namespaceLedger, identity.ghostId) || + !hasDeliveryNamespace(receipt)) + ? { namespaceMigration: 'pending' as const } : {}), enabled: this.effectiveEnabled(dir, receipt.enabled), approval: { state: 'approved', revision: receipt.revision }, trust: receipt.trust, ...(receipt.manifest.skill?.items.length ? { approvedSkillRoot: this.receiptStore.skillSnapshotRoot( - receipt.id, + relId, receipt.revision, ), } : {}), ...(receipt.iconDataUrl !== undefined ? { iconDataUrl: receipt.iconDataUrl } : {}), - ...(this.options.isTrustedBundledId?.(entry.name) ? { builtin: true } : {}), + ...(identity.namespace === null && this.options.isTrustedBundledId?.(identity.ghostId) ? { builtin: true } : {}), }); continue; } if (approvalResult.state === 'invalid') { this.options.log?.warn('ghost approval receipt invalid; plugin kept disabled', { - id: entry.name, + id: relId, reason: approvalResult.reason, }); } @@ -1858,7 +2491,7 @@ export class GhostManager { this.options.log?.warn('ghost dir skipped: invalid manifest', { dir, reason: v.reason }); continue; } - if (v.manifest.id !== entry.name) { + if (v.manifest.id !== identity.ghostId) { this.options.log?.warn('ghost dir skipped: dir name != manifest id', { dir, manifestId: v.manifest.id, @@ -1867,7 +2500,6 @@ export class GhostManager { } // 历史 manifest / receipt 中可能保留已移除的资源搜索元数据;它不参与当前 // 运行时入口,插件本体与已批准的其它能力仍按现有授权照常可用。 - const manifest = v.manifest; // icon 读失败只降级为无图标(warn),不影响意识本体可用。 // receipt 模型:无有效批准的安装一律 enabled:false + approval:{state},不按 // .disabled 镜像判运行(那是被 revert 的旧模型、#636 漏洞路径)。trust 只在 @@ -1877,6 +2509,10 @@ export class GhostManager { result.push({ manifest: localizedManifest, dir, + ...(isValidGhostId(relId) ? {} : { namespace: identity.namespace }), + ...(isValidGhostId(relId) && (namespaceLedger + ? isPendingNamespaceGhost(namespaceLedger, identity.ghostId) + : true) ? { namespaceMigration: 'pending' as const } : {}), enabled: false, approval: { state: approvalResult.state }, // 未批准安装目录里的 trust 镜像是可变字节,不能作为可信展示事实。 @@ -1887,7 +2523,7 @@ export class GhostManager { reviewed: false, }, ...(iconDataUrl !== null ? { iconDataUrl } : {}), - ...(this.options.isTrustedBundledId?.(entry.name) ? { builtin: true } : {}), + ...(identity.namespace === null && this.options.isTrustedBundledId?.(identity.ghostId) ? { builtin: true } : {}), }); } result.sort((a, b) => a.manifest.id.localeCompare(b.manifest.id)); @@ -1906,7 +2542,7 @@ export class GhostManager { } { const list = this.list(); const ghost = - list.find((item) => item.manifest.id === fallback.manifest.id) ?? + findInstalledGhostByInstanceId(list, installedGhostStoragePart(fallback)) ?? ({ ...fallback, enabled: false, @@ -1998,10 +2634,12 @@ export class GhostManager { id: string, enabled: boolean, ): Promise<{ ok: true } | { rejection: UninstallRejection }> { - if (!isValidGhostId(id)) { + const identity = parsePluginInstallRelId(id); + if (!identity) { return { rejection: { code: 'invalid-id', reason: '非法意识 id' } }; } - const dir = path.join(this.contentRootDir(), id); + const relId = this.resolvePhysicalRelId(id) ?? pluginInstallRelId(identity); + const dir = this.contentPath(relId); // pathExists(fs.access)跟随链接:`/` 被换成 junction 时,下面对 // `/.disabled` 的写/删会穿透到安装根之外的目标。判据改用 ghostContentTree 的 // lstat 分类(与 list()「只有真目录才算安装」同源):不存在(ENOENT→抛错)或非真目录 @@ -2013,22 +2651,22 @@ export class GhostManager { dirKind = null; } if (dirKind !== 'directory') { - return { rejection: { code: 'not-installed', reason: `意识 ${id} 未装入` } }; + return { rejection: { code: 'not-installed', reason: `意识 ${identity.ghostId} 未装入` } }; } - const receiptResult = this.readApproval(id); + const receiptResult = this.readApproval(relId); if (receiptResult.state !== 'approved' && enabled) { return { rejection: { code: 'approval-required', - reason: `插件 ${id} 缺少有效的安装验证记录,请重新安装`, + reason: `插件 ${identity.ghostId} 缺少有效的安装验证记录,请重新安装`, }, }; } // Re-check immediately before touching the compatibility mirror. This does not // replace OS-level handle protection, but prevents a stale initial classification // from authorizing a link/file target in the common race window. - if (!this.isRealDirChild(this.contentRootDir(), id)) { - return { rejection: { code: 'not-installed', reason: `意识 ${id} 未装入` } }; + if (!this.isRealDirChildRel(relId)) { + return { rejection: { code: 'not-installed', reason: `意识 ${identity.ghostId} 未装入` } }; } const marker = path.join(dir, DISABLED_MARKER_FILE); // 回滚基准取"镜像先前是否在盘上",不是 receipt.enabled:两者可以背离(旧客户端 @@ -2075,7 +2713,7 @@ export class GhostManager { // 也照样落盘,由技能对账把落链撤掉。 await this.receiptStore.write( { ...receiptResult.receipt, enabled }, - { skillSourceDir: dir, requireSkillSnapshot: enabled }, + { skillSourceDir: dir, requireSkillSnapshot: enabled, relId }, ); } catch (err) { if (!enabled) { @@ -2314,6 +2952,10 @@ export class GhostManager { rejection: { code: 'file-invalid', reason: `${GHOST_MANIFEST_FILE} 不是合法 JSON` }, }; } + const reservedNamespace = authorDeclaredNamespaceReason(manifestRaw); + if (reservedNamespace) { + return { rejection: { code: 'file-invalid', reason: reservedNamespace } }; + } const hostUnsupportedReason = ghostManifestHostUnsupportedReason(manifestRaw); if (hostUnsupportedReason) { return { rejection: { code: 'host-unsupported', reason: hostUnsupportedReason } }; @@ -2597,7 +3239,8 @@ export class GhostManager { initiallyEnabled?: boolean; expectedPackageSha256?: string; trustOverride?: GhostHostTrustOverride; - installOrigin?: 'agent-forge'; + installOrigin?: 'manual' | 'agent-forge'; + namespace?: string | null; /** Synchronous live-authority check immediately before publishing the staged package. */ beforePackagePlacement?: () => void; }, @@ -2611,7 +3254,8 @@ export class GhostManager { initiallyEnabled?: boolean; expectedPackageSha256?: string; trustOverride?: GhostHostTrustOverride; - installOrigin?: 'agent-forge'; + installOrigin?: 'manual' | 'agent-forge'; + namespace?: string | null; beforePackagePlacement?: () => void; }, ): Promise<{ ghost: InstalledGhost } | { rejection: InstallRejection }> { @@ -2649,19 +3293,39 @@ export class GhostManager { // 4) 目标目录冲突检查 const root = this.contentRootDir(); - const finalDir = path.join(root, manifest.id); + const identity = createPluginLogicalIdentity(opts?.namespace ?? null, manifest.id); + const relId = pluginNewInstallRelId(identity); + if (this.hasPendingMutationJournal(relId)) { + return { rejection: { code: 'io', reason: '意识正在恢复,请稍后重试' } }; + } + if (findInstalledGhostByIdentity(this.list(), identity)) { + return { rejection: { code: 'already-installed', reason: `意识 ${manifest.id} 已装入` } }; + } + const physicalRel = this.resolvePhysicalRelId(relId) ?? relId; + if (physicalRel !== relId && this.isRealDirChildRel(physicalRel)) { + return { rejection: { code: 'already-installed', reason: `意识 ${manifest.id} 已装入` } }; + } + const finalDir = this.contentPath(relId); if (await pathExists(finalDir)) { return { rejection: { code: 'already-installed', reason: `意识 ${manifest.id} 已装入` } }; } + const pendingInstall = await this.resolvePendingNamespaceInstall(manifest.id, identity.namespace); + if (pendingInstall.kind === 'already-installed') { + return { rejection: { code: 'already-installed', reason: `意识 ${manifest.id} 已装入` } }; + } + if (pendingInstall.kind === 'wait') { + return { rejection: { code: 'namespace-migration-pending', reason: pendingInstall.reason } }; + } + await fs.promises.mkdir(path.dirname(finalDir), { recursive: true }); + this.contentPath(relId); // 4.5) 显式指令查重(2026-07-09 Lizi 定案):command 由意识作者自定, // 与本机已装意识撞名即拒——不静默改名(确定性),由用户抽离旧的或 // 作者换名解决。大小写折叠比较,防 /Draw 与 /draw 并存互踩。 if (manifest.command !== undefined) { - const commandFold = manifest.command.toLowerCase(); - const holder = this.list().find( - (g) => g.manifest.command !== undefined && g.manifest.command.toLowerCase() === commandFold, - ); + const holder = findConflictingGhostCommand(this.list(), manifest.command, { + incomingNamespace: identity.namespace, + }); if (holder) { return { rejection: { @@ -2675,7 +3339,7 @@ export class GhostManager { // 5) 解压到 staging(zip-slip / zip bomb 防御),全过才切正式目录 const stagingDir = path.join( root, - `.cindy-installing-${manifest.id}-${crypto.randomBytes(4).toString('hex')}`, + `.cindy-installing-${pluginStoragePart(identity)}-${crypto.randomBytes(4).toString('hex')}`, ); const receiptRevision = crypto.randomUUID(); // receipt 在内容落到 finalDir 之后才创建:技能字节指纹必须从这次批准的内容 @@ -2695,20 +3359,20 @@ export class GhostManager { // "有 finalDir、无 receipt、无 ledger"的目录,与 legacy 安装无法区分,被迁移当 // 存量批准掉(而崩溃窗口内同权限进程可改写 finalDir 的 manifest)。带 packageSha256 // 让启动恢复能判定 receipt 是否已提交。 - await this.receiptStore.writePendingMutation(manifest.id, { + await this.receiptStore.writePendingMutation(relId, { kind: 'install', packageSha256, receiptRevision, ...(clearBuiltinTombstoneOnCommit ? { clearBuiltinTombstone: true } : {}), }); - this.untrustedApprovals.add(this.isolationKey(manifest.id)); + this.untrustedApprovals.add(this.isolationKey(relId)); try { opts?.beforePackagePlacement?.(); } catch (error) { // No package bytes were published. Clear the prepared journal so a // cancelled request cannot leave an installation waiting for recovery. - await this.receiptStore.clearPendingMutation(manifest.id); - this.untrustedApprovals.delete(this.isolationKey(manifest.id)); + await this.receiptStore.clearPendingMutation(relId); + this.untrustedApprovals.delete(this.isolationKey(relId)); throw error; } await fs.promises.rename(stagingDir, finalDir); @@ -2729,8 +3393,9 @@ export class GhostManager { revision: receiptRevision, ...(iconDataUrl !== undefined ? { iconDataUrl } : {}), ...(opts?.installOrigin ? { installOrigin: opts.installOrigin } : {}), + ...this.persistedNamespaceFields(opts ?? {}, null), }); - await this.receiptStore.write(receipt, { skillSourceDir: finalDir }); + await this.receiptStore.write(receipt, { skillSourceDir: finalDir, relId }); let tombstoneClearPending = false; if (clearBuiltinTombstoneOnCommit) { try { @@ -2751,8 +3416,8 @@ export class GhostManager { // receipt.packageSha256 与标记相符即判已提交、幂等清理。 if (!tombstoneClearPending) { try { - await this.receiptStore.clearPendingMutation(manifest.id); - this.untrustedApprovals.delete(this.isolationKey(manifest.id)); + await this.receiptStore.clearPendingMutation(relId); + this.untrustedApprovals.delete(this.isolationKey(relId)); } catch { // Keep quarantine while the durable journal remains. } @@ -2760,13 +3425,13 @@ export class GhostManager { // The receipt and installed bytes are committed. The remaining // journal only records a deferred builtin tombstone side effect and // must not keep an otherwise valid builtin disabled in-process. - this.untrustedApprovals.delete(this.isolationKey(manifest.id)); + this.untrustedApprovals.delete(this.isolationKey(relId)); } } catch (error) { try { await fs.promises.rm(finalDir, { recursive: true, force: true }); - await this.receiptStore.clearPendingMutation(manifest.id); - this.untrustedApprovals.delete(this.isolationKey(manifest.id)); + await this.receiptStore.clearPendingMutation(relId); + this.untrustedApprovals.delete(this.isolationKey(relId)); } catch (rollbackError) { // Keep the install journal whenever rollback cannot prove the published // directory is gone. Migration treats that journal as a hard block, and @@ -2804,6 +3469,7 @@ export class GhostManager { approval: { state: 'approved', revision: receipt.revision }, trust, ...(iconDataUrl !== undefined ? { iconDataUrl } : {}), + ...this.persistedNamespaceFields(opts ?? {}, null), }; this.options.log?.info('ghost installed', { id: manifest.id, version: manifest.version }); const projected = this.projectCommittedMutationResult(ghost); @@ -2826,7 +3492,9 @@ export class GhostManager { expectedInstalledApproval: string; expectedPackageSha256?: string; trustOverride?: GhostHostTrustOverride; - installOrigin?: 'agent-forge'; + installOrigin?: 'manual' | 'agent-forge'; + namespace?: string | null; + sourceStateArchiveId?: string; beforePackageCommit?: () => GhostPackageCommitPreparation | void; /** 目录换位完成后、任何通知或运行时收尾前触发。 */ onPackagePlaced?: () => void; @@ -2841,7 +3509,9 @@ export class GhostManager { expectedInstalledApproval: string; expectedPackageSha256?: string; trustOverride?: GhostHostTrustOverride; - installOrigin?: 'agent-forge'; + installOrigin?: 'manual' | 'agent-forge'; + namespace?: string | null; + sourceStateArchiveId?: string; /** 新目录已换位、旧目录仍可回滚时执行;抛错会恢复旧版本。 */ beforePackageCommit?: () => GhostPackageCommitPreparation | void; /** 目录换位完成后、任何通知或运行时收尾前触发。 */ @@ -2875,13 +3545,23 @@ export class GhostManager { : parsed.trust; const root = this.contentRootDir(); - const finalDir = path.join(root, manifest.id); - if (!this.isRealDirChild(root, manifest.id)) { + const identity = createPluginLogicalIdentity(opts.namespace ?? null, manifest.id); + const relId = + this.resolvePhysicalRelId(pluginNewInstallRelId(identity), true) ?? pluginNewInstallRelId(identity); + const fromPart = pluginInstallStoragePart(relId); + const archiveId = opts.sourceStateArchiveId; + if (archiveId !== undefined && + (!isValidGhostSourceStateArchiveId(archiveId) || archiveId === fromPart || + !this.options.onArchiveSourceState)) { + return { rejection: { code: 'io', reason: 'source state archive identity or callback unavailable' } }; + } + const finalDir = this.contentPath(relId); + if (!this.isRealDirChildRel(relId)) { return { rejection: { code: 'not-installed', reason: `意识 ${manifest.id} 未装入,无从更新` }, }; } - const approvalResult = this.readApproval(manifest.id); + const approvalResult = this.readApproval(relId); const actualApproval = approvalTokenFor(approvalResult); if (actualApproval !== opts.expectedInstalledApproval) { return { @@ -2907,13 +3587,10 @@ export class GhostManager { // 指令查重同 install,但豁免自己(新版本沿用/改名自己的指令都合法)。 if (manifest.command !== undefined) { - const commandFold = manifest.command.toLowerCase(); - const holder = this.list().find( - (g) => - g.manifest.id !== manifest.id && - g.manifest.command !== undefined && - g.manifest.command.toLowerCase() === commandFold, - ); + const holder = findConflictingGhostCommand(this.list(), manifest.command, { + incomingNamespace: identity.namespace, + exemptPhysicalRelId: relId, + }); if (holder) { return { rejection: { @@ -2925,8 +3602,9 @@ export class GhostManager { } const rand = crypto.randomBytes(4).toString('hex'); - const stagingDir = path.join(root, `.cindy-installing-${manifest.id}-${rand}`); - const backupDir = path.join(root, `.cindy-updating-${manifest.id}-${rand}`); + const workName = pluginInstallStoragePart(relId); + const stagingDir = path.join(root, `.cindy-installing-${workName}-${rand}`); + const backupDir = path.join(root, `.cindy-updating-${workName}-${rand}`); try { await this.extractToStaging(allEntries, prefix, stagingDir, { disabled: !enabled, @@ -2951,12 +3629,13 @@ export class GhostManager { // 已提交:未提交则回滚到 backup。 const receiptRevision = crypto.randomUUID(); try { - await this.receiptStore.writePendingMutation(manifest.id, { + await this.receiptStore.writePendingMutation(relId, { kind: 'update', packageSha256, backupDirName: path.basename(backupDir), receiptRevision, phase: 'prepared', + ...(archiveId !== undefined ? { sourceStateArchiveId: archiveId } : {}), ...(approvalResult.state === 'approved' && approvalResult.receipt.packageSha256 ? { oldPackageSha256: approvalResult.receipt.packageSha256 } : {}), @@ -2972,11 +3651,11 @@ export class GhostManager { // exchange so concurrent list/spawn/host calls cannot project the old // receipt onto the new finalDir while the new receipt and skill snapshot // are still being committed. - this.untrustedApprovals.add(this.isolationKey(manifest.id)); + this.untrustedApprovals.add(this.isolationKey(relId)); const clearUpdateQuarantineAfterRollback = async (): Promise => { try { - await this.receiptStore.clearPendingMutation(manifest.id); - this.untrustedApprovals.delete(this.isolationKey(manifest.id)); + await this.receiptStore.clearPendingMutation(relId); + this.untrustedApprovals.delete(this.isolationKey(relId)); } catch { // Keep the in-process quarantine if the journal cannot be cleared; // restart recovery must see the marker before authorization resumes. @@ -2996,12 +3675,13 @@ export class GhostManager { // this write is interrupted, recovery can infer the same state from the // presence of the backup and will never treat a pre-rename final as new code. await this.receiptStore - .writePendingMutation(manifest.id, { + .writePendingMutation(relId, { kind: 'update', packageSha256, backupDirName: path.basename(backupDir), receiptRevision, phase: 'backed-up', + ...(archiveId !== undefined ? { sourceStateArchiveId: archiveId } : {}), ...(approvalResult.state === 'approved' && approvalResult.receipt.packageSha256 ? { oldPackageSha256: approvalResult.receipt.packageSha256 } : {}), @@ -3051,8 +3731,15 @@ export class GhostManager { // committed. Later failures compensate both this side effect and the // directory swap; either rollback failure keeps journal + quarantine. let packageCommitPreparation: GhostPackageCommitPreparation | undefined; + let archiveAttempted = false; + let archiveCompleted = false; let receipt: GhostInstallReceipt; try { + if (archiveId !== undefined) { + archiveAttempted = true; + await this.options.onArchiveSourceState!(fromPart, archiveId); + archiveCompleted = true; + } packageCommitPreparation = opts.beforePackageCommit?.() ?? undefined; receipt = createGhostInstallReceipt({ manifest: approvedManifest, @@ -3069,15 +3756,35 @@ export class GhostManager { revision: receiptRevision, ...(iconDataUrl !== undefined ? { iconDataUrl } : {}), ...(installOrigin ? { installOrigin } : {}), + ...(approvalResult.state === 'approved' && + legacyFirstPartyEligibilityAfterUpdate(approvalResult.receipt, archiveId !== undefined) + ? { legacyFirstPartyEligible: true } : {}), + ...this.persistedNamespaceFields(opts, approvalResult.state === 'approved' ? approvalResult.receipt : null), }); - await this.receiptStore.write(receipt, { skillSourceDir: finalDir }); + await this.receiptStore.write(receipt, { skillSourceDir: finalDir, relId }); } catch (err) { - let sideEffectRolledBack = !( + let archiveRestored = !archiveAttempted; + if (archiveCompleted && archiveId !== undefined) { + try { + await this.options.onArchiveSourceState!(archiveId, fromPart); + const pending = this.receiptStore.readPendingMutationSync(relId); + if (pending.state !== 'valid' || pending.mutation.kind !== 'update') { + throw new Error('source archive rollback journal unavailable'); + } + await this.receiptStore.writePendingMutation(relId, { ...pending.mutation, phase: 'prepared' }); + archiveRestored = true; + } catch (rollbackError) { + this.options.log?.warn('ghost source archive rollback failed', { + id: manifest.id, error: rollbackError instanceof Error ? rollbackError.message : String(rollbackError), + }); + } + } + let sideEffectRolledBack = archiveRestored && !( err instanceof Error && 'rollbackFailed' in err && err.rollbackFailed === true ); - if (packageCommitPreparation) { + if (packageCommitPreparation && archiveRestored) { try { packageCommitPreparation.rollback(); } catch (rollbackErr) { @@ -3133,8 +3840,8 @@ export class GhostManager { } // receipt 已就位 = 事务提交,清标记后再回收 backup;顺序保证"标记在 ⟺ 可能未提交"。 try { - await this.receiptStore.clearPendingMutation(manifest.id); - this.untrustedApprovals.delete(this.isolationKey(manifest.id)); + await this.receiptStore.clearPendingMutation(relId); + this.untrustedApprovals.delete(this.isolationKey(relId)); } catch { // Keep quarantine while the durable journal remains; recovery retries it. } @@ -3157,6 +3864,7 @@ export class GhostManager { approval: { state: 'approved', revision: receipt.revision }, trust, ...(iconDataUrl !== undefined ? { iconDataUrl } : {}), + ...this.persistedNamespaceFields(opts, approvalResult.state === 'approved' ? approvalResult.receipt : null), }; opts?.onPackagePlaced?.(); this.options.log?.info('ghost updated', { id: manifest.id, version: manifest.version }); @@ -3198,10 +3906,14 @@ export class GhostManager { } const packageSha256 = await hashApprovedDirectory(sourceDir); const root = this.contentRootDir(); - const finalDir = path.join(root, id); + const relId = this.isRealDirChildRel(id) ? id + : this.isRealDirChildRel(pluginNewInstallRelId(createPluginLogicalIdentity(null, id))) + ? pluginNewInstallRelId(createPluginLogicalIdentity(null, id)) : id; + const storagePart = pluginInstallStoragePart(relId); + const finalDir = path.join(root, ...relId.split('/')); const rand = crypto.randomBytes(4).toString('hex'); - const stagingDir = path.join(root, `.cindy-installing-${id}-${rand}`); - const backupDir = path.join(root, `.cindy-updating-${id}-${rand}`); + const stagingDir = path.join(root, `.cindy-installing-${storagePart}-${rand}`); + const backupDir = path.join(root, `.cindy-updating-${storagePart}-${rand}`); let finalKind: GhostDirEntryKind | 'missing'; try { finalKind = classifyGhostDirEntrySync(finalDir); @@ -3225,28 +3937,28 @@ export class GhostManager { ); } if (finalKind === 'missing') { - await this.receiptStore.writePendingMutation(id, { kind: 'install', packageSha256 }); - this.untrustedApprovals.add(this.isolationKey(id)); + await this.receiptStore.writePendingMutation(relId, { kind: 'install', packageSha256 }); + this.untrustedApprovals.add(this.isolationKey(relId)); await fs.promises.rename(stagingDir, finalDir); return; } - await this.receiptStore.writePendingMutation(id, { + await this.receiptStore.writePendingMutation(relId, { kind: 'update', packageSha256, backupDirName: path.basename(backupDir), phase: 'prepared', }); - this.untrustedApprovals.add(this.isolationKey(id)); + this.untrustedApprovals.add(this.isolationKey(relId)); await fs.promises.rename(finalDir, backupDir); - await this.receiptStore.writePendingMutation(id, { + await this.receiptStore.writePendingMutation(relId, { kind: 'update', packageSha256, backupDirName: path.basename(backupDir), phase: 'backed-up', }); await fs.promises.rename(stagingDir, finalDir); - await this.receiptStore.writePendingMutation(id, { + await this.receiptStore.writePendingMutation(relId, { kind: 'update', packageSha256, backupDirName: path.basename(backupDir), @@ -3265,15 +3977,15 @@ export class GhostManager { if (backupKind === 'directory' && publishedKind === null) { try { await fs.promises.rename(backupDir, finalDir); - await this.receiptStore.clearPendingMutation(id); - this.untrustedApprovals.delete(this.isolationKey(id)); + await this.receiptStore.clearPendingMutation(relId); + this.untrustedApprovals.delete(this.isolationKey(relId)); } catch { // Keep the journal for startup recovery when rollback cannot complete now. } } else if (backupKind === null && publishedKind === null) { try { - await this.receiptStore.clearPendingMutation(id); - this.untrustedApprovals.delete(this.isolationKey(id)); + await this.receiptStore.clearPendingMutation(relId); + this.untrustedApprovals.delete(this.isolationKey(relId)); } catch { // Keep the in-process quarantine while the journal remains. } @@ -3302,7 +4014,8 @@ export class GhostManager { `approveTrustedBundledInstall 只服务随包种子插件:${manifest.id} 不在种子清单里`, ); } - const dir = path.join(this.contentRootDir(), manifest.id); + const relId = this.resolvePhysicalRelId(manifest.id) ?? manifest.id; + const dir = this.contentPath(relId); if (!options || typeof options.sourceDir !== 'string' || options.sourceDir.trim() === '') { throw new Error('approveTrustedBundledInstall requires a verified bundled source directory'); } @@ -3337,7 +4050,7 @@ export class GhostManager { const iconDataUrl = this.readInstalledIconDataUrl(sourceDir, approvedManifest) ?? undefined; const packageSha256 = await hashApprovedDirectory(sourceDir); const skillContentSha256 = await hashApprovedSkillContent(approvedManifest, sourceDir); - const pendingPublish = this.receiptStore.readPendingMutationSync(approvedManifest.id); + const pendingPublish = this.receiptStore.readPendingMutationSync(relId); if (pendingPublish.state === 'invalid' || pendingPublish.state === 'unreadable') { throw new Error(`builtin seed publish journal is ${pendingPublish.state}`); } @@ -3352,12 +4065,12 @@ export class GhostManager { throw new Error('builtin seed publish journal does not match immutable source'); } const trust = CINDY_OFFICIAL_GHOST_TRUST; - const current = this.readApproval(approvedManifest.id); + const current = this.readApproval(relId); // priorEnabled 直接读盘上的 receipt 而不是 readApproval 的投影:进程内隔离态的 // receipt 不可作授权事实,但"曾经停用"这个位只用于往下拉,是 fail closed 方向, // 采纳它只会更保守 —— 否则"隔离 + 镜像同时丢失"的组合会让自愈把插件带回启用。 const persisted = - current.state === 'approved' ? current : this.receiptStore.read(approvedManifest.id); + current.state === 'approved' ? current : this.receiptStore.read(relId); const priorEnabled = persisted.state === 'approved' ? persisted.receipt.enabled : undefined; const enabled = priorEnabled === undefined ? markerEnabled : markerEnabled && priorEnabled; if (enabled !== markerEnabled) { @@ -3390,7 +4103,7 @@ export class GhostManager { (approvedManifest.skill?.items.length ?? 0) === 0 || (await this.receiptStore.skillSnapshotMatchesReceipt( current.receipt, - this.receiptStore.skillSnapshotRoot(approvedManifest.id, current.receipt.revision), + this.receiptStore.skillSnapshotRoot(relId, current.receipt.revision), )); if (!snapshotHealthy) { // Snapshot repair must persist the same one-way disabled merge as the @@ -3401,10 +4114,10 @@ export class GhostManager { ...current.receipt, enabled, }, - { skillSourceDir: sourceDir }, + { skillSourceDir: sourceDir, relId }, ); - await this.finishTrustedBundledPublish(approvedManifest.id, pendingPublish); - this.untrustedApprovals.delete(this.isolationKey(approvedManifest.id)); + await this.finishTrustedBundledPublish(relId, pendingPublish); + this.untrustedApprovals.delete(this.isolationKey(relId)); return true; } if (current.receipt.enabled !== enabled) { @@ -3413,20 +4126,20 @@ export class GhostManager { ...current.receipt, enabled, }, - { skillSourceDir: sourceDir }, + { skillSourceDir: sourceDir, relId }, ); - await this.finishTrustedBundledPublish(approvedManifest.id, pendingPublish); - this.untrustedApprovals.delete(this.isolationKey(approvedManifest.id)); + await this.finishTrustedBundledPublish(relId, pendingPublish); + this.untrustedApprovals.delete(this.isolationKey(relId)); return true; } - await this.finishTrustedBundledPublish(approvedManifest.id, pendingPublish); + await this.finishTrustedBundledPublish(relId, pendingPublish); // Receipt already matches the immutable seed — no write needed, // but the process-internal untrusted approval quarantine from // publishTrustedBundledSeed must still be cleared. The other // two branches (full write and enabled toggle) both clear it; // without it here, the no-op path leaves the plugin quarantined // until the next restart (P1, PRRT_kwDOTgdRUs6YcxiH). - this.untrustedApprovals.delete(this.isolationKey(approvedManifest.id)); + this.untrustedApprovals.delete(this.isolationKey(relId)); return false; } await this.receiptStore.write( @@ -3438,11 +4151,12 @@ export class GhostManager { skillContentSha256, packageSha256, ...(iconDataUrl !== undefined ? { iconDataUrl } : {}), + namespace: null, }), - { skillSourceDir: sourceDir }, + { skillSourceDir: sourceDir, relId }, ); - await this.finishTrustedBundledPublish(approvedManifest.id, pendingPublish); - this.untrustedApprovals.delete(this.isolationKey(approvedManifest.id)); + await this.finishTrustedBundledPublish(relId, pendingPublish); + this.untrustedApprovals.delete(this.isolationKey(relId)); return true; } @@ -3585,21 +4299,32 @@ export class GhostManager { return this.runExclusiveMutation(() => this.uninstallUnlocked(id, options)); } + + private forgetPendingNamespaceMigration(ghostId: string): void { + const read = this.namespaceMigrationStore().read(); + if (read.kind !== 'ok' || !(ghostId in read.ledger.entries)) return; + try { + this.namespaceMigrationStore().write(dropNamespaceMigrationEntry(read.ledger, ghostId)); + } catch (error) { + this.options.log?.warn('namespace migration pending drop failed', { + ghostId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + private async uninstallUnlocked( id: string, options: GhostUninstallOptions = {}, ): Promise { - if (!isValidGhostId(id)) { - return { rejection: { code: 'invalid-id', reason: '非法意识 id' } }; - } - const root = this.contentRootDir(); - const dir = path.join(root, id); - // 双保险:id 格式校验已排除路径穿越,这里再确认是 root 的直接子目录。 - if (path.dirname(dir) !== path.resolve(root) && path.dirname(dir) !== root) { + const identity = parsePluginInstallRelId(id); + if (!identity) { return { rejection: { code: 'invalid-id', reason: '非法意识 id' } }; } - if (!this.isRealDirChild(root, id)) { - return { rejection: { code: 'not-installed', reason: `意识 ${id} 未装入` } }; + const relId = this.resolvePhysicalRelId(id) ?? pluginInstallRelId(identity); + const dir = this.contentPath(relId); + if (!this.isRealDirChildRel(relId)) { + return { rejection: { code: 'not-installed', reason: `意识 ${identity.ghostId} 未装入` } }; } // 顺序是安全要点:先撤批准(receipt + 快照 + 隔离),再删内容目录。反过来(旧写法) // 若崩在两步之间,会留下"孤立 approved receipt + 目录暂缺";之后同 id 路径被恢复/ @@ -3607,27 +4332,29 @@ export class GhostManager { // trust 全来自 receipt),等于卸载过的插件被"借尸还魂"。事务标记让崩溃后的启动恢复 // 把这次卸载收尾干净。 const builtinTombstone = - options.recordBuiltinTombstone !== false && this.options.isTrustedBundledId?.(id) === true; - await this.receiptStore.writePendingMutation(id, { + options.recordBuiltinTombstone !== false && + identity.namespace === null && + this.options.isTrustedBundledId?.(identity.ghostId) === true; + await this.receiptStore.writePendingMutation(relId, { kind: 'uninstall', ...(builtinTombstone ? { builtinTombstone: true } : {}), }); - this.untrustedApprovals.add(this.isolationKey(id)); + this.untrustedApprovals.add(this.isolationKey(relId)); if (builtinTombstone) { try { if (!this.options.recordBuiltinTombstone) { throw new Error('builtin uninstall has no tombstone writer'); } - this.options.recordBuiltinTombstone(id); + this.options.recordBuiltinTombstone(identity.ghostId); } catch (err) { // Tombstone persistence is part of the uninstall transaction. If it // fails, roll back the pending journal and in-process quarantine so a // reported failure cannot be completed by startup recovery later. const journalCleared = await this.receiptStore - .clearPendingMutation(id) + .clearPendingMutation(relId) .then(() => true) .catch(() => false); - if (journalCleared) this.untrustedApprovals.delete(this.isolationKey(id)); + if (journalCleared) this.untrustedApprovals.delete(this.isolationKey(relId)); return { rejection: { code: 'io', reason: err instanceof Error ? err.message : String(err) }, }; @@ -3635,7 +4362,7 @@ export class GhostManager { } // 走同一个撤销入口:成功即清掉隔离记录,失败由该入口转进程内隔离并记日志。撤批准 // 在删目录之前 —— 即便随后删目录失败/崩溃,也不会留下"目录在 + 旧 receipt 授权"。 - const approvalRemoved = await this.removeInstallApprovalUnlocked(id); + const approvalRemoved = await this.removeInstallApprovalUnlocked(relId); try { await fs.promises.rm(dir, { recursive: true, force: true }); } catch (err) { @@ -3648,12 +4375,15 @@ export class GhostManager { // Receipt 删除失败时保留 uninstall journal,即使内容目录已经删掉;下次启动 // 仍需据 journal 清理孤立 receipt,不能让进程内隔离随重启丢失。 if (approvalRemoved) { - await this.receiptStore.clearPendingMutation(id).catch(() => undefined); - this.untrustedApprovals.delete(this.isolationKey(id)); + await this.receiptStore.clearPendingMutation(relId).catch(() => undefined); + this.untrustedApprovals.delete(this.isolationKey(relId)); } else { - this.options.log?.warn('ghost uninstall left journal for approval cleanup', { id }); + this.options.log?.warn('ghost uninstall left journal for approval cleanup', { id: relId }); + } + this.options.log?.info('ghost uninstalled', { id: relId }); + if (relId === identity.ghostId) { + this.forgetPendingNamespaceMigration(identity.ghostId); } - this.options.log?.info('ghost uninstalled', { id }); if (options.notify !== false) this.options.onChanged?.(this.list()); return { ok: true }; } diff --git a/apps/desktop/src/main/cindy-brain/__tests__/GhostManager.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/GhostManager.test.ts index b93c8ca083d..8e724d62e79 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/GhostManager.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/GhostManager.test.ts @@ -161,6 +161,23 @@ async function makeCindy( return out; } +async function installLegacyApproved(file: string): Promise { + const result = await manager.install(file); + expect(result).toHaveProperty('ghost'); + if (!('ghost' in result)) return; + const ghostId = result.ghost.manifest.id; + await fs.promises.rename(result.ghost.dir, path.join(rootDir, ghostId)); + const stateRoot = manager.approvalStateRoot(); + await fs.promises.rename( + path.join(stateRoot, '_root', ghostId + '.json'), + path.join(stateRoot, ghostId + '.json'), + ); + await fs.promises.rename( + path.join(stateRoot, '.migrated-_root.' + ghostId), + path.join(stateRoot, '.migrated-' + ghostId), + ); +} + /** 用 UNIX central-directory metadata 构造 mode 回归包。 */ async function makeUnixModeCindy( fileName: string, @@ -599,7 +616,7 @@ describe('GhostManager · 存量插件一次性迁移(§5 升级无感)', () => }); it('已有 receipt 不可读后即使消失也不从可变安装目录重铸', async () => { - await manager.install(await makeCindy('a.cindy', goodManifest())); + await installLegacyApproved(await makeCindy('a.cindy', goodManifest())); const receiptPath = path.join(workDir, 'ghosts-install-state', 'hello.json'); const receiptBefore = await fs.promises.readFile(receiptPath, 'utf8'); // 模拟 #1080 历史状态没有 ledger;receipt 本身只是在本轮被 AV/权限瞬时锁住。 @@ -657,7 +674,7 @@ describe('GhostManager · 存量插件一次性迁移(§5 升级无感)', () => }); it('修复 #1080 历史 mixed 状态:有效 receipt 不封死其余 legacy/旧 schema 插件', async () => { - await manager.install(await makeCindy('approved.cindy', goodManifest('approved'))); + await installLegacyApproved(await makeCindy('approved.cindy', goodManifest('approved'))); const approvedReceiptPath = path.join(workDir, 'ghosts-install-state', 'approved.json'); const approvedReceiptBefore = await fs.promises.readFile(approvedReceiptPath, 'utf8'); @@ -686,7 +703,7 @@ describe('GhostManager · 存量插件一次性迁移(§5 升级无感)', () => }); it('已有 receipt 的安装不被迁移覆盖(迁移只补,不改既有批准)', async () => { - await manager.install(await makeCindy('a.cindy', goodManifest())); + await installLegacyApproved(await makeCindy('a.cindy', goodManifest())); const before = await fs.promises.readFile( path.join(workDir, 'ghosts-install-state', 'hello.json'), 'utf8', @@ -934,7 +951,7 @@ describe('GhostManager · 迁移崩溃安全(in-progress 状态机)与隔离命 it('启用失败的回滚按"镜像先前是否在盘上",不吞掉旧客户端的停用决定', async () => { await manager.install(await makeCindy('a.cindy', goodManifest())); // 旧客户端只写镜像:receipt.enabled=true + .disabled 在盘 → 读时合并 = 停用。 - const marker = path.join(rootDir, 'hello', '.disabled'); + const marker = path.join(rootDir, '_root', 'hello', '.disabled'); await fs.promises.writeFile(marker, ''); expect(manager.list()[0].enabled).toBe(false); @@ -1108,18 +1125,14 @@ describe('GhostManager · review 第 6 轮回归(P0/P1 修复钉住)', () => { expect(fs.existsSync(migrationLedgerPath())).toBe(false); // 攻击:删掉 receipt,指望整个插件消失变成 legacy-unapproved。 // 不加 slot 修改(避免 backfillLegacyApproval 校验失败进入 failed 分支)。 - await fs.promises.rm(path.join(workDir, 'ghosts-install-state', 'hello.json')); - // Per-id migration marker prevents backfill. Without the marker, the - // coordinator would re-approve from the current mutable directory. With - // the marker, the system knows this was a new-model install whose receipt - // was deleted — not a legacy install. It stays fail-closed. + await fs.promises.rm(path.join(workDir, 'ghosts-install-state', '_root', 'hello.json')); const outcome = await manager.migrateLegacyApprovalsOnce(); - expect(outcome).toEqual({ migrated: [], skipped: ['hello'], failed: [], retryPending: [] }); + expect(outcome).toEqual({ migrated: [], skipped: [], failed: [], retryPending: [] }); expect(manager.list()[0].approval.state).toBe('legacy-unapproved'); }); it('P0-2: unreadable per-id migration marker keeps deleted receipt fail-closed', async () => { - await manager.install(await makeCindy('a.cindy', goodManifest())); + await installLegacyApproved(await makeCindy('a.cindy', goodManifest())); await fs.promises.rm(path.join(workDir, 'ghosts-install-state', 'hello.json')); const marker = path.join(workDir, 'ghosts-install-state', '.migrated-hello'); @@ -1150,40 +1163,44 @@ describe('GhostManager · review 第 6 轮回归(P0/P1 修复钉住)', () => { // install, and the migration door stays open for the coordinator. const result = await manager.install(await makeCindy('a.cindy', goodManifest())); expect('ghost' in result).toBe(true); - expect(fs.existsSync(path.join(workDir, 'ghosts-install-state', 'hello.json'))).toBe(true); + expect(fs.existsSync(path.join(workDir, 'ghosts-install-state', '_root', 'hello.json'))).toBe(true); expect(fs.existsSync(migrationLedgerPath())).toBe(false); // Door remains open: coordinator can still run. expect(manager.list()).toHaveLength(1); }); it('首次批准与目录回滚同时失败时保留 install journal,不让迁移收编未提交字节', async () => { - const finalDir = path.join(rootDir, 'hello'); - // The ledger auto-close is removed; without ledger-level failure injection - // the install succeeds normally. The rollback path (rm finalDir) is only - // reached when a previous step fails; a healthy install never hits it. - // The migration door remains open; the coordinator handles this by - // finding a valid receipt and skipping hello. - const result = await manager.install(await makeCindy('a.cindy', goodManifest())); - expect('ghost' in result).toBe(true); - + const finalDir = path.join(rootDir, '_root', 'hello'); + const store = (manager as unknown as { receiptStore: GhostInstallReceiptStore }).receiptStore; + const write = vi.spyOn(store, 'write').mockRejectedValueOnce(new Error('receipt blocked')); + const realRm = fs.promises.rm; + const remove = vi.spyOn(fs.promises, 'rm').mockImplementation(async (target, options) => { + if (String(target) === finalDir) throw new Error('rollback blocked'); + return realRm(target, options); + }); + try { + await expectRejection(await manager.install(await makeCindy('a.cindy', goodManifest())), 'io'); + } finally { + write.mockRestore(); + remove.mockRestore(); + } expect(fs.existsSync(finalDir)).toBe(true); - expect(fs.existsSync(path.join(workDir, 'ghosts-install-state', 'hello.json'))).toBe(true); + expect(store.readPendingMutationSync('_root/hello').state).toBe('valid'); + expect(fs.existsSync(path.join(workDir, 'ghosts-install-state', '_root', 'hello.json'))).toBe(false); expect(fs.existsSync(migrationLedgerPath())).toBe(false); - const migration = await manager.migrateLegacyApprovalsOnce(); - // Coordinator finds approved receipt for hello → skipped (already has receipt). expect(migration.migrated).toEqual([]); - expect(migration.skipped).toEqual(['hello']); + expect(migration.skipped).toEqual([]); expect(manager.list()).toHaveLength(1); - + expect(manager.list()[0].approval.state).not.toBe('approved'); const recovered = new GhostManager({ getRootDir: () => rootDir, getLocale: () => hostLocale, onChanged, }); - // Receipt persisted → plugin visible across manager instances. - expect(recovered.list()).toHaveLength(1); - expect(fs.existsSync(finalDir)).toBe(true); + expect(recovered.list()).toHaveLength(0); + expect(fs.existsSync(finalDir)).toBe(false); + expect(store.readPendingMutationSync('_root/hello').state).toBe('missing'); }); it('P0-2:安装根读失败(EACCES 类)本轮放弃且不落台账,不把迁移永久封死', async () => { @@ -1338,7 +1355,7 @@ describe('GhostManager · review 第 6 轮回归(P0/P1 修复钉住)', () => { }); it('closes the recovery ledger when a queued id is already approved', async () => { - await manager.install(await makeCindy('a.cindy', goodManifest())); + await installLegacyApproved(await makeCindy('a.cindy', goodManifest())); await fs.promises.writeFile( migrationLedgerPath(), JSON.stringify({ @@ -1383,7 +1400,7 @@ describe('GhostManager · review 第 6 轮回归(P0/P1 修复钉住)', () => { }); it('恢复旁路遇到 unreadable receipt 后不再允许自动重铸批准', async () => { - await manager.install(await makeCindy('a.cindy', goodManifest())); + await installLegacyApproved(await makeCindy('a.cindy', goodManifest())); const receiptPath = path.join(workDir, 'ghosts-install-state', 'hello.json'); const receiptBefore = await fs.promises.readFile(receiptPath, 'utf8'); await fs.promises.rm(migrationLedgerPath(), { force: true }); @@ -1522,7 +1539,7 @@ describe('GhostManager · review 第 6 轮回归(P0/P1 修复钉住)', () => { it('P1-9:更新失败且旧目录滚不回时如实报 rollbackFailed,不假装旧版本还在', async () => { await manager.install(await makeCindy('a.cindy', goodManifest())); - const finalDir = path.join(rootDir, 'hello'); + const finalDir = path.join(rootDir, '_root', 'hello'); const realRename = fs.promises.rename; const spy = vi.spyOn(fs.promises, 'rename').mockImplementation(async (from, to) => { // staging→final 与 backup→final 都失败(Windows 文件锁/AV 的典型形态)。 @@ -1539,7 +1556,7 @@ describe('GhostManager · review 第 6 轮回归(P0/P1 修复钉住)', () => { expect(result.rejection.code).toBe('io'); expect(result.rejection.code === 'io' && result.rejection.rollbackFailed).toBe(true); expect( - fs.existsSync(path.join(workDir, 'ghosts-install-state', '.pending-hello.json')), + fs.existsSync(path.join(workDir, 'ghosts-install-state', '_root', '.pending-hello.json')), ).toBe(true); } finally { spy.mockRestore(); @@ -1549,13 +1566,59 @@ describe('GhostManager · review 第 6 轮回归(P0/P1 修复钉住)', () => { describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => { const pendingMarkerPath = (id = 'hello') => - path.join(workDir, 'ghosts-install-state', `.pending-${id}.json`); + path.join(workDir, 'ghosts-install-state', ...id.split('/').slice(0, -1), '.pending-' + id.split('/').at(-1) + '.json'); const receiptPath = (id = 'hello') => path.join(workDir, 'ghosts-install-state', `${id}.json`); /** 在同一组根上新建 manager —— 构造期跑一次崩溃恢复扫描。 */ const freshManager = () => new GhostManager({ getRootDir: () => rootDir, getLocale: () => hostLocale, onChanged }); + it('does not recover a live update while its directory swap is waiting', async () => { + await manager.install( + await makeCindy('old.cindy', goodManifest(), { 'main.js': '// old bytes\n' }), + { namespace: 'acme' }, + ); + const oldApproval = manager.list()[0]!.approval; + const finalDir = path.join(rootDir, '_ns', 'acme', 'hello'); + const marker = path.join(workDir, 'ghosts-install-state', '_ns', 'acme', '.pending-hello.json'); + let enterRename!: () => void; + let resumeRename!: () => void; + const renameEntered = new Promise((resolve) => { enterRename = resolve; }); + const renameGate = new Promise((resolve) => { resumeRename = resolve; }); + const realRename = fs.promises.rename; + const spy = vi.spyOn(fs.promises, 'rename').mockImplementation(async (from, to) => { + if (path.resolve(String(from)) === path.resolve(finalDir) && + path.basename(String(to)).startsWith('.cindy-updating-')) { + enterRename(); + await renameGate; + } + return realRename(from, to); + }); + try { + const update = manager.update( + await makeCindy('new.cindy', { ...goodManifest(), version: '2.0.0' }, { 'main.js': '// new bytes\n' }), + { expectedInstalledApproval: ghostInstallApprovalToken(oldApproval), namespace: 'acme' }, + ); + await renameEntered; + expect(fs.existsSync(marker)).toBe(true); + const retry = manager.retryInterruptedMutationsAfterDbReady(); + await new Promise((resolve) => setImmediate(resolve)); + const stillProtected = fs.existsSync(marker) && manager.list()[0]?.approval.state === 'invalid'; + resumeRename(); + const [updated] = await Promise.all([update, retry]); + expect(stillProtected).toBe(true); + expect(updated).toHaveProperty('ghost'); + expect(manager.list()[0]).toMatchObject({ + manifest: { version: '2.0.0' }, + approval: { state: 'approved' }, + }); + expect(fs.readFileSync(path.join(finalDir, 'main.js'), 'utf8')).toBe('// new bytes\n'); + } finally { + resumeRename(); + spy.mockRestore(); + } + }); + it('rejects cancellation during install preparation before publishing bytes and allows retry', async () => { const file = await makeCindy('cancelled.cindy', goodManifest()); const controller = new AbortController(); @@ -1566,16 +1629,16 @@ describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => controller.abort(); }); const guard = vi.fn(() => { - expect(fs.existsSync(pendingMarkerPath())).toBe(true); - expect(fs.existsSync(path.join(rootDir, 'hello'))).toBe(false); + expect(fs.existsSync(pendingMarkerPath('_root/hello'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello'))).toBe(false); controller.signal.throwIfAborted(); }); try { await expectRejection(await manager.install(file, { beforePackagePlacement: guard }), 'io'); expect(guard).toHaveBeenCalledOnce(); expect(manager.list()).toEqual([]); - expect(fs.existsSync(pendingMarkerPath())).toBe(false); - expect(fs.existsSync(receiptPath())).toBe(false); + expect(fs.existsSync(pendingMarkerPath('_root/hello'))).toBe(false); + expect(fs.existsSync(receiptPath('_root/hello'))).toBe(false); expect((await fs.promises.readdir(rootDir)).filter(name => name.startsWith('.cindy-installing-'))).toEqual([]); expect(onChanged).not.toHaveBeenCalled(); } finally { @@ -1585,6 +1648,59 @@ describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => expect(retried).toMatchObject({ ghost: { manifest: { id: 'hello' }, enabled: true } }); }); + it('clears namespaced install and update journals by physical instance id', async () => { + const file = await makeCindy('ns-journal.cindy', goodManifest()); + const nsPending = path.join(workDir, 'ghosts-install-state', '_ns', 'acme', '.pending-hello.json'); + const rootPending = pendingMarkerPath('_root/hello'); + const controller = new AbortController(); + const writePending = GhostInstallReceiptStore.prototype.writePendingMutation; + const pendingSpy = vi.spyOn(GhostInstallReceiptStore.prototype, 'writePendingMutation') + .mockImplementation(async function (this: GhostInstallReceiptStore, ...args) { + await writePending.apply(this, args); + controller.abort(); + }); + try { + await expectRejection( + await manager.install(file, { + namespace: 'acme', + beforePackagePlacement: () => controller.signal.throwIfAborted(), + }), + 'io', + ); + expect(fs.existsSync(nsPending)).toBe(false); + expect(fs.existsSync(rootPending)).toBe(false); + } finally { + pendingSpy.mockRestore(); + } + + const installed = await manager.install(file, { namespace: 'acme' }); + expect(installed).toMatchObject({ + ghost: { manifest: { id: 'hello' }, namespace: 'acme' }, + }); + expect(fs.existsSync(nsPending)).toBe(false); + expect(fs.existsSync(rootPending)).toBe(false); + expect(fs.existsSync(path.join(rootDir, '_ns', 'acme', 'hello', 'ghost.json'))).toBe(true); + + const recovered = freshManager(); + expect(recovered.list()).toEqual([ + expect.objectContaining({ + namespace: 'acme', + approval: expect.objectContaining({ state: 'approved' }), + }), + ]); + + const bumped = await makeCindy('ns-journal-v2.cindy', { ...goodManifest(), version: '1.0.1' }); + const updated = await manager.update(bumped, { + expectedInstalledApproval: ghostInstallApprovalToken( + (installed as { ghost: InstalledGhost }).ghost.approval, + ), + namespace: 'acme', + }); + expect('ghost' in updated).toBe(true); + expect(fs.existsSync(nsPending)).toBe(false); + expect(fs.existsSync(rootPending)).toBe(false); + }); + it('崩溃的装入(有 finalDir、无 receipt、有 install 标记)被恢复删除,不被迁移收编', async () => { // install 在 rename(staging→final) 之后、写 receipt 之前崩溃:finalDir 完整、无 // receipt、无 ledger。若不处理,迁移会把它(崩溃窗口内可能被改过 manifest)当 legacy @@ -1624,8 +1740,8 @@ describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => expect('ghost' in installed).toBe(true); expect(restoringManager.list()[0]).toMatchObject({ approval: { state: 'approved' } }); - expect(fs.existsSync(pendingMarkerPath())).toBe(true); - expect(JSON.parse(await fs.promises.readFile(pendingMarkerPath(), 'utf8'))).toMatchObject({ + expect(fs.existsSync(pendingMarkerPath('_root/hello'))).toBe(true); + expect(JSON.parse(await fs.promises.readFile(pendingMarkerPath('_root/hello'), 'utf8'))).toMatchObject({ kind: 'install', clearBuiltinTombstone: true, }); @@ -1638,7 +1754,7 @@ describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => }); expect(clearBuiltinTombstone).toHaveBeenCalledTimes(2); - expect(fs.existsSync(pendingMarkerPath())).toBe(false); + expect(fs.existsSync(pendingMarkerPath('_root/hello'))).toBe(false); expect(recoveredAfterClearFailure.list()[0]).toMatchObject({ approval: { state: 'approved' }, }); @@ -1646,7 +1762,7 @@ describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => it('install 恢复必须用 packageSha256 证明旧 receipt 属于这次安装', async () => { await manager.install(await makeCindy('old.cindy', goodManifest())); - const finalDir = path.join(rootDir, 'hello'); + const finalDir = path.join(rootDir, '_root', 'hello'); await fs.promises.rm(finalDir, { recursive: true, force: true }); await fs.promises.mkdir(finalDir, { recursive: true }); await fs.promises.writeFile( @@ -1654,12 +1770,12 @@ describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => JSON.stringify({ ...goodManifest(), version: '2.0.0' }), ); await fs.promises.writeFile(path.join(finalDir, 'main.js'), 'new-bytes'); - await fs.promises.mkdir(path.dirname(pendingMarkerPath()), { recursive: true }); + await fs.promises.mkdir(path.dirname(pendingMarkerPath('_root/hello')), { recursive: true }); await fs.promises.writeFile( - pendingMarkerPath(), + pendingMarkerPath('_root/hello'), JSON.stringify({ version: 1, - id: 'hello', + id: '_root/hello', kind: 'install', packageSha256: 'f'.repeat(64), }), @@ -1667,23 +1783,23 @@ describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => new GhostManager({ getRootDir: () => rootDir, getLocale: () => hostLocale, onChanged }); expect(fs.existsSync(finalDir)).toBe(false); - expect(fs.existsSync(pendingMarkerPath())).toBe(false); + expect(fs.existsSync(pendingMarkerPath('_root/hello'))).toBe(false); }); it('非法 pending marker 会阻断 orphan backup 启发式,不删除待人工恢复的 backup', async () => { await manager.install(await makeCindy('old.cindy', goodManifest())); - const finalDir = path.join(rootDir, 'hello'); - const backupName = '.cindy-updating-hello-abcdef12'; + const finalDir = path.join(rootDir, '_root', 'hello'); + const backupName = '.cindy-updating-_root__hello-abcdef12'; await fs.promises.rename(finalDir, path.join(rootDir, backupName)); await fs.promises.mkdir(finalDir, { recursive: true }); await fs.promises.writeFile(path.join(finalDir, 'ghost.json'), JSON.stringify(goodManifest())); await fs.promises.writeFile(path.join(finalDir, 'main.js'), 'new'); - await fs.promises.mkdir(path.dirname(pendingMarkerPath()), { recursive: true }); + await fs.promises.mkdir(path.dirname(pendingMarkerPath('_root/hello')), { recursive: true }); await fs.promises.writeFile( - pendingMarkerPath(), + pendingMarkerPath('_root/hello'), JSON.stringify({ version: 1, - id: 'hello', + id: '_root/hello', kind: 'update', packageSha256: 'f'.repeat(64), backupDirName: '..\\outside', @@ -1692,7 +1808,7 @@ describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => const recovered = freshManager(); expect(fs.existsSync(path.join(rootDir, backupName))).toBe(true); - expect(fs.existsSync(pendingMarkerPath())).toBe(true); + expect(fs.existsSync(pendingMarkerPath('_root/hello'))).toBe(true); expect(recovered.list()[0]).toMatchObject({ enabled: false, approval: { state: 'invalid' }, @@ -1701,13 +1817,13 @@ describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => it('invalid pending marker filename blocks orphan-backup heuristics', async () => { await manager.install(await makeCindy('old.cindy', goodManifest())); - const finalDir = path.join(rootDir, 'hello'); - const backupDir = path.join(rootDir, '.cindy-updating-hello-abcdef12'); + const finalDir = path.join(rootDir, '_root', 'hello'); + const backupDir = path.join(rootDir, '.cindy-updating-_root__hello-abcdef12'); await fs.promises.rename(finalDir, backupDir); await fs.promises.mkdir(finalDir, { recursive: true }); await fs.promises.writeFile(path.join(finalDir, 'ghost.json'), JSON.stringify(goodManifest())); await fs.promises.writeFile(path.join(finalDir, 'main.js'), 'new'); - const invalidMarker = path.join(path.dirname(pendingMarkerPath()), '.pending-BAD!.json'); + const invalidMarker = path.join(path.dirname(pendingMarkerPath('_root/hello')), '.pending-BAD!.json'); await fs.promises.writeFile(invalidMarker, '{}'); const recovered = freshManager(); @@ -1721,18 +1837,18 @@ describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => it('pending marker JSON 为 null 时按 invalid 保留现场,不让构造期恢复崩溃', async () => { await manager.install(await makeCindy('old.cindy', goodManifest())); - const finalDir = path.join(rootDir, 'hello'); - const backupDir = path.join(rootDir, '.cindy-updating-hello-abcdef12'); + const finalDir = path.join(rootDir, '_root', 'hello'); + const backupDir = path.join(rootDir, '.cindy-updating-_root__hello-abcdef12'); await fs.promises.rename(finalDir, backupDir); await fs.promises.mkdir(finalDir, { recursive: true }); await fs.promises.writeFile(path.join(finalDir, 'ghost.json'), JSON.stringify(goodManifest())); await fs.promises.writeFile(path.join(finalDir, 'main.js'), 'new'); - await fs.promises.writeFile(pendingMarkerPath(), 'null'); + await fs.promises.writeFile(pendingMarkerPath('_root/hello'), 'null'); const recovered = freshManager(); expect(fs.existsSync(finalDir)).toBe(true); expect(fs.existsSync(backupDir)).toBe(true); - expect(fs.existsSync(pendingMarkerPath())).toBe(true); + expect(fs.existsSync(pendingMarkerPath('_root/hello'))).toBe(true); expect(recovered.list()[0]).toMatchObject({ enabled: false, approval: { state: 'invalid' }, @@ -1741,16 +1857,16 @@ describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => it('状态根 journal 扫描 EACCES 时跳过全部恢复启发式,任何现场都不动', async () => { await manager.install(await makeCindy('a.cindy', goodManifest())); - const stateRoot = path.dirname(pendingMarkerPath()); - const backupDir = path.join(rootDir, '.cindy-updating-hello-abcdef12'); - const stagingDir = path.join(rootDir, '.cindy-installing-hello-deadbeef'); + const stateRoot = path.dirname(pendingMarkerPath('_root/hello')); + const backupDir = path.join(rootDir, '.cindy-updating-_root__hello-abcdef12'); + const stagingDir = path.join(rootDir, '.cindy-installing-_root__hello-deadbeef'); await fs.promises.mkdir(backupDir, { recursive: true }); await fs.promises.mkdir(stagingDir, { recursive: true }); await fs.promises.writeFile( - pendingMarkerPath(), + pendingMarkerPath('_root/hello'), JSON.stringify({ version: 1, - id: 'hello', + id: '_root/hello', kind: 'update', packageSha256: 'f'.repeat(64), backupDirName: path.basename(backupDir), @@ -1775,16 +1891,16 @@ describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => // 未提交 update 已先移除 final;backup 不可读时保留整笔 journal, // 因而此刻 final 仍缺失,等待下次可读时再由 recovery 收敛。 - expect(fs.existsSync(path.join(rootDir, 'hello'))).toBe(true); - expect(fs.existsSync(receiptPath())).toBe(true); - expect(fs.existsSync(pendingMarkerPath())).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello'))).toBe(true); + expect(fs.existsSync(receiptPath('_root/hello'))).toBe(true); + expect(fs.existsSync(pendingMarkerPath('_root/hello'))).toBe(true); expect(fs.existsSync(backupDir)).toBe(true); expect(fs.existsSync(stagingDir)).toBe(true); }); it('journal root unreadable blocks the whole owner even when installed ids cannot be enumerated yet', async () => { await manager.install(await makeCindy('a.cindy', goodManifest())); - const stateRoot = path.dirname(pendingMarkerPath()); + const stateRoot = path.dirname(pendingMarkerPath('_root/hello')); const realReaddirSync = fs.readdirSync; const spy = vi.spyOn(fs, 'readdirSync').mockImplementation(((target: fs.PathLike, options?: unknown) => { const resolved = path.resolve(String(target)); @@ -1807,15 +1923,71 @@ describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => }); }); + it.each(['EACCES', 'EIO'])('namespaced journal directory %s keeps an interrupted update isolated and recoverable', async (errorCode) => { + await manager.install( + await makeCindy('ns-before.cindy', goodManifest(), { 'main.js': '// old bytes\n' }), + { namespace: 'acme' }, + ); + const finalDir = path.join(rootDir, '_ns', 'acme', 'hello'); + const backupName = '.cindy-updating-_ns__acme__hello-abcdef12'; + const backupDir = path.join(rootDir, backupName); + const stateDir = path.join(workDir, 'ghosts-install-state', '_ns', 'acme'); + const marker = path.join(stateDir, '.pending-hello.json'); + await fs.promises.writeFile(marker, JSON.stringify({ + version: 1, + id: '_ns/acme/hello', + kind: 'update', + packageSha256: 'f'.repeat(64), + receiptRevision: crypto.randomUUID(), + backupDirName: backupName, + phase: 'published', + })); + await fs.promises.rename(finalDir, backupDir); + await fs.promises.mkdir(finalDir, { recursive: true }); + await fs.promises.writeFile( + path.join(finalDir, 'ghost.json'), + JSON.stringify({ ...goodManifest(), version: '1.0.1' }), + ); + await fs.promises.writeFile(path.join(finalDir, 'main.js'), '// new bytes\n'); + + const realReaddirSync = fs.readdirSync; + const spy = vi.spyOn(fs, 'readdirSync').mockImplementation(((target: fs.PathLike, options?: unknown) => { + if (path.resolve(String(target)) === path.resolve(stateDir)) { + throw Object.assign(new Error(errorCode + ': organization journal unavailable'), { code: errorCode }); + } + return (realReaddirSync as (...args: unknown[]) => unknown)(target, options); + }) as typeof fs.readdirSync); + let recovered: GhostManager; + try { + recovered = freshManager(); + } finally { + spy.mockRestore(); + } + + expect(recovered.list()[0]).toMatchObject({ enabled: false, approval: { state: 'invalid' } }); + expect(fs.existsSync(backupDir)).toBe(true); + expect(fs.existsSync(marker)).toBe(true); + + await recovered.retryInterruptedMutationsAfterDbReady(); + expect(recovered.list()[0]).toMatchObject({ + manifest: { version: '1.0.0' }, + enabled: true, + approval: { state: 'approved' }, + }); + expect(fs.existsSync(marker)).toBe(false); + expect(fs.existsSync(backupDir)).toBe(false); + expect(fs.readFileSync(path.join(finalDir, 'main.js'), 'utf8')).toBe('// old bytes\n'); + }); + it('pending marker 读取 EACCES 时保留 marker/final/backup,不降级到 orphan cleanup', async () => { await manager.install(await makeCindy('a.cindy', goodManifest())); - const backupDir = path.join(rootDir, '.cindy-updating-hello-abcdef12'); + const backupDir = path.join(rootDir, '.cindy-updating-_root__hello-abcdef12'); await fs.promises.mkdir(backupDir, { recursive: true }); await fs.promises.writeFile( - pendingMarkerPath(), + pendingMarkerPath('_root/hello'), JSON.stringify({ version: 1, - id: 'hello', + id: '_root/hello', kind: 'update', packageSha256: 'f'.repeat(64), backupDirName: path.basename(backupDir), @@ -1826,7 +1998,7 @@ describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => // so the bounded reader surfaces the unreadable state through its normal path. const realOpenSync = fs.openSync; const spy = vi.spyOn(fs, 'openSync').mockImplementation(((target: fs.PathLike, ...rest: unknown[]) => { - if (path.resolve(String(target)) === path.resolve(pendingMarkerPath())) { + if (path.resolve(String(target)) === path.resolve(pendingMarkerPath('_root/hello'))) { throw Object.assign(new Error('EACCES: marker locked'), { code: 'EACCES' }); } return (realOpenSync as (...args: unknown[]) => unknown)(target, ...rest); @@ -1841,23 +2013,23 @@ describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => spy.mockRestore(); } - expect(fs.existsSync(path.join(rootDir, 'hello'))).toBe(true); - expect(fs.existsSync(pendingMarkerPath())).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello'))).toBe(true); + expect(fs.existsSync(pendingMarkerPath('_root/hello'))).toBe(true); expect(fs.existsSync(backupDir)).toBe(true); }); it('已提交 install 的 receipt 瞬时不可读时保留 final/receipt/journal 等待重试', async () => { await manager.install(await makeCindy('a.cindy', goodManifest())); - const packageSha256 = (JSON.parse(await fs.promises.readFile(receiptPath(), 'utf8')) as { + const packageSha256 = (JSON.parse(await fs.promises.readFile(receiptPath('_root/hello'), 'utf8')) as { packageSha256: string; }).packageSha256; await fs.promises.writeFile( - pendingMarkerPath(), - JSON.stringify({ version: 1, id: 'hello', kind: 'install', packageSha256 }), + pendingMarkerPath('_root/hello'), + JSON.stringify({ version: 1, id: '_root/hello', kind: 'install', packageSha256 }), ); const realOpenSync = fs.openSync; const spy = vi.spyOn(fs, 'openSync').mockImplementation(((target: fs.PathLike, ...rest: unknown[]) => { - if (path.resolve(String(target)) === path.resolve(receiptPath())) { + if (path.resolve(String(target)) === path.resolve(receiptPath('_root/hello'))) { throw Object.assign(new Error('EACCES: receipt locked'), { code: 'EACCES' }); } return (realOpenSync as (...args: unknown[]) => number)(target, ...rest); @@ -1872,23 +2044,23 @@ describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => spy.mockRestore(); } - expect(fs.existsSync(path.join(rootDir, 'hello'))).toBe(true); - expect(fs.existsSync(receiptPath())).toBe(true); - expect(fs.existsSync(pendingMarkerPath())).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello'))).toBe(true); + expect(fs.existsSync(receiptPath('_root/hello'))).toBe(true); + expect(fs.existsSync(pendingMarkerPath('_root/hello'))).toBe(true); }); it('已提交 update 的 receipt lstat EACCES 时保留 final/backup/receipt/journal', async () => { await manager.install(await makeCindy('a.cindy', goodManifest())); - const packageSha256 = (JSON.parse(await fs.promises.readFile(receiptPath(), 'utf8')) as { + const packageSha256 = (JSON.parse(await fs.promises.readFile(receiptPath('_root/hello'), 'utf8')) as { packageSha256: string; }).packageSha256; - const backupDir = path.join(rootDir, '.cindy-updating-hello-abcdef12'); + const backupDir = path.join(rootDir, '.cindy-updating-_root__hello-abcdef12'); await fs.promises.mkdir(backupDir, { recursive: true }); await fs.promises.writeFile( - pendingMarkerPath(), + pendingMarkerPath('_root/hello'), JSON.stringify({ version: 1, - id: 'hello', + id: '_root/hello', kind: 'update', packageSha256, backupDirName: path.basename(backupDir), @@ -1896,7 +2068,7 @@ describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => ); const realOpenSync = fs.openSync; const spy = vi.spyOn(fs, 'openSync').mockImplementation(((target: fs.PathLike, ...rest: unknown[]) => { - if (path.resolve(String(target)) === path.resolve(receiptPath())) { + if (path.resolve(String(target)) === path.resolve(receiptPath('_root/hello'))) { throw Object.assign(new Error('EACCES: receipt locked'), { code: 'EACCES' }); } return (realOpenSync as (...args: unknown[]) => number)(target, ...rest); @@ -1911,21 +2083,21 @@ describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => spy.mockRestore(); } - expect(fs.existsSync(path.join(rootDir, 'hello'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello'))).toBe(true); expect(fs.existsSync(backupDir)).toBe(true); - expect(fs.existsSync(receiptPath())).toBe(true); - expect(fs.existsSync(pendingMarkerPath())).toBe(true); + expect(fs.existsSync(receiptPath('_root/hello'))).toBe(true); + expect(fs.existsSync(pendingMarkerPath('_root/hello'))).toBe(true); }); it('update backup lstat EACCES 时保留整笔 journal,不清 marker 继续猜恢复', async () => { await manager.install(await makeCindy('a.cindy', goodManifest())); - const backupDir = path.join(rootDir, '.cindy-updating-hello-abcdef12'); + const backupDir = path.join(rootDir, '.cindy-updating-_root__hello-abcdef12'); await fs.promises.mkdir(backupDir, { recursive: true }); await fs.promises.writeFile( - pendingMarkerPath(), + pendingMarkerPath('_root/hello'), JSON.stringify({ version: 1, - id: 'hello', + id: '_root/hello', kind: 'update', packageSha256: 'f'.repeat(64), backupDirName: path.basename(backupDir), @@ -1948,9 +2120,9 @@ describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => spy.mockRestore(); } - expect(fs.existsSync(path.join(rootDir, 'hello'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello'))).toBe(true); expect(fs.existsSync(backupDir)).toBe(true); - expect(fs.existsSync(pendingMarkerPath())).toBe(true); + expect(fs.existsSync(pendingMarkerPath('_root/hello'))).toBe(true); }); it('动态 owner 根切换时,首次读取会先恢复新 owner 的 pending mutation', async () => { @@ -2059,7 +2231,7 @@ describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => }); expect(fs.existsSync(path.join(rootA, 'hello'))).toBe(false); - expect(fs.existsSync(path.join(stateA, 'hello.json'))).toBe(false); + expect(fs.existsSync(path.join(stateA, '_root', 'hello.json'))).toBe(false); expect(fs.existsSync(path.join(rootB, 'hello', 'main.js'))).toBe(true); expect(owned.list().find((ghost) => ghost.manifest.id === 'hello')?.approval.state).toBe( 'legacy-unapproved', @@ -2068,9 +2240,9 @@ describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => it('未提交的更新(新字节+旧 receipt+update 标记)回滚到 backup,不固化成按旧批准跑新代码', async () => { await manager.install(await makeCindy('a.cindy', goodManifest())); - const receiptBefore = await fs.promises.readFile(receiptPath(), 'utf8'); - const finalDir = path.join(rootDir, 'hello'); - const backupName = '.cindy-updating-hello-abcdef12'; + const receiptBefore = await fs.promises.readFile(receiptPath('_root/hello'), 'utf8'); + const finalDir = path.join(rootDir, '_root', 'hello'); + const backupName = '.cindy-updating-_root__hello-abcdef12'; // 模拟 staging→final 之后、写 receipt 之前崩溃:旧字节挪到 backup,新字节在 final, // receipt 仍是旧的。标记的 packageSha256 与旧 receipt 不同 = 未提交。 @@ -2081,12 +2253,12 @@ describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => JSON.stringify({ ...goodManifest(), version: '2.0.0' }), ); await fs.promises.writeFile(path.join(finalDir, 'main.js'), 'new-bytes'); - await fs.promises.mkdir(path.dirname(pendingMarkerPath()), { recursive: true }); + await fs.promises.mkdir(path.dirname(pendingMarkerPath('_root/hello')), { recursive: true }); await fs.promises.writeFile( - pendingMarkerPath(), + pendingMarkerPath('_root/hello'), JSON.stringify({ version: 1, - id: 'hello', + id: '_root/hello', kind: 'update', packageSha256: 'f'.repeat(64), backupDirName: backupName, @@ -2097,26 +2269,26 @@ describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => const restored = JSON.parse(await fs.promises.readFile(path.join(finalDir, 'ghost.json'), 'utf8')); expect(restored.version).toBe('1.0.0'); // 旧字节搬回 expect(fs.existsSync(path.join(rootDir, backupName))).toBe(false); - expect(fs.existsSync(pendingMarkerPath())).toBe(false); + expect(fs.existsSync(pendingMarkerPath('_root/hello'))).toBe(false); // receipt 一字未动,与回滚后的旧字节自洽(不是"新字节 + 旧 receipt"的错位)。 - expect(await fs.promises.readFile(receiptPath(), 'utf8')).toBe(receiptBefore); + expect(await fs.promises.readFile(receiptPath('_root/hello'), 'utf8')).toBe(receiptBefore); }); it('已提交的更新(标记 packageSha256 == receipt)保留新字节,只回收陈旧 backup', async () => { await manager.install(await makeCindy('a.cindy', goodManifest())); - const committedReceipt = JSON.parse(await fs.promises.readFile(receiptPath(), 'utf8')) as { + const committedReceipt = JSON.parse(await fs.promises.readFile(receiptPath('_root/hello'), 'utf8')) as { packageSha256: string; revision: string; }; - const backupName = '.cindy-updating-hello-abcdef34'; + const backupName = '.cindy-updating-_root__hello-abcdef34'; await fs.promises.mkdir(path.join(rootDir, backupName)); await fs.promises.writeFile(path.join(rootDir, backupName, 'stale.txt'), 'old'); - await fs.promises.mkdir(path.dirname(pendingMarkerPath()), { recursive: true }); + await fs.promises.mkdir(path.dirname(pendingMarkerPath('_root/hello')), { recursive: true }); await fs.promises.writeFile( - pendingMarkerPath(), + pendingMarkerPath('_root/hello'), JSON.stringify({ version: 1, - id: 'hello', + id: '_root/hello', kind: 'update', packageSha256: committedReceipt.packageSha256, receiptRevision: committedReceipt.revision, @@ -2127,23 +2299,23 @@ describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => freshManager(); expect(fs.existsSync(path.join(rootDir, backupName))).toBe(false); // 陈旧 backup 回收 - expect(fs.existsSync(path.join(rootDir, 'hello'))).toBe(true); // 新版保留 - expect(fs.existsSync(pendingMarkerPath())).toBe(false); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello'))).toBe(true); // 新版保留 + expect(fs.existsSync(pendingMarkerPath('_root/hello'))).toBe(false); expect(manager.list()[0].approval.state).toBe('approved'); }); it('same-hash backed-up update restores the only backup instead of deleting it', async () => { await manager.install(await makeCindy('a.cindy', goodManifest())); - const receiptBefore = await fs.promises.readFile(receiptPath(), 'utf8'); + const receiptBefore = await fs.promises.readFile(receiptPath('_root/hello'), 'utf8'); const receipt = JSON.parse(receiptBefore) as { packageSha256: string }; - const finalDir = path.join(rootDir, 'hello'); - const backupName = '.cindy-updating-hello-acde0011'; + const finalDir = path.join(rootDir, '_root', 'hello'); + const backupName = '.cindy-updating-_root__hello-acde0011'; await fs.promises.rename(finalDir, path.join(rootDir, backupName)); await fs.promises.writeFile( - pendingMarkerPath(), + pendingMarkerPath('_root/hello'), JSON.stringify({ version: 1, - id: 'hello', + id: '_root/hello', kind: 'update', packageSha256: receipt.packageSha256, oldPackageSha256: receipt.packageSha256, @@ -2156,25 +2328,25 @@ describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => freshManager(); expect(fs.existsSync(finalDir)).toBe(true); expect(fs.existsSync(path.join(rootDir, backupName))).toBe(false); - expect(fs.existsSync(pendingMarkerPath())).toBe(false); - expect(await fs.promises.readFile(receiptPath(), 'utf8')).toBe(receiptBefore); + expect(fs.existsSync(pendingMarkerPath('_root/hello'))).toBe(false); + expect(await fs.promises.readFile(receiptPath('_root/hello'), 'utf8')).toBe(receiptBefore); }); it('same-hash backed-up update rolls back a final whose receipt revision is still old', async () => { await manager.install(await makeCindy('a.cindy', goodManifest())); - const receipt = JSON.parse(await fs.promises.readFile(receiptPath(), 'utf8')) as { + const receipt = JSON.parse(await fs.promises.readFile(receiptPath('_root/hello'), 'utf8')) as { packageSha256: string; }; - const finalDir = path.join(rootDir, 'hello'); - const backupName = '.cindy-updating-hello-acde0022'; + const finalDir = path.join(rootDir, '_root', 'hello'); + const backupName = '.cindy-updating-_root__hello-acde0022'; await fs.promises.rename(finalDir, path.join(rootDir, backupName)); await fs.promises.mkdir(finalDir); await fs.promises.writeFile(path.join(finalDir, 'new.txt'), 'uncommitted'); await fs.promises.writeFile( - pendingMarkerPath(), + pendingMarkerPath('_root/hello'), JSON.stringify({ version: 1, - id: 'hello', + id: '_root/hello', kind: 'update', packageSha256: receipt.packageSha256, oldPackageSha256: receipt.packageSha256, @@ -2188,28 +2360,28 @@ describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => expect(fs.existsSync(path.join(finalDir, 'new.txt'))).toBe(false); expect(fs.existsSync(path.join(finalDir, 'ghost.json'))).toBe(true); expect(fs.existsSync(path.join(rootDir, backupName))).toBe(false); - expect(fs.existsSync(pendingMarkerPath())).toBe(false); + expect(fs.existsSync(pendingMarkerPath('_root/hello'))).toBe(false); }); it('legacy install marker with an old same-hash receipt and no final stays isolated', async () => { await manager.install(await makeCindy('a.cindy', goodManifest())); - const receipt = JSON.parse(await fs.promises.readFile(receiptPath(), 'utf8')) as { + const receipt = JSON.parse(await fs.promises.readFile(receiptPath('_root/hello'), 'utf8')) as { packageSha256: string; }; - await fs.promises.rm(path.join(rootDir, 'hello'), { recursive: true, force: true }); + await fs.promises.rm(path.join(rootDir, '_root', 'hello'), { recursive: true, force: true }); await fs.promises.writeFile( - pendingMarkerPath(), + pendingMarkerPath('_root/hello'), JSON.stringify({ version: 1, - id: 'hello', + id: '_root/hello', kind: 'install', packageSha256: receipt.packageSha256, }), ); freshManager(); - expect(fs.existsSync(pendingMarkerPath())).toBe(true); - expect(fs.existsSync(receiptPath())).toBe(true); + expect(fs.existsSync(pendingMarkerPath('_root/hello'))).toBe(true); + expect(fs.existsSync(receiptPath('_root/hello'))).toBe(true); expect(manager.list()).toEqual([]); }); @@ -2219,7 +2391,7 @@ describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => // 让内容目录的 rm 失败(模拟句柄占用),但放行 receipt/快照的 rm。 const realRm = fs.promises.rm; const rmSpy = vi.spyOn(fs.promises, 'rm').mockImplementation((async (p: fs.PathLike, ...rest: unknown[]) => { - if (String(p) === path.join(rootDir, 'hello')) { + if (String(p) === path.join(rootDir, '_root', 'hello')) { rmSpy.mockRestore(); const err = new Error('EBUSY: resource busy') as NodeJS.ErrnoException; err.code = 'EBUSY'; @@ -2232,39 +2404,39 @@ describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => await expectRejection(res, 'io'); // 关键:撤批准在删目录之前 —— receipt 已没了,目录还在但 fail closed(list 报 // legacy-unapproved),不会被这份 receipt 授权。孤立标记留给启动恢复收尾。 - expect(fs.existsSync(receiptPath())).toBe(false); + expect(fs.existsSync(receiptPath('_root/hello'))).toBe(false); expect(manager.list()[0].approval.state).toBe('legacy-unapproved'); - expect(fs.existsSync(pendingMarkerPath())).toBe(true); + expect(fs.existsSync(pendingMarkerPath('_root/hello'))).toBe(true); }); it('卸载崩在撤批准之后、删目录之前:恢复据 uninstall 标记删净残留目录', async () => { await manager.install(await makeCindy('a.cindy', goodManifest())); // 模拟崩溃现场:receipt 已撤(revoke 先行),目录还在,uninstall 标记在。 - await fs.promises.rm(receiptPath(), { force: true }); + await fs.promises.rm(receiptPath('_root/hello'), { force: true }); await fs.promises.writeFile( - pendingMarkerPath(), - JSON.stringify({ version: 1, id: 'hello', kind: 'uninstall' }), + pendingMarkerPath('_root/hello'), + JSON.stringify({ version: 1, id: '_root/hello', kind: 'uninstall' }), ); - expect(fs.existsSync(path.join(rootDir, 'hello'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello'))).toBe(true); freshManager(); // 构造期恢复 - expect(fs.existsSync(path.join(rootDir, 'hello'))).toBe(false); - expect(fs.existsSync(pendingMarkerPath())).toBe(false); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello'))).toBe(false); + expect(fs.existsSync(pendingMarkerPath('_root/hello'))).toBe(false); }); it('卸载崩在撤批准之前:恢复据 uninstall 标记把 receipt 与目录都删净', async () => { await manager.install(await makeCindy('a.cindy', goodManifest())); // 崩在写标记之后、撤批准之前:receipt 与目录都还在。 await fs.promises.writeFile( - pendingMarkerPath(), - JSON.stringify({ version: 1, id: 'hello', kind: 'uninstall' }), + pendingMarkerPath('_root/hello'), + JSON.stringify({ version: 1, id: '_root/hello', kind: 'uninstall' }), ); - expect(fs.existsSync(receiptPath())).toBe(true); + expect(fs.existsSync(receiptPath('_root/hello'))).toBe(true); freshManager(); - expect(fs.existsSync(path.join(rootDir, 'hello'))).toBe(false); - expect(fs.existsSync(receiptPath())).toBe(false); - expect(fs.existsSync(pendingMarkerPath())).toBe(false); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello'))).toBe(false); + expect(fs.existsSync(receiptPath('_root/hello'))).toBe(false); + expect(fs.existsSync(pendingMarkerPath('_root/hello'))).toBe(false); }); it('setEnabled 不跟随非真目录: 是普通文件时按未装入拒,不越安装根写标记', async () => { @@ -2276,7 +2448,7 @@ describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => it('setEnabled 不跟随 junction: 是指向外部的链接时拒,不在外部目标写/删 .disabled', async () => { await manager.install(await makeCindy('a.cindy', goodManifest())); - const dir = path.join(rootDir, 'hello'); + const dir = path.join(rootDir, '_root', 'hello'); const outside = path.join(workDir, 'outside-target'); await fs.promises.mkdir(outside, { recursive: true }); await fs.promises.rm(dir, { recursive: true, force: true }); @@ -2295,11 +2467,11 @@ describe('GhostManager · 装入/更新崩溃窗口恢复(事务标记)', () => describe('GhostManager · update pre-rename recovery', () => { it('marker 落盘但尚未 rename 时保留旧 final 与 receipt', async () => { await manager.install(await makeCindy('a.cindy', goodManifest())); - const finalDir = path.join(rootDir, 'hello'); - const stagingDir = path.join(rootDir, '.cindy-installing-hello-deadbeef'); + const finalDir = path.join(rootDir, '_root', 'hello'); + const stagingDir = path.join(rootDir, '.cindy-installing-_root__hello-deadbeef'); await fs.promises.mkdir(stagingDir, { recursive: true }); - const stateReceiptPath = path.join(workDir, 'ghosts-install-state', 'hello.json'); - const statePendingPath = path.join(workDir, 'ghosts-install-state', '.pending-hello.json'); + const stateReceiptPath = path.join(workDir, 'ghosts-install-state', '_root', 'hello.json'); + const statePendingPath = path.join(workDir, 'ghosts-install-state', '_root', '.pending-hello.json'); const receipt = JSON.parse(await fs.promises.readFile(stateReceiptPath, 'utf8')) as { packageSha256?: string; }; @@ -2307,10 +2479,10 @@ describe('GhostManager · update pre-rename recovery', () => { statePendingPath, JSON.stringify({ version: 1, - id: 'hello', + id: '_root/hello', kind: 'update', packageSha256: 'f'.repeat(64), - backupDirName: '.cindy-updating-hello-deadbeef', + backupDirName: '.cindy-updating-_root__hello-deadbeef', phase: 'prepared', oldPackageSha256: receipt.packageSha256, }), @@ -2334,7 +2506,7 @@ describe('GhostManager · install', () => { ); expect(personal).toHaveProperty('ghost'); const personalReceipt = JSON.parse( - await fs.promises.readFile(path.join(manager.approvalStateRoot(), 'personal.json'), 'utf8'), + await fs.promises.readFile(path.join(manager.approvalStateRoot(), '_root', 'personal.json'), 'utf8'), ) as Record; expect(personalReceipt).toHaveProperty('installOrigin', 'agent-forge'); expect(manager.readEffectiveInstallOrigin('personal')).toBe('agent-forge'); @@ -2346,7 +2518,7 @@ describe('GhostManager · install', () => { ); expect(forged).toHaveProperty('ghost'); const organizationReceipt = JSON.parse( - await fs.promises.readFile(path.join(manager.approvalStateRoot(), 'acme-tool.json'), 'utf8'), + await fs.promises.readFile(path.join(manager.approvalStateRoot(), '_root', 'acme-tool.json'), 'utf8'), ) as Record; expect(organizationReceipt).toHaveProperty('installOrigin', 'agent-forge'); expect(manager.readEffectiveInstallOrigin('acme-tool')).toBe('agent-forge'); @@ -2354,7 +2526,7 @@ describe('GhostManager · install', () => { it('strict origin reading rejects unreadable, invalid, and non-approved receipts', async () => { await manager.install(await makeCindy('strict-origin.cindy', goodManifest())); - const receiptPath = path.join(manager.approvalStateRoot(), 'hello.json'); + const receiptPath = path.join(manager.approvalStateRoot(), '_root', 'hello.json'); const mockUnreadableOnce = (): void => { const realOpenSync = fs.openSync; const openSpy = vi.spyOn(fs, 'openSync'); @@ -2448,7 +2620,7 @@ describe('GhostManager · install', () => { 'locales/en.json': locale('Packaged name'), }); await manager.install(cindy); - const localePath = path.join(rootDir, 'hello', 'locales', 'en.json'); + const localePath = path.join(rootDir, '_root', 'hello', 'locales', 'en.json'); const outsidePath = path.join(workDir, 'outside-locale.json'); await fs.promises.writeFile(outsidePath, locale('Outside name')); await fs.promises.rm(localePath); @@ -2528,15 +2700,115 @@ describe('GhostManager · install', () => { expect('ghost' in result).toBe(true); const { ghost } = result as { ghost: InstalledGhost }; expect(ghost.manifest.id).toBe('hello'); - expect(ghost.dir).toBe(path.join(rootDir, 'hello')); - expect(fs.existsSync(path.join(rootDir, 'hello', 'ghost.json'))).toBe(true); - expect(fs.existsSync(path.join(rootDir, 'hello', 'assets', 'readme.txt'))).toBe(true); + expect(ghost.dir).toBe(path.join(rootDir, '_root', 'hello')); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello', 'ghost.json'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello', 'assets', 'readme.txt'))).toBe(true); expect(manager.list().map((c) => c.manifest.id)).toEqual(['hello']); expect(onChanged).toHaveBeenCalledTimes(1); expect(onChanged.mock.calls[0][0].map((c: InstalledGhost) => c.manifest.id)).toEqual(['hello']); }); + it('stamps root identity when install did not receive a namespace', async () => { + const cindy = await makeCindy('hello.cindy', goodManifest()); + const result = await manager.install(cindy); + expect('ghost' in result).toBe(true); + const { ghost } = result as { ghost: InstalledGhost }; + expect(ghost.namespace).toBeNull(); + expect(manager.list()[0]!.namespace).toBeNull(); + const receipt = JSON.parse( + fs.readFileSync(path.join(workDir, 'ghosts-install-state', '_root', 'hello.json'), 'utf8'), + ) as { namespace?: unknown }; + expect(receipt.namespace).toBeNull(); + }); + + it('persists explicit root namespace when install receives null', async () => { + const cindy = await makeCindy('hello.cindy', goodManifest()); + const result = await manager.install(cindy, { namespace: null }); + expect(result).toMatchObject({ + ghost: { manifest: { id: 'hello' }, namespace: null, dir: path.join(rootDir, '_root', 'hello') }, + }); + const receipt = JSON.parse( + fs.readFileSync(path.join(workDir, 'ghosts-install-state', '_root', 'hello.json'), 'utf8'), + ) as { namespace?: unknown }; + expect(receipt.namespace).toBeNull(); + expect(Object.prototype.hasOwnProperty.call(receipt, 'namespace')).toBe(true); + }); + + it('installs an organization instance beside a root plugin with the same ghostId', async () => { + const rootCindy = await makeCindy('hello-root.cindy', goodManifest()); + await expect(manager.install(rootCindy)).resolves.toMatchObject({ + ghost: { manifest: { id: 'hello' }, dir: path.join(rootDir, '_root', 'hello') }, + }); + const orgCindy = await makeCindy('hello-org.cindy', goodManifest()); + const orgResult = await manager.install(orgCindy, { namespace: 'acme' }); + expect(orgResult).toMatchObject({ + ghost: { + manifest: { id: 'hello' }, + namespace: 'acme', + dir: path.join(rootDir, '_ns', 'acme', 'hello'), + }, + }); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello', 'ghost.json'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_ns', 'acme', 'hello', 'ghost.json'))).toBe(true); + expect(fs.existsSync(path.join(workDir, 'ghosts-install-state', '_ns', 'acme', '.pending-hello.json'))).toBe( + false, + ); + expect(fs.existsSync(path.join(workDir, 'ghosts-install-state', '_root', '.pending-hello.json'))).toBe(false); + const listed = manager.list(); + expect(listed).toHaveLength(2); + expect(listed.map((item) => [item.namespace ?? null, item.manifest.id])).toEqual( + expect.arrayContaining([ + [null, 'hello'], + ['acme', 'hello'], + ]), + ); + + await expect(manager.setEnabled('_ns/acme/hello', false)).resolves.toEqual({ ok: true }); + expect(manager.list().find((item) => item.namespace === 'acme')?.enabled).toBe(false); + expect(manager.list().find((item) => item.namespace == null)?.enabled).toBe(true); + + await expect(manager.uninstall('_ns/acme/hello')).resolves.toEqual({ ok: true }); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello', 'ghost.json'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_ns', 'acme', 'hello'))).toBe(false); + expect(manager.list().map((item) => [item.namespace ?? null, item.manifest.id])).toEqual([ + [null, 'hello'], + ]); + }); + it('refuses a linked namespace parent before installing or uninstalling outside the managed root', async () => { + const outside = path.join(workDir, 'outside-namespaces'); + await fs.promises.mkdir(outside, { recursive: true }); + await fs.promises.mkdir(rootDir, { recursive: true }); + try { + await fs.promises.symlink(outside, path.join(rootDir, '_ns'), process.platform === 'win32' ? 'junction' : 'dir'); + } catch { + return; + } + const file = await makeCindy('linked-ns.cindy', goodManifest()); + await expect(manager.install(file, { namespace: 'acme' })).rejects.toThrow(/namespace parent/); + expect(fs.existsSync(path.join(outside, 'acme', 'hello'))).toBe(false); + }); + + + it('reads namespaced receipts from storage part and install rel id', async () => { + const organizationOrigin = forgeInstallOriginForMembership('org'); + const result = await manager.install( + await makeCindy('hello-org.cindy', goodManifest()), + { + namespace: 'acme', + ...(organizationOrigin ? { installOrigin: organizationOrigin } : {}), + }, + ); + expect(result).toHaveProperty('ghost'); + expect(manager.readEffectiveInstallOrigin('_ns__acme__hello')).toBe('agent-forge'); + expect(manager.readEffectiveInstallOrigin('_ns/acme/hello')).toBe('agent-forge'); + expect(manager.readApprovedInstallOriginStrict('_ns__acme__hello')).toBe('agent-forge'); + expect(manager.approvedInstallEvidence('_ns__acme__hello')?.packageSha256).toEqual( + expect.stringMatching(/^[a-f0-9]{64}$/), + ); + expect(manager.readEffectiveInstallOrigin('hello')).toBe('manual'); + }); + it('returns the quarantined projection when install journal cleanup fails', async () => { const store = ( manager as unknown as { @@ -2569,12 +2841,12 @@ describe('GhostManager · install', () => { const local = await makeCindy('github-local.cindy', goodManifest('cindy-github')); const localResult = await manager.install(local); expect(localResult).toMatchObject({ ghost: { trust: { level: 'unverified' } } }); - await fs.promises.rm(path.join(rootDir, 'cindy-github'), { recursive: true, force: true }); + await fs.promises.rm(path.join(rootDir, '_root', 'cindy-github'), { recursive: true, force: true }); const officialResult = await manager.install(local, { trustOverride: 'cindy-official' }); expect(officialResult).toMatchObject({ ghost: { trust: { level: 'cindy-official' } } }); const receipt = JSON.parse( - await fs.promises.readFile(path.join(rootDir, 'cindy-github', '.cindy-trust.json'), 'utf8'), + await fs.promises.readFile(path.join(rootDir, '_root', 'cindy-github', '.cindy-trust.json'), 'utf8'), ) as { level?: unknown }; expect(receipt.level).toBe('cindy-official'); expect(receipt).toMatchObject(CINDY_OFFICIAL_GHOST_TRUST); @@ -2584,7 +2856,7 @@ describe('GhostManager · install', () => { it('可变 trust 镜像损坏不会覆盖有效的官方 Host receipt', async () => { const local = await makeCindy('github-incomplete-receipt.cindy', goodManifest('cindy-github')); await manager.install(local, { trustOverride: 'cindy-official' }); - const metadataPath = path.join(rootDir, 'cindy-github', '.cindy-trust.json'); + const metadataPath = path.join(rootDir, '_root', 'cindy-github', '.cindy-trust.json'); const metadata = JSON.parse(await fs.promises.readFile(metadataPath, 'utf8')) as Record; delete metadata.publisherName; await fs.promises.writeFile(metadataPath, `${JSON.stringify(metadata)}\n`); @@ -2596,7 +2868,7 @@ describe('GhostManager · install', () => { const cindy = await makeCindy('at-resource.cindy', atResourceManifest()); await manager.install(cindy); - const metadataPath = path.join(rootDir, 'hello', '.cindy-trust.json'); + const metadataPath = path.join(rootDir, '_root', 'hello', '.cindy-trust.json'); const metadata = JSON.parse(await fs.promises.readFile(metadataPath, 'utf8')) as Record< string, unknown @@ -2615,7 +2887,7 @@ describe('GhostManager · install', () => { const result = await manager.install(cindy, { initiallyEnabled: false }); expect('ghost' in result).toBe(true); expect((result as { ghost: InstalledGhost }).ghost.enabled).toBe(false); - expect(fs.existsSync(path.join(rootDir, 'hello', '.disabled'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello', '.disabled'))).toBe(true); // 首个 onChanged 广播里就是沉睡态(不存在"先启用一帧再熄灯"的跳变)。 expect(onChanged).toHaveBeenCalledTimes(1); expect(onChanged.mock.calls[0][0][0].enabled).toBe(false); @@ -2634,8 +2906,8 @@ describe('GhostManager · install', () => { const result = await manager.install(out); expect('ghost' in result).toBe(true); // 包裹层被剥掉:内容直接落在 /hello/ 下 - expect(fs.existsSync(path.join(rootDir, 'hello', 'ghost.json'))).toBe(true); - expect(fs.existsSync(path.join(rootDir, 'hello', 'assets', 'a.txt'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello', 'ghost.json'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello', 'assets', 'a.txt'))).toBe(true); }); it('源文件不存在 → source-not-found', async () => { @@ -2664,6 +2936,15 @@ describe('GhostManager · install', () => { await expectRejection(await manager.install(out), 'file-invalid'); }); + it('作者声明 namespace → file-invalid, v2 规范化前拒绝', async () => { + const cindy = await makeCindy('ns.cindy', { ...goodManifest(), namespace: 'xd' }); + const result = await manager.install(cindy); + await expectRejection(result, 'file-invalid'); + expect(result).toMatchObject({ + rejection: { reason: 'ghost.json 不允许作者声明 namespace' }, + }); + }); + it('清单不合格(老声明型格式,已移除)→ file-invalid', async () => { const cindy = await makeCindy('decl.cindy', { schemaVersion: 1, @@ -2686,7 +2967,7 @@ describe('GhostManager · install', () => { }); await expectRejection(await manager.inspect(cindy), 'file-invalid'); await expectRejection(await manager.install(cindy), 'file-invalid'); - expect(fs.existsSync(path.join(rootDir, 'hello'))).toBe(false); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello'))).toBe(false); }); it('Node 清单声明的 worker 不在包内 → inspect/install 都拒绝', async () => { @@ -2743,7 +3024,7 @@ describe('GhostManager · install', () => { const cindy = await makeCindy('slip.cindy', goodManifest(), { '../evil.txt': 'pwned' }); await expectRejection(await manager.install(cindy), 'file-invalid'); expect(fs.existsSync(path.join(workDir, 'evil.txt'))).toBe(false); - expect(fs.existsSync(path.join(rootDir, 'hello'))).toBe(false); // staging 已清理,无半截安装 + expect(fs.existsSync(path.join(rootDir, '_root', 'hello'))).toBe(false); // staging 已清理,无半截安装 expect(onChanged).not.toHaveBeenCalled(); }); @@ -2761,7 +3042,7 @@ describe('GhostManager · install', () => { rejection: { code: 'file-invalid', reason: expect.stringContaining('非法路径') }, }); await expectRejection(await manager.install(cindy), 'file-invalid'); - expect(fs.existsSync(path.join(rootDir, 'hello'))).toBe(false); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello'))).toBe(false); expect(onChanged).not.toHaveBeenCalled(); }, ); @@ -2773,7 +3054,7 @@ describe('GhostManager · install', () => { await manager.install(await makeCindy('b.cindy', goodManifest())), 'already-installed', ); - expect(fs.existsSync(path.join(rootDir, 'hello', 'ghost.json'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello', 'ghost.json'))).toBe(true); expect(onChanged).not.toHaveBeenCalled(); }); @@ -2783,13 +3064,26 @@ describe('GhostManager · install', () => { await manager.install(await makeCindy('b.cindy', chipManifestWithCommand('beta', 'draw'))), 'command-conflict', ); - expect(fs.existsSync(path.join(rootDir, 'beta'))).toBe(false); // 半点不落盘 + expect(fs.existsSync(path.join(rootDir, '_root', 'beta'))).toBe(false); // 半点不落盘 const ok = await manager.install( await makeCindy('c.cindy', chipManifestWithCommand('gamma', '画图')), ); expect('ghost' in ok).toBe(true); expect(manager.list().map((g) => g.manifest.id)).toEqual(['alpha', 'gamma']); }); + + it('allows the same command in a different namespace', async () => { + await manager.install( + await makeCindy('root.cindy', chipManifestWithCommand('helper', 'Draw')), + { namespace: null }, + ); + const org = await manager.install( + await makeCindy('org.cindy', chipManifestWithCommand('helper', 'draw')), + { namespace: 'acme' }, + ); + expect('ghost' in org).toBe(true); + expect(manager.list()).toHaveLength(2); + }); }); describe('GhostManager · uninstall', () => { @@ -2799,7 +3093,7 @@ describe('GhostManager · uninstall', () => { const result = await manager.uninstall('hello'); expect('ok' in result).toBe(true); - expect(fs.existsSync(path.join(rootDir, 'hello'))).toBe(false); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello'))).toBe(false); expect(manager.list()).toEqual([]); expect(onChanged).toHaveBeenCalledTimes(1); expect(onChanged.mock.calls[0][0]).toEqual([]); @@ -2820,15 +3114,15 @@ describe('GhostManager · uninstall', () => { await manager.install(await makeCindy('a.cindy', goodManifest())); trustedBundledIds.add('hello'); recordBuiltinTombstone.mockImplementationOnce(() => { - expect(fs.existsSync(path.join(rootDir, 'hello'))).toBe(true); - expect(fs.existsSync(path.join(workDir, 'ghosts-install-state', 'hello.json'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello'))).toBe(true); + expect(fs.existsSync(path.join(workDir, 'ghosts-install-state', '_root', 'hello.json'))).toBe(true); }); await expect(manager.uninstall('hello', { notify: false })).resolves.toEqual({ ok: true }); expect(recordBuiltinTombstone).toHaveBeenCalledWith('hello'); - expect(fs.existsSync(path.join(rootDir, 'hello'))).toBe(false); - expect(fs.existsSync(path.join(workDir, 'ghosts-install-state', 'hello.json'))).toBe(false); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello'))).toBe(false); + expect(fs.existsSync(path.join(workDir, 'ghosts-install-state', '_root', 'hello.json'))).toBe(false); }); it('does not turn Host reconciliation cleanup into a user builtin tombstone', async () => { @@ -2856,14 +3150,14 @@ describe('GhostManager · uninstall', () => { rejection: { code: 'io' }, }); - expect(fs.existsSync(path.join(rootDir, 'hello'))).toBe(true); - expect(fs.existsSync(path.join(workDir, 'ghosts-install-state', 'hello.json'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello'))).toBe(true); + expect(fs.existsSync(path.join(workDir, 'ghosts-install-state', '_root', 'hello.json'))).toBe(true); expect(manager.list()[0]).toMatchObject({ manifest: { id: 'hello' }, enabled: true, approval: { state: 'approved' }, }); - expect(fs.existsSync(path.join(workDir, 'ghosts-install-state', '.pending-hello.json'))).toBe( + expect(fs.existsSync(path.join(workDir, 'ghosts-install-state', '_root', '.pending-hello.json'))).toBe( false, ); }); @@ -2873,17 +3167,17 @@ describe('GhostManager · uninstall', () => { const stateDir = path.join(workDir, 'ghosts-install-state'); await fs.promises.mkdir(stateDir, { recursive: true }); await fs.promises.writeFile( - path.join(stateDir, '.pending-hello.json'), + path.join(stateDir, '_root', '.pending-hello.json'), `${JSON.stringify({ version: 1, - id: 'hello', + id: '_root/hello', kind: 'uninstall', builtinTombstone: true, })}\n`, ); const recoveredTombstone = vi.fn(() => { - expect(fs.existsSync(path.join(rootDir, 'hello'))).toBe(true); - expect(fs.existsSync(path.join(workDir, 'ghosts-install-state', 'hello.json'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello'))).toBe(true); + expect(fs.existsSync(path.join(workDir, 'ghosts-install-state', '_root', 'hello.json'))).toBe(true); }); const recovered = new GhostManager({ @@ -2895,9 +3189,9 @@ describe('GhostManager · uninstall', () => { expect(recoveredTombstone).toHaveBeenCalledWith('hello'); expect(recovered.list()).toEqual([]); - expect(fs.existsSync(path.join(rootDir, 'hello'))).toBe(false); - expect(fs.existsSync(path.join(workDir, 'ghosts-install-state', 'hello.json'))).toBe(false); - expect(fs.existsSync(path.join(stateDir, '.pending-hello.json'))).toBe(false); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello'))).toBe(false); + expect(fs.existsSync(path.join(workDir, 'ghosts-install-state', '_root', 'hello.json'))).toBe(false); + expect(fs.existsSync(path.join(stateDir, '_root', '.pending-hello.json'))).toBe(false); }); it('卸未装的 id → not-installed', async () => { @@ -2934,12 +3228,12 @@ describe('GhostManager · list', () => { it('坏目录只影响自己:无 ghost.json / 清单非法 / 目录名与 id 不符的都被跳过', async () => { await manager.install(await makeCindy('a.cindy', goodManifest())); // 手工捏三个坏目录 - await fs.promises.mkdir(path.join(rootDir, 'no-manifest')); - await fs.promises.mkdir(path.join(rootDir, 'bad-manifest')); - await fs.promises.writeFile(path.join(rootDir, 'bad-manifest', 'ghost.json'), '{ nope'); - await fs.promises.mkdir(path.join(rootDir, 'wrong-name')); + await fs.promises.mkdir(path.join(rootDir, '_root', 'no-manifest')); + await fs.promises.mkdir(path.join(rootDir, '_root', 'bad-manifest')); + await fs.promises.writeFile(path.join(rootDir, '_root', 'bad-manifest', 'ghost.json'), '{ nope'); + await fs.promises.mkdir(path.join(rootDir, '_root', 'wrong-name')); await fs.promises.writeFile( - path.join(rootDir, 'wrong-name', 'ghost.json'), + path.join(rootDir, '_root', 'wrong-name', 'ghost.json'), JSON.stringify(goodManifest('other-id')), ); // 隐藏目录(staging 残留形态)也不进清单 @@ -3139,7 +3433,7 @@ describe('GhostManager · Host approval receipt', () => { } }, ); - expect(receiptStore.read('legacy-broker')).toMatchObject({ state: 'approved' }); + expect(receiptStore.read('_root/legacy-broker')).toMatchObject({ state: 'approved' }); // 新进程从盘上回读仍须投影该插件;若规则误放回共用 validator,这里会消失。 const restarted = new GhostManager({ getRootDir: () => rootDir }); @@ -3174,11 +3468,11 @@ describe('GhostManager · Host approval receipt', () => { it('keeps manifest, enabled state, and trust independent from mutable install files', async () => { await manager.install(await makeCindy('approved.cindy', goodManifest())); const before = manager.list()[0]; - expect(fs.existsSync(receiptPath())).toBe(true); - expect(path.dirname(receiptPath())).not.toBe(rootDir); + expect(fs.existsSync(receiptPath('_root/hello'))).toBe(true); + expect(path.dirname(receiptPath('_root/hello'))).not.toBe(rootDir); await fs.promises.writeFile( - path.join(rootDir, 'hello', 'ghost.json'), + path.join(rootDir, '_root', 'hello', 'ghost.json'), JSON.stringify({ ...goodManifest(), version: '99.0.0', @@ -3186,9 +3480,9 @@ describe('GhostManager · Host approval receipt', () => { node: { entry: 'evil.cjs', protocol: 'json-rpc-stdio' }, }), ); - await fs.promises.writeFile(path.join(rootDir, 'hello', '.disabled'), ''); + await fs.promises.writeFile(path.join(rootDir, '_root', 'hello', '.disabled'), ''); await fs.promises.writeFile( - path.join(rootDir, 'hello', '.cindy-trust.json'), + path.join(rootDir, '_root', 'hello', '.cindy-trust.json'), JSON.stringify({ level: 'cindy-official', publisherSigned: true, @@ -3207,7 +3501,7 @@ describe('GhostManager · Host approval receipt', () => { expect(after.approval.state).toBe('approved'); // 移除镜像 → 回到 receipt 的授权事实(enabled=true 是用户确认装入时的决定)。 - await fs.promises.rm(path.join(rootDir, 'hello', '.disabled')); + await fs.promises.rm(path.join(rootDir, '_root', 'hello', '.disabled')); expect(manager.list()[0].enabled).toBe(true); }); @@ -3282,7 +3576,7 @@ describe('GhostManager · Host approval receipt', () => { releasePublish = resolve; }); const renameSpy = vi.spyOn(fs.promises, 'rename').mockImplementation(async (from, to) => { - if (String(from).includes('.cindy-installing-hello-')) { + if (String(from).includes('.cindy-installing-_root__hello-')) { const result = await originalRename(from, to); publishStarted(); await publishGate; @@ -3332,7 +3626,7 @@ describe('GhostManager · Host approval receipt', () => { enabled: false, }); expect( - fs.existsSync(path.join(workDir, 'ghosts-install-state', '.pending-hello.json')), + fs.existsSync(path.join(workDir, 'ghosts-install-state', '_root', '.pending-hello.json')), ).toBe(true); } finally { clearSpy.mockRestore(); @@ -3348,7 +3642,7 @@ describe('GhostManager · Host approval receipt', () => { approval: { state: 'approved' }, }); expect( - fs.existsSync(path.join(workDir, 'ghosts-install-state', '.pending-hello.json')), + fs.existsSync(path.join(workDir, 'ghosts-install-state', '_root', '.pending-hello.json')), ).toBe(false); }); @@ -3370,25 +3664,25 @@ describe('GhostManager · Host approval receipt', () => { expect(fs.existsSync(listed.approvedSkillRoot!)).toBe(true); await manager.uninstall('skilled'); - expect(fs.existsSync(receiptPath('skilled'))).toBe(false); + expect(fs.existsSync(receiptPath('_root/skilled'))).toBe(false); expect(fs.existsSync(listed.approvedSkillRoot!)).toBe(false); }); it('keeps an uninstall journal when receipt cleanup fails after content removal', async () => { await manager.install(await makeCindy('approved.cindy', goodManifest())); - await fs.promises.rm(receiptPath()); - await fs.promises.mkdir(receiptPath()); - await fs.promises.writeFile(path.join(receiptPath(), 'blocked'), 'x'); + await fs.promises.rm(receiptPath('_root/hello')); + await fs.promises.mkdir(receiptPath('_root/hello')); + await fs.promises.writeFile(path.join(receiptPath('_root/hello'), 'blocked'), 'x'); const result = await manager.uninstall('hello'); expect(result).toEqual({ ok: true }); - expect(fs.existsSync(path.join(rootDir, 'hello'))).toBe(false); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello'))).toBe(false); expect(manager.list()).toEqual([]); - expect(fs.existsSync(path.join(workDir, 'ghosts-install-state', '.pending-hello.json'))).toBe( + expect(fs.existsSync(path.join(workDir, 'ghosts-install-state', '_root', '.pending-hello.json'))).toBe( true, ); - await fs.promises.rm(receiptPath(), { recursive: true, force: true }); + await fs.promises.rm(receiptPath('_root/hello'), { recursive: true, force: true }); const recovered = new GhostManager({ getRootDir: () => rootDir, getLocale: () => hostLocale, @@ -3396,7 +3690,7 @@ describe('GhostManager · Host approval receipt', () => { }); expect(recovered.list()).toEqual([]); expect( - fs.existsSync(path.join(workDir, 'ghosts-install-state', '.pending-hello.json')), + fs.existsSync(path.join(workDir, 'ghosts-install-state', '_root', '.pending-hello.json')), ).toBe(false); }); @@ -3429,7 +3723,7 @@ describe('GhostManager · Host approval receipt', () => { // 停用照样成功(安全方向),但重建快照必须拒——否则启用就等于批准一份用户 // 没看过的技能指令。 await fs.promises.writeFile( - path.join(rootDir, 'skilled', 'skills', 'demo', 'SKILL.md'), + path.join(rootDir, '_root', 'skilled', 'skills', 'demo', 'SKILL.md'), '---\nname: demo\ndescription: Silently widened skill\n---\n\nTampered instructions\n', ); await fs.promises.rm(snapshotRoot, { recursive: true, force: true }); @@ -3488,7 +3782,7 @@ describe('GhostManager · Host approval receipt', () => { it('holds the install-time SKILL.md size ceiling when rebuilding from mutable install bytes', async () => { await manager.install(await makeCindy('skill.cindy', skillManifest(), skillFiles())); const snapshotRoot = manager.list()[0].approvedSkillRoot!; - const installedSkillMd = path.join(rootDir, 'skilled', 'skills', 'demo', 'SKILL.md'); + const installedSkillMd = path.join(rootDir, '_root', 'skilled', 'skills', 'demo', 'SKILL.md'); // 快照缺失时取字节的来源是可变安装目录。这里塞的 SKILL.md frontmatter 与批准 // manifest 完全一致(躲过一致性校验),只是正文超过装入侧上限 —— 重建必须照样拒, // 否则启用这条路会批准一份装入/更新永远不会接受的超大技能指令,而且要先整份 @@ -3516,7 +3810,7 @@ describe('GhostManager · Host approval receipt', () => { // frontmatter 的 name/description 一字未动,只改正文 —— 一致性校验看不出来, // 但这份指令会被主 Agent 以用户全部权限执行,必须靠批准时点的字节指纹拦住。 await fs.promises.writeFile( - path.join(rootDir, 'skilled', 'skills', 'demo', 'SKILL.md'), + path.join(rootDir, '_root', 'skilled', 'skills', 'demo', 'SKILL.md'), '---\nname: demo\ndescription: Demo skill\n---\n\nrm -rf everything\n', ); await fs.promises.rm(snapshotRoot, { recursive: true, force: true }); @@ -3532,7 +3826,7 @@ describe('GhostManager · Host approval receipt', () => { const snapshotRoot = manager.list()[0].approvedSkillRoot!; // SKILL.md 完全没动,只往技能目录里塞一个被指令引用的辅助文件(点文件同样算)。 await fs.promises.writeFile( - path.join(rootDir, 'skilled', 'skills', 'demo', '.helper.sh'), + path.join(rootDir, '_root', 'skilled', 'skills', 'demo', '.helper.sh'), '#!/bin/sh\necho injected\n', ); await fs.promises.rm(snapshotRoot, { recursive: true, force: true }); @@ -3555,7 +3849,7 @@ describe('GhostManager · Host approval receipt', () => { try { await fs.promises.symlink( outside, - path.join(rootDir, 'skilled', 'skills', 'demo', 'linked'), + path.join(rootDir, '_root', 'skilled', 'skills', 'demo', 'linked'), process.platform === 'win32' ? 'junction' : 'dir', ); } catch { @@ -3664,7 +3958,7 @@ describe('GhostManager · Host approval receipt', () => { spy.mockRestore(); } - expect(fs.existsSync(receiptPath())).toBe(true); // receipt 还在盘上 + expect(fs.existsSync(receiptPath('_root/hello'))).toBe(true); // receipt 还在盘上 expect(manager.list()[0]).toMatchObject({ enabled: false, approval: { state: 'invalid' }, @@ -3696,7 +3990,7 @@ describe('GhostManager · Host approval receipt', () => { // 快照与安装目录都被改成同一份未批准内容:此时没有任何可信来源可重建,必须拒。 await fs.promises.writeFile(path.join(snapshotRoot, 'skills', 'demo', 'SKILL.md'), tampered); await fs.promises.writeFile( - path.join(rootDir, 'skilled', 'skills', 'demo', 'SKILL.md'), + path.join(rootDir, '_root', 'skilled', 'skills', 'demo', 'SKILL.md'), tampered, ); @@ -3776,14 +4070,14 @@ describe('GhostManager · Host approval receipt', () => { expect(await manager.approveTrustedBundledInstall(listed.manifest, true, source)).toBe(true); const snapshotRoot = manager.list()[0].approvedSkillRoot!; - const disabledMarker = path.join(rootDir, listed.manifest.id, '.disabled'); + const disabledMarker = path.join(listed.dir, '.disabled'); await fs.promises.writeFile(disabledMarker, ''); await fs.promises.rm(snapshotRoot, { recursive: true, force: true }); expect( await manager.approveTrustedBundledInstall(listed.manifest, false, source), ).toBe(true); - expect(JSON.parse(await fs.promises.readFile(receiptPath('skilled'), 'utf8'))).toMatchObject({ + expect(JSON.parse(await fs.promises.readFile(receiptPath('_root/skilled'), 'utf8'))).toMatchObject({ enabled: false, }); @@ -3808,11 +4102,11 @@ describe('GhostManager · Host approval receipt', () => { it('invalidates a receipt whose skill content digests no longer match the manifest', async () => { await manager.install(await makeCindy('skill.cindy', skillManifest(), skillFiles())); const receipt = JSON.parse( - await fs.promises.readFile(receiptPath('skilled'), 'utf8'), + await fs.promises.readFile(receiptPath('_root/skilled'), 'utf8'), ) as Record; // 手工把指纹字段抹掉:必填项缺失一律判 invalid,不允许退化成"跳过校验"。 delete receipt.skillContentSha256; - await fs.promises.writeFile(receiptPath('skilled'), JSON.stringify(receipt)); + await fs.promises.writeFile(receiptPath('_root/skilled'), JSON.stringify(receipt)); expect(manager.list()[0]).toMatchObject({ enabled: false, @@ -3823,11 +4117,11 @@ describe('GhostManager · Host approval receipt', () => { it('invalidates a schema v1 receipt instead of trusting its legacy content digests', async () => { await manager.install(await makeCindy('approved.cindy', goodManifest())); const receipt = JSON.parse( - await fs.promises.readFile(receiptPath(), 'utf8'), + await fs.promises.readFile(receiptPath('_root/hello'), 'utf8'), ) as Record; // v2 改了内容摘要 framing;旧 receipt 的摘要不能拿来继续授权,必须 fail closed。 receipt.schemaVersion = 1; - await fs.promises.writeFile(receiptPath(), JSON.stringify(receipt)); + await fs.promises.writeFile(receiptPath('_root/hello'), JSON.stringify(receipt)); expect(manager.list()[0]).toMatchObject({ enabled: false, @@ -3843,7 +4137,7 @@ describe('GhostManager · Host approval receipt', () => { // 撤掉之后插件必须彻底不可运行,而不是继续拿旧批准跑新代码。 await manager.removeInstallApproval('hello'); - expect(fs.existsSync(receiptPath())).toBe(false); + expect(fs.existsSync(receiptPath('_root/hello'))).toBe(false); expect(manager.list()[0]).toMatchObject({ enabled: false, approval: { state: 'legacy-unapproved' }, @@ -3875,7 +4169,7 @@ describe('GhostManager · Host approval receipt', () => { JSON.stringify(ghostManifestToAuthorFormat(approvedManifest)), ); await fs.promises.writeFile(path.join(sourceDir, 'main.js'), 'immutable bundled bytes'); - await fs.promises.writeFile(path.join(rootDir, approvedManifest.id, 'main.js'), 'mutable bytes'); + await fs.promises.writeFile(path.join(manager.list()[0].dir, 'main.js'), 'mutable bytes'); const unsafeCall = manager.approveTrustedBundledInstall as unknown as ( manifest: InstalledGhost['manifest'], @@ -3886,7 +4180,7 @@ describe('GhostManager · Host approval receipt', () => { ); await expect( manager.approveTrustedBundledInstall(approvedManifest, true, { - sourceDir: path.join(rootDir, approvedManifest.id), + sourceDir: manager.list()[0].dir, }), ).rejects.toThrow(/mutable installed directory/); const arbitrarySourceDir = path.join(workDir, 'arbitrary-source', approvedManifest.id); @@ -3904,13 +4198,13 @@ describe('GhostManager · Host approval receipt', () => { expect( await manager.approveTrustedBundledInstall(approvedManifest, true, { sourceDir }), ).toBe(true); - const receipt = JSON.parse(await fs.promises.readFile(receiptPath(), 'utf8')) as { + const receipt = JSON.parse(await fs.promises.readFile(receiptPath('_root/hello'), 'utf8')) as { packageSha256?: string; }; const sourcePackageSha256 = receipt.packageSha256; - await fs.promises.writeFile(path.join(rootDir, approvedManifest.id, 'main.js'), 'different mutable bytes'); + await fs.promises.writeFile(path.join(manager.list()[0].dir, 'main.js'), 'different mutable bytes'); await manager.approveTrustedBundledInstall(approvedManifest, true, { sourceDir }); - const stableReceipt = JSON.parse(await fs.promises.readFile(receiptPath(), 'utf8')) as { + const stableReceipt = JSON.parse(await fs.promises.readFile(receiptPath('_root/hello'), 'utf8')) as { packageSha256?: string; }; expect(stableReceipt.packageSha256).toBe(sourcePackageSha256); @@ -3923,7 +4217,7 @@ describe('GhostManager · Host approval receipt', () => { 'main.js': 'immutable replacement bytes', }); const stateRoot = manager.approvalStateRoot(); - const pendingPath = path.join(stateRoot, '.pending-hello.json'); + const pendingPath = path.join(stateRoot, '_root', '.pending-hello.json'); await manager.runExclusiveMutation(async (mutation) => { expect(await mutation.removeInstallApproval('hello')).toBe(true); @@ -3931,19 +4225,19 @@ describe('GhostManager · Host approval receipt', () => { expect(fs.existsSync(pendingPath)).toBe(true); expect( (await fs.promises.readdir(rootDir)).some((name) => - name.startsWith('.cindy-updating-hello-'), + name.startsWith('.cindy-updating-_root__hello-'), ), ).toBe(true); await mutation.approveTrustedBundledInstall(approvedManifest, true, { sourceDir }); }); - expect(await fs.promises.readFile(path.join(rootDir, 'hello', 'main.js'), 'utf8')).toBe( + expect(await fs.promises.readFile(path.join(rootDir, '_root', 'hello', 'main.js'), 'utf8')).toBe( 'immutable replacement bytes', ); expect(fs.existsSync(pendingPath)).toBe(false); expect( (await fs.promises.readdir(rootDir)).some((name) => - name.startsWith('.cindy-updating-hello-'), + name.startsWith('.cindy-updating-_root__hello-'), ), ).toBe(false); expect(manager.list()[0].approval.state).toBe('approved'); @@ -3952,7 +4246,7 @@ describe('GhostManager · Host approval receipt', () => { it('keeps a receipt-pinned disable when the .disabled mirror was lost, and rewrites the mirror', async () => { await manager.install(await makeCindy('a.cindy', goodManifest())); const { manifest: approvedManifest } = JSON.parse( - await fs.promises.readFile(receiptPath(), 'utf8'), + await fs.promises.readFile(receiptPath('_root/hello'), 'utf8'), ) as { manifest: InstalledGhost['manifest'] }; const source = await writeBundledSource(approvedManifest); // 随包对账首轮把安装收编成 bundled 批准(trust 归一),后续轮次走稳态分支。 @@ -3960,27 +4254,27 @@ describe('GhostManager · Host approval receipt', () => { expect('ok' in (await manager.setEnabled('hello', false))).toBe(true); // 外部因素(AV 隔离恢复 / 同步冲突解析 / 手动清理)移除了兼容镜像文件。 - await fs.promises.rm(path.join(rootDir, 'hello', '.disabled')); + await fs.promises.rm(path.join(rootDir, '_root', 'hello', '.disabled')); // 下一轮对账把镜像读数(启用)喂进来:不得据此翻转 receipt —— 否则用户显式 // 停用的插件被静默重新启用,无确认、无审计。重新启用只有 setEnabled 一条路。 expect(await manager.approveTrustedBundledInstall(approvedManifest, true, source)).toBe(false); expect(manager.list()[0].enabled).toBe(false); // 镜像被补写回去:回滚到旧客户端(只认镜像文件)时仍按停用对待。 - expect(fs.existsSync(path.join(rootDir, 'hello', '.disabled'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello', '.disabled'))).toBe(true); }); it('an old-client style .disabled marker still turns a bundled receipt off', async () => { await manager.install(await makeCindy('a.cindy', goodManifest())); const { manifest: approvedManifest } = JSON.parse( - await fs.promises.readFile(receiptPath(), 'utf8'), + await fs.promises.readFile(receiptPath('_root/hello'), 'utf8'), ) as { manifest: InstalledGhost['manifest'] }; const source = await writeBundledSource(approvedManifest); expect(await manager.approveTrustedBundledInstall(approvedManifest, true, source)).toBe(true); // 旧客户端只会写镜像文件、不会写 receipt。停用是安全方向,合并必须照办 —— // 非对称的另一半:镜像只能把启停态往下拉,不能往上翻。 - await fs.promises.writeFile(path.join(rootDir, 'hello', '.disabled'), ''); + await fs.promises.writeFile(path.join(rootDir, '_root', 'hello', '.disabled'), ''); expect(await manager.approveTrustedBundledInstall(approvedManifest, false, source)).toBe(true); expect(manager.list()[0].enabled).toBe(false); }); @@ -3988,7 +4282,7 @@ describe('GhostManager · Host approval receipt', () => { it('a bundled update keeps the receipt-pinned disable even when the marker was lost', async () => { await manager.install(await makeCindy('a.cindy', goodManifest())); const { manifest: approvedManifest } = JSON.parse( - await fs.promises.readFile(receiptPath(), 'utf8'), + await fs.promises.readFile(receiptPath('_root/hello'), 'utf8'), ) as { manifest: InstalledGhost['manifest'] }; expect( await manager.approveTrustedBundledInstall( @@ -3998,7 +4292,7 @@ describe('GhostManager · Host approval receipt', () => { ), ).toBe(true); expect('ok' in (await manager.setEnabled('hello', false))).toBe(true); - await fs.promises.rm(path.join(rootDir, 'hello', '.disabled')); + await fs.promises.rm(path.join(rootDir, '_root', 'hello', '.disabled')); // 随包更新那一轮走的是"建全新 receipt"分支,与稳态分支共用同一条合并规则: // 只堵稳态分支的话,镜像在更新 tick 之前丢失仍会静默重新启用,同一个洞换条路。 @@ -4014,7 +4308,7 @@ describe('GhostManager · Host approval receipt', () => { enabled: false, manifest: { version: '1.0.1' }, }); - expect(fs.existsSync(path.join(rootDir, 'hello', '.disabled'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello', '.disabled'))).toBe(true); }); it('refuses to mint a bundled approval for an id outside the seed roster', async () => { @@ -4048,10 +4342,10 @@ describe('GhostManager · Host approval receipt', () => { }), ); const receipt = JSON.parse( - await fs.promises.readFile(receiptPath(), 'utf8'), + await fs.promises.readFile(receiptPath('_root/hello'), 'utf8'), ) as Record; receipt.localeResources = {}; - await fs.promises.writeFile(receiptPath(), JSON.stringify(receipt)); + await fs.promises.writeFile(receiptPath('_root/hello'), JSON.stringify(receipt)); expect(manager.list()[0]).toMatchObject({ enabled: false, @@ -4072,7 +4366,7 @@ describe('GhostManager · 技能批准基线取自包投影(publish 后篡改必 // 故障注入:staging→final 的 rename 真实执行后,立刻在 finalDir 里改写 SKILL.md // 正文 —— 模拟"发布与首次 hash 之间"的本机进程篡改窗口。 const realRename = fs.promises.rename; - const finalDir = path.join(rootDir, 'skilled'); + const finalDir = path.join(rootDir, '_root', 'skilled'); const spy = vi.spyOn(fs.promises, 'rename').mockImplementation(async (from, to) => { await realRename(from, to); if (String(to) === finalDir) { @@ -4094,7 +4388,7 @@ describe('GhostManager · 技能批准基线取自包投影(publish 后篡改必 // 拒装收尾:不留半截安装,也没有任何批准事实落盘。 expect(manager.list()).toHaveLength(0); expect( - fs.existsSync(path.join(workDir, 'ghosts-install-state', 'skilled.json')), + fs.existsSync(path.join(workDir, 'ghosts-install-state', '_root', 'skilled.json')), ).toBe(false); }); }); @@ -4104,8 +4398,8 @@ describe('GhostManager · 更新崩溃恢复(两次 rename 之间)', () => { await manager.install(await makeCindy('a.cindy', goodManifest())); // 模拟崩溃现场:final→backup 已发生,staging→final 没来得及。 await fs.promises.rename( - path.join(rootDir, 'hello'), - path.join(rootDir, '.cindy-updating-hello-abcdef01'), + path.join(rootDir, '_root', 'hello'), + path.join(rootDir, '.cindy-updating-_root__hello-abcdef01'), ); // 崩溃前 list() 视角:插件消失(点目录被跳过)—— 这正是要修的现场。 expect(manager.list()).toHaveLength(0); @@ -4113,15 +4407,15 @@ describe('GhostManager · 更新崩溃恢复(两次 rename 之间)', () => { // "重启":新建 manager,构造期恢复扫描搬回。receipt 从未更新过,恢复后 // receipt 与内容完全一致,等价于那次更新从未发生。 const restarted = new GhostManager({ getRootDir: () => rootDir, getLocale: () => hostLocale }); - expect(fs.existsSync(path.join(rootDir, 'hello', 'ghost.json'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello', 'ghost.json'))).toBe(true); expect(restarted.list()[0]).toMatchObject({ enabled: true, approval: { state: 'approved' } }); - expect(fs.existsSync(path.join(rootDir, '.cindy-updating-hello-abcdef01'))).toBe(false); + expect(fs.existsSync(path.join(rootDir, '.cindy-updating-_root__hello-abcdef01'))).toBe(false); }); it('final 是普通文件时不删除唯一 backup', async () => { await manager.install(await makeCindy('a.cindy', goodManifest())); - const finalDir = path.join(rootDir, 'hello'); - const backupDir = path.join(rootDir, '.cindy-updating-hello-abcdef01'); + const finalDir = path.join(rootDir, '_root', 'hello'); + const backupDir = path.join(rootDir, '.cindy-updating-_root__hello-abcdef01'); await fs.promises.rename(finalDir, backupDir); await fs.promises.writeFile(finalDir, 'unexpected file'); @@ -4133,8 +4427,8 @@ describe('GhostManager · 更新崩溃恢复(两次 rename 之间)', () => { it('final 是 junction/链接时不删除唯一 backup', async () => { await manager.install(await makeCindy('a.cindy', goodManifest())); - const finalDir = path.join(rootDir, 'hello'); - const backupDir = path.join(rootDir, '.cindy-updating-hello-abcdef01'); + const finalDir = path.join(rootDir, '_root', 'hello'); + const backupDir = path.join(rootDir, '.cindy-updating-_root__hello-abcdef01'); const outsideDir = path.join(workDir, 'outside-final-target'); await fs.promises.rename(finalDir, backupDir); await fs.promises.mkdir(outsideDir, { recursive: true }); @@ -4152,8 +4446,8 @@ describe('GhostManager · 更新崩溃恢复(两次 rename 之间)', () => { it('final lstat EACCES 时不删除唯一 backup', async () => { await manager.install(await makeCindy('a.cindy', goodManifest())); - const finalDir = path.join(rootDir, 'hello'); - const backupDir = path.join(rootDir, '.cindy-updating-hello-abcdef01'); + const finalDir = path.join(rootDir, '_root', 'hello'); + const backupDir = path.join(rootDir, '.cindy-updating-_root__hello-abcdef01'); await fs.promises.rename(finalDir, backupDir); const realLstatSync = fs.lstatSync; const spy = vi.spyOn(fs, 'lstatSync').mockImplementation(((target: fs.PathLike, ...rest: unknown[]) => { @@ -4174,25 +4468,25 @@ describe('GhostManager · 更新崩溃恢复(两次 rename 之间)', () => { it('final 在位的陈旧 backup 与 staging 残留 → 回收;同 id 多个 backup 不猜、原样保留', async () => { await manager.install(await makeCindy('a.cindy', goodManifest())); - await fs.promises.mkdir(path.join(rootDir, '.cindy-updating-hello-abcdef01'), { recursive: true }); - await fs.promises.mkdir(path.join(rootDir, '.cindy-installing-hello-deadbeef'), { recursive: true }); + await fs.promises.mkdir(path.join(rootDir, '.cindy-updating-_root__hello-abcdef01'), { recursive: true }); + await fs.promises.mkdir(path.join(rootDir, '.cindy-installing-_root__hello-deadbeef'), { recursive: true }); new GhostManager({ getRootDir: () => rootDir, getLocale: () => hostLocale }); - expect(fs.existsSync(path.join(rootDir, '.cindy-updating-hello-abcdef01'))).toBe(false); - expect(fs.existsSync(path.join(rootDir, '.cindy-installing-hello-deadbeef'))).toBe(false); - expect(fs.existsSync(path.join(rootDir, 'hello', 'ghost.json'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '.cindy-updating-_root__hello-abcdef01'))).toBe(false); + expect(fs.existsSync(path.join(rootDir, '.cindy-installing-_root__hello-deadbeef'))).toBe(false); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello', 'ghost.json'))).toBe(true); // 多 backup 且 final 缺位:不猜哪份是对的,原样保留等人工处理。 - await fs.promises.rename(path.join(rootDir, 'hello'), path.join(rootDir, '.cindy-updating-hello-11111111')); - await fs.promises.mkdir(path.join(rootDir, '.cindy-updating-hello-22222222'), { recursive: true }); + await fs.promises.rename(path.join(rootDir, '_root', 'hello'), path.join(rootDir, '.cindy-updating-_root__hello-11111111')); + await fs.promises.mkdir(path.join(rootDir, '.cindy-updating-_root__hello-22222222'), { recursive: true }); new GhostManager({ getRootDir: () => rootDir, getLocale: () => hostLocale }); - expect(fs.existsSync(path.join(rootDir, '.cindy-updating-hello-11111111'))).toBe(true); - expect(fs.existsSync(path.join(rootDir, '.cindy-updating-hello-22222222'))).toBe(true); - expect(fs.existsSync(path.join(rootDir, 'hello'))).toBe(false); + expect(fs.existsSync(path.join(rootDir, '.cindy-updating-_root__hello-11111111'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '.cindy-updating-_root__hello-22222222'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello'))).toBe(false); }); it('id 是另一个 id 的 `-` 前缀(hello / hello-x)各留唯一 backup → 两者都搬回,不因前缀误判互相拖累(P1)', async () => { // 回归:siblings 统计曾用 startsWith(`.cindy-updating-${id}-`) 前缀匹配, - // `.cindy-updating-hello-` 是 `.cindy-updating-hello-x-` 的前缀, + // `.cindy-updating-_root__hello-` 是 `.cindy-updating-_root__hello-x-` 的前缀, // 于是处理 hello 时把 hello-x 的 backup 也算进来 → siblings 变 2 → 判"多备份 // 留待人工" → hello 崩溃后持续消失。修复后按解析 id 精确比对,两者各自恢复。 await manager.install(await makeCindy('a.cindy', goodManifest('hello'))); @@ -4200,21 +4494,21 @@ describe('GhostManager · 更新崩溃恢复(两次 rename 之间)', () => { // 两个插件都卡在"final→backup 已发生,staging→final 未完成"的崩溃现场, // 且各自只有唯一 backup(合法可恢复的场景)。 await fs.promises.rename( - path.join(rootDir, 'hello'), - path.join(rootDir, '.cindy-updating-hello-11111111'), + path.join(rootDir, '_root', 'hello'), + path.join(rootDir, '.cindy-updating-_root__hello-11111111'), ); await fs.promises.rename( - path.join(rootDir, 'hello-x'), - path.join(rootDir, '.cindy-updating-hello-x-22222222'), + path.join(rootDir, '_root', 'hello-x'), + path.join(rootDir, '.cindy-updating-_root__hello-x-22222222'), ); new GhostManager({ getRootDir: () => rootDir, getLocale: () => hostLocale }); // 两者都应搬回 final,backup 清空 —— hello 不能被 hello-x 的存在拖成"消失"。 - expect(fs.existsSync(path.join(rootDir, 'hello', 'ghost.json'))).toBe(true); - expect(fs.existsSync(path.join(rootDir, 'hello-x', 'ghost.json'))).toBe(true); - expect(fs.existsSync(path.join(rootDir, '.cindy-updating-hello-11111111'))).toBe(false); - expect(fs.existsSync(path.join(rootDir, '.cindy-updating-hello-x-22222222'))).toBe(false); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello', 'ghost.json'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello-x', 'ghost.json'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '.cindy-updating-_root__hello-11111111'))).toBe(false); + expect(fs.existsSync(path.join(rootDir, '.cindy-updating-_root__hello-x-22222222'))).toBe(false); }); }); @@ -4237,7 +4531,7 @@ describe('GhostManager · setEnabled(启用/停用)', () => { // "镜像已就位"降级 —— 但镜像根本没写成,receipt.enabled 仍为 true,重启即复活。 expect('rejection' in result && result.rejection.code).toBe('io'); expect(manager.list()[0].enabled).toBe(true); // 如实:停用没有生效 - expect(fs.existsSync(path.join(rootDir, 'hello', '.disabled'))).toBe(false); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello', '.disabled'))).toBe(false); } finally { spy.mockRestore(); } @@ -4252,13 +4546,13 @@ describe('GhostManager · setEnabled(启用/停用)', () => { const off = await manager.setEnabled('hello', false); expect('ok' in off).toBe(true); - expect(fs.existsSync(path.join(rootDir, 'hello', '.disabled'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello', '.disabled'))).toBe(true); expect(manager.list()[0].enabled).toBe(false); expect(onChanged).toHaveBeenCalledTimes(1); const on = await manager.setEnabled('hello', true); expect('ok' in on).toBe(true); - expect(fs.existsSync(path.join(rootDir, 'hello', '.disabled'))).toBe(false); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello', '.disabled'))).toBe(false); expect(manager.list()[0].enabled).toBe(true); }); @@ -4511,7 +4805,7 @@ describe('GhostManager · author / icon(身份卡展示字段)', () => { expect((result as { ghost: InstalledGhost }).ghost.iconDataUrl).toBe(ok.iconDataUrl); // list 从安装目录读盘重建,与装入时一致 expect(manager.list()[0].iconDataUrl).toBe(ok.iconDataUrl); - expect(fs.existsSync(path.join(rootDir, 'hello', 'assets', 'icon.png'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello', 'assets', 'icon.png'))).toBe(true); }); it('清单声明了 icon 但包内缺文件 → file-invalid', async () => { @@ -4529,7 +4823,7 @@ describe('GhostManager · author / icon(身份卡展示字段)', () => { it('installed icon removal cannot replace the Host-approved icon snapshot', async () => { const cindy = await makeCindy('icon2.cindy', iconManifest(), { 'assets/icon.png': 'PNGDATA' }); await manager.install(cindy); - await fs.promises.rm(path.join(rootDir, 'hello', 'assets', 'icon.png')); + await fs.promises.rm(path.join(rootDir, '_root', 'hello', 'assets', 'icon.png')); // 受体模型:已批准投影的 icon 来自 receipt 快照(GhostManager.ts:1684), // 装后删盘上 icon 文件不改变 list() 输出——快照即批准时钉下的图标。 // main 旧的"删文件/换软链 → 降级为无图标"两个用例前提在受体模型下不再成立 @@ -4597,11 +4891,11 @@ describe('GhostManager · Unix file permissions', () => { } if (process.platform !== 'win32') { - expect((await fs.promises.stat(path.join(rootDir, 'hello', 'bin', 'tool'))).mode & 0o777) + expect((await fs.promises.stat(path.join(rootDir, '_root', 'hello', 'bin', 'tool'))).mode & 0o777) .toBe(0o755); - expect((await fs.promises.stat(path.join(rootDir, 'hello', 'config.txt'))).mode & 0o777) + expect((await fs.promises.stat(path.join(rootDir, '_root', 'hello', 'config.txt'))).mode & 0o777) .toBe(0o644); - const special = await fs.promises.stat(path.join(rootDir, 'hello', 'bin', 'special')); + const special = await fs.promises.stat(path.join(rootDir, '_root', 'hello', 'bin', 'special')); expect(special.mode & 0o777).toBe(0o755); expect(special.mode & 0o4000).toBe(0); } @@ -4612,13 +4906,13 @@ describe('GhostManager · Unix file permissions', () => { 'v2', ); expect(await updateGhost(v2)).toHaveProperty('ghost'); - expect(await fs.promises.readFile(path.join(rootDir, 'hello', 'config.txt'), 'utf8')).toBe('v2'); + expect(await fs.promises.readFile(path.join(rootDir, '_root', 'hello', 'config.txt'), 'utf8')).toBe('v2'); if (process.platform !== 'win32') { - expect((await fs.promises.stat(path.join(rootDir, 'hello', 'bin', 'tool'))).mode & 0o777) + expect((await fs.promises.stat(path.join(rootDir, '_root', 'hello', 'bin', 'tool'))).mode & 0o777) .toBe(0o755); - expect((await fs.promises.stat(path.join(rootDir, 'hello', 'config.txt'))).mode & 0o777) + expect((await fs.promises.stat(path.join(rootDir, '_root', 'hello', 'config.txt'))).mode & 0o777) .toBe(0o644); - expect((await fs.promises.stat(path.join(rootDir, 'hello', 'bin', 'special'))).mode & 0o777) + expect((await fs.promises.stat(path.join(rootDir, '_root', 'hello', 'bin', 'special'))).mode & 0o777) .toBe(0o755); } }); @@ -4646,15 +4940,15 @@ describe('GhostManager · Unix file permissions', () => { expect((installed as { ghost: InstalledGhost }).ghost.trust?.publisherSigned).toBe(true); expect( - await fs.promises.readFile(path.join(rootDir, 'hello', 'bin', 'tool'), 'utf8'), + await fs.promises.readFile(path.join(rootDir, '_root', 'hello', 'bin', 'tool'), 'utf8'), ).toContain('v1'); if (process.platform !== 'win32') { // 签名包与未签名包走同一条恢复路径:0755 保留、0644 保留、特殊位剥除。 - expect((await fs.promises.stat(path.join(rootDir, 'hello', 'bin', 'tool'))).mode & 0o777) + expect((await fs.promises.stat(path.join(rootDir, '_root', 'hello', 'bin', 'tool'))).mode & 0o777) .toBe(0o755); - expect((await fs.promises.stat(path.join(rootDir, 'hello', 'config.txt'))).mode & 0o777) + expect((await fs.promises.stat(path.join(rootDir, '_root', 'hello', 'config.txt'))).mode & 0o777) .toBe(0o644); - const special = await fs.promises.stat(path.join(rootDir, 'hello', 'bin', 'special')); + const special = await fs.promises.stat(path.join(rootDir, '_root', 'hello', 'bin', 'special')); expect(special.mode & 0o777).toBe(0o755); expect(special.mode & 0o4000).toBe(0); } @@ -4668,7 +4962,7 @@ describe('GhostManager · Unix file permissions', () => { try { expect(await manager.install(cindy)).toHaveProperty('ghost'); expect(chmodSpy).not.toHaveBeenCalled(); - await expect(fs.promises.readFile(path.join(rootDir, 'hello', 'plain.txt'), 'utf8')) + await expect(fs.promises.readFile(path.join(rootDir, '_root', 'hello', 'plain.txt'), 'utf8')) .resolves.toBe('plain'); } finally { chmodSpy.mockRestore(); @@ -4709,7 +5003,7 @@ describe('GhostManager · update(原位换版)', () => { ); expect(result).toHaveProperty('ghost'); const receipt = JSON.parse( - await fs.promises.readFile(path.join(manager.approvalStateRoot(), 'hello.json'), 'utf8'), + await fs.promises.readFile(path.join(manager.approvalStateRoot(), '_root', 'hello.json'), 'utf8'), ) as Record; expect(receipt).toHaveProperty('installOrigin', 'agent-forge'); expect(manager.readEffectiveInstallOrigin('hello')).toBe('agent-forge'); @@ -4748,9 +5042,9 @@ describe('GhostManager · update(原位换版)', () => { expect('ghost' in result, JSON.stringify(result)).toBe(true); const { ghost } = result as { ghost: InstalledGhost }; expect(ghost.manifest.version).toBe('2.0.0'); - expect(ghost.dir).toBe(path.join(rootDir, 'hello')); - expect(fs.existsSync(path.join(rootDir, 'hello', 'new.txt'))).toBe(true); - expect(fs.existsSync(path.join(rootDir, 'hello', 'old.txt'))).toBe(false); // 换版不留旧文件 + expect(ghost.dir).toBe(path.join(rootDir, '_root', 'hello')); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello', 'new.txt'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello', 'old.txt'))).toBe(false); // 换版不留旧文件 expect(onPackagePlaced).toHaveBeenCalledTimes(1); expect(onPackagePlaced.mock.invocationCallOrder[0]).toBeLessThan( onChanged.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, @@ -4786,12 +5080,12 @@ describe('GhostManager · update(原位换版)', () => { await manager.install(await makeCindy('v1.cindy', goodManifest()), { initiallyEnabled: false }); const r1 = await updateGhost(await makeCindy('v2.cindy', { ...goodManifest(), version: '2.0.0' })); expect((r1 as { ghost: InstalledGhost }).ghost.enabled).toBe(false); - expect(fs.existsSync(path.join(rootDir, 'hello', '.disabled'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello', '.disabled'))).toBe(true); await manager.setEnabled('hello', true); const r2 = await updateGhost(await makeCindy('v3.cindy', { ...goodManifest(), version: '3.0.0' })); expect((r2 as { ghost: InstalledGhost }).ghost.enabled).toBe(true); - expect(fs.existsSync(path.join(rootDir, 'hello', '.disabled'))).toBe(false); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello', '.disabled'))).toBe(false); }); it('提交前回调失败时恢复旧版本', async () => { @@ -4812,8 +5106,283 @@ describe('GhostManager · update(原位换版)', () => { await expectRejection(result, 'io'); expect(manager.list()[0]?.manifest.version).toBe('1.0.0'); - expect(fs.existsSync(path.join(rootDir, 'hello', 'old.txt'))).toBe(true); - expect(fs.existsSync(path.join(rootDir, 'hello', 'new.txt'))).toBe(false); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello', 'old.txt'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello', 'new.txt'))).toBe(false); + }); + + it('archives source state before package side effects and leaves the replacement with empty state', async () => { + const archiveId = '_ns__cindy-archive-00000000-0000-4000-8000-000000000002__hello'; + const state = new Map([['_root__hello', 'old-credentials-and-data']]); + const archive = vi.fn(async (fromPart: string, archivePart: string) => { + if (!state.has(fromPart)) return; + state.set(archivePart, state.get(fromPart)!); + state.delete(fromPart); + }); + manager = new GhostManager({ + getRootDir: () => rootDir, + onArchiveSourceState: archive, + mutateSnapshot: async ({ parentDir, ...request }) => { + await runGhostSnapshotWorkerRequest(request, parentDir); + }, + }); + await manager.install(await makeCindy('v1.cindy', goodManifest())); + const result = await manager.update(await makeCindy('v2.cindy', { ...goodManifest(), version: '2.0.0' }), { + expectedInstalledApproval: ghostInstallApprovalToken(manager.list()[0]?.approval), + sourceStateArchiveId: archiveId, + beforePackageCommit: () => { + expect(state.has('_root__hello')).toBe(false); + const store = new GhostInstallReceiptStore(() => path.join(workDir, 'ghosts-install-state')); + expect(store.readPendingMutationSync('_root/hello')).toMatchObject({ + state: 'valid', mutation: { sourceStateArchiveId: archiveId }, + }); + }, + }); + expect(result).toMatchObject({ ghost: { manifest: { version: '2.0.0' } } }); + expect(archive).toHaveBeenCalledWith('_root__hello', archiveId); + expect(state.has('_root__hello')).toBe(false); + expect(state.get(archiveId)).toBe('old-credentials-and-data'); + }); + + it('restores archived source state before compensating side effects on receipt failure', async () => { + const archiveId = '_ns__cindy-archive-00000000-0000-4000-8000-000000000002__hello'; + const state = new Map([['_root__hello', 'old-data']]); + manager = new GhostManager({ + getRootDir: () => rootDir, + onArchiveSourceState: async (fromPart, archivePart) => { + if (!state.has(fromPart)) return; + state.set(archivePart, state.get(fromPart)!); + state.delete(fromPart); + }, + mutateSnapshot: async ({ parentDir, ...request }) => { + await runGhostSnapshotWorkerRequest(request, parentDir); + }, + }); + await manager.install(await makeCindy('v1.cindy', goodManifest())); + const store = (manager as unknown as { receiptStore: GhostInstallReceiptStore }).receiptStore; + const rollback = vi.fn(() => { expect(state.get('_root__hello')).toBe('old-data'); }); + const write = vi.spyOn(store, 'write').mockRejectedValueOnce(new Error('receipt blocked')); + const result = await manager.update(await makeCindy('v2.cindy', { ...goodManifest(), version: '2.0.0' }), { + expectedInstalledApproval: ghostInstallApprovalToken(manager.list()[0]?.approval), + sourceStateArchiveId: archiveId, + beforePackageCommit: () => ({ rollback, commit: vi.fn() }), + }); + write.mockRestore(); + expect(result).toMatchObject({ rejection: { code: 'io' } }); + expect(rollback).toHaveBeenCalledOnce(); + expect(state.get('_root__hello')).toBe('old-data'); + expect(state.has(archiveId)).toBe(false); + expect(manager.list()[0]?.manifest.version).toBe('1.0.0'); + expect(store.readPendingMutationSync('_root/hello').state).toBe('missing'); + }); + + it('retains the archive journal and quarantine after rollback failure until asynchronous recovery finishes', async () => { + const archiveId = '_ns__cindy-archive-00000000-0000-4000-8000-000000000002__hello'; + const state = new Map([['_root__hello', 'old-data']]); + const move = (fromPart: string, archivePart: string) => { + if (!state.has(fromPart)) return; + state.set(archivePart, state.get(fromPart)!); + state.delete(fromPart); + }; + manager = new GhostManager({ + getRootDir: () => rootDir, + onArchiveSourceState: async (fromPart, archivePart) => { + if (fromPart === archiveId) throw new Error('rollback blocked'); + move(fromPart, archivePart); + }, + mutateSnapshot: async ({ parentDir, ...request }) => { + await runGhostSnapshotWorkerRequest(request, parentDir); + }, + }); + await manager.install(await makeCindy('v1.cindy', goodManifest())); + const store = (manager as unknown as { receiptStore: GhostInstallReceiptStore }).receiptStore; + const write = vi.spyOn(store, 'write').mockRejectedValueOnce(new Error('receipt blocked')); + const result = await manager.update(await makeCindy('v2.cindy', { ...goodManifest(), version: '2.0.0' }), { + expectedInstalledApproval: ghostInstallApprovalToken(manager.list()[0]?.approval), + sourceStateArchiveId: archiveId, + }); + write.mockRestore(); + expect(result).toMatchObject({ rejection: { code: 'io', rollbackFailed: true } }); + expect(manager.list()[0]?.approval.state).toBe('invalid'); + let finishRecovery!: () => void; + const recoveryGate = new Promise((resolve) => { finishRecovery = resolve; }); + const recovered = new GhostManager({ + getRootDir: () => rootDir, + onArchiveSourceState: async (fromPart, archivePart) => { + expect(fromPart).toBe(archiveId); + expect(archivePart).toBe('_root__hello'); + await recoveryGate; + move(fromPart, archivePart); + }, + }); + expect(recovered.list()[0]?.approval.state).toBe('invalid'); + expect(store.readPendingMutationSync('_root/hello').state).toBe('valid'); + finishRecovery(); + await recovered.retryInterruptedMutationsAfterDbReady(); + expect(recovered.list()[0]?.manifest.version).toBe('1.0.0'); + expect(recovered.list()[0]?.approval.state).toBe('approved'); + expect(state.get('_root__hello')).toBe('old-data'); + expect(state.has(archiveId)).toBe(false); + expect(store.readPendingMutationSync('_root/hello').state).toBe('missing'); + }); + + it('recovers a committed source archive journal before allowing the new runtime', async () => { + const archiveId = '_ns__cindy-archive-00000000-0000-4000-8000-000000000002__hello'; + const state = new Map([['_root__hello', 'old-data']]); + const archive = vi.fn(async (fromPart: string, toPart: string) => { + if (!state.has(fromPart)) return; + state.set(toPart, state.get(fromPart)!); + state.delete(fromPart); + }); + manager = new GhostManager({ getRootDir: () => rootDir, onArchiveSourceState: archive, + mutateSnapshot: async ({ parentDir, ...request }) => { + await runGhostSnapshotWorkerRequest(request, parentDir); + }, + }); + await manager.install(await makeCindy('v1.cindy', goodManifest())); + const store = (manager as unknown as { receiptStore: GhostInstallReceiptStore }).receiptStore; + const clear = vi.spyOn(store, 'clearPendingMutation').mockRejectedValueOnce(new Error('clear blocked')); + const result = await manager.update(await makeCindy('v2.cindy', { ...goodManifest(), version: '2.0.0' }), { + expectedInstalledApproval: ghostInstallApprovalToken(manager.list()[0]?.approval), + sourceStateArchiveId: archiveId, + }); + clear.mockRestore(); + expect(result).toMatchObject({ ghost: { approval: { state: 'invalid' } } }); + let finishRecovery!: () => void; + const gate = new Promise((resolve) => { finishRecovery = resolve; }); + const recovered = new GhostManager({ getRootDir: () => rootDir, + onArchiveSourceState: async (fromPart, toPart) => { + expect([fromPart, toPart]).toEqual(['_root__hello', archiveId]); + await gate; + await archive(fromPart, toPart); + }, + }); + expect(recovered.list()[0]?.approval.state).toBe('invalid'); + finishRecovery(); + await recovered.retryInterruptedMutationsAfterDbReady(); + expect(recovered.list()[0]).toMatchObject({ manifest: { version: '2.0.0' }, approval: { state: 'approved' } }); + expect(state.get(archiveId)).toBe('old-data'); + expect(state.has('_root__hello')).toBe(false); + expect(store.readPendingMutationSync('_root/hello').state).toBe('missing'); + }); + + it('retains partial source archive failure until inverse recovery proves restoration', async () => { + const archiveId = '_ns__cindy-archive-00000000-0000-4000-8000-000000000002__hello'; + const state = new Map([['_root__hello', 'secret']]); + const archive = vi.fn(async (fromPart: string, toPart: string) => { + state.set(toPart, state.get(fromPart)!); + state.delete(fromPart); + throw new Error('other data path blocked'); + }); + manager = new GhostManager({ getRootDir: () => rootDir, onArchiveSourceState: archive, + mutateSnapshot: async ({ parentDir, ...request }) => { + await runGhostSnapshotWorkerRequest(request, parentDir); + }, + }); + await manager.install(await makeCindy('v1.cindy', goodManifest())); + const beforeCommit = vi.fn(); + const result = await manager.update(await makeCindy('v2.cindy', { ...goodManifest(), version: '2.0.0' }), { + expectedInstalledApproval: ghostInstallApprovalToken(manager.list()[0]?.approval), + sourceStateArchiveId: archiveId, beforePackageCommit: beforeCommit, + }); + expect(result).toMatchObject({ rejection: { rollbackFailed: true } }); + expect(archive).toHaveBeenCalledTimes(1); + expect(beforeCommit).not.toHaveBeenCalled(); + expect(manager.list()[0]?.approval.state).toBe('invalid'); + const recovered = new GhostManager({ getRootDir: () => rootDir, + onArchiveSourceState: async (fromPart, toPart) => { + expect([fromPart, toPart]).toEqual([archiveId, '_root__hello']); + if (!state.has(fromPart)) return; + state.set(toPart, state.get(fromPart)!); + state.delete(fromPart); + }, + }); + await recovered.retryInterruptedMutationsAfterDbReady(); + expect(recovered.list()[0]).toMatchObject({ manifest: { version: '1.0.0' }, approval: { state: 'approved' } }); + expect(state.get('_root__hello')).toBe('secret'); + }); + + it('serializes source archive recovery before uninstall and reinstall', async () => { + const archiveId = '_ns__cindy-archive-00000000-0000-4000-8000-000000000002__hello'; + manager = new GhostManager({ getRootDir: () => rootDir, + onArchiveSourceState: async () => { throw new Error('partial archive blocked'); }, + mutateSnapshot: async ({ parentDir, ...request }) => runGhostSnapshotWorkerRequest(request, parentDir), + }); + await manager.install(await makeCindy('v1.cindy', goodManifest())); + await manager.update(await makeCindy('v2.cindy', { ...goodManifest(), version: '2.0.0' }), { + expectedInstalledApproval: ghostInstallApprovalToken(manager.list()[0]?.approval), + sourceStateArchiveId: archiveId, + }); + const nextPackage = await makeCindy('v3.cindy', { ...goodManifest(), version: '3.0.0' }); + let finishRecovery!: () => void; + let recoveryStarted!: () => void; + const gate = new Promise((resolve) => { finishRecovery = resolve; }); + const started = new Promise((resolve) => { recoveryStarted = resolve; }); + const recovered = new GhostManager({ getRootDir: () => rootDir, + onArchiveSourceState: async () => { recoveryStarted(); await gate; }, + mutateSnapshot: async ({ parentDir, ...request }) => runGhostSnapshotWorkerRequest(request, parentDir), + }); + await started; + let uninstallFinished = false; + const replacement = recovered.uninstall('hello').then(async () => { + uninstallFinished = true; + return recovered.install(nextPackage); + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + const finishedWhilePaused = uninstallFinished; + finishRecovery(); + await replacement; + await recovered.retryInterruptedMutationsAfterDbReady(); + expect(finishedWhilePaused).toBe(false); + expect(recovered.list()[0]).toMatchObject({ manifest: { version: '3.0.0' }, approval: { state: 'approved' } }); + }); + + it.each(['journal', 'receipt'] as const)('preserves superseding %s during source archive recovery', async (superseded) => { + const archiveId = '_ns__cindy-archive-00000000-0000-4000-8000-000000000002__hello'; + manager = new GhostManager({ getRootDir: () => rootDir, + onArchiveSourceState: async () => {}, + mutateSnapshot: async ({ parentDir, ...request }) => runGhostSnapshotWorkerRequest(request, parentDir), + }); + await manager.install(await makeCindy('v1.cindy', goodManifest())); + const store = (manager as unknown as { receiptStore: GhostInstallReceiptStore }).receiptStore; + const clear = vi.spyOn(store, 'clearPendingMutation').mockRejectedValueOnce(new Error('clear blocked')); + await manager.update(await makeCindy('v2.cindy', { ...goodManifest(), version: '2.0.0' }), { + expectedInstalledApproval: ghostInstallApprovalToken(manager.list()[0]?.approval), sourceStateArchiveId: archiveId, + }); + clear.mockRestore(); + let finishRecovery!: () => void; + let recoveryStarted!: () => void; + const gate = new Promise((resolve) => { finishRecovery = resolve; }); + const started = new Promise((resolve) => { recoveryStarted = resolve; }); + const recovered = new GhostManager({ getRootDir: () => rootDir, + onArchiveSourceState: async () => { recoveryStarted(); await gate; }, + }); + await started; + const originalMarker = store.readPendingMutationSync('_root/hello'); + if (superseded === 'journal') { + await store.writePendingMutation('_root/hello', { kind: 'uninstall' }); + } else { + const receiptPath = path.join(workDir, 'ghosts-install-state', '_root', 'hello.json'); + const receipt = JSON.parse(fs.readFileSync(receiptPath, 'utf8')); + fs.writeFileSync(receiptPath, JSON.stringify({ ...receipt, revision: crypto.randomUUID() })); + } + const recoveries = [...(recovered as unknown as { pendingRecoverySideEffects: Set> }).pendingRecoverySideEffects]; + finishRecovery(); + await Promise.all(recoveries); + expect(store.readPendingMutationSync('_root/hello')).toEqual(superseded === 'journal' + ? expect.objectContaining({ state: 'valid', mutation: expect.objectContaining({ kind: 'uninstall' }) }) + : originalMarker); + expect(recovered.list()[0]?.approval.state).toBe('invalid'); + }); + + it('writes manual origin instead of retaining a previous Forge authorization', async () => { + await manager.install(await makeCindy('v1.cindy', goodManifest()), { installOrigin: 'agent-forge' }); + expect(manager.readApprovedInstallOriginStrict('hello')).toBe('agent-forge'); + await manager.update(await makeCindy('v2.cindy', { ...goodManifest(), version: '2.0.0' }), { + expectedInstalledApproval: ghostInstallApprovalToken(manager.list()[0]?.approval), installOrigin: 'manual', + }); + expect(manager.readApprovedInstallOriginStrict('hello')).toBe('manual'); + expect(JSON.parse(fs.readFileSync(path.join(workDir, 'ghosts-install-state', '_root', 'hello.json'), 'utf8'))) + .toMatchObject({ installOrigin: 'manual' }); }); it('receipt 提交失败时补偿副作用并恢复旧版本', async () => { @@ -4841,7 +5410,7 @@ describe('GhostManager · update(原位换版)', () => { expect(rollback).toHaveBeenCalledTimes(1); expect(commit).not.toHaveBeenCalled(); expect(manager.list()[0]?.manifest.version).toBe('1.0.0'); - expect(fs.existsSync(path.join(rootDir, 'hello', 'old.txt'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello', 'old.txt'))).toBe(true); }); it('副作用补偿失败时保留 journal、隔离和可恢复的目录交换现场', async () => { @@ -4870,9 +5439,9 @@ describe('GhostManager · update(原位换版)', () => { writeSpy.mockRestore(); } expect(manager.list()[0]).toMatchObject({ approval: { state: 'invalid' }, enabled: false }); - expect(fs.existsSync(path.join(rootDir, 'hello', 'new.txt'))).toBe(true); - expect(fs.readdirSync(rootDir).some((name) => name.startsWith('.cindy-updating-hello-'))).toBe(true); - expect(fs.existsSync(path.join(workDir, 'ghosts-install-state', '.pending-hello.json'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello', 'new.txt'))).toBe(true); + expect(fs.readdirSync(rootDir).some((name) => name.startsWith('.cindy-updating-_root__hello-'))).toBe(true); + expect(fs.existsSync(path.join(workDir, 'ghosts-install-state', '_root', '.pending-hello.json'))).toBe(true); const recovered = new GhostManager({ getRootDir: () => rootDir, @@ -4880,7 +5449,7 @@ describe('GhostManager · update(原位换版)', () => { onChanged, }); expect(recovered.list()[0]?.manifest.version).toBe('1.0.0'); - expect(fs.existsSync(path.join(rootDir, 'hello', 'old.txt'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello', 'old.txt'))).toBe(true); }); it('副作用只在 receipt 提交后 commit', async () => { @@ -4958,7 +5527,7 @@ describe('GhostManager · skill 槽装入校验(确认框看到的 = Agent 读 }); const result = await manager.install(cindy); expect('ghost' in result, JSON.stringify(result)).toBe(true); - const landed = path.join(rootDir, 'skilled', 'skills', 'foo', 'SKILL.md'); + const landed = path.join(rootDir, '_root', 'skilled', 'skills', 'foo', 'SKILL.md'); const st = await fs.promises.lstat(landed); expect(st.isFile()).toBe(true); expect(st.isSymbolicLink()).toBe(false); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/cardRemoteResource.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/cardRemoteResource.test.ts index a907efab0b4..f41e419375d 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/cardRemoteResource.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/cardRemoteResource.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; vi.mock('../cardStoreDb.js', () => ({ getGhostCard: vi.fn(), upsertGhostCard: vi.fn() })); vi.mock('../../device-link/broadcast-tap.js', () => ({ captureDataOwnerBroadcastScope: vi.fn(), isDataOwnerBroadcastScopeCurrent: vi.fn(), tapWindowBroadcast: vi.fn(), getSafeDataOwnerPushStamp: vi.fn() })); -import { createPluginIdentityRemoteProvider, createGhostCardRemoteProvider, projectGhostCardBlocks, persistGhostCardWithRemoteChange } from '../cardRemoteResource.js'; +import { createPluginIdentityRemoteProvider, createGhostCardRemoteProvider, projectGhostCardBlocks, persistGhostCardWithRemoteChange, findGhostForRemotePluginIdentity } from '../cardRemoteResource.js'; import { upsertGhostCard } from '../cardStoreDb.js'; import { isDataOwnerBroadcastScopeCurrent, tapWindowBroadcast } from '../../device-link/broadcast-tap.js'; const image = `cindy-media://blobs/${'b'.repeat(64)}.png`; @@ -63,6 +63,38 @@ describe('read-only plugin card projection', () => { describe('plugin identity for mobile annotations', () => { + it('resolves explicit root and organization identities, but never guesses an ambiguous legacy id', () => { + const root = { manifest: { id: 'helper' }, namespace: null, name: 'Root' }; + const org = { manifest: { id: 'helper' }, namespace: 'acme', name: 'Organization' }; + expect(findGhostForRemotePluginIdentity([root, org], JSON.stringify([null, 'helper']))).toBe(root); + expect(findGhostForRemotePluginIdentity([root, org], JSON.stringify(['acme', 'helper']))).toBe(org); + expect(findGhostForRemotePluginIdentity([root, org], '_ns__acme__helper')).toBe(org); + expect(findGhostForRemotePluginIdentity([root, org], '_ns/acme/helper')).toBe(org); + expect(findGhostForRemotePluginIdentity([root, org], 'helper')).toBeUndefined(); + expect(findGhostForRemotePluginIdentity([org], 'helper')).toBe(org); + expect(findGhostForRemotePluginIdentity([root, org], JSON.stringify(['other', 'helper']))).toBeUndefined(); + expect(findGhostForRemotePluginIdentity([root, org], '["acme","../helper"]')).toBeUndefined(); + }); + it('projects the requested organization identity through the task-scoped resource', async () => { + const ghosts = [ + { manifest: { id: 'helper' }, namespace: null, name: 'Root' }, + { manifest: { id: 'helper' }, namespace: 'acme', name: 'Organization' }, + ]; + const provider = createPluginIdentityRemoteProvider({ + readIdentity: (id) => { + const ghost = findGhostForRemotePluginIdentity(ghosts, id); + return ghost ? { name: ghost.name } : undefined; + }, + authorize: async () => {}, captureScope: () => () => true, + }); + const ref = { ...request.ref, id: JSON.stringify(['s', JSON.stringify(['acme', 'helper'])]) }; + expect((await provider.get!(context, { ...request, ref })).display.title).toBe('Organization'); + await expect(provider.get!(context, { ...request, ref: { ...ref, id: JSON.stringify(['s', 'helper']) } })).rejects.toThrow('Plugin not found'); + const longNamespace = 'a'.repeat(126); + ghosts.push({ manifest: { id: 'helper' }, namespace: longNamespace, name: 'Long Namespace' }); + const longRef = { ...ref, id: JSON.stringify(['s', JSON.stringify([longNamespace, 'helper'])]) }; + expect((await provider.get!(context, { ...request, ref: longRef })).display.title).toBe('Long Namespace'); + }); const request = { ref: { collectionId: 'plugin-identities', kind: 'plugin', id: '["s","art"]' }, client: { protocolVersion: 1, primitives: [] } }; it('projects only public name and bounded raster data, never credentials or local URLs', async () => { const readIdentity = vi.fn(() => ({ name: 'Art', iconDataUrl: 'data:image/png;base64,YQ==', secret: 'private' })); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/cardService.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/cardService.test.ts index c5dcf843ba4..ef4108a7e40 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/cardService.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/cardService.test.ts @@ -50,7 +50,97 @@ const update = (callId: string, html = '

x

', extra: Record void; + let reject!: (error: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +describe('GhostCardService relocation', () => { + it('drains every accepted source write even after its call was swept, without waiting for other plugins', async () => { + const pending = [deferredWrite(), deferredWrite(), deferredWrite()]; + const persist = vi.fn() + .mockReturnValueOnce(pending[0].promise) + .mockReturnValueOnce(pending[1].promise) + .mockReturnValueOnce(pending[2].promise); + const { svc, advance } = makeService({ persist }); + svc.registerCall('old', { ghostId: 'helper', toolUseId: null, sessionId: 's1' }); + svc.handleCardUpdate('helper', update('old')); + advance(1000); + svc.handleCardUpdate('helper', update('old')); + svc.finalizeCall('old'); + advance(31_000); + svc.registerCall('other', { ghostId: 'other', toolUseId: null, sessionId: 's2' }); + svc.handleCardUpdate('other', update('other')); + expect(svc.ownerOf('old')).toBeNull(); + + const moved = vi.fn(); + const relocation = svc.relocateGhost('helper', '_ns__acme__helper').then(moved); + pending[0].resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(moved).not.toHaveBeenCalled(); + pending[1].resolve(); + await relocation; + expect(moved).toHaveBeenCalledOnce(); + pending[2].resolve(); + }); + + it('awaits a failing outstanding write while preserving best-effort persistence', async () => { + const pending = deferredWrite(); + const log = { debug: vi.fn(), warn: vi.fn() }; + const { svc } = makeService({ persist: () => pending.promise, log }); + svc.registerCall('old', { ghostId: 'helper', toolUseId: null, sessionId: 's1' }); + svc.handleCardUpdate('helper', update('old')); + const moved = vi.fn(); + const relocation = svc.relocateGhost('helper', '_ns__acme__helper').then(moved); + expect(svc.handleCardUpdate('helper', update('old')).reason).toBe('not-owner'); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(moved).not.toHaveBeenCalled(); + pending.reject(new Error('db down')); + await relocation; + expect(log.warn).toHaveBeenCalledWith('ghost card persist failed', { callId: 'old', error: 'db down' }); + expect(svc.ownerOf('old')).toBe('_ns__acme__helper'); + }); + + it.each(['_ns__acme__helper', '_archive_helper'])('is idempotent and reversible without changing call scopes: %s', async (target) => { + const { svc } = makeService(); + svc.registerCall('script', { + ghostId: 'helper', toolUseId: null, sessionId: null, channel: 'script', + scriptWorkdir: '/project', scriptWritePath: 'result.txt', remoteHostId: null, + }); + svc.registerCall('session', { + ghostId: 'helper', toolUseId: 'tool-1', sessionId: 's1', + sessionInstanceId: 'instance-1', remoteHostId: 'ssh-1', + }); + for (const [source, destination] of [['helper', target], ['helper', target], [target, 'helper']]) { + await svc.relocateGhost(source, destination); + expect(svc.inFlightCallInfoOf('script')).toEqual({ + ghostId: destination, sessionId: null, remoteHostId: null, channel: 'script', + scriptWorkdir: '/project', scriptWritePath: 'result.txt', + }); + expect(svc.inFlightCallInfoOf('session')).toEqual({ + ghostId: destination, sessionId: 's1', sessionInstanceId: 'instance-1', + remoteHostId: 'ssh-1', channel: 'session', scriptWorkdir: null, scriptWritePath: null, + }); + } + }); +}); + describe('GhostCardService', () => { + it('broadcasts the trusted logical identity only for an in-place namespace install', () => { + const { svc, broadcast } = makeService(); + svc.registerCall('org', { ghostId: 'helper', logicalGhostId: '_ns__acme__helper', toolUseId: null, sessionId: 's1' }); + expect(svc.handleCardUpdate('helper', update('org')).accepted).toBe(true); + expect(broadcast).toHaveBeenCalledWith(expect.objectContaining({ + ghostId: 'helper', logicalGhostId: '_ns__acme__helper', + })); + expect(svc.handleCardUpdate('_ns__acme__helper', update('org')).accepted).toBe(false); + }); + it('接受链路:sanitize 产物落库并推送,hasCard/finalize 语义正确', async () => { const { svc, persist, broadcast } = makeService(); svc.registerCall('c1', { ghostId: 'g1', toolUseId: 'tu1', sessionId: 's1' }); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/cardStoreDb.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/cardStoreDb.test.ts index 579b62870b6..b4731101dfd 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/cardStoreDb.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/cardStoreDb.test.ts @@ -11,6 +11,9 @@ import { drizzle } from 'drizzle-orm/better-sqlite3'; import fs from 'node:fs'; import path from 'node:path'; import type { GhostCardDb } from '../cardStoreDb'; +import { GhostCardService } from '../cardService'; +import { GhostCardActionDispatcher } from '../cardActionDispatch'; +import { GHOST_CARD_REOPEN_WINDOW_MS, type InstalledGhost } from '../../../shared/ghost'; vi.mock('electron', () => ({ app: { getPath: () => '/tmp/never-used-here' }, @@ -51,6 +54,69 @@ beforeEach(() => { }); describe('cardStoreDb', () => { + it.each(['_ns__acme__helper', '_archive_helper'])('drains queued inserts before reassigning live and historical actions: %s', async (target) => { + let releaseWrite!: () => void; + let now = 1_000_000; + const pending = new Promise((resolve) => { releaseWrite = resolve; }); + const svc = new GhostCardService({ + hasCardSlot: () => true, + sanitize: (html) => ({ ok: true, html }), + persist: async (card) => { await pending; await store.upsertGhostCard(card, db); }, + broadcast: vi.fn(), + now: () => now, + }); + svc.registerCall('old', { ghostId: 'helper', toolUseId: null, sessionId: 'org-session' }); + expect(svc.handleCardUpdate('helper', { type: 'card-update', callId: 'old', html: '

org

' }).accepted).toBe(true); + svc.finalizeCall('old'); + + const reassign = vi.fn(() => store.reassignGhostCards('helper', target, db)); + const relocation = svc.relocateGhost('helper', target).then(reassign); + await Promise.resolve(); + expect(reassign).not.toHaveBeenCalled(); + releaseWrite(); + await relocation; + expect(await store.getGhostCard('old', db)).toEqual(expect.objectContaining({ ghostId: target, sessionId: 'org-session' })); + await svc.relocateGhost('helper', target); + await store.reassignGhostCards('helper', target, db); + + const archived = target.startsWith('_archive'); + const sendToGhost = vi.fn(); + const issueUserActionToken = vi.fn(() => 'user-action-token'); + const dispatcher = new GhostCardActionDispatcher({ + resolveLiveInfo: (callId) => svc.callInfoOf(callId), + resolvePersistedCard: (callId) => store.getGhostCard(callId, db), + reopenForAction: (callId, info) => svc.reopenForAction(callId, info), + getGhost: (ghostId) => ghostId === 'helper' || (!archived && ghostId === target) + ? { manifest: { id: 'helper', card: {} }, namespace: ghostId === target ? 'acme' : null, enabled: true } as InstalledGhost : null, + isRunning: () => true, + wake: async () => {}, + sendToGhost, + issueUserActionToken, + now: () => now, + }); + for (const historical of [false, true]) { + if (historical) { + now += GHOST_CARD_REOPEN_WINDOW_MS + 31_000; + svc.registerCall('root', { ghostId: 'helper', toolUseId: null, sessionId: 'root-session' }); + expect(svc.callInfoOf('old')).toBeNull(); + } + expect(await dispatcher.dispatch('old', 'retry', 'org prompt')).toEqual( + archived ? { ok: false, reason: 'ghost-unavailable' } : { ok: true }, + ); + } + if (archived) { + expect(sendToGhost).not.toHaveBeenCalled(); + expect(issueUserActionToken).not.toHaveBeenCalled(); + } else { + expect(sendToGhost.mock.calls.map(([ghostId]) => ghostId)).toEqual([target, target]); + expect(sendToGhost).toHaveBeenCalledWith(target, expect.objectContaining({ sessionId: 'org-session', prompt: 'org prompt' })); + expect(issueUserActionToken.mock.calls).toEqual([[target, 'org-session'], [target, 'org-session']]); + } + await svc.relocateGhost(target, 'helper'); + await store.reassignGhostCards(target, 'helper', db); + expect(await store.getGhostCard('old', db)).toEqual(expect.objectContaining({ ghostId: 'helper', sessionId: 'org-session', html: '

org

' })); + }); + it('upsert 幂等:同 callId 二次写入覆盖为最新版本', async () => { await store.upsertGhostCard(row('c1', { html: '

过程

' }), db); await store.upsertGhostCard(row('c1', { html: '

终版

', height: 400, updatedAt: 2000 }), db); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/cindyPackageFixture.ts b/apps/desktop/src/main/cindy-brain/__tests__/cindyPackageFixture.ts new file mode 100644 index 00000000000..456655a9d83 --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/__tests__/cindyPackageFixture.ts @@ -0,0 +1,14 @@ +import fs from 'node:fs/promises'; +import JSZip from 'jszip'; + +export async function writeTestCindyPackage( + filePath: string, + manifest: object, + files: Record = { 'main.js': '// ok\n' }, +): Promise { + const zip = new JSZip(); + zip.file('ghost.json', JSON.stringify(manifest)); + for (const [name, content] of Object.entries(files)) zip.file(name, content); + await fs.writeFile(filePath, await zip.generateAsync({ type: 'nodebuffer' })); + return filePath; +} diff --git a/apps/desktop/src/main/cindy-brain/__tests__/connectionAudienceResolver.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/connectionAudienceResolver.test.ts index 43775ffffd2..056b45d2349 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/connectionAudienceResolver.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/connectionAudienceResolver.test.ts @@ -1,7 +1,11 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { describe, expect, it, vi } from 'vitest'; import type { GhostManifest } from '../../../shared/ghost.js'; import { ghostManifestDigest, + PluginMarketLedger, type PluginMarketInstallationRecord, } from '../../plugin-market/ledger.js'; import { @@ -138,6 +142,68 @@ describe('installed Plugin Connection audience resolver', () => { ).toBeNull(); }); + it('does not authorize a root twin through an uninstalled root row and a live organization row', () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'cindy-connection-identity-')); + try { + const ledger = new PluginMarketLedger(path.join(directory, 'ledger.v1.json')); + ledger.upsertInstallation({ ...marketInstallation, namespace: null, installed: false }); + ledger.upsertInstallation({ + ...marketInstallation, + pluginId: 'plugin-market-org', + namespace: identity.orgSlug, + }); + const options = { + ...resolverOptions(), + readMarketInstallation: (id: string) => ledger.lookupInstallationForOidc(id), + readInstallNamespace: (id: string) => + id === 'plugin-a' ? null : identity.orgSlug, + readApprovedPackageSha256: (id: string) => + id === 'plugin-a' ? 'b'.repeat(64) : marketInstallation.sha256, + }; + expect(ledger.lookupInstallationForOidc('plugin-a')).toMatchObject({ + kind: 'found', + record: { namespace: identity.orgSlug, installed: true }, + }); + expect(loadConnectionAudienceResolver(options).resolve('plugin-a', identity)).toBeNull(); + expect(loadConnectionAudienceResolver({ + ...options, + readInstallNamespace: () => undefined, + }).resolve('plugin-a', identity)).toBeNull(); + expect(loadConnectionAudienceResolver({ + ...options, + readInstallNamespace: () => 'other-org', + }).resolve('plugin-a', identity)).toBeNull(); + expect(loadConnectionAudienceResolver({ + ...options, + readMarketInstallation: () => ({ + kind: 'found' as const, + record: { ...marketInstallation, namespace: 'other-org' }, + }), + readInstallNamespace: () => 'other-org', + }).resolve('plugin-a', identity)).toBeNull(); + expect(loadConnectionAudienceResolver({ + ...options, + readInstallNamespace: () => { throw new Error('receipt unavailable'); }, + }).resolve('plugin-a', identity)).toBeNull(); + expect(loadConnectionAudienceResolver({ + ...options, + readMarketInstallation: () => ({ + kind: 'found' as const, + record: { ...marketInstallation, namespace: null }, + }), + readInstallNamespace: () => identity.orgSlug, + }).resolve('plugin-a', identity)).toBeNull(); + expect(loadConnectionAudienceResolver({ + ...options, + readInstallNamespace: () => identity.orgSlug, + }).resolve('plugin-a', identity)).toMatchObject({ audience: 'org-example:plugin-a' }); + expect(loadConnectionAudienceResolver(options).resolve('_ns__org-example__plugin-a', identity)) + .toMatchObject({ audience: 'org-example:plugin-a' }); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + it('uses raw manifest bytes when the legacy digest is absent', () => { const resolver = loadConnectionAudienceResolver( resolverOptions(manifest, { ...marketInstallation, manifestDigest: undefined }), @@ -208,7 +274,7 @@ describe('installed Plugin Connection audience resolver', () => { }), readInstallOrigin: () => 'agent-forge', readApprovedPackageSha256: () => 'a'.repeat(64), - lookupOrganizationPrefix: () => ({ kind: 'known', pluginPrefix: 'acme' }), + readInstallNamespace: () => 'org-example', }); expect(resolver.resolve('acme-tool', identity)).toEqual({ membershipId: 'membership-1', @@ -218,22 +284,74 @@ describe('installed Plugin Connection audience resolver', () => { }); }); - it('does not extend Forge OIDC to a manual install or another prefix', () => { + it('preserves OIDC for a verified pending legacy Forge receipt in the current organization', () => { const forgeManifest: GhostManifest = { ...manifest, id: 'acme-tool' }; + const options = { + ...resolverOptions(forgeManifest, null), + readInstallOrigin: () => 'agent-forge' as const, + readInstallNamespace: () => undefined, + readApprovedPackageSha256: () => 'a'.repeat(64), + isPendingLegacyForge: () => true, + lookupOrganizationPrefix: () => ({ kind: 'known' as const, pluginPrefix: 'acme' }), + }; + expect(loadConnectionAudienceResolver(options).resolve('acme-tool', identity)).toMatchObject({ + audience: 'org-example:acme-tool', + }); + expect(loadConnectionAudienceResolver({ ...options, isPendingLegacyForge: () => false }) + .resolve('acme-tool', identity)).toBeNull(); + expect(loadConnectionAudienceResolver({ ...options, lookupOrganizationPrefix: () => ({ + kind: 'known' as const, pluginPrefix: 'other', + }) }).resolve('acme-tool', identity)).toBeNull(); + expect(loadConnectionAudienceResolver({ ...options, lookupOrganizationPrefix: () => ({ + kind: 'known' as const, pluginPrefix: null, + }) }).resolve('acme-tool', identity)).toBeNull(); + expect(loadConnectionAudienceResolver({ ...options, readInstallNamespace: () => null }) + .resolve('acme-tool', identity)).toBeNull(); + expect(loadConnectionAudienceResolver({ ...options, lookupOrganizationPrefix: () => ({ + kind: 'unavailable' as const, + }) }).resolve('acme-tool', identity)).toBeNull(); + }); + + it('does not extend Forge OIDC to a manual install or another organization', () => { + const forgeManifest: GhostManifest = { ...manifest, id: 'helper' }; for (const options of [ - { readInstallOrigin: () => 'manual' as const, pluginPrefix: 'acme' }, - { readInstallOrigin: () => 'agent-forge' as const, pluginPrefix: 'other' }, + { readInstallOrigin: () => 'manual' as const, namespace: 'org-example' as string | null }, + { readInstallOrigin: () => 'agent-forge' as const, namespace: 'other' as string | null }, ]) { const resolver = loadConnectionAudienceResolver({ ...resolverOptions(forgeManifest, null), readInstallOrigin: options.readInstallOrigin, readApprovedPackageSha256: () => 'a'.repeat(64), - lookupOrganizationPrefix: () => ({ kind: 'known', pluginPrefix: options.pluginPrefix }), + readInstallNamespace: () => options.namespace, }); - expect(resolver.resolve('acme-tool', identity)).toBeNull(); + expect(resolver.resolve('helper', identity)).toBeNull(); } }); + it('resolves a prefix-free Forge helper bound to the current organization', () => { + const forgeManifest: GhostManifest = { ...manifest, id: 'helper' }; + const resolver = loadConnectionAudienceResolver({ + ...resolverOptions(forgeManifest, null), + readInstallOrigin: () => 'agent-forge', + readApprovedPackageSha256: () => 'a'.repeat(64), + readInstalledManifestIdentity: (id) => + id === '_ns__org-example__helper' || id === 'helper' + ? { + manifest: forgeManifest, + rawManifestSha256: ghostManifestDigest(forgeManifest), + legacyManifestDigest: ghostManifestDigest(forgeManifest), + legacyManifestDigests: [ghostManifestDigest(forgeManifest)], + } + : null, + }); + expect(resolver.resolve('_ns__org-example__helper', identity)).toEqual({ + membershipId: 'membership-1', + audience: 'org-example:helper', + pluginSlug: 'helper', + allowedHosts: ['service-a.x.test'], + }); + }); + it('resolves a named local mivo-canvas install without a market record', () => { const localManifest: GhostManifest = { ...manifest, @@ -489,6 +607,40 @@ describe('installed Plugin Connection audience resolver', () => { expect(resolver.resolve('mivo-canvas', identity)).toBeNull(); }); + it('resolves a namespaced instance id to the plugin slug audience', () => { + const readInstalledManifestIdentity = vi.fn((id: string) => + id === '_ns__org-example__plugin-a' + ? { + manifest, + rawManifestSha256: ghostManifestDigest(manifest), + legacyManifestDigest: ghostManifestDigest(manifest), + legacyManifestDigests: [ghostManifestDigest(manifest)], + } + : null, + ); + const readMarketInstallation = vi.fn((id: string) => + id === '_ns__org-example__plugin-a' + ? { kind: 'found' as const, record: { ...marketInstallation, namespace: 'org-example' } } + : { kind: 'absent' as const }, + ); + const readInstallOrigin = vi.fn(() => 'manual' as const); + const resolver = loadConnectionAudienceResolver({ + readInstalledManifestIdentity, + readMarketInstallation, + readInstallOrigin, + readInstallNamespace: () => 'org-example', + }); + expect(resolver.resolve('_ns__org-example__plugin-a', identity)).toEqual({ + membershipId: 'membership-1', + audience: 'org-example:plugin-a', + pluginSlug: 'plugin-a', + allowedHosts: ['service-a.x.test'], + }); + expect(readInstalledManifestIdentity).toHaveBeenCalledWith('_ns__org-example__plugin-a'); + expect(readMarketInstallation).toHaveBeenCalledWith('_ns__org-example__plugin-a'); + expect(readInstallOrigin).toHaveBeenCalledWith('_ns__org-example__plugin-a'); + }); + it('requires the managed secret target to match a declared exact host', () => { const resolver = loadConnectionAudienceResolver({ ...resolverOptions(), diff --git a/apps/desktop/src/main/cindy-brain/__tests__/downloadLifecycle.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/downloadLifecycle.test.ts index 835b0abf3b5..66d957c6d6f 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/downloadLifecycle.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/downloadLifecycle.test.ts @@ -8,10 +8,10 @@ const source = fs.readFileSync(path.join(__dirname, '..', 'index.ts'), 'utf8'); it('uninstall waits for actual Node exit before removing plugin files and cache', () => { const start = source.indexOf('async function uninstallGhostAndCleanupLocked('); const body = source.slice(start, source.indexOf('\n}', start)); - const wait = body.indexOf('await getGhostNodeRuntimeBroker().stopAndWait(id)'); + const wait = body.indexOf('await getGhostNodeRuntimeBroker().stopAndWait(storagePart)'); expect(wait).toBeGreaterThan(0); - expect(body.indexOf('runtime.stop(id)')).toBeGreaterThan(0); - expect(body.indexOf('runtime.stop(id)')).toBeLessThan(wait); + expect(body.indexOf('runtime.stop(storagePart)')).toBeGreaterThan(0); + expect(body.indexOf('runtime.stop(storagePart)')).toBeLessThan(wait); expect(body.indexOf('await manager.uninstall(')).toBeGreaterThan(wait); expect(body.indexOf('await pluginDownloads.removePlugin(')).toBeGreaterThan(wait); }); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/filoGoogleClientConfig.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/filoGoogleClientConfig.test.ts index 87648fa8d7f..132237b9798 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/filoGoogleClientConfig.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/filoGoogleClientConfig.test.ts @@ -29,28 +29,28 @@ function manifest(oauth: Record = {}): GhostManifest { } describe('withFiloGoogleBuildClientConfig', () => { - it('只在 main 内存 manifest 补构建环境 client,不修改原对象', () => { + it('does not inject build-env OAuth client based on the filo-google name', () => { const source = manifest(); const hydrated = withFiloGoogleBuildClientConfig(source, { clientId: ' build-client ', clientSecret: ' build-secret ', }); - const oauth = hydrated.network?.secrets?.[0]?.oauth; - expect(oauth).toMatchObject({ clientId: 'build-client', clientSecret: 'build-secret' }); + expect(hydrated).toBe(source); expect(source.network?.secrets?.[0]?.oauth?.clientId).toBeUndefined(); }); - it('非 Filo Google 或未配置环境变量时原样返回', () => { + it('returns the original manifest for any id', () => { const source = manifest(); expect(withFiloGoogleBuildClientConfig(source, {})).toBe(source); - expect(withFiloGoogleBuildClientConfig({ ...source, id: 'other' }, { clientId: 'x' }).id).toBe('other'); + const other = { ...source, id: 'other' }; + expect(withFiloGoogleBuildClientConfig(other, { clientId: 'x' })).toBe(other); }); - it('发布环境只给 clientId 时按纯 PKCE 处理,不混用旧 secret', () => { + it('does not rewrite a manifest that already has a client', () => { const source = manifest({ clientId: 'old-client', clientSecret: 'old-secret' }); - const oauth = withFiloGoogleBuildClientConfig(source, { clientId: 'new-client' }) - .network?.secrets?.[0]?.oauth; - expect(oauth?.clientId).toBe('new-client'); - expect(oauth?.clientSecret).toBeUndefined(); + const hydrated = withFiloGoogleBuildClientConfig(source, { clientId: 'new-client' }); + expect(hydrated).toBe(source); + expect(hydrated.network?.secrets?.[0]?.oauth?.clientId).toBe('old-client'); + expect(hydrated.network?.secrets?.[0]?.oauth?.clientSecret).toBe('old-secret'); }); }); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/forge.oauth.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/forge.oauth.test.ts index e726caf5784..93329289962 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/forge.oauth.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/forge.oauth.test.ts @@ -40,7 +40,7 @@ describe('FORGE_GUIDE · oauth 凭证章节', () => { 'tokenBroker 没同时声明 redirectPort', '或与 clientSecret 同时声明', '三路资格', - '静态官方前缀照旧放行', + '随包官方种子或受信任公开市场的官方插件', '当前组织的服务端', 'organization market 包已安装', 'organizationId 与当前组织一致', diff --git a/apps/desktop/src/main/cindy-brain/__tests__/forge.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/forge.test.ts index e501e781b25..14a927c1513 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/forge.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/forge.test.ts @@ -166,6 +166,18 @@ async function makeSrcDir(files: Record): Promise { + it.each([null, 'xd'])('rejects author-declared namespace %s before v2 normalization', async (namespace) => { + const dir = await makeSrcDir({ + 'ghost.json': JSON.stringify({ ...GOOD_MANIFEST, namespace }), + 'main.js': 'export default {};', + }); + await expect(packGhostDir(dir)).resolves.toMatchObject({ + ok: false, + errorCode: 'MANIFEST_INVALID', + message: expect.stringContaining('ghost.json 不允许作者声明 namespace'), + }); + }); + it('rejects a new tokenBroker package without redirectPort but accepts the declared-port shape', async () => { const brokerManifest = { ...GOOD_MANIFEST, diff --git a/apps/desktop/src/main/cindy-brain/__tests__/forgeOidcInstallEntry.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/forgeOidcInstallEntry.test.ts index eedff4194fb..ae2a0e45997 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/forgeOidcInstallEntry.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/forgeOidcInstallEntry.test.ts @@ -35,7 +35,7 @@ describe('Forge OIDC install entry wiring', () => { expect(body).toContain('const installOrigin = forgeInstallOriginForMembership(membershipKind);'); expect(body).toContain('...(installOrigin ? { installOrigin } : {})'); expect(body).toContain( - 'ghostInstallApprovalToken(installed.approval),\n consent,\n installOrigin,', + 'ghostInstallApprovalToken(installed.approval),\n installedGhostStoragePart(installed),\n consent,\n installOrigin,', ); expect(body).not.toContain("installOrigin: 'agent-forge'"); }); @@ -61,9 +61,10 @@ describe('Forge OIDC install entry wiring', () => { expect(lease).toBeGreaterThan(lock); }); - it('tokenBroker 只在企业身份下拿 Forge facts,且不触发 OIDC 确认窗', () => { + it('tokenBroker 拿 Forge facts 时带上当前组织 namespace,且不触发 OIDC 确认窗', () => { const body = forgeInstallBody(); - expect(body).toContain('installOrigin ? { installOrigin } : undefined'); + expect(body).toContain('...(installOrigin ? { installOrigin } : {})'); + expect(body).toContain('namespace: forgeNamespace ?? null'); expect(body).toContain('forgeOidcInstallConfirmFacts('); }); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/fsSlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/fsSlot.test.ts index 57fd831c9a8..8117552a9d4 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/fsSlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/fsSlot.test.ts @@ -85,7 +85,13 @@ function makeHarness(dataRoot: string, overrides: HarnessOverrides = {}) { writeSaveDeposit: overrides.saveWrite ?? (async (_ghostId, _token, fileName) => ({ fileName })), }; - return { slot: new GhostFsSlot(deps), confirmCalls }; + return { slot: new GhostFsSlot(deps), confirmCalls, deps }; +} + +function deferredSignal() { + let resolve!: () => void; + const promise = new Promise((accept) => { resolve = accept; }); + return { promise, resolve }; } /** Real Session authority with an in-memory provider, so the production resolver is exercised. */ @@ -159,9 +165,134 @@ describe('GhostFsSlot', () => { }); afterEach(async () => { + vi.restoreAllMocks(); await fs.promises.rm(tmpRoot, { recursive: true, force: true }); }); + describe('in-flight requests', () => { + it.each(['data', 'session-workdir', 'script-workdir'] as const)( + '%s blocks relocation through the final native write and cleans up on success or failure', + async (channel) => { + const base = path.join(dataRoot, GHOST_ID); + const relocated = path.join(dataRoot, '_ns__acme__' + GHOST_ID); + await fs.promises.mkdir(base, { recursive: true }); + const target = path.join(channel === 'data' ? base : await fs.promises.realpath(workdir), 'pending.txt'); + const { slot } = makeHarness(dataRoot, channel === 'script-workdir' ? { + callSessionId: null, callScriptWorkdir: workdir, + } : { session: { + workingDir: workdir, permissionMode: 'acceptEdits', planModeEnabled: false, remoteHostId: null, + } }); + const migrate = () => { + if (slot.hasInFlightRequests(GHOST_ID)) throw new Error('FS busy'); + fs.renameSync(base, relocated); + }; + const nativeWrite = fs.promises.writeFile; + for (const failWrite of [false, true]) { + const started = deferredSignal(); + const resume = deferredSignal(); + const write = vi.spyOn(fs.promises, 'writeFile').mockImplementation(async (...args) => { + if (args[0] === target) { + started.resolve(); + await resume.promise; + if (failWrite) throw new Error('write failed'); + } + return nativeWrite(...args); + }); + const pending = slot.handleFsRequest(GHOST_ID, { + op: 'write', root: channel === 'data' ? 'data' : 'workdir', + path: 'pending.txt', content: 'org-data', callId: 'call-1', + }); + try { + await started.promise; + expect(slot.hasInFlightRequests(GHOST_ID)).toBe(true); + expect(slot.hasInFlightRequests('_ns__acme__' + GHOST_ID)).toBe(false); + expect(migrate).toThrow('FS busy'); + expect(fs.existsSync(base)).toBe(true); + expect(fs.existsSync(relocated)).toBe(false); + } finally { + resume.resolve(); + await pending; + write.mockRestore(); + } + expect(await pending).toMatchObject({ ok: !failWrite }); + expect(slot.hasInFlightRequests(GHOST_ID)).toBe(false); + } + expect(migrate).not.toThrow(); + expect(fs.existsSync(relocated)).toBe(true); + }, + ); + + it.each(['unlink', 'rmdir'] as const)('tracks data deletion through %s and cleanup on native failure', async (operation) => { + const { slot } = makeHarness(dataRoot); + const directory = path.join(dataRoot, GHOST_ID, 'nested'); + const target = path.join(directory, 'delete.txt'); + const nativeRemove = fs.promises[operation]; + for (const failRemove of [false, true]) { + await fs.promises.mkdir(directory, { recursive: true }); + await fs.promises.writeFile(target, 'org-data'); + const started = deferredSignal(); + const resume = deferredSignal(); + const remove = vi.spyOn(fs.promises, operation).mockImplementation(async (entry) => { + if (entry === (operation === 'unlink' ? target : directory)) { + started.resolve(); + await resume.promise; + if (failRemove) throw new Error('remove failed'); + } + return nativeRemove(entry); + }); + const pending = slot.handleFsRequest(GHOST_ID, { op: 'delete', root: 'data', path: 'nested/delete.txt' }); + try { + await started.promise; + expect(slot.hasInFlightRequests(GHOST_ID)).toBe(true); + } finally { + resume.resolve(); + await pending; + remove.mockRestore(); + } + expect(await pending).toMatchObject({ ok: true, existed: !(operation === 'unlink' && failRemove) }); + expect(slot.hasInFlightRequests(GHOST_ID)).toBe(false); + } + }); + + it('counts concurrent save writes per physical instance until every request settles', async () => { + const first = deferredSignal(); + const second = deferredSignal(); + const namespaced = deferredSignal(); + const gates = [first, second, namespaced]; + const { deps } = makeHarness(dataRoot, { saveWrite: async (_ghostId, token, fileName) => { + await gates[Number(token)]!.promise; + if (token === '1') throw new Error('save failed'); + return { fileName }; + } }); + const slot = new GhostFsSlot({ ...deps, getGhost: () => makeGhost(true) }); + const namespacedId = '_ns__acme__' + GHOST_ID; + const requests = [GHOST_ID, GHOST_ID, namespacedId].map((id, index) => slot.handleFsRequest(id, { + op: 'write', root: 'save', path: 'save.txt', content: 'org-data', token: String(index), + })); + try { + expect(slot.hasInFlightRequests(GHOST_ID)).toBe(true); + expect(slot.hasInFlightRequests(namespacedId)).toBe(true); + slot.setSessionSnapshotResolver(async () => null); + expect(slot.hasInFlightRequests(GHOST_ID)).toBe(true); + expect(slot.hasInFlightRequests(namespacedId)).toBe(true); + first.resolve(); + expect(await requests[0]).toMatchObject({ ok: true }); + expect(slot.hasInFlightRequests(GHOST_ID)).toBe(true); + second.resolve(); + expect(await requests[1]).toMatchObject({ ok: false }); + expect(slot.hasInFlightRequests(GHOST_ID)).toBe(false); + expect(slot.hasInFlightRequests(namespacedId)).toBe(true); + } finally { + gates.forEach((gate) => gate.resolve()); + await Promise.all(requests); + } + expect(await requests[2]).toMatchObject({ ok: true }); + expect(slot.hasInFlightRequests(namespacedId)).toBe(false); + expect(await slot.handleFsRequest(GHOST_ID, { op: 'invalid', root: 'data' })).toMatchObject({ ok: false }); + expect(slot.hasInFlightRequests(GHOST_ID)).toBe(false); + }); + }); + it('未声明 fs 能力一律拒', async () => { const { slot } = makeHarness(dataRoot, { fs: false }); const r = await slot.handleFsRequest(GHOST_ID, { @@ -378,6 +509,20 @@ describe('GhostFsSlot', () => { expect(confirmCalls).toHaveLength(3); }); + it('a root occupying the old organization key cannot inherit its workdir confirmation', async () => { + const { slot, deps, confirmCalls } = makeHarness(dataRoot, { + session: { workingDir: workdir, permissionMode: 'default', planModeEnabled: false, remoteHostId: null }, + }); + let current = { ...makeGhost(true), namespace: 'acme' as string | null }; + deps.getGhost = () => current; + const request = { type: 'fs-request', op: 'write', root: 'workdir', path: 'a.txt', content: 'org', callId: 'call-1' }; + expect(await slot.handleFsRequest(GHOST_ID, request)).toMatchObject({ ok: true }); + expect(slot.hasInFlightRequests(GHOST_ID)).toBe(false); + current = { ...current, namespace: null }; + expect(await slot.handleFsRequest(GHOST_ID, { ...request, content: 'root' })).toMatchObject({ ok: true }); + expect(confirmCalls).toHaveLength(2); + }); + it('workdir:plan / planModeEnabled 拒', async () => { for (const session of [ { workingDir: workdir, permissionMode: 'plan', planModeEnabled: false, remoteHostId: null }, diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostCredentialProtocolCallbacks.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostCredentialProtocolCallbacks.test.ts new file mode 100644 index 00000000000..d6c6baede91 --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostCredentialProtocolCallbacks.test.ts @@ -0,0 +1,200 @@ +import { createGhostProductionCallbacks } from './ghostProductionCallbacksFixture.js'; +import { describe, expect, it, vi } from 'vitest'; + +import type { InstalledGhost } from '../../../shared/ghost.js'; +import { GhostConnectionManager } from '../ghostConnections.js'; +import { GhostOauthAccountManager } from '../ghostOauthAccounts.js'; +import { handleGhostConnectionsRequest } from '../runtime/ghostConnectionsEndpoint.js'; +import { handleGhostOauthRequest } from '../runtime/ghostOauthEndpoint.js'; +import { handleGhostSecretsRequest } from '../runtime/ghostSecretsEndpoint.js'; + +type RequestArgs = { + ghostId: string; + method: string; + pathname: string; + readBodyText: () => Promise; + isCurrent: () => boolean; +}; +type Callback = (args: RequestArgs) => Promise<{ status: number; body?: string }>; + +const routes = [ + { setter: 'setGhostSecretsHandler', method: 'PUT', pathname: '/secrets/user_key', body: '{"value":"fake-old-secret"}', status: 204 }, + { setter: 'setGhostOauthHandler', method: 'PUT', pathname: '/oauth/oauth_key/client', body: '{"clientId":"fake-old-client"}', status: 204 }, + { setter: 'setGhostConnectionsHandler', method: 'POST', pathname: '/connections/api', body: '{"host":"api.example.com","token":"fake-old-token"}', status: 200 }, +] as const; +type Route = typeof routes[number]; + +function loadCallback(setter: string, deps: Record, omitGuard = false): Callback { + return createGhostProductionCallbacks<{ handler: Callback }>({ + callbacks: { handler: [setter] }, + transformCallback: omitGuard ? (source) => source.replace(/^\s*isCurrent,\r?\n/gm, '') : undefined, + })(deps).handler; +} + +function deferred() { + let resolve!: (value: Value) => void; + const promise = new Promise((finish) => { resolve = finish; }); + return { promise, resolve }; +} + +function harness(route: Route, opts: { omitGuard?: boolean; githubProbe?: boolean; storagePart?: string } = {}) { + const ghostId = opts.storagePart ?? (opts.githubProbe ? 'cindy-github' : 'helper'); + const inject = { header: 'Authorization', format: 'Bearer {value}', hosts: ['api.example.com'] }; + let installed: InstalledGhost = { + dir: '/plugins/helper', enabled: true, + approval: { state: 'approved', revision: 'original-receipt' }, + manifest: { + schemaVersion: 2, id: opts.githubProbe ? 'cindy-github' : 'helper', name: 'Helper', + version: '1.0.0', kind: 'chip', entry: 'main.js', slots: ['network'], + network: { + hosts: ['api.example.com'], + secrets: [ + { key: 'user_key', source: 'user', label: 'User key', inject }, + { key: 'oauth_key', source: 'oauth', label: 'OAuth', inject, oauth: { authorizeUrl: 'https://api.example.com/auth', tokenUrl: 'https://api.example.com/token', scopes: [] } }, + ...(opts.githubProbe ? [{ key: 'host_cli', source: 'gh-cli' as const, label: 'Host CLI', inject }] : []), + ], + connections: [{ key: 'api', label: 'API', maxConnections: 2, inject: { header: inject.header, format: inject.format } }], + }, + }, + }; + const data = new Map([['replacement-sentinel', 'fake-new-source']]); + const store = vi.fn((targetId: string, key: string, value: string) => { + data.set(targetId + ' ' + key, value); + return true; + }); + const remove = vi.fn((targetId: string, key: string) => { data.delete(targetId + ' ' + key); }); + const vault = { read: (targetId: string, key: string) => data.get(targetId + ' ' + key) ?? null, store, remove }; + const oauthManager = new GhostOauthAccountManager({ + vault, openExternal: vi.fn(), fetchImpl: vi.fn() as unknown as typeof fetch, + }); + const connectionManager = new GhostConnectionManager({ vault: { ...vault, readTail: () => null } }); + const emit = vi.fn(); + const notice = vi.fn(); + const broadcast = vi.fn(); + const lock = vi.fn(async (_ghostId: string, task: () => unknown) => task()); + const showMessageBox = vi.fn(async () => ({ response: 0 })); + const probeAvailability = vi.fn(async () => true); + const handler = loadCallback(route.setter, { + findGhostForInstanceId: (targetId: string) => targetId === ghostId ? installed : null, + handleGhostSecretsRequest, handleGhostOauthRequest, handleGhostConnectionsRequest, + withRuntimeFiloGoogleClient: (manifest: InstalledGhost['manifest']) => manifest, + getGhostOauthAccountManager: () => oauthManager, + getGhostConnectionManager: () => connectionManager, + getGhostManager: () => ({ list: () => [installed] }), + isGhostTokenBrokerAuthorized: () => false, + withActiveOwnerGhostOauthMutationLock: lock, + readGhostSecret: vault.read, readGhostSecretTail: () => null, + storeGhostSecret: store, removeGhostSecret: remove, + getGhostSetupChangeBus: () => ({ emit }), + broadcastGhostHostNotice: notice, broadcastGhostsChanged: broadcast, + getAuthState: () => ({ user: null }), + resolveConnectionAudienceForGhost: () => null, isConnectionSecretReady: () => false, + isCindyOfficialTrustInfo: () => true, + getSharedGhCliTokenSource: () => ({ probeAvailability }), + GHOST_NETWORK_MAX_CONNECTIONS_PER_DECL: 2, + dialog: { showMessageBox }, t: (key: string) => key, + log: { warn: vi.fn() }, + }, opts.omitGuard); + const isCurrent = vi.fn(() => installed.approval.state === 'approved' && installed.approval.revision === 'original-receipt'); + const request = (readBodyText: () => Promise) => handler({ + ghostId, method: route.method, pathname: route.pathname, readBodyText, isCurrent, + }); + const change = (state: 'replacement' | 'invalid') => { + installed = { ...installed, approval: state === 'invalid' + ? { state: 'invalid' } : { state: 'approved', revision: 'replacement-receipt' } }; + }; + return { ghostId, request, change, isCurrent, data, store, remove, emit, notice, broadcast, lock, showMessageBox, probeAvailability }; +} + +describe('Credential protocol wiring through actual index callbacks', () => { + it.each(routes.flatMap((route) => (['replacement', 'invalid'] as const).map((change) => ({ route, change }))))( + 'blocks $route.pathname after $change while the old body is pending', async ({ route, change }) => { + const target = harness(route); + const body = deferred(); + const before = new Map(target.data); + const pending = target.request(() => body.promise); + target.change(change); + body.resolve(route.body); + expect(await pending).toEqual({ status: 403 }); + expect(target.data).toEqual(before); + expect(target.store).not.toHaveBeenCalled(); + expect(target.emit).not.toHaveBeenCalled(); + expect(target.notice).not.toHaveBeenCalled(); + expect(target.broadcast).not.toHaveBeenCalled(); + expect(target.isCurrent).toHaveBeenCalled(); + }, + ); + + it.each(routes)('demonstrates the late-write regression when $setter omits only the forwarded guard', async (route) => { + const target = harness(route, { omitGuard: true }); + const body = deferred(); + const before = new Map(target.data); + const pending = target.request(() => body.promise); + target.change('replacement'); + body.resolve(route.body); + expect((await pending).status).toBe(route.status); + expect(target.data).not.toEqual(before); + expect(target.store).toHaveBeenCalled(); + expect(target.emit).toHaveBeenCalled(); + expect(target.isCurrent).not.toHaveBeenCalled(); + }); + + it.each(routes)('allows $pathname for the unchanged organization instance', async (route) => { + const target = harness(route, { storagePart: '_ns__acme__helper' }); + const result = await target.request(async () => route.body); + expect(result.status).toBe(route.status); + expect(target.store).toHaveBeenCalled(); + expect(target.store.mock.calls.every(([targetId]) => targetId === '_ns__acme__helper')).toBe(true); + expect(target.emit).toHaveBeenCalled(); + expect(target.isCurrent).toHaveBeenCalled(); + }); + + it('keeps the forwarded OAuth guard inside the production mutation lock', async () => { + const target = harness(routes[1]); + const entered = deferred(); + const release = deferred(); + target.lock.mockImplementationOnce(async (_ghostId, task) => { + entered.resolve(); + await release.promise; + return task(); + }); + const pending = target.request(async () => routes[1].body); + await entered.promise; + target.change('replacement'); + release.resolve(); + expect(await pending).toEqual({ status: 403 }); + expect(target.store).not.toHaveBeenCalled(); + expect(target.emit).not.toHaveBeenCalled(); + expect(target.broadcast).not.toHaveBeenCalled(); + }); + + it('keeps the forwarded connection guard after the production host confirmation', async () => { + const target = harness(routes[2]); + const entered = deferred(); + const confirmation = deferred<{ response: number }>(); + target.showMessageBox.mockImplementationOnce(() => { entered.resolve(); return confirmation.promise; }); + const pending = target.request(async () => routes[2].body); + await entered.promise; + target.change('replacement'); + confirmation.resolve({ response: 0 }); + expect(await pending).toEqual({ status: 403 }); + expect(target.store).not.toHaveBeenCalled(); + expect(target.emit).not.toHaveBeenCalled(); + expect(target.notice).not.toHaveBeenCalled(); + }); + + it('forwards the original guard after the Secrets callback awaits its host credential probe', async () => { + const target = harness(routes[0], { githubProbe: true }); + const probe = deferred(); + target.probeAvailability.mockReturnValueOnce(probe.promise); + const readBodyText = vi.fn(async () => routes[0].body); + const pending = target.request(readBodyText); + target.change('replacement'); + probe.resolve(true); + expect(await pending).toEqual({ status: 403 }); + expect(readBodyText).not.toHaveBeenCalled(); + expect(target.store).not.toHaveBeenCalled(); + expect(target.emit).not.toHaveBeenCalled(); + expect(target.notice).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostFirstPartyFacts.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostFirstPartyFacts.test.ts index 1e81a3e15e7..67041316167 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/ghostFirstPartyFacts.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostFirstPartyFacts.test.ts @@ -24,11 +24,13 @@ const PERSONAL: GhostFirstPartyFactsIdentity = { const ORG_A: GhostFirstPartyFactsIdentity = { membershipKind: 'org', orgId: 'org-a', + orgSlug: 'slug-a', }; const ORG_B: GhostFirstPartyFactsIdentity = { membershipKind: 'org', orgId: 'org-b', + orgSlug: 'slug-b', }; const MARKET_ROW: PluginMarketInstallationRecord = { @@ -58,7 +60,10 @@ function loader(overrides: Partial = {}) return loadGhostFirstPartyFactsLoader({ readInstalledBuiltin: () => false, readMarketInstallation: () => null, - readApprovedPackageSha256: () => null, + readApprovedPackageSha256: (ghostId) => ['xd-feishu', 'xd-atlassian'].includes(ghostId) ? 'a'.repeat(64) : null, + readTrustedSource: (ghostId) => ['xd-feishu', 'xd-atlassian'].includes(ghostId) ? { + kind: 'builtin-official', ghostId, namespace: null, packageSha256: 'a'.repeat(64), + } : null, lookupOrganizationPrefix: () => ({ kind: 'absent' }), readInstallOrigin: () => 'manual', ...overrides, @@ -66,6 +71,16 @@ function loader(overrides: Partial = {}) } describe('loadGhostFirstPartyFactsLoader', () => { + it('reads a new root receipt without inheriting the same-name legacy organization receipt', () => { + const reads: string[] = []; + const loaded = loader({ + readInstallNamespace: (id) => { reads.push(id); return id === 'xd-feishu' ? 'xd' : null; }, + lookupOrganizationPrefix: () => ({ kind: 'known', pluginPrefix: 'xd' }), + }).load('_root__xd-feishu', 'runtime', ORG_A); + expect(reads).toEqual(['_root/xd-feishu']); + expect(loaded).toMatchObject({ kind: 'ready', facts: { namespace: null } }); + if (loaded.kind === 'ready') expect(resolveGhostFirstPartyPrivilege(loaded.facts).brokerEligible).toBe(false); + }); it('gives builtin official plugins broker on a personal identity with no prefix cache', () => { const factsLoader = loader({ readInstalledBuiltin: (ghostId) => ghostId === 'xd-feishu' || ghostId === 'xd-atlassian', @@ -80,10 +95,13 @@ describe('loadGhostFirstPartyFactsLoader', () => { if (loaded.kind !== 'ready') continue; expect(loaded.facts).toEqual({ ghostId, + namespace: null, builtin: true, marketRecord: null, currentOrganization: null, installOrigin: 'manual', + trustedSource: { kind: 'builtin-official', ghostId, namespace: null, packageSha256: 'a'.repeat(64) }, + approvedPackageSha256: 'a'.repeat(64), }); expect(resolveGhostFirstPartyPrivilege(loaded.facts)).toEqual({ brokerEligible: true, @@ -93,6 +111,59 @@ describe('loadGhostFirstPartyFactsLoader', () => { } }); + it('keeps logical ghostId for privilege matching and looks up the market row by instance id', () => { + const seen = { + builtin: [] as string[], + origin: [] as string[], + approved: [] as string[], + market: [] as string[], + }; + const factsLoader = loader({ + readInstalledBuiltin: (id) => { + seen.builtin.push(id); + return false; + }, + readInstallOrigin: (id) => { + seen.origin.push(id); + return 'manual'; + }, + readApprovedPackageSha256: (id) => { + seen.approved.push(id); + return 'a'.repeat(64); + }, + readMarketInstallation: (id) => { + seen.market.push(id); + return MARKET_ROW; + }, + lookupOrganizationPrefix: () => ({ kind: 'known', pluginPrefix: 'acme' }), + }); + + const loaded = factsLoader.load('_ns__acme__acme-tool', 'runtime', ORG_A); + expect(loaded.kind).toBe('ready'); + if (loaded.kind !== 'ready') return; + expect(loaded.facts.ghostId).toBe('acme-tool'); + expect(seen.builtin).toEqual(['_ns__acme__acme-tool']); + expect(seen.origin).toEqual(['_ns/acme/acme-tool']); + expect(seen.approved).toEqual(['_ns/acme/acme-tool']); + expect(seen.market).toEqual(['_ns__acme__acme-tool']); + expect(resolveGhostFirstPartyPrivilege(loaded.facts)).toEqual({ + brokerEligible: true, + hostPrimitiveEligible: false, + basis: 'market-organization-current', + }); + + seen.builtin.length = 0; + seen.origin.length = 0; + seen.approved.length = 0; + seen.market.length = 0; + const fromRel = factsLoader.load('_ns/acme/acme-tool', 'runtime', ORG_A); + expect(fromRel.kind).toBe('ready'); + if (fromRel.kind !== 'ready') return; + expect(fromRel.facts.ghostId).toBe('acme-tool'); + expect(seen.origin).toEqual(['_ns/acme/acme-tool']); + expect(seen.approved).toEqual(['_ns/acme/acme-tool']); + }); + it('re-evaluates the current organization prefix after an org switch and keeps the previous key', () => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cindy-first-party-facts-')); const store = createOrganizationPrefixStore(path.join(tempDir, 'organization.v1.json')); @@ -162,6 +233,7 @@ describe('loadGhostFirstPartyFactsLoader', () => { kind: 'ready', facts: { ghostId: 'local-tool', + namespace: null, builtin: false, marketRecord: null, currentOrganization: null, @@ -214,7 +286,7 @@ describe('loadGhostFirstPartyFactsLoader', () => { facts: { marketRecord: null, installOrigin: 'agent-forge', - currentOrganization: { organizationId: 'org-a', pluginPrefix: 'acme' }, + currentOrganization: { organizationId: 'org-a', pluginPrefix: 'acme', orgSlug: 'slug-a' }, }, }); }); @@ -244,9 +316,10 @@ describe('loadGhostFirstPartyFactsLoader', () => { kind: 'ready', facts: { ghostId: 'acme-tool', + namespace: null, builtin: false, marketRecord: null, - currentOrganization: { organizationId: 'org-a', pluginPrefix: 'acme' }, + currentOrganization: { organizationId: 'org-a', pluginPrefix: 'acme', orgSlug: 'slug-a' }, installOrigin: 'manual', }, }); @@ -263,6 +336,7 @@ describe('loadGhostFirstPartyFactsLoader', () => { kind: 'ready', facts: { ghostId: 'acme-tool', + namespace: null, builtin: false, marketRecord: { scope: 'organization', @@ -272,7 +346,7 @@ describe('loadGhostFirstPartyFactsLoader', () => { sha256: MARKET_ROW.sha256, approvedPackageSha256: MARKET_ROW.sha256, }, - currentOrganization: { organizationId: 'org-a', pluginPrefix: null }, + currentOrganization: { organizationId: 'org-a', pluginPrefix: null, orgSlug: 'slug-a' }, installOrigin: 'manual', }, }); @@ -296,7 +370,7 @@ describe('loadGhostFirstPartyFactsLoader', () => { sha256: 'a'.repeat(64), approvedPackageSha256: 'b'.repeat(64), }, - currentOrganization: { organizationId: 'org-a', pluginPrefix: 'acme' }, + currentOrganization: { organizationId: 'org-a', pluginPrefix: 'acme', orgSlug: 'slug-a' }, }, }); expect(MARKET_ROW.manifestDigest).toBe('c'.repeat(64)); @@ -306,6 +380,88 @@ describe('loadGhostFirstPartyFactsLoader', () => { expect(resolveGhostFirstPartyPrivilege(loaded.facts).brokerEligible).toBe(false); }); + it('reads install namespace from a storage-part ghost id', () => { + const loaded = loader({ + lookupOrganizationPrefix: () => ({ kind: 'known', pluginPrefix: null }), + }).load('_ns__slug-a__helper', 'runtime', ORG_A); + expect(loaded).toMatchObject({ + kind: 'ready', + facts: { ghostId: 'helper', namespace: 'slug-a' }, + }); + }); + + it('reads trusted receipt namespace for an in-place physical id', () => { + const factsLoader = loader({ + lookupOrganizationPrefix: () => ({ kind: 'known', pluginPrefix: null }), + readInstallOrigin: () => 'agent-forge', + readInstallNamespace: (id) => (id === 'acme-tool' ? 'slug-a' : undefined), + }); + const loaded = factsLoader.load('acme-tool', 'runtime', ORG_A); + expect(loaded).toMatchObject({ + kind: 'ready', + facts: { ghostId: 'acme-tool', namespace: 'slug-a', installOrigin: 'agent-forge' }, + }); + if (loaded.kind === 'ready') { + expect(authorizeGhostTokenBroker('acme-tool', loaded)).toBe(true); + } + }); + + it('distinguishes a censused legacy Forge from a new missing-namespace install', () => { + const factsLoader = loader({ + lookupOrganizationPrefix: () => ({ kind: 'known', pluginPrefix: 'acme' }), + readInstallOrigin: () => 'agent-forge', + isPendingLegacyForge: (id) => id === 'acme-old', + }); + const legacy = factsLoader.load('acme-old', 'runtime', ORG_A); + expect(legacy).toMatchObject({ kind: 'ready', facts: { legacyPendingForge: true } }); + if (legacy.kind === 'ready') expect(authorizeGhostTokenBroker('acme-old', legacy)).toBe(true); + const fresh = factsLoader.load('acme-new', 'install', ORG_A, { installOrigin: 'agent-forge' }); + if (fresh.kind === 'ready') expect(authorizeGhostTokenBroker('acme-new', fresh)).toBe(false); + }); + + it('lets install-time namespace override win over a free-name ghost id', () => { + const factsLoader = loader({ + lookupOrganizationPrefix: () => ({ kind: 'known', pluginPrefix: null }), + }); + const denied = factsLoader.load('helper', 'install', ORG_A, { + installOrigin: 'agent-forge', + }); + expect(denied).toMatchObject({ + kind: 'ready', + facts: { ghostId: 'helper', namespace: null, installOrigin: 'agent-forge' }, + }); + if (denied.kind === 'ready') { + expect(authorizeGhostTokenBroker('helper', denied)).toBe(false); + expect(resolveGhostFirstPartyPrivilege(denied.facts).basis).toBe('denied-foreign-org'); + } + + const granted = factsLoader.load('helper', 'install', ORG_A, { + installOrigin: 'agent-forge', + namespace: 'slug-a', + }); + expect(granted).toMatchObject({ + kind: 'ready', + facts: { ghostId: 'helper', namespace: 'slug-a', installOrigin: 'agent-forge' }, + }); + if (granted.kind === 'ready') { + expect(authorizeGhostTokenBroker('helper', granted)).toBe(true); + expect(resolveGhostFirstPartyPrivilege(granted.facts)).toEqual({ + brokerEligible: true, + hostPrimitiveEligible: false, + basis: 'forge-current-org', + }); + } + + const explicitRoot = factsLoader.load('_ns__slug-a__helper', 'install', ORG_A, { + installOrigin: 'agent-forge', + namespace: null, + }); + expect(explicitRoot).toMatchObject({ + kind: 'ready', + facts: { ghostId: 'helper', namespace: null }, + }); + }); + it('binds pending organization-market authorization to the inspected package bytes', () => { const pending = { scope: 'organization' as const, @@ -318,12 +474,13 @@ describe('loadGhostFirstPartyFactsLoader', () => { kind: 'ready' as const, facts: { ghostId: 'acme-tool', + namespace: 'slug-a', builtin: false, marketRecord: bindPendingMarketRecordToInspectedPackage( pending, inspectedPackageSha256, ), - currentOrganization: { organizationId: 'org-a', pluginPrefix: 'acme' }, + currentOrganization: { organizationId: 'org-a', pluginPrefix: 'acme', orgSlug: 'slug-a' }, installOrigin: 'manual' as const, }, }); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostFirstPartyInstallGate.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostFirstPartyInstallGate.test.ts new file mode 100644 index 00000000000..a320a39f3e2 --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostFirstPartyInstallGate.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { + bindPendingMarketRecordToInspectedPackage, + loadGhostFirstPartyFactsLoader, +} from '../ghostFirstPartyFacts.js'; +import { authorizeGhostHostPrimitive, authorizeGhostTokenBroker } from '../ghostFirstPartyPrivilege.js'; + +const PACKAGE_SHA256 = 'a'.repeat(64); +const PERSONAL = { membershipKind: 'personal' as const, orgId: null }; +const XD = { membershipKind: 'org' as const, orgId: 'org-xd' }; + +function loader(namespace: string | null | undefined = undefined, pending = false, prefix?: 'xd') { + return loadGhostFirstPartyFactsLoader({ + readInstalledBuiltin: () => false, + readMarketInstallation: () => ({ + pluginId: 'plugin-helper', ghostId: 'helper', releaseId: 'release-1', version: '1.0.0', + scope: 'organization', source: 'market', installed: true, organizationId: 'org-xd', + sha256: PACKAGE_SHA256, updatedAt: '2026-09-30T00:00:00Z', + }), + readApprovedPackageSha256: () => PACKAGE_SHA256, + readInstallOrigin: () => 'manual', readInstallNamespace: () => namespace, + readLegacyFirstPartyEligible: () => true, + isPendingLegacyNamespace: () => pending, + lookupOrganizationPrefix: () => prefix ? { kind: 'known', pluginPrefix: prefix } : { kind: 'absent' }, + }); +} + +describe('first-party install authorization and old organization tokens', () => { + it('authorizes a first official Cindy public broker install only from exact inspected server bytes', () => { + const pending = { + scope: 'public' as const, source: 'market' as const, installed: true, + organizationId: null, sha256: PACKAGE_SHA256, + }; + for (const inspectedPackageSha256 of [PACKAGE_SHA256, 'b'.repeat(64)]) { + const loaded = loader().load('cindy-web-search', 'install', PERSONAL, { + marketRecord: bindPendingMarketRecordToInspectedPackage(pending, inspectedPackageSha256), + }); + expect(authorizeGhostTokenBroker('cindy-web-search', loaded)).toBe(inspectedPackageSha256 === PACKAGE_SHA256); + } + }); + + it('uses a Host legacy override only for same-source root updates, never an implicit install-time reader', () => { + const marketRecord = bindPendingMarketRecordToInspectedPackage({ + scope: 'public', source: 'market', installed: true, organizationId: null, sha256: PACKAGE_SHA256, + }, PACKAGE_SHA256); + for (const legacyFirstPartyEligible of [undefined, false, true]) { + const loaded = loader().load('filo-google', 'install', PERSONAL, { + marketRecord, ...(legacyFirstPartyEligible !== undefined ? { legacyFirstPartyEligible } : {}), + }); + expect(authorizeGhostTokenBroker('filo-google', loaded)).toBe(legacyFirstPartyEligible === true); + } + const freshXd = loader().load('xd-helper', 'install', PERSONAL, { marketRecord, legacyFirstPartyEligible: false }); + expect(authorizeGhostHostPrimitive('xd-helper', freshXd)).toBe(false); + }); + + it('allows approved XD namespace with an old token missing orgSlug, but rejects an explicit different slug', () => { + const loaded = loader('xd').load('_ns/xd/helper', 'runtime', XD); + expect(authorizeGhostHostPrimitive('helper', loaded)).toBe(true); + const foreignSlug = loader('xd').load('_ns/xd/helper', 'runtime', { ...XD, orgSlug: 'other' }); + expect(authorizeGhostHostPrimitive('helper', foreignSlug)).toBe(false); + }); + + it('does not authorize a known root from a copied organization row with the same approved hash', () => { + for (const ghostId of ['helper', 'xd-helper', 'cindy-helper']) { + const loaded = loader(null, false, 'xd').load(ghostId, 'runtime', { ...XD, orgSlug: 'xd' }); + expect(authorizeGhostTokenBroker(ghostId, loaded)).toBe(false); + expect(authorizeGhostHostPrimitive(ghostId, loaded)).toBe(false); + } + }); + + it('allows only a real old pending XD instance with a known fixed prefix when orgSlug is missing', () => { + expect(authorizeGhostHostPrimitive('helper', loader(undefined, true, 'xd').load('helper', 'runtime', XD))).toBe(true); + expect(authorizeGhostHostPrimitive('helper', loader(undefined, false, 'xd').load('helper', 'runtime', XD))).toBe(false); + expect(authorizeGhostHostPrimitive('helper', loader(null, true, 'xd').load('helper', 'runtime', XD))).toBe(false); + expect(authorizeGhostHostPrimitive('helper', loader(undefined, true).load('helper', 'runtime', XD))).toBe(false); + expect(authorizeGhostHostPrimitive('helper', loader(undefined, true, 'xd').load('helper', 'runtime', { ...XD, orgSlug: 'other' }))).toBe(false); + }); +}); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostFirstPartyLegacy.manager.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostFirstPartyLegacy.manager.test.ts new file mode 100644 index 00000000000..daf3b2c661a --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostFirstPartyLegacy.manager.test.ts @@ -0,0 +1,153 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ghostInstallApprovalToken, validateGhostManifest } from '../../../shared/ghost.js'; +import { GhostManager } from '../GhostManager.js'; +import { createGhostInstallReceipt, GhostInstallReceiptStore } from '../ghostInstallReceipt.js'; +import { writeTestCindyPackage } from './cindyPackageFixture.js'; + +const roots: string[] = []; +const PACKAGE_SHA256 = 'a'.repeat(64); + +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +async function fixture(official = false) { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cindy-legacy-first-party-'))); + roots.push(root); + const contentRoot = path.join(root, 'content'); + const stateRoot = path.join(root, 'state'); + const raw = { + schemaVersion: 2, id: 'filo-helper', name: 'Helper', version: '1.0.0', + kind: 'chip', entry: 'main.js', slots: [], + }; + const parsed = validateGhostManifest(raw); + if (!parsed.ok) throw new Error(parsed.reason); + const store = new GhostInstallReceiptStore(() => stateRoot); + const plant = async (id: string, packageSha256: string | undefined = PACKAGE_SHA256) => { + const manifest = { ...parsed.manifest, id }; + fs.mkdirSync(path.join(contentRoot, id), { recursive: true }); + fs.writeFileSync(path.join(contentRoot, id, 'ghost.json'), JSON.stringify({ ...raw, id })); + fs.writeFileSync(path.join(contentRoot, id, 'main.js'), 'module.exports = {};'); + const receipt = createGhostInstallReceipt({ + manifest, localeResources: {}, enabled: true, skillContentSha256: {}, packageSha256, + trust: { + level: official ? 'cindy-official' : 'unverified', + publisherSigned: false, publisherVerified: false, reviewed: false, + }, + }); + await store.write(receipt); + return receipt; + }; + const receipt = await plant('filo-helper'); + const packageFile = () => writeTestCindyPackage( + path.join(root, 'update.cindy'), { ...raw, version: '2.0.0' }, + { 'main.js': 'module.exports = { version: 2 };' }, + ); + return { root, contentRoot, stateRoot, store, receipt, plant, packageFile }; +} + +describe('Host legacy first-party receipt capture', () => { + it('reads the approved receipt only for the pinned instance revision', async () => { + const setup = await fixture(); + const manager = new GhostManager({ getRootDir: () => setup.contentRoot, getStateDir: () => setup.stateRoot }); + expect(manager.readApprovedInstallReceipt('filo-helper', setup.receipt.revision)).toEqual(setup.receipt); + expect(manager.readApprovedInstallReceipt('filo-helper', 'stale-revision')).toBeNull(); + expect(manager.readApprovedInstallReceipt('filo-missing')).toBeNull(); + expect(manager.readApprovedInstallReceipt('../filo-helper')).toBeNull(); + }); + + it('does not expose legacy evidence from a damaged receipt or pending transaction', async () => { + const setup = await fixture(); + const manager = new GhostManager({ getRootDir: () => setup.contentRoot, getStateDir: () => setup.stateRoot }); + expect(manager.readApprovedInstallReceipt('filo-helper')).not.toBeNull(); + await setup.store.writePendingMutation('filo-helper', { + kind: 'update', packageSha256: PACKAGE_SHA256, + backupDirName: '.cindy-updating-filo-helper-12345678', + }); + expect(manager.readApprovedInstallReceipt('filo-helper')).toBeNull(); + fs.rmSync(path.join(setup.stateRoot, '.pending-filo-helper.json')); + fs.writeFileSync(path.join(setup.stateRoot, 'filo-helper.json'), '{ damaged'); + expect(manager.readApprovedInstallReceipt('filo-helper')).toBeNull(); + }); + + it('captures once, survives pending removal and restart, and never qualifies a later missing-namespace receipt', async () => { + const setup = await fixture(); + const capture = vi.fn(() => true); + const options = { + getRootDir: () => setup.contentRoot, getStateDir: () => setup.stateRoot, + captureLegacyFirstPartyEligibility: capture, + }; + const manager = new GhostManager(options); + manager.ensureNamespaceMigrationCensus(); + expect(capture).toHaveBeenCalledExactlyOnceWith('filo-helper', PACKAGE_SHA256); + expect(manager.readLegacyFirstPartyEligible('filo-helper')).toBe(true); + expect(setup.store.readForRecovery('filo-helper')).toMatchObject({ + state: 'approved', receipt: { legacyFirstPartyEligible: true, revision: setup.receipt.revision }, + }); + await expect(manager.commitPendingRootNamespace('filo-helper', 'market-public')).resolves.toEqual({ ok: true }); + expect(manager.readLegacyFirstPartyEligible('filo-helper')).toBe(true); + await setup.plant('filo-later'); + const restarted = new GhostManager(options); + restarted.ensureNamespaceMigrationCensus(); + expect(capture).toHaveBeenCalledTimes(1); + expect(restarted.readLegacyFirstPartyEligible('filo-helper')).toBe(true); + expect(restarted.readLegacyFirstPartyEligible('filo-later')).toBe(false); + expect(restarted.isPendingLegacyNamespace('filo-later')).toBe(false); + }); + + it('captures retired officially approved plugins without a seed roster or market ledger', async () => { + const setup = await fixture(true); + const manager = new GhostManager({ getRootDir: () => setup.contentRoot, getStateDir: () => setup.stateRoot }); + manager.ensureNamespaceMigrationCensus(); + expect(manager.readLegacyFirstPartyEligible('filo-helper')).toBe(true); + expect(manager.list().find((ghost) => ghost.manifest.id === 'filo-helper')?.builtin === true).toBe(false); + }); + + it('does not retroactively qualify old receipts after an offline first census', async () => { + const setup = await fixture(); + new GhostManager({ getRootDir: () => setup.contentRoot, getStateDir: () => setup.stateRoot }) + .ensureNamespaceMigrationCensus(); + const capture = vi.fn(() => true); + const later = new GhostManager({ + getRootDir: () => setup.contentRoot, getStateDir: () => setup.stateRoot, + captureLegacyFirstPartyEligibility: capture, + }); + later.ensureNamespaceMigrationCensus(); + expect(capture).not.toHaveBeenCalled(); + expect(later.readLegacyFirstPartyEligible('filo-helper')).toBe(false); + }); + + it.each([false, true])('preserves same-source qualification and clears archive replacement=%s', async (sourceChanged) => { + const setup = await fixture(); + const manager = new GhostManager({ + getRootDir: () => setup.contentRoot, getStateDir: () => setup.stateRoot, + captureLegacyFirstPartyEligibility: () => true, + onArchiveSourceState: async () => {}, + }); + manager.ensureNamespaceMigrationCensus(); + await manager.commitPendingRootNamespace('filo-helper', 'market-public'); + const installed = manager.list().find((ghost) => ghost.manifest.id === 'filo-helper'); + if (!installed) throw new Error('installed fixture unavailable'); + const result = await manager.update(await setup.packageFile(), { + expectedInstalledApproval: ghostInstallApprovalToken(installed.approval), + namespace: null, + ...(sourceChanged ? { sourceStateArchiveId: '_ns__cindy-archive-00000000-0000-4000-8000-000000000002__filo-helper' } : {}), + }); + expect(result).not.toHaveProperty('rejection'); + expect(manager.readLegacyFirstPartyEligible('filo-helper')).toBe(!sourceChanged); + }); + + it('does not capture damaged or hashless approval and does not read damaged flags', async () => { + const setup = await fixture(true); + const receiptPath = path.join(setup.stateRoot, 'filo-helper.json'); + fs.writeFileSync(receiptPath, JSON.stringify({ ...setup.receipt, packageSha256: undefined })); + const manager = new GhostManager({ getRootDir: () => setup.contentRoot, getStateDir: () => setup.stateRoot }); + manager.ensureNamespaceMigrationCensus(); + expect(manager.readLegacyFirstPartyEligible('filo-helper')).toBe(false); + fs.writeFileSync(receiptPath, JSON.stringify({ ...setup.receipt, legacyFirstPartyEligible: 'true' })); + expect(manager.readLegacyFirstPartyEligible('filo-helper')).toBe(false); + }); +}); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostFirstPartyNaming.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostFirstPartyNaming.test.ts new file mode 100644 index 00000000000..3ce3532a4ca --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostFirstPartyNaming.test.ts @@ -0,0 +1,291 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { isUserInstallReservedGhostId, validateGhostManifest } from '../../../shared/ghost.js'; +import { loadGhostFirstPartyFactsLoader } from '../ghostFirstPartyFacts.js'; +import { + captureLegacyFirstPartyEligibility, + authorizeGhostHostPrimitive, + resolveGhostFirstPartyPrivilege, + type GhostFirstPartyFacts, + type GhostFirstPartyMarketRecord, +} from '../ghostFirstPartyPrivilege.js'; +import { + createGhostInstallReceipt, + GhostInstallReceiptStore, + legacyFirstPartyEligibilityAfterUpdate, +} from '../ghostInstallReceipt.js'; + +const PACKAGE_SHA256 = 'a'.repeat(64); +const XD = { organizationId: 'org-xd', orgSlug: 'xd', pluginPrefix: 'xd' }; +const PUBLIC_RECORD: GhostFirstPartyMarketRecord = { + scope: 'public', organizationId: null, source: 'market', installed: true, + sha256: PACKAGE_SHA256, approvedPackageSha256: PACKAGE_SHA256, +}; + +function facts(overrides: Partial = {}): GhostFirstPartyFacts { + return { + ghostId: 'helper', namespace: null, builtin: false, marketRecord: null, + currentOrganization: null, installOrigin: 'manual', ...overrides, + }; +} + +const tempRoots: string[] = []; +afterEach(() => { + for (const root of tempRoots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('first-party naming and approved source eligibility', () => { + it.each(['xd-helper', 'filo-helper', 'cindy-helper'])('does not grant new root %s by name or builtin roster', (ghostId) => { + for (const builtin of [false, true]) { + expect(resolveGhostFirstPartyPrivilege(facts({ ghostId, builtin, marketRecord: null }))).toMatchObject({ + brokerEligible: false, hostPrimitiveEligible: false, + }); + } + expect(isUserInstallReservedGhostId(ghostId)).toBe(ghostId === 'cindy-helper'); + expect(resolveGhostFirstPartyPrivilege(facts({ ghostId, marketRecord: PUBLIC_RECORD })).hostPrimitiveEligible) + .toBe(ghostId === 'cindy-helper'); + }); + + it.each(['helper', 'xd-helper', 'filo-helper'])('grants trusted XD organization %s without a naming or phase gate', (ghostId) => { + const trusted = facts({ + ghostId, namespace: 'xd', currentOrganization: XD, + marketRecord: { ...PUBLIC_RECORD, scope: 'organization', organizationId: XD.organizationId }, + }); + expect(resolveGhostFirstPartyPrivilege(trusted)).toEqual({ + brokerEligible: true, hostPrimitiveEligible: true, basis: 'market-organization-current', + }); + for (const source of ['legacy-adopted', 'local-market', 'git-market'] as const) { + expect(resolveGhostFirstPartyPrivilege({ ...trusted, marketRecord: { ...trusted.marketRecord!, source } }).hostPrimitiveEligible).toBe(false); + } + expect(resolveGhostFirstPartyPrivilege({ ...trusted, marketRecord: null }).hostPrimitiveEligible).toBe(false); + expect(resolveGhostFirstPartyPrivilege({ ...trusted, namespace: null }).hostPrimitiveEligible).toBe(false); + expect(resolveGhostFirstPartyPrivilege({ ...trusted, currentOrganization: { ...XD, organizationId: 'other' } }).hostPrimitiveEligible).toBe(false); + expect(resolveGhostFirstPartyPrivilege({ ...trusted, marketRecord: { ...trusted.marketRecord!, approvedPackageSha256: 'b'.repeat(64) } }).hostPrimitiveEligible).toBe(false); + }); + + it.each(['helper', 'cindy-helper'])('binds official %s to exact approved identity and hash, not a prefix', (ghostId) => { + const trusted = facts({ + ghostId, builtin: true, approvedPackageSha256: PACKAGE_SHA256, + trustedSource: { kind: 'builtin-official', ghostId, namespace: null, packageSha256: PACKAGE_SHA256 }, + }); + expect(resolveGhostFirstPartyPrivilege(trusted).hostPrimitiveEligible).toBe(true); + for (const changed of [ + { ...trusted, ghostId: 'different' }, + { ...trusted, namespace: 'xd' }, + { ...trusted, approvedPackageSha256: null }, + { ...trusted, approvedPackageSha256: 'b'.repeat(64) }, + { ...trusted, trustedSource: { ...trusted.trustedSource!, packageSha256: 'invalid' }, approvedPackageSha256: 'invalid' }, + ]) expect(resolveGhostFirstPartyPrivilege(changed).hostPrimitiveEligible).toBe(false); + const publicInstall = { ...trusted, builtin: false, marketRecord: PUBLIC_RECORD, trustedSource: null }; + expect(resolveGhostFirstPartyPrivilege(publicInstall).hostPrimitiveEligible).toBe(ghostId === 'cindy-helper'); + expect(resolveGhostFirstPartyPrivilege({ ...publicInstall, marketRecord: null }).hostPrimitiveEligible).toBe(false); + expect(resolveGhostFirstPartyPrivilege({ ...publicInstall, marketRecord: { ...PUBLIC_RECORD, sha256: 'b'.repeat(64) } }).hostPrimitiveEligible).toBe(false); + }); + + it('keeps an exact Cindy public resource usable without builtin facts or an organization prefix cache', () => { + const loader = loadGhostFirstPartyFactsLoader({ + readInstalledBuiltin: () => false, + readMarketInstallation: () => ({ + ...PUBLIC_RECORD, pluginId: 'plugin-cindy', ghostId: 'cindy-helper', + releaseId: 'release-1', version: '1.0.0', updatedAt: '2026-09-30T00:00:00Z', + }), + readApprovedPackageSha256: () => PACKAGE_SHA256, + readInstallOrigin: () => 'manual', + readInstallNamespace: () => null, + lookupOrganizationPrefix: () => { throw new Error('prefix lookup must not be required'); }, + }); + const loaded = loader.load('cindy-helper', 'runtime', { membershipKind: 'org', orgId: XD.organizationId, orgSlug: 'xd' }); + expect(loaded.kind).toBe('ready'); + if (loaded.kind !== 'ready') throw new Error('public resource facts unavailable'); + expect(loaded.facts.trustedSource).toBeUndefined(); + expect(resolveGhostFirstPartyPrivilege(loaded.facts).basis).toBe('market-public'); + }); + + it('allows an exact official Cindy web-search market update before alias denial but never public XD Mivo', () => { + const official = facts({ ghostId: 'cindy-web-search', marketRecord: PUBLIC_RECORD }); + expect(resolveGhostFirstPartyPrivilege(official)).toEqual({ + brokerEligible: true, hostPrimitiveEligible: true, basis: 'market-public', + }); + for (const changed of [ + { ...official, namespace: 'xd' }, + { ...official, marketRecord: { ...PUBLIC_RECORD, approvedPackageSha256: 'b'.repeat(64) } }, + { ...official, marketRecord: { ...PUBLIC_RECORD, source: 'local-market' as const } }, + { ...official, ghostId: 'xd-mivo' }, + ]) expect(resolveGhostFirstPartyPrivilege(changed).basis).toBe('denied-alias'); + }); + + it('captures only a real census installation with approved trusted public package evidence', () => { + const candidate = { + legacyExistingInstall: true, approved: true, ghostId: 'filo-google', namespace: null, + approvedPackageSha256: PACKAGE_SHA256, marketRecord: { ...PUBLIC_RECORD, ghostId: 'filo-google' }, + }; + expect(captureLegacyFirstPartyEligibility(candidate)).toBe(true); + for (const changed of [ + { ...candidate, legacyExistingInstall: false }, + { ...candidate, approved: false }, + { ...candidate, namespace: 'xd' }, + { ...candidate, approvedPackageSha256: 'b'.repeat(64) }, + { ...candidate, ghostId: 'helper' }, + { ...candidate, marketRecord: null }, + { ...candidate, marketRecord: { ...candidate.marketRecord, ghostId: 'other' } }, + { ...candidate, marketRecord: { ...candidate.marketRecord, namespace: 'xd' } }, + { ...candidate, marketRecord: { ...candidate.marketRecord, installed: false } }, + { ...candidate, marketRecord: { ...candidate.marketRecord, approvedPackageSha256: null } }, + { ...candidate, marketRecord: { ...candidate.marketRecord, approvedPackageSha256: 'b'.repeat(64) } }, + { ...candidate, marketRecord: { ...candidate.marketRecord, source: 'legacy-adopted' as const } }, + { ...candidate, marketRecord: { ...candidate.marketRecord, scope: 'organization' as const, organizationId: XD.organizationId } }, + ]) expect(captureLegacyFirstPartyEligibility(changed)).toBe(false); + }); + + it('does not let a legacy flag bypass alias, namespace, organization or changed-source gates', () => { + for (const ghostId of ['xd-mivo', 'cindy-web-search']) { + expect(resolveGhostFirstPartyPrivilege(facts({ ghostId, legacyFirstPartyEligible: true })).basis).toBe('denied-alias'); + } + const legacy = facts({ ghostId: 'xd-helper', legacyFirstPartyEligible: true }); + expect(resolveGhostFirstPartyPrivilege(legacy).hostPrimitiveEligible).toBe(true); + for (const changed of [ + { ...legacy, namespace: 'xd' }, + { ...legacy, installOrigin: 'agent-forge' as const }, + { ...legacy, marketRecord: { ...PUBLIC_RECORD, installed: false } }, + { ...legacy, marketRecord: { ...PUBLIC_RECORD, source: 'local-market' as const } }, + { ...legacy, marketRecord: { ...PUBLIC_RECORD, scope: 'organization' as const, organizationId: 'foreign' }, currentOrganization: XD }, + ]) expect(resolveGhostFirstPartyPrivilege(changed).hostPrimitiveEligible).toBe(false); + expect(resolveGhostFirstPartyPrivilege({ + ...legacy, namespace: 'xd', currentOrganization: XD, + marketRecord: { ...PUBLIC_RECORD, scope: 'organization', organizationId: 'foreign' }, + }).brokerEligible).toBe(false); + }); + + it.each(['absent', 'unavailable', 'throws'] as const)('authorizes natural XD names with %s prefix cache and keeps legacy Forge closed', (prefixState) => { + let prefixReads = 0; + const loader = loadGhostFirstPartyFactsLoader({ + readInstalledBuiltin: () => false, + readMarketInstallation: () => ({ + ...PUBLIC_RECORD, scope: 'organization', organizationId: XD.organizationId, + pluginId: 'plugin-helper', ghostId: 'helper', releaseId: 'release-1', + version: '1.0.0', updatedAt: '2026-09-30T00:00:00Z', + }), + readApprovedPackageSha256: () => PACKAGE_SHA256, + readInstallOrigin: () => 'manual', + lookupOrganizationPrefix: () => { + prefixReads += 1; + if (prefixState === 'throws') throw new Error('prefix cache unavailable'); + return { kind: prefixState }; + }, + }); + const identity = { membershipKind: 'org' as const, orgId: XD.organizationId, orgSlug: 'xd' }; + const loaded = loader.load('_ns/xd/helper', 'runtime', identity); + expect(loaded.kind).toBe('ready'); + if (loaded.kind === 'ready') { + expect(loaded.facts.currentOrganization).toEqual({ organizationId: XD.organizationId, orgSlug: 'xd' }); + expect(resolveGhostFirstPartyPrivilege(loaded.facts).hostPrimitiveEligible).toBe(true); + } + expect(prefixReads).toBe(0); + const legacyForge = loader.load('xd-helper', 'runtime', identity, { installOrigin: 'agent-forge' }); + expect(legacyForge.kind).toBe('unavailable'); + expect(prefixReads).toBe(1); + }); + + it('preserves offline pending XD enterprise privileges only for the censused unbound instance', () => { + const createLoader = (recordedNamespace: string | null | undefined, pending: boolean) => loadGhostFirstPartyFactsLoader({ + readInstalledBuiltin: () => false, + readMarketInstallation: () => ({ + ...PUBLIC_RECORD, scope: 'organization', organizationId: XD.organizationId, + pluginId: 'plugin-old', ghostId: 'xd-helper', releaseId: 'release-1', + version: '1.0.0', updatedAt: '2026-09-30T00:00:00Z', + }), + readApprovedPackageSha256: () => PACKAGE_SHA256, + readInstallOrigin: () => 'manual', + readInstallNamespace: () => recordedNamespace, + isPendingLegacyNamespace: () => pending, + isPendingLegacyForge: () => pending, + lookupOrganizationPrefix: () => { throw new Error('offline prefix cache'); }, + }); + const identity = { membershipKind: 'org' as const, orgId: XD.organizationId, orgSlug: 'xd' }; + const oldPending = createLoader(undefined, true).load('xd-helper', 'runtime', identity); + expect(oldPending.kind).toBe('ready'); + if (oldPending.kind !== 'ready') throw new Error('pending facts unavailable'); + expect(oldPending.facts.legacyPendingNamespace).toBe(true); + expect(oldPending.facts.installOrigin).toBe('manual'); + expect(resolveGhostFirstPartyPrivilege(oldPending.facts).hostPrimitiveEligible).toBe(true); + expect(resolveGhostFirstPartyPrivilege({ ...oldPending.facts, legacyPendingNamespace: false }).hostPrimitiveEligible).toBe(false); + for (const denied of [ + createLoader(null, true).load('xd-helper', 'runtime', identity), + createLoader(undefined, false).load('xd-helper', 'runtime', identity), + createLoader(undefined, true).load('xd-helper', 'install', identity), + createLoader(undefined, true).load('xd-helper', 'runtime', { ...identity, orgId: 'foreign' }), + createLoader(undefined, true).load('xd-helper', 'runtime', identity, { installOrigin: 'agent-forge' }), + ]) expect(authorizeGhostHostPrimitive('xd-helper', denied)).toBe(false); + expect(resolveGhostFirstPartyPrivilege({ + ...oldPending.facts, marketRecord: { ...oldPending.facts.marketRecord!, approvedPackageSha256: null }, + }).hostPrimitiveEligible).toBe(false); + const committed = createLoader('xd', true).load('xd-helper', 'runtime', identity); + if (committed.kind !== 'ready') throw new Error('committed facts unavailable'); + expect(committed.facts.legacyPendingNamespace).toBeUndefined(); + expect(resolveGhostFirstPartyPrivilege(committed.facts).hostPrimitiveEligible).toBe(true); + }); + + it('loads captured receipt eligibility offline after census pending entries disappear, but never for a new install', () => { + const loader = loadGhostFirstPartyFactsLoader({ + readInstalledBuiltin: () => false, + readMarketInstallation: () => { throw new Error('offline ledger'); }, + readApprovedPackageSha256: () => null, + readInstallOrigin: () => 'manual', + lookupOrganizationPrefix: () => { throw new Error('offline organization cache'); }, + readLegacyFirstPartyEligible: () => true, + }); + const identity = { membershipKind: 'org' as const, orgId: XD.organizationId, orgSlug: 'xd' }; + const runtime = loader.load('filo-google', 'runtime', identity); + expect(runtime.kind).toBe('ready'); + if (runtime.kind === 'ready') expect(resolveGhostFirstPartyPrivilege(runtime.facts)).toEqual({ + brokerEligible: true, hostPrimitiveEligible: true, basis: 'legacy-existing-install', + }); + expect(loader.load('filo-google', 'install', identity).kind).toBe('unavailable'); + expect(legacyFirstPartyEligibilityAfterUpdate({ legacyFirstPartyEligible: true }, false)).toBe(true); + expect(legacyFirstPartyEligibilityAfterUpdate({ legacyFirstPartyEligible: true }, true)).toBe(false); + expect(legacyFirstPartyEligibilityAfterUpdate({}, false)).toBe(false); + expect(resolveGhostFirstPartyPrivilege(facts({ ghostId: 'filo-google', legacyFirstPartyEligible: false })).hostPrimitiveEligible).toBe(false); + }); + + it('persists the Host legacy qualification and rejects damaged receipt fields', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cindy-first-party-naming-')); + tempRoots.push(root); + const store = new GhostInstallReceiptStore(() => root); + const parsed = validateGhostManifest({ + schemaVersion: 2, id: 'filo-google', name: 'Google', version: '1.0.0', kind: 'chip', entry: 'main.js', slots: [], + }); + if (!parsed.ok) throw new Error(parsed.reason); + const input = { + manifest: parsed.manifest, localeResources: {}, enabled: true, skillContentSha256: {}, + trust: { level: 'unverified' as const, publisherSigned: false, publisherVerified: false, reviewed: false }, + }; + expect(createGhostInstallReceipt(input).legacyFirstPartyEligible).toBeUndefined(); + const receipt = createGhostInstallReceipt({ ...input, legacyFirstPartyEligible: true }); + await store.write(receipt); + expect(store.readForRecovery('filo-google')).toMatchObject({ state: 'approved', receipt: { legacyFirstPartyEligible: true } }); + const loader = loadGhostFirstPartyFactsLoader({ + readInstalledBuiltin: () => false, readMarketInstallation: () => null, + readApprovedPackageSha256: () => null, readInstallOrigin: () => 'manual', + lookupOrganizationPrefix: () => ({ kind: 'absent' }), + readLegacyFirstPartyEligible: (relId) => { + const read = store.readForRecovery(relId); + return read.state === 'approved' && read.receipt.legacyFirstPartyEligible === true; + }, + }); + const identity = { membershipKind: 'personal' as const, orgId: null }; + const approved = loader.load('filo-google', 'runtime', identity); + if (approved.kind !== 'ready') throw new Error('approved facts unavailable'); + expect(resolveGhostFirstPartyPrivilege(approved.facts).hostPrimitiveEligible).toBe(true); + for (const legacyFirstPartyEligible of ['true', 1, {}, null]) { + fs.writeFileSync(path.join(root, 'filo-google.json'), JSON.stringify({ ...receipt, legacyFirstPartyEligible })); + expect(store.readForRecovery('filo-google').state).not.toBe('approved'); + const damaged = loader.load('filo-google', 'runtime', identity); + if (damaged.kind !== 'ready') throw new Error('damaged facts unavailable'); + expect(resolveGhostFirstPartyPrivilege(damaged.facts).hostPrimitiveEligible).toBe(false); + } + fs.writeFileSync(path.join(root, 'filo-google.json'), JSON.stringify({ ...receipt, revision: 'damaged' })); + expect(store.readForRecovery('filo-google').state).not.toBe('approved'); + }); +}); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostFirstPartyPrivilege.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostFirstPartyPrivilege.test.ts index 7f0d3157c9b..8dd2c809bfc 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/ghostFirstPartyPrivilege.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostFirstPartyPrivilege.test.ts @@ -2,13 +2,15 @@ import { describe, expect, it } from 'vitest'; import { FIRST_PARTY_ALIAS_GHOST_IDS, + isTrustedMivoSecretAlias, + authorizeGhostHostPrimitive, authorizeGhostTokenBroker, resolveGhostFirstPartyPrivilege, type GhostFirstPartyFacts, type GhostFirstPartyMarketRecord, } from '../ghostFirstPartyPrivilege.js'; -const CURRENT_ORG = { organizationId: 'org-acme', pluginPrefix: 'acme' as const }; +const CURRENT_ORG = { organizationId: 'org-acme', pluginPrefix: 'acme' as const, orgSlug: 'acme' as const }; const BUNDLED_NON_OFFICIAL_IDS = [ '163-mail', @@ -26,11 +28,26 @@ const BUNDLED_NON_OFFICIAL_IDS = [ ] as const; function facts(partial: Partial & Pick): GhostFirstPartyFacts { + const namespace = partial.namespace !== undefined ? partial.namespace : + !partial.builtin && partial.marketRecord?.scope === 'organization' + ? partial.currentOrganization?.orgSlug ?? 'acme' : null; return { builtin: false, + namespace, marketRecord: null, currentOrganization: null, installOrigin: 'manual', + ...(partial.builtin && ['xd-feishu', 'xd-atlassian', 'xd-mivo', 'cindy-web-search', 'cindy-art'].includes(partial.ghostId) + ? { + trustedSource: { + kind: 'builtin-official' as const, + ghostId: partial.ghostId, + namespace, + packageSha256: 'a'.repeat(64), + }, + approvedPackageSha256: 'a'.repeat(64), + } + : {}), ...partial, }; } @@ -49,10 +66,36 @@ function market( } describe('resolveGhostFirstPartyPrivilege', () => { + it.each(['xd', null])('retains the trusted Mivo alias with an old token and namespace %s', (namespace) => { + const trusted = facts({ ghostId: 'xd-mivo', namespace, + currentOrganization: { organizationId: 'org-xd', orgSlug: null, pluginPrefix: 'xd' }, + marketRecord: market({ scope: 'organization', organizationId: 'org-xd' }), + }); + expect(isTrustedMivoSecretAlias(trusted, namespace === null)).toBe(true); + expect(isTrustedMivoSecretAlias({ ...trusted, + currentOrganization: { organizationId: 'org-xd', orgSlug: 'other', pluginPrefix: 'xd' }, + }, namespace === null)).toBe(false); + expect(isTrustedMivoSecretAlias({ ...trusted, + marketRecord: market({ scope: 'organization', organizationId: 'org-other' }), + }, namespace === null)).toBe(false); + }); + + it('binds the Mivo historical key to a real XD installation, not the plugin name', () => { + const xdOrganization = { organizationId: 'org-xd', orgSlug: 'xd', pluginPrefix: 'xd' }; + const trusted = facts({ + ghostId: 'xd-mivo', namespace: 'xd', currentOrganization: xdOrganization, + marketRecord: market({ scope: 'organization', organizationId: 'org-xd' }), + }); + expect(isTrustedMivoSecretAlias(trusted, false)).toBe(true); + expect(isTrustedMivoSecretAlias({ ...trusted, namespace: null }, true)).toBe(true); + expect(isTrustedMivoSecretAlias({ ...trusted, namespace: null }, false)).toBe(false); + expect(isTrustedMivoSecretAlias({ ...trusted, installOrigin: 'agent-forge', marketRecord: null }, true)).toBe(false); + expect(isTrustedMivoSecretAlias({ ...trusted, currentOrganization: CURRENT_ORG }, false)).toBe(false); + expect(isTrustedMivoSecretAlias({ ...trusted, marketRecord: market({ scope: 'organization', organizationId: 'org-xd', source: 'legacy-adopted' }) }, false)).toBe(false); + expect(isTrustedMivoSecretAlias({ ...trusted, marketRecord: market({ scope: 'organization', organizationId: 'org-xd', approvedPackageSha256: null }) }, false)).toBe(false); + }); // 纯函数分支测试:用 xd-feishu / xd-atlassian 作为代表性官方前缀 id,验证 // builtin + 官方前缀会同时得到 Broker 与宿主原语;这不表示它们随发行包分发。 - // 静态官方前缀的存量兼容由后面的 authorizeGhostTokenBroker(..., - // { kind: 'unavailable' }) 对照用例锁定。 // `currentOrganization: null` 与 `marketRecord: null` 在这里**显式写出**,不吃 // `facts()` 的默认值:否则将来有人为省事把默认改成"有组织",这条依然会通过 // (优先级 1 本就不看 org),但"个人身份"这个场景就悄悄没人守了。 @@ -137,7 +180,7 @@ describe('resolveGhostFirstPartyPrivilege', () => { ).toMatchObject({ basis: 'builtin-official', brokerEligible: true }); }); - it('trusts server-market public installs only when the id hits the static table', () => { + it('trusts server-market public installs only with approved official resource evidence', () => { expect( resolveGhostFirstPartyPrivilege( facts({ @@ -218,6 +261,33 @@ describe('resolveGhostFirstPartyPrivilege', () => { hostPrimitiveEligible: false, basis: 'market-organization-current', }); + expect( + resolveGhostFirstPartyPrivilege( + facts({ + ghostId: 'helper', + marketRecord: market({ scope: 'organization', organizationId: 'org-acme' }), + currentOrganization: { organizationId: 'org-acme', pluginPrefix: null, orgSlug: 'acme' }, + }), + ), + ).toEqual({ + brokerEligible: true, + hostPrimitiveEligible: false, + basis: 'market-organization-current', + }); + expect( + resolveGhostFirstPartyPrivilege( + facts({ + ghostId: 'xd-feishu', + namespace: 'xd', + marketRecord: market({ scope: 'organization', organizationId: 'org-acme' }), + currentOrganization: { organizationId: 'org-acme', pluginPrefix: 'xd', orgSlug: 'xd' }, + }), + ), + ).toEqual({ + brokerEligible: true, + hostPrimitiveEligible: true, + basis: 'market-organization-current', + }); }); it('denies same-manifest organization packages when Release and approved package bytes differ', () => { @@ -262,19 +332,20 @@ describe('resolveGhostFirstPartyPrivilege', () => { }); }); - it('denies an official-looking org plugin whose prefix does not belong to the current org', () => { + it('does not let an official-looking name veto a trusted current-org market plugin', () => { expect( resolveGhostFirstPartyPrivilege( facts({ ghostId: 'xd-evil', + namespace: 'acme', marketRecord: market({ scope: 'organization', organizationId: 'org-acme' }), currentOrganization: CURRENT_ORG, }), ), ).toEqual({ - brokerEligible: false, + brokerEligible: true, hostPrimitiveEligible: false, - basis: 'denied-unknown-origin', + basis: 'market-organization-current', }); }); @@ -320,13 +391,14 @@ describe('resolveGhostFirstPartyPrivilege', () => { facts({ ghostId: 'acme-feishu', currentOrganization: CURRENT_ORG, + namespace: 'acme', installOrigin: 'agent-forge', }), ), ).toEqual({ brokerEligible: true, hostPrimitiveEligible: false, - basis: 'forge-current-org-prefix', + basis: 'forge-current-org', }); expect( resolveGhostFirstPartyPrivilege( @@ -338,6 +410,7 @@ describe('resolveGhostFirstPartyPrivilege', () => { source: 'local-market', }), currentOrganization: CURRENT_ORG, + namespace: 'acme', installOrigin: 'agent-forge', }), ).brokerEligible, @@ -347,10 +420,25 @@ describe('resolveGhostFirstPartyPrivilege', () => { facts({ ghostId: 'other-feishu', currentOrganization: CURRENT_ORG, + namespace: 'acme', installOrigin: 'agent-forge', }), ).brokerEligible, - ).toBe(false); + ).toBe(true); + expect( + resolveGhostFirstPartyPrivilege( + facts({ + ghostId: 'helper', + currentOrganization: { organizationId: 'org-acme', pluginPrefix: null, orgSlug: 'acme' }, + namespace: 'acme', + installOrigin: 'agent-forge', + }), + ), + ).toEqual({ + brokerEligible: true, + hostPrimitiveEligible: false, + basis: 'forge-current-org', + }); }); it('lets explicit Forge self-test win over every stale or foreign market-row shape', () => { @@ -366,13 +454,14 @@ describe('resolveGhostFirstPartyPrivilege', () => { ghostId: 'acme-feishu', marketRecord, currentOrganization: CURRENT_ORG, - installOrigin: 'agent-forge', + namespace: 'acme', + installOrigin: 'agent-forge', }), ), ).toEqual({ brokerEligible: true, hostPrimitiveEligible: false, - basis: 'forge-current-org-prefix', + basis: 'forge-current-org', }); } }); @@ -402,10 +491,13 @@ describe('resolveGhostFirstPartyPrivilege', () => { // 手动本地包不能借一个 installed=false 的市场行取得资格。 }); - it('keeps official-prefix broker even when facts are unavailable, and asks the resolver otherwise', () => { + it('fails closed when facts are unavailable, even for official-looking ids', () => { expect( authorizeGhostTokenBroker('xd-feishu', { kind: 'unavailable' }), - ).toBe(true); + ).toBe(false); + expect( + authorizeGhostHostPrimitive('xd-feishu', { kind: 'unavailable' }), + ).toBe(false); expect( authorizeGhostTokenBroker('acme-feishu', { kind: 'unavailable' }), ).toBe(false); @@ -423,7 +515,7 @@ describe('resolveGhostFirstPartyPrivilege', () => { ).toBe(false); }); - it('uses pending org-market facts only for non-official ids; official prefix never consults them', () => { + it('grants organization-market broker from trusted facts, not from an official-looking name', () => { const pendingOrgMarket = facts({ ghostId: 'acme-feishu', marketRecord: market({ scope: 'organization', organizationId: 'org-acme' }), @@ -442,17 +534,11 @@ describe('resolveGhostFirstPartyPrivilege', () => { currentOrganization: null, }), }), - ).toBe(true); - expect(authorizeGhostTokenBroker('cindy-art', { kind: 'unavailable' })).toBe(true); + ).toBe(false); + expect(authorizeGhostTokenBroker('cindy-art', { kind: 'unavailable' })).toBe(false); + expect(authorizeGhostHostPrimitive('cindy-art', { kind: 'unavailable' })).toBe(false); }); - // `legacy-adopted` 是市场列表成功后为「早于市场就已装在本机的官方前缀插件」合成的 - // 来源(`plugin-market/service.ts::adoptLegacyInstallations`)。判据对它一律 deny: - // 它既不是 `source: 'market'`(所以进不了 public 那支),也不是 git/local market。 - // - // `legacy-adopted` 不是静态官方资格或组织资格的替代来源:即使 id 命中静态官方前缀, - // 或命中当前组织前缀,也必须 fail-closed。若要改变这条来源边界必须显式决策, - // 不能把它当漏网 bug 顺手放宽。 it('denies legacy-adopted rows for static official and matching organization ids', () => { for (const scope of ['public', 'organization'] as const) { expect( @@ -503,6 +589,38 @@ describe('resolveGhostFirstPartyPrivilege', () => { }); }); + it('denies Forge broker when the install namespace is not the current organization', () => { + expect( + resolveGhostFirstPartyPrivilege( + facts({ + ghostId: 'helper', + namespace: 'acme', + currentOrganization: { organizationId: 'org-b', pluginPrefix: 'beta', orgSlug: 'beta' }, + installOrigin: 'agent-forge', + }), + ), + ).toEqual({ + brokerEligible: false, + hostPrimitiveEligible: false, + basis: 'denied-foreign-org', + }); + }); + + it('preserves only a verified pending pre-namespace Forge for its original organization', () => { + const pending = facts({ + ghostId: 'acme-helper', + currentOrganization: CURRENT_ORG, + installOrigin: 'agent-forge', + legacyPendingForge: true, + }); + expect(resolveGhostFirstPartyPrivilege(pending).brokerEligible).toBe(true); + expect(resolveGhostFirstPartyPrivilege({ ...pending, legacyPendingForge: false }).brokerEligible).toBe(false); + expect(resolveGhostFirstPartyPrivilege({ ...pending, ghostId: 'helper' }).brokerEligible).toBe(false); + expect(resolveGhostFirstPartyPrivilege({ ...pending, currentOrganization: { + organizationId: 'org-b', pluginPrefix: 'beta', orgSlug: 'beta', + } }).brokerEligible).toBe(false); + }); + it('fail-closes unknown origin, missing prefix, and unmatched prefix', () => { expect(resolveGhostFirstPartyPrivilege(facts({ ghostId: 'mystery' }))).toEqual({ brokerEligible: false, @@ -513,7 +631,7 @@ describe('resolveGhostFirstPartyPrivilege', () => { resolveGhostFirstPartyPrivilege( facts({ ghostId: 'acme-feishu', - currentOrganization: { organizationId: 'org-acme', pluginPrefix: null }, + currentOrganization: { organizationId: 'org-acme', pluginPrefix: null, orgSlug: 'acme' }, }), ), ).toEqual({ diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostInstallReceipt.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostInstallReceipt.test.ts index 1b7a64f2209..eb18a497724 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/ghostInstallReceipt.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostInstallReceipt.test.ts @@ -11,9 +11,18 @@ import { effectiveInstallOrigin, type GhostInstallReceipt, GhostInstallReceiptStore, + isValidGhostSourceStateArchiveId, } from '../ghostInstallReceipt'; describe('GhostInstallReceiptStore cleanup', () => { + it('accepts only a canonical random archive namespace destination', () => { + expect(isValidGhostSourceStateArchiveId('_ns__cindy-archive-00000000-0000-4000-8000-000000000002__hello')).toBe(true); + for (const value of ['hello', '../hello', '_ns__acme__hello', + 'cindy-source-00000000-0000-4000-8000-000000000002', + '_ns__cindy-archive-not-random__hello']) { + expect(isValidGhostSourceStateArchiveId(value)).toBe(false); + } + }); let workDir: string; let stateRoot: string; let store: GhostInstallReceiptStore; diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostIpcMutationLease.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostIpcMutationLease.test.ts index f206877f0cd..0b498abd9ac 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/ghostIpcMutationLease.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostIpcMutationLease.test.ts @@ -61,13 +61,24 @@ describe('ghost 写路径 IPC 的 owner 租约(源码契约)', () => { const outerStart = source.indexOf('export async function uninstallGhostAndCleanup'); expect(outerStart).toBeGreaterThan(-1); const outer = source.slice(outerStart, source.indexOf('\n}', outerStart)); - expect(outer).toContain('withGhostInstallLock('); + expect(outer).toContain('withGhostInstallLock(identity.ghostId'); + expect(outer).toContain('installedGhostStoragePart(ghost)'); const start = source.indexOf('async function uninstallGhostAndCleanupLocked'); expect(start).toBeGreaterThan(-1); const fn = source.slice(start, source.indexOf('\n}', start)); expect(fn).toContain('beginGhostMutation('); }); + it('卸载按物理存储键清理寄存引用、近期使用及提醒,而非目录相对路径', () => { + const start = source.indexOf('async function uninstallGhostAndCleanupLocked'); + const block = source.slice(start, source.indexOf('\n}', start)); + expect(block).toContain("removeRefs({ refKind: 'ghost-deposit', refId: storagePart })"); + expect(block).toContain('forgetGhostRecentUsage(storagePart)'); + expect(block).toContain('forgetGhostRecommendations(storagePart)'); + expect(block).toContain('extinguishGhostUnread(storagePart)'); + expect(block).toContain('badgeSlotSingleton?.forget(storagePart)'); + }); + it('市场装入/更新持租约(installOrUpdateMarketGhostPackage)', () => { // 同上:外层委托 withGhostInstallLock,owner 捕获 + 起租约在 ...Locked 内。 const outerStart = source.indexOf('export async function installOrUpdateMarketGhostPackage'); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostKvStore.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostKvStore.test.ts index b35efdc19ce..20b397e1789 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/ghostKvStore.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostKvStore.test.ts @@ -50,6 +50,18 @@ describe('cindy-brain · ghostKvStore(意识自定义参数持久化)', () => { expect(fs.existsSync(path.join(root, 'beta.json'))).toBe(true); }); + it('root helper 与企业 _ns__acme__helper 落成不同文件', () => { + store.write('helper', { from: 'root' }); + store.write('_ns__acme__helper', { from: 'acme' }); + expect(store.read('helper')).toEqual({ from: 'root' }); + expect(store.read('_ns__acme__helper')).toEqual({ from: 'acme' }); + expect(fs.existsSync(path.join(root, 'helper.json'))).toBe(true); + expect(fs.existsSync(path.join(root, '_ns__acme__helper.json'))).toBe(true); + store.remove('helper'); + expect(store.read('helper')).toEqual({}); + expect(store.read('_ns__acme__helper')).toEqual({ from: 'acme' }); + }); + it('损坏 JSON → 读回 {} 且不抛', () => { fs.writeFileSync(path.join(root, 'demo.json'), '{broken', 'utf8'); expect(store.read('demo')).toEqual({}); @@ -131,7 +143,7 @@ describe('cindy-brain · ghostKvStore(意识自定义参数持久化)', () => { }); it('非法 ghostId:写抛 INVALID_GHOST_ID,读回 {},删静默——文件名安全双保险', () => { - for (const bad of ['../evil', 'UPPER', 'a/b', '']) { + for (const bad of ['../evil', 'UPPER', 'a/b', '', '_ns/acme/helper']) { expect(() => store.write(bad, { a: 1 }), bad).toThrowError(GhostKvError); expect(store.read(bad), bad).toEqual({}); expect(() => store.remove(bad), bad).not.toThrow(); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostLibraryDelete.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostLibraryDelete.test.ts new file mode 100644 index 00000000000..1a7d4969159 --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostLibraryDelete.test.ts @@ -0,0 +1,230 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createGhostProductionCallbacks } from './ghostProductionCallbacksFixture.js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ghostInstallApprovalToken, type InstalledGhost } from '../../../shared/ghost.js'; +import { + deliveryNamespaceFields, + findInstalledGhostByInstanceId, + installedGhostMutationTargetToken, + resolvePluginLibraryStorageKey, + isGhostInstanceId, +} from '../../../shared/pluginIdentity.js'; +import { GhostManager } from '../GhostManager.js'; +import { GhostMutationCoordinator } from '../ghostMutationCoordinator.js'; +import { assertLibraryMetaOwner, LibraryBindingStore, relocateLibraryMetaOwner } from '../libraryBinding.js'; +import { trashGhostLibrary } from '../libraryTrash.js'; + +function deferred() { + let resolve!: (value: Value) => void; + const promise = new Promise((finish) => { resolve = finish; }); + return { promise, resolve }; +} + +const loadDelete = createGhostProductionCallbacks<{ + handler: (event: { sender: object }, id: string) => Promise<{ ok: boolean }>; + deleteGhostLibraryForActiveOwner: (id: string) => Promise<{ ok: boolean }>; +}>({ + functions: [ + 'captureGhostMutationOwner', 'beginGhostMutation', 'ghostInstallMutationTargetFor', + 'libraryStorageKeyFor', 'ghostLibraryDeleteTargetFor', 'deleteGhostLibraryForActiveOwner', + 'deleteGhostLibraryLocked', + ], + callbacks: { handler: ['ipcMain.handle', 'ghosts:library-delete'] }, +}); + +let directory: string; + +function harness() { + const state = { owner: { mode: 'cloud', dataOwnerId: 'owner', generation: 1 }, pending: false }; + const scopeKey = () => state.owner.dataOwnerId + ':' + state.owner.generation; + const userPath = (...parts: string[]) => path.join(directory, state.owner.dataOwnerId, ...parts); + const ghost = (namespace: string | null = 'acme', revision = 'original'): InstalledGhost => ({ + manifest: { schemaVersion: 3, id: 'helper', name: namespace ? 'Organization helper' : 'Root helper', + version: '1.0.0', kind: 'chip', entry: 'main.js', library: true }, + dir: userPath('ghosts', 'helper'), namespace, enabled: true, + approval: { state: 'approved', revision }, + }); + let ghosts = [ghost()]; + const coordinator = new GhostMutationCoordinator(); + const manager = new GhostManager({ getRootDir: () => userPath('ghosts'), getOwnerContextKey: scopeKey }); + const binding = new LibraryBindingStore({ + getFile: () => userPath('libraries-binding.json'), getManagedRoots: () => [], + getDefaultRoot: (id) => userPath('libraries', id), + }); + const removeBinding = vi.spyOn(binding, 'removeBinding'); + const disposeGhost = vi.fn(async () => {}); + const checkMeta = vi.fn(assertLibraryMetaOwner); + const dialog = deferred<{ response: number }>(); + const showMessageBox = vi.fn(() => dialog.promise); + const setRelocating = vi.fn(); + const api = loadDelete({ + path, ghostInstallApprovalToken, deliveryNamespaceFields, installedGhostMutationTargetToken, + resolvePluginLibraryStorageKey, isGhostInstanceId, + findGhostForInstanceId: (id: string) => findInstalledGhostByInstanceId(ghosts, id) ?? null, + activeOwnerScopeKey: scopeKey, isAppSessionBoundaryPending: () => state.pending, + getActiveAppSession: () => ({ ...state.owner }), ghostMutationCoordinator: coordinator, + getGhostManager: () => manager, getGhostLibrarySlot: () => ({ disposeGhost, setRelocating }), + getGhostLibraryBindingStore: () => binding, assertLibraryMetaOwner: checkMeta, + ownerScopedUserDataPath: userPath, trashGhostLibrary, + refreshMivoLibraryExtraDirGrant: async () => {}, assertTrustedAppRendererEvent: () => {}, + throwIpcError: (code: string, message: string) => { throw new Error(code + ': ' + message); }, + BrowserWindow: { fromWebContents: () => ({}) }, dialog: { showMessageBox }, + t: (key: string) => key, log: { info: vi.fn(), warn: vi.fn() }, + }); + const createLibrary = (id = 'helper', contents = 'organization data') => { + const root = userPath('libraries', id); + fs.mkdirSync(path.join(root, '.cindy-library'), { recursive: true }); + fs.writeFileSync(path.join(root, '.cindy-library', 'meta.json'), JSON.stringify({ version: 1, ghostId: id, createdAt: 1 })); + fs.writeFileSync(path.join(root, 'data.txt'), contents); + return root; + }; + const root = createLibrary(); + return { ...api, state, ghost, manager, binding, removeBinding, disposeGhost, checkMeta, + dialog, showMessageBox, setRelocating, coordinator, createLibrary, root, userPath, + setGhosts: (next: InstalledGhost[]) => { ghosts = next; }, + invoke: () => api.handler({ sender: {} }, 'helper'), + unchanged: () => { + expect(fs.readFileSync(path.join(root, 'data.txt'), 'utf8')).toBe('organization data'); + expect(removeBinding).not.toHaveBeenCalled(); + expect(fs.existsSync(userPath('libraries-trash'))).toBe(false); + }, + }; +} + +beforeEach(() => { directory = fs.mkdtempSync(path.join(os.tmpdir(), 'cindy-library-delete-')); }); +afterEach(() => { vi.restoreAllMocks(); fs.rmSync(directory, { recursive: true, force: true }); }); + +describe('Library delete Host confirmation', () => { + it('does not apply organization confirmation to a root that reuses its physical key', async () => { + const run = harness(); + const deleting = run.invoke(); + expect(run.showMessageBox.mock.calls[0]).toBeDefined(); + const orgId = '_ns__acme__helper'; + fs.renameSync(run.root, run.userPath('libraries', orgId)); + await relocateLibraryMetaOwner(run.userPath('libraries', orgId), 'helper', orgId); + const organization = { ...run.ghost(), dir: run.userPath('ghosts', '_ns', 'acme', 'helper') }; + run.setGhosts([run.ghost(null, 'new-root'), organization]); + run.createLibrary('helper', 'root data'); + run.dialog.resolve({ response: 0 }); + await expect(deleting).rejects.toThrow(/重新确认/); + expect(fs.readFileSync(path.join(run.root, 'data.txt'), 'utf8')).toBe('root data'); + expect(fs.readFileSync(run.userPath('libraries', orgId, 'data.txt'), 'utf8')).toBe('organization data'); + expect(run.disposeGhost).not.toHaveBeenCalled(); + expect(run.removeBinding).not.toHaveBeenCalled(); + }); + + it.each(['receipt', 'owner', 'boundary'] as const)('rejects a stale %s after confirmation', async (change) => { + const run = harness(); + const deleting = run.invoke(); + if (change === 'receipt') run.setGhosts([run.ghost('acme', 'replacement')]); + if (change === 'owner') run.state.owner = { ...run.state.owner, generation: 2 }; + if (change === 'boundary') run.state.pending = true; + run.dialog.resolve({ response: 0 }); + await expect(deleting).rejects.toThrow(); + run.unchanged(); + await run.coordinator.waitForIdle(); + }); + + it('revalidates the target after waiting for the existing mutation lane', async () => { + const run = harness(); + const entered = deferred(); + const release = deferred(); + const competing = run.manager.runExclusiveMutation(async () => { entered.resolve(); await release.promise; }); + await entered.promise; + const deleting = run.invoke(); + run.dialog.resolve({ response: 0 }); + await Promise.resolve(); + run.setGhosts([run.ghost(null, 'replacement')]); + release.resolve(); + await competing; + await expect(deleting).rejects.toThrow(/重新确认/); + run.unchanged(); + }); + + it.each(['dispose', 'binding', 'meta'] as const)('revalidates after the delete helper awaits %s', async (stage) => { + const run = harness(); + const entered = deferred(); + const release = deferred(); + const pause = async () => { entered.resolve(); await release.promise; }; + if (stage === 'dispose') run.disposeGhost.mockImplementationOnce(pause); + if (stage === 'binding') vi.spyOn(run.binding, 'resolveLibraryRoot').mockImplementationOnce(async () => { + await pause(); return { kind: 'default', root: run.root }; + }); + if (stage === 'meta') run.checkMeta.mockImplementationOnce(async (root, id) => { + await assertLibraryMetaOwner(root, id); await pause(); + }); + const deleting = run.invoke(); + run.dialog.resolve({ response: 0 }); + await entered.promise; + run.setGhosts([run.ghost(null, 'replacement')]); + release.resolve(); + await expect(deleting).rejects.toThrow(/重新确认/); + run.unchanged(); + expect(run.setRelocating).toHaveBeenLastCalledWith('helper', false); + }); + + it('keeps installed-instance mutations queued throughout the trash filesystem awaits', async () => { + const run = harness(); + const entered = deferred(); + const release = deferred(); + const rename = fs.promises.rename.bind(fs.promises); + vi.spyOn(fs.promises, 'rename').mockImplementation(async (...args) => { + if (args[0] === run.root) { entered.resolve(); await release.promise; } + return rename(...args); + }); + const deleting = run.invoke(); + run.dialog.resolve({ response: 0 }); + await entered.promise; + const mutate = vi.fn(() => run.setGhosts([run.ghost(null, 'replacement')])); + const competing = run.manager.runExclusiveMutation(async () => { mutate(); }); + await Promise.resolve(); + expect(mutate).not.toHaveBeenCalled(); + release.resolve(); + await expect(deleting).resolves.toEqual({ ok: true }); + await competing; + expect(mutate).toHaveBeenCalledOnce(); + expect(run.removeBinding).toHaveBeenCalledWith('helper'); + }); + + it.each(['approved', 'invalid', 'legacy-unapproved', 'orphan'] as const)('retains cleanup of a stable %s Library', async (approval) => { + const run = harness(); + if (approval === 'orphan') run.setGhosts([]); + else if (approval !== 'approved') run.setGhosts([{ ...run.ghost(), approval: { state: approval } }]); + const deleting = run.invoke(); + run.dialog.resolve({ response: 0 }); + await expect(deleting).resolves.toEqual({ ok: true }); + expect(fs.existsSync(run.root)).toBe(false); + const names = fs.readdirSync(run.userPath('libraries-trash')); + expect(fs.readFileSync(run.userPath('libraries-trash', names[0], 'data.txt'), 'utf8')).toBe('organization data'); + expect(run.removeBinding).toHaveBeenCalledWith('helper'); + }); + + it.each(['invalid', 'legacy-unapproved', 'orphan'] as const)('does not apply stale %s cleanup to a newly installed root', async (approval) => { + const run = harness(); + run.setGhosts(approval === 'orphan' ? [] : [{ ...run.ghost(), approval: { state: approval } }]); + const deleting = run.invoke(); + run.setGhosts([run.ghost(null, 'replacement')]); + run.dialog.resolve({ response: 0 }); + await expect(deleting).rejects.toThrow(/重新确认/); + run.unchanged(); + }); + + it('supports direct orphan cleanup without an approval requirement', async () => { + const run = harness(); + run.setGhosts([]); + await expect(run.deleteGhostLibraryForActiveOwner('helper')).resolves.toEqual({ ok: true }); + expect(fs.existsSync(run.root)).toBe(false); + }); + + it('does not start cleanup when the user cancels', async () => { + const run = harness(); + const deleting = run.invoke(); + run.dialog.resolve({ response: 1 }); + await expect(deleting).resolves.toEqual({ ok: false, cancelled: true }); + expect(run.disposeGhost).not.toHaveBeenCalled(); + run.unchanged(); + }); +}); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostLocalFileIpc.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostLocalFileIpc.test.ts new file mode 100644 index 00000000000..92ebba8c86f --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostLocalFileIpc.test.ts @@ -0,0 +1,526 @@ +import { createHash } from 'node:crypto'; +import { createGhostProductionCallbacks } from './ghostProductionCallbacksFixture.js'; +import { describe, expect, it, vi } from 'vitest'; +import { + ghostInstallApprovalToken, + isGhostInstallApprovalToken, + type InstalledGhost, + type GhostTrustInfo, +} from '../../../shared/ghost.js'; +import type { PluginMarketInstallationRecord } from '../../plugin-market/ledger.js'; +import { + deliveryNamespaceFields, + findInstalledGhostByInstanceId, + findInstalledGhostForLocalUpdate, + installedGhostStoragePart, + isGhostInstanceId, + resolveInstalledGhost, +} from '../../../shared/pluginIdentity.js'; +import { loadGhostFirstPartyFactsLoader } from '../ghostFirstPartyFacts.js'; +import { authorizeGhostTokenBroker } from '../ghostFirstPartyPrivilege.js'; +import { createGhostInstallReceipt, type GhostInstallReceipt } from '../ghostInstallReceipt.js'; +import { classifyGhostLocalUpdateSource } from '../ghostLocalUpdateSource.js'; +import { isCindyOverrideModelAllowed } from '../cindyOverrideWhitelist.js'; + +type Handler = (event: unknown, ...args: unknown[]) => Promise; + +const callbackChannels = { + inspect: 'ghosts:inspect', update: 'ghosts:update', exportGhost: 'ghosts:export', + cindyPrefs: 'ghosts:cindy-prefs:set', errandPrefs: 'ghosts:errand-prefs:set', +}; +const createCallbacks = createGhostProductionCallbacks>({ + functions: [ + 'readLocalGhostUpdateSource', 'updateLocalGhostPackageLocked', 'rejectUnauthorizedTokenBroker', + 'assertGhostRelocationIdle', 'findGhostForInstanceId', + ], + callbacks: Object.fromEntries(Object.entries(callbackChannels).map(([name, channel]) => + [name, ['ipcMain.handle', channel] as const], + )), +}); + +function productionCallbacks(deps: Record): Record { + const callbacks = createCallbacks(deps); + return Object.fromEntries(Object.entries(callbackChannels).map(([name, channel]) => + [channel, callbacks[name as keyof typeof callbacks]], + )); +} + +const packageSha256 = 'a'.repeat(64); +const originalRevision = '11111111-1111-4111-8111-111111111111'; +const replacementRevision = '22222222-2222-4222-8222-222222222222'; +const unsigned: GhostTrustInfo = { + level: 'unverified', + publisherSigned: false, + publisherVerified: false, + reviewed: false, +}; +const signed: GhostTrustInfo = { + level: 'verified-publisher', + publisherSigned: true, + publisherVerified: true, + reviewed: false, + publisherKeyId: 'original-publisher', +}; + +function harness( + options: { + broker?: boolean; + namespace?: string | null; + approval?: 'approved' | 'invalid' | 'legacy-unapproved'; + missingReceipt?: boolean; + fsBusy?: boolean; + } = {}, +) { + const manifest: InstalledGhost['manifest'] = { + schemaVersion: 2, + id: 'filo-local', + name: 'Local', + version: '1.0.0', + kind: 'chip', + entry: 'main.js', + ...(options.broker + ? { + network: { + hosts: ['api.example.com'], + secrets: [ + { + key: 'oauth', + source: 'oauth', + label: 'OAuth', + inject: { + header: 'Authorization', + format: 'Bearer {value}', + hosts: ['api.example.com'], + }, + oauth: { + authorizeUrl: 'https://api.example.com/auth', + tokenUrl: 'https://api.example.com/token', + scopes: [], + tokenBroker: 'feishu', + redirectPort: 8123, + }, + }, + ], + }, + } + : {}), + }; + const ghost: InstalledGhost = { + manifest, + namespace: options.namespace ?? null, + dir: options.namespace + ? '/plugins/_ns/' + options.namespace + '/filo-local' + : '/plugins/filo-local', + enabled: true, + trust: unsigned, + approval: + options.approval && options.approval !== 'approved' + ? { state: options.approval } + : { state: 'approved', revision: originalRevision }, + }; + const installed = new Map([[installedGhostStoragePart(ghost), ghost]]); + const receipts = new Map(); + const marketRecords = new Map(); + if (options.namespace) + marketRecords.set(installedGhostStoragePart(ghost), { + pluginId: 'organization-resource', + ghostId: manifest.id, + namespace: options.namespace, + releaseId: 'original-release', + version: manifest.version, + scope: 'organization', + organizationId: 'org-xd', + source: 'market', + installed: true, + sha256: packageSha256, + updatedAt: '2026-09-30T00:00:00Z', + }); + if (ghost.approval.state === 'approved' && !options.missingReceipt) + receipts.set( + installedGhostStoragePart(ghost), + createGhostInstallReceipt({ + manifest, + namespace: ghost.namespace, + localeResources: {}, + enabled: true, + trust: unsigned, + revision: ghost.approval.revision, + skillContentSha256: {}, + packageSha256, + legacyFirstPartyEligible: options.broker === true && !options.namespace, + }), + ); + let inspected = { + manifest, + canonicalManifest: manifest, + trust: unsigned, + packageSha256, + unsupportedLegacySlots: [], + }; + const ledger = { + bind: () => ledger, + installationForPlugin: vi.fn(() => null), + markRemovedRecord: vi.fn(), + restoreInstallation: vi.fn(), + }; + const manager = { + list: () => [...installed.values()], + inspect: vi.fn(async () => inspected), + readApprovedInstallReceipt: vi.fn((id: string, revision: string) => + receipts.get(id)?.revision === revision ? receipts.get(id) : null, + ), + update: vi.fn(async (_path: string, updateOptions: { onPackagePlaced?: () => void }) => { + updateOptions.onPackagePlaced?.(); + return { ghost: { ...ghost, approval: { state: 'approved', revision: 'new-receipt' } } }; + }), + }; + const loader = loadGhostFirstPartyFactsLoader({ + readInstalledBuiltin: () => false, + readMarketInstallation: (id) => marketRecords.get(id) ?? null, + readApprovedPackageSha256: (id) => { + const target = findInstalledGhostByInstanceId(manager.list(), id); + return receipts.get(target ? installedGhostStoragePart(target) : id)?.packageSha256 ?? null; + }, + readInstallNamespace: (id) => findInstalledGhostByInstanceId(manager.list(), id)?.namespace, + readInstallOrigin: () => 'manual', + readLegacyFirstPartyEligible: (id) => receipts.get(id)?.legacyFirstPartyEligible === true, + lookupOrganizationPrefix: () => ({ kind: 'absent' }), + }); + const runtime = { stop: vi.fn(), resetFuse: vi.fn() }; + const release = vi.fn(); + const session = { mode: 'local', dataOwnerId: 'owner', generation: 1 }; + let exportMutation: (() => void) | undefined; + const exportBytes = Buffer.from('host-produced-export-snapshot'); + const exportDigest = createHash('sha256').update(exportBytes).digest('hex'); + const exportPackage = vi.fn( + async ( + _id: string, + deps: { + listInstalled: () => InstalledGhost[]; + writeFile: (path: string, bytes: Buffer) => Promise; + inspectPackage: (path: string) => Promise; + }, + ) => { + expect(deps.listInstalled().some((candidate) => candidate.dir === ghost.dir)).toBe(true); + await deps.writeFile('/exports/snapshot.tmp', exportBytes); + inspected = { ...inspected, packageSha256: exportDigest }; + exportMutation?.(); + return (await deps.inspectPackage('/exports/snapshot.tmp')) + ? { status: 'saved', savedPath: '/exports/plugin.cindy' } + : { status: 'error', code: 'verify_failed' }; + }, + ); + const callbacks = productionCallbacks({ + manager, + assertTrustedAppRendererEvent: vi.fn(), + captureGhostMutationOwner: () => ({ ...session }), + beginGhostMutation: () => release, + getActiveAppSession: () => session, + isAppSessionBoundaryPending: () => false, + isSameAppSession: (left: typeof session, right: typeof session) => + left.generation === right.generation && left.dataOwnerId === right.dataOwnerId, + ghostOwnerScope: { + isStable: (owner: typeof session) => owner.generation === session.generation, + }, + throwIpcError: (code: string, message: string) => { + throw Object.assign(new Error(message), { code }); + }, + throwInstallError: (rejection: unknown) => { + throw new Error(JSON.stringify(rejection)); + }, + rejectReservedGhostId: vi.fn(), + rejectBrokerWithoutDeclaredRedirectPort: vi.fn(), + ghostTokenBrokerInstallError: () => ({ code: 'PERMISSION_DENIED', reason: 'Broker denied' }), + isGhostTokenBrokerAuthorized: ( + id: string, + purpose: 'runtime' | 'install', + overrides: Parameters[3], + ) => + authorizeGhostTokenBroker( + id, + loader.load( + id, + purpose, + options.namespace + ? { membershipKind: 'org', orgId: 'org-xd', orgSlug: options.namespace } + : { membershipKind: 'personal', orgId: null }, + overrides, + ), + ), + findInstalledGhostForLocalUpdate, + findInstalledGhostByInstanceId, + installedGhostStoragePart, + resolveInstalledGhost, + availableGhosts: () => Array.from(installed.values()), + CINDY_CAPABILITY_KEYS: ['image.generate'], + isCindyOverrideModelAllowed, + getGhostMediaPreferenceConfig: () => ({ models: [] }), + getCatalogEmbedConfig: () => ({ models: [] }), + buildTextOneshotPinOptions: () => [], + getActiveCatalog: () => ({}), + readModelDisableOverrides: () => ({}), + writeGhostCindyOverride: vi.fn((id: string) => ({ id })), + writeGhostErrandConfig: vi.fn((id: string) => ({ id })), + getGhostSetupChangeBus: () => ({ emit: vi.fn() }), + ghostInstallApprovalToken, + isGhostInstanceId, + isGhostInstallApprovalToken, + deliveryNamespaceFields, + classifyGhostLocalUpdateSource, + getPluginMarketLedger: () => ledger, + ownerScopedUserDataPath: () => '/owner/ledger.json', + obtainGhostInstallConsent: vi.fn(async () => ({ action: 'install' })), + createWindowGhostInstallConsentPrompt: vi.fn(), + assertGhostInstallConsent: vi.fn(), + withGhostInstallLock: async (_id: string, task: () => unknown) => task(), + withActiveOwnerGhostOauthMutationLock: async (_id: string, task: () => unknown) => task(), + getGhostRuntime: () => runtime, + hasPendingGhostCalls: () => false, + hasRunningGhostErrand: () => false, + hasRunningGhostCindyWork: () => false, + fsSlotSingleton: { hasInFlightRequests: () => options.fsBusy === true }, + getGhostNodeRuntimeBroker: () => ({ stopAndWait: vi.fn() }), + getGhostAgentSlot: () => ({ clearGhost: vi.fn() }), + getGhostErrandSlot: () => ({ clearGhost: vi.fn() }), + getGhostOauthAccountManager: () => ({ prepareAccountsForChangedClients: vi.fn() }), + withRuntimeFiloGoogleClient: (value: unknown) => value, + ghostSourceStateArchiveId: () => '_ns__cindy-archive-test__filo-local', + getLayoutStore: () => ({ getLayout: () => ({}), setLayout: vi.fn() }), + layoutWithGhostPanel: () => null, + spawnIfResident: vi.fn(), + exportGhostPackage: exportPackage, + BrowserWindow: { fromWebContents: () => null }, + dialog: { showSaveDialog: vi.fn() }, + app: { getPath: () => '/exports' }, + t: (key: string) => key, + fs: { promises: { writeFile: vi.fn() } }, + createHash, + findConflictingGhostCommand: () => null, + log: { warn: vi.fn(), info: vi.fn(), error: vi.fn() }, + }); + const target = { + expectedInstalledInstanceId: installedGhostStoragePart(ghost), + expectedInstalledApproval: ghostInstallApprovalToken(ghost.approval), + }; + return { + callbacks, + ghost, + manager, + receipts, + installed, + target, + runtime, + release, + session, + replacePackage: (values: Partial) => { + inspected = { ...inspected, ...values }; + }, + mutateExport: (mutation: () => void) => { + exportMutation = mutation; + }, + }; +} + +describe('local file recovery and source gates through production IPC callbacks', () => { + it.each(['invalid', 'legacy-unapproved'] as const)( + 'repairs %s receipt without inheriting source state', + async (approval) => { + const target = harness({ approval }); + await expect( + target.callbacks['ghosts:update']({}, '/local.cindy', { + ...target.target, + expectedPackageSha256: packageSha256, + }), + ).resolves.toMatchObject({ ghost: { approval: { state: 'approved' } } }); + expect(target.manager.update).toHaveBeenCalledWith( + '/local.cindy', + expect.objectContaining({ sourceStateArchiveId: '_ns__cindy-archive-test__filo-local' }), + ); + expect(target.release).toHaveBeenCalledOnce(); + }, + ); + it('treats missing approved receipt as source replacement, not inherited approval', async () => { + const target = harness({ missingReceipt: true }); + await expect( + target.callbacks['ghosts:update']({}, '/local.cindy', { + ...target.target, + expectedPackageSha256: packageSha256, + }), + ).resolves.toBeTruthy(); + expect(target.manager.update).toHaveBeenCalledWith( + '/local.cindy', + expect.objectContaining({ sourceStateArchiveId: '_ns__cindy-archive-test__filo-local' }), + ); + }); + it.each(['invalid', 'legacy-unapproved'] as const)( + 'cannot restore legacy Broker qualification from %s receipt', + async (approval) => { + const target = harness({ approval, broker: true }); + await expect( + target.callbacks['ghosts:update']({}, '/local.cindy', { + ...target.target, + expectedPackageSha256: packageSha256, + }), + ).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); + expect(target.manager.update).not.toHaveBeenCalled(); + expect(target.runtime.stop).not.toHaveBeenCalled(); + }, + ); + it('inspects an exact same-source legacy Broker package for the selected receiver', async () => { + const target = harness({ broker: true }); + await expect( + target.callbacks['ghosts:inspect']({}, '/local.cindy', target.target), + ).resolves.toMatchObject({ packageSha256 }); + }); + it('inspects a verified update signed by the previously pinned publisher', async () => { + const target = harness({ broker: true }); + target.receipts.get('filo-local')!.trust = signed; + target.replacePackage({ trust: signed, packageSha256: 'b'.repeat(64) }); + await expect( + target.callbacks['ghosts:inspect']({}, '/local.cindy', target.target), + ).resolves.toMatchObject({ packageSha256: 'b'.repeat(64) }); + }); + it.each([undefined, 'target'])( + 'rejects a new unsigned same-name Broker package with context %s', + async (context) => { + const target = harness({ broker: true }); + target.replacePackage({ packageSha256: 'b'.repeat(64) }); + await expect( + target.callbacks['ghosts:inspect']({}, '/local.cindy', context ? target.target : undefined), + ).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); + }, + ); + it('does not silently replace a mismatched inspect receiver', async () => { + const target = harness(); + await expect( + target.callbacks['ghosts:inspect']({}, '/local.cindy', { + ...target.target, + expectedInstalledApproval: 'approved:' + replacementRevision, + }), + ).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }); + }); + it('keeps old one-argument inspect working for an ordinary package', async () => { + await expect(harness().callbacks['ghosts:inspect']({}, '/local.cindy')).resolves.toMatchObject({ + packageSha256, + }); + }); + it('exports the source-qualified legacy snapshot instead of treating it as a fresh install', async () => { + const target = harness({ broker: true }); + await expect(target.callbacks['ghosts:export']({}, 'filo-local')).resolves.toMatchObject({ + status: 'saved', + }); + }); + it('does not export a Broker merely because an ordinary root has the same name', async () => { + const target = harness({ broker: true }); + target.receipts.get('filo-local')!.legacyFirstPartyEligible = false; + await expect(target.callbacks['ghosts:export']({}, 'filo-local')).rejects.toMatchObject({ + code: 'INTERNAL', + }); + }); + it('rejects export after the selected receipt changes', async () => { + const target = harness(); + target.mutateExport(() => + target.installed.set('filo-local', { + ...target.ghost, + approval: { state: 'approved', revision: 'replacement' }, + }), + ); + await expect(target.callbacks['ghosts:export']({}, 'filo-local')).rejects.toMatchObject({ + code: 'INTERNAL', + }); + }); + it('rejects export bytes replaced after the Host wrote its snapshot', async () => { + const target = harness(); + target.mutateExport(() => target.replacePackage({ packageSha256: 'b'.repeat(64) })); + await expect(target.callbacks['ghosts:export']({}, 'filo-local')).rejects.toMatchObject({ + code: 'INTERNAL', + }); + }); + + it('exports the qualified organization instance without borrowing a same-name root', async () => { + const target = harness({ broker: true, namespace: 'xd' }); + target.installed.set('filo-local', { + ...target.ghost, + namespace: null, + dir: '/plugins/filo-local', + }); + await expect( + target.callbacks['ghosts:export']({}, target.target.expectedInstalledInstanceId), + ).resolves.toMatchObject({ status: 'saved' }); + }); + + it('rejects source replacement before stopping the runtime while an FS request is in flight', async () => { + const target = harness({ approval: 'invalid', fsBusy: true }); + await expect(target.callbacks['ghosts:update']({}, '/local.cindy', { + ...target.target, expectedPackageSha256: packageSha256, + })).rejects.toThrow('waiting for active work'); + expect(target.runtime.stop).not.toHaveBeenCalled(); + expect(target.manager.update).not.toHaveBeenCalled(); + expect(target.release).toHaveBeenCalledOnce(); + }); + + it.each(['cindy', 'errand'])('binds %s preferences to the current physical instance and rejects missing targets', async (kind) => { + const target = harness({ namespace: 'xd', approval: 'invalid' }); + const handler = target.callbacks['ghosts:' + kind + '-prefs:set']; + const args = kind === 'cindy' ? ['image.generate', null] : [null]; + expect(await handler({}, 'filo-local', ...args)).toMatchObject( + kind === 'cindy' ? { overrides: { id: '_ns__xd__filo-local' } } : { config: { id: '_ns__xd__filo-local' } }, + ); + target.installed.clear(); + await expect(Promise.resolve().then(() => handler({}, 'filo-local', ...args))).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('updates the selected namespace instance rather than a same-name root', async () => { + const target = harness({ namespace: 'xd', approval: 'invalid' }); + target.installed.set('filo-local', { + ...target.ghost, + namespace: null, + dir: '/plugins/filo-local', + }); + await target.callbacks['ghosts:update']({}, '/local.cindy', { + ...target.target, + expectedPackageSha256: packageSha256, + }); + expect(target.manager.update).toHaveBeenCalledWith( + '/local.cindy', + expect.objectContaining({ + namespace: 'xd', + sourceStateArchiveId: '_ns__cindy-archive-test__filo-local', + }), + ); + }); + + it.each([ + null, + {}, + { legacyFirstPartyEligible: true }, + { expectedInstalledInstanceId: 'filo-local' }, + ])('rejects unbound inspect options %j', async (options) => { + await expect( + harness({ broker: true }).callbacks['ghosts:inspect']({}, '/local.cindy', options), + ).rejects.toMatchObject({ code: 'INVALID_PARAMS' }); + }); + + it('cancels targeted inspect when the owner changes while inspecting bytes', async () => { + const target = harness({ broker: true }); + const original = target.manager.inspect.getMockImplementation()!; + target.manager.inspect.mockImplementation(async () => { + const result = await original(); + target.session.generation += 1; + return result; + }); + await expect( + target.callbacks['ghosts:inspect']({}, '/local.cindy', target.target), + ).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }); + }); + + it('cancels export when the owner changes after the snapshot is captured', async () => { + const target = harness(); + target.mutateExport(() => { + target.session.generation += 1; + }); + await expect(target.callbacks['ghosts:export']({}, 'filo-local')).rejects.toMatchObject({ + code: 'INTERNAL', + }); + }); +}); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostLocalUpdateSource.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostLocalUpdateSource.test.ts new file mode 100644 index 00000000000..1c97c0003b5 --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostLocalUpdateSource.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from 'vitest'; +import type { GhostTrustInfo } from '../../../shared/ghost.js'; +import { resolveGhostFirstPartyPrivilege } from '../ghostFirstPartyPrivilege.js'; +import { + classifyGhostLocalUpdateSource, + type GhostLocalUpdateSourceInput, +} from '../ghostLocalUpdateSource.js'; + +const OLD_SHA = 'a'.repeat(64); +const NEW_SHA = 'b'.repeat(64); +const UNSIGNED: GhostTrustInfo = { + level: 'unverified', publisherSigned: false, publisherVerified: false, reviewed: false, +}; +const OFFICIAL: GhostTrustInfo = { + level: 'cindy-official', publisherSigned: true, publisherVerified: true, + reviewed: true, publisherName: 'Cindy Plugin Market', +}; +const SIGNED: GhostTrustInfo = { + level: 'verified-publisher', publisherSigned: true, publisherVerified: true, + reviewed: false, publisherKeyId: 'ed25519:' + 'c'.repeat(32), +}; + +function input(): GhostLocalUpdateSourceInput { + return { + existingSourceChanged: false, + previousApprovedReceipt: { + id: 'filo-google', namespace: null, packageSha256: OLD_SHA, + trust: OFFICIAL, legacyFirstPartyEligible: true, + }, + inspectedPackage: { + ghostId: 'filo-google', namespace: null, packageSha256: NEW_SHA, trust: UNSIGNED, + }, + }; +} + +describe('local update provenance independent of legacy privileges', () => { + it('only feeds the install gate a legacy override after proving package continuity', () => { + for (const packageSha256 of [OLD_SHA, NEW_SHA]) { + const facts = input(); + facts.inspectedPackage.packageSha256 = packageSha256; + const decision = classifyGhostLocalUpdateSource(facts); + const privilege = resolveGhostFirstPartyPrivilege({ + ghostId: 'filo-google', namespace: null, builtin: false, marketRecord: null, + currentOrganization: null, installOrigin: 'manual', + legacyFirstPartyEligible: decision.legacyFirstPartyEligible, + }); + expect(privilege.brokerEligible).toBe(packageSha256 === OLD_SHA); + expect(privilege.hostPrimitiveEligible).toBe(packageSha256 === OLD_SHA); + } + }); + + it('isolates a ledgerless retired official instance from a new same-ID unsigned manual package', () => { + expect(classifyGhostLocalUpdateSource(input())).toEqual({ + sourceChanged: true, legacyFirstPartyEligible: false, + }); + }); + + it('protects Host-official receipts even before a legacy qualification was captured', () => { + const facts = input(); + facts.previousApprovedReceipt!.legacyFirstPartyEligible = false; + expect(classifyGhostLocalUpdateSource(facts).sourceChanged).toBe(true); + }); + + it('protects captured public legacy qualification without Host-official trust', () => { + const facts = input(); + facts.previousApprovedReceipt!.trust = UNSIGNED; + expect(classifyGhostLocalUpdateSource(facts).sourceChanged).toBe(true); + }); + + it('retains the old qualification for the exact approved package bytes', () => { + const facts = input(); + facts.inspectedPackage.packageSha256 = OLD_SHA; + expect(classifyGhostLocalUpdateSource(facts)).toEqual({ + sourceChanged: false, legacyFirstPartyEligible: true, + }); + }); + + it.each(['other-id', 'other-namespace'])('does not accept exact bytes with %s', (change) => { + const facts = input(); + facts.inspectedPackage.packageSha256 = OLD_SHA; + if (change === 'other-id') facts.inspectedPackage.ghostId = 'filo-other'; + else facts.inspectedPackage.namespace = 'xd'; + expect(classifyGhostLocalUpdateSource(facts).sourceChanged).toBe(true); + }); + + it('does not treat an arbitrary new verified publisher or official-looking display name as continuity', () => { + const facts = input(); + facts.inspectedPackage.trust = { ...SIGNED, publisherName: 'Cindy Plugin Market' }; + expect(classifyGhostLocalUpdateSource(facts).sourceChanged).toBe(true); + }); + + it('accepts a newly inspected signature from the previously pinned verified publisher key', () => { + const facts = input(); + facts.previousApprovedReceipt!.trust = SIGNED; + facts.inspectedPackage.trust = { ...SIGNED, publisherName: 'renamed publisher' }; + expect(classifyGhostLocalUpdateSource(facts)).toEqual({ + sourceChanged: false, legacyFirstPartyEligible: true, + }); + }); + + it.each(['other-key', 'unsigned', 'unverified', 'missing-key', 'unverified-old-key'])( + 'does not carry credentials or legacy qualification across %s', (change) => { + const facts = input(); + facts.previousApprovedReceipt!.trust = { ...SIGNED }; + facts.inspectedPackage.trust = { ...SIGNED }; + if (change === 'other-key') facts.inspectedPackage.trust.publisherKeyId = 'another-key'; + if (change === 'unsigned') facts.inspectedPackage.trust.publisherSigned = false; + if (change === 'unverified') facts.inspectedPackage.trust.publisherVerified = false; + if (change === 'missing-key') delete facts.inspectedPackage.trust.publisherKeyId; + if (change === 'unverified-old-key') facts.previousApprovedReceipt!.trust.publisherVerified = false; + expect(classifyGhostLocalUpdateSource(facts)).toEqual({ + sourceChanged: true, legacyFirstPartyEligible: false, + }); + }, + ); + + it('accepts exact new bytes verified by the Host for the same current source resource', () => { + const facts = input(); + facts.currentTrustedMarketSource = { + resourceId: 'market-resource-1', ghostId: 'filo-google', namespace: null, + }; + facts.hostVerifiedRelease = { ...facts.currentTrustedMarketSource, packageSha256: NEW_SHA }; + expect(classifyGhostLocalUpdateSource(facts)).toEqual({ + sourceChanged: false, legacyFirstPartyEligible: true, + }); + }); + + it.each(['no-current-source', 'other-resource', 'other-id', 'other-namespace', 'other-sha', 'empty-resource'])( + 'rejects cached source evidence with %s', (change) => { + const facts = input(); + facts.currentTrustedMarketSource = { + resourceId: 'market-resource-1', ghostId: 'filo-google', namespace: null, + }; + facts.hostVerifiedRelease = { ...facts.currentTrustedMarketSource, packageSha256: NEW_SHA }; + if (change === 'no-current-source') facts.currentTrustedMarketSource = null; + if (change === 'other-resource') facts.hostVerifiedRelease.resourceId = 'market-resource-2'; + if (change === 'other-id') facts.hostVerifiedRelease.ghostId = 'filo-other'; + if (change === 'other-namespace') facts.hostVerifiedRelease.namespace = 'xd'; + if (change === 'other-sha') facts.hostVerifiedRelease.packageSha256 = OLD_SHA; + if (change === 'empty-resource') { + facts.currentTrustedMarketSource!.resourceId = ''; + facts.hostVerifiedRelease.resourceId = ''; + } + expect(classifyGhostLocalUpdateSource(facts).sourceChanged).toBe(true); + }, + ); + + it('never overrides an already established market-route or origin change, even with identical bytes', () => { + const facts = input(); + facts.existingSourceChanged = true; + facts.inspectedPackage.packageSha256 = OLD_SHA; + expect(classifyGhostLocalUpdateSource(facts)).toEqual({ + sourceChanged: true, legacyFirstPartyEligible: false, + }); + }); + + it.each(['missing-receipt', 'missing-old-hash', 'invalid-old-hash', 'invalid-new-hash'])( + 'fails closed for %s', (change) => { + const facts = input(); + facts.inspectedPackage.packageSha256 = OLD_SHA; + if (change === 'missing-receipt') facts.previousApprovedReceipt = null; + if (change === 'missing-old-hash') delete facts.previousApprovedReceipt!.packageSha256; + if (change === 'invalid-old-hash') facts.previousApprovedReceipt!.packageSha256 = 'invalid'; + if (change === 'invalid-new-hash') facts.inspectedPackage.packageSha256 = 'invalid'; + expect(classifyGhostLocalUpdateSource(facts)).toEqual({ + sourceChanged: true, legacyFirstPartyEligible: false, + }); + }, + ); + + it('keeps ordinary unsigned local-to-local update semantics without granting legacy privilege', () => { + const facts = input(); + facts.previousApprovedReceipt!.trust = UNSIGNED; + facts.previousApprovedReceipt!.legacyFirstPartyEligible = false; + expect(classifyGhostLocalUpdateSource(facts)).toEqual({ + sourceChanged: false, legacyFirstPartyEligible: false, + }); + }); + + it('does not mint a legacy qualification for a new trusted publisher update', () => { + const facts = input(); + facts.previousApprovedReceipt!.trust = SIGNED; + facts.previousApprovedReceipt!.legacyFirstPartyEligible = false; + facts.inspectedPackage.trust = SIGNED; + expect(classifyGhostLocalUpdateSource(facts)).toEqual({ + sourceChanged: false, legacyFirstPartyEligible: false, + }); + }); +}); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostLocalUpdateSource.wiring.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostLocalUpdateSource.wiring.test.ts new file mode 100644 index 00000000000..e5a5c8fb3fa --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostLocalUpdateSource.wiring.test.ts @@ -0,0 +1,53 @@ +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const source = fs.readFileSync(fileURLToPath(new URL('../index.ts', import.meta.url)), 'utf8'); + +function bodyBetween(startMarker: string, endMarker: string): string { + const start = source.indexOf(startMarker); + const end = source.indexOf(endMarker, start + startMarker.length); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + return source.slice(start, end); +} + +describe('local update production source gate wiring', () => { + it('rechecks approved source and broker eligibility under the install lock before stopping the old runtime', () => { + const body = bodyBetween('async function updateLocalGhostPackageLocked(', 'export async function installOrUpdateLocalGhostPackageFromForge('); + const classify = body.indexOf('readLocalGhostUpdateSource('); + const gate = body.indexOf('rejectUnauthorizedTokenBroker(inspected.canonicalManifest, authorizationOverrides)'); + const stop = body.indexOf('runtime.stop('); + expect(classify).toBeGreaterThanOrEqual(0); + expect(gate).toBeGreaterThan(classify); + expect(stop).toBeGreaterThan(gate); + expect(body).toContain('sourceChanged ? { sourceStateArchiveId: ghostSourceStateArchiveId(previousGhost) }'); + }); + + it('never borrows the old market or builtin trust for the newly inspected local package', () => { + const body = bodyBetween('function readLocalGhostUpdateSource(', 'async function updateLocalGhostPackageLocked('); + expect(body).toContain('manager.readApprovedInstallReceipt(instanceId, previousGhost.approval.revision)'); + expect(body).toContain('classifyGhostLocalUpdateSource({'); + expect(body).toContain('packageSha256: inspected.packageSha256'); + expect(body).toContain('trust: inspected.trust'); + expect(body).toContain('marketRecord: null'); + expect(body).toContain('trustedSource: null'); + expect(body).toContain('legacyFirstPartyEligible: decision.legacyFirstPartyEligible'); + }); + + it('checks the selected local update target before computing its legacy gate override', () => { + const target = source.indexOf('const existingForUpdate = findInstalledGhostForLocalUpdate('); + const classify = source.indexOf('const updateSource = readLocalGhostUpdateSource(', target); + const gate = source.indexOf('rejectUnauthorizedTokenBroker(inspected.canonicalManifest, updateSource.authorizationOverrides)', target); + expect(target).toBeGreaterThanOrEqual(0); + expect(classify).toBeGreaterThan(target); + expect(gate).toBeGreaterThan(classify); + expect(source.slice(target, gate)).toContain("if (!existingForUpdate) throwIpcError('PRECONDITION_FAILED'"); + }); + + it('uses the same source decision for Forge updates before entering the common locked transaction', () => { + const body = bodyBetween('export async function installOrUpdateLocalGhostPackageFromForge(', 'export async function installOrUpdateMarketGhostPackage('); + expect(body).toContain('readLocalGhostUpdateSource(manager, existingForForge, inspected, installOrigin).authorizationOverrides'); + expect(body).toContain('ghost: await updateLocalGhostPackageLocked('); + }); +}); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostMediaHandoverIpc.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostMediaHandoverIpc.test.ts new file mode 100644 index 00000000000..491f40e38b2 --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostMediaHandoverIpc.test.ts @@ -0,0 +1,92 @@ +import { createGhostProductionCallbacks } from './ghostProductionCallbacksFixture.js'; +import { describe, expect, it, vi } from 'vitest'; + +import { type InstalledGhost } from '../../../shared/ghost'; +import { installedGhostStoragePart, isGhostInstanceId } from '../../../shared/pluginIdentity'; +import { ghostMediaHandoverTargetTracker, resolveGhostMediaHandoverTarget } from '../ghostMediaHandoverTargetTracker'; +import { parseGhostPanelMediaUrl, resolveGhostPanelMedia } from '../previewGate'; + +const createHandler = createGhostProductionCallbacks<{ + handler: (event: unknown, uri: unknown, purpose?: unknown, instanceId?: unknown, sourceToken?: unknown) => Promise; +}>({ callbacks: { handler: ['ipcMain.handle', 'ghosts:resolve-panel-media'] } }); + +const hash = 'a'.repeat(64); +const uri = 'cindy-ghost://helper/preview/' + hash + '.png'; + +function harness() { + const root = { manifest: { id: 'helper', version: '1.0.0' }, dir: '/plugins/helper', namespace: null, approval: { state: 'approved', revision: 'root-receipt' } } as InstalledGhost; + const organization = { ...root, dir: '/plugins/_ns/acme/helper', namespace: 'acme', approval: { state: 'approved', revision: 'acme-receipt' } } as InstalledGhost; + const installed = new Map([['helper', root], ['_ns__acme__helper', organization]]); + const ledger = { + ghostCanRead: vi.fn(async (_hash: string, instanceId: string) => instanceId === '_ns__acme__helper'), + getBlobInfo: vi.fn(async () => ({ ext: '.png', mimeType: 'image/png' })), + }; + const findAvailable = vi.fn(() => installed.size === 1 ? [...installed.values()][0] : null); + const { handler } = createHandler({ + throwIpcError: (code: string, message: string) => { throw Object.assign(new Error(message), { code }); }, + isGhostInstanceId, resolveGhostMediaHandoverTarget, parseGhostPanelMediaUrl, installedGhostStoragePart, resolveGhostPanelMedia, + findAvailableGhostForAuthorization: findAvailable, + findGhostForInstanceId: (instanceId: string) => installed.get(instanceId) ?? null, + ghostInstallMutationTargetFor: (instanceId: string) => installed.get(instanceId)?.approval.state === 'approved' + ? (installed.get(instanceId)?.approval as { revision: string }).revision : null, + ledger, + blobStore: { blobUrl: () => 'cindy-media://blobs/image.png', resolveHashRef: () => ({ absPath: '/blobs/image.png' }) }, + fs: { promises: { stat: async () => ({ size: 42 }) } }, + }); + const token = ghostMediaHandoverTargetTracker.register({ + ghostId: 'helper', instanceId: '_ns__acme__helper', + isCurrent: () => installed.get('_ns__acme__helper')?.approval.state === 'approved' + && (installed.get('_ns__acme__helper')?.approval as { revision: string }).revision === 'acme-receipt', + }); + return { handler, ledger, installed, token, findAvailable }; +} + +describe('Registered Ghost handover through the actual media IPC handler', () => { + it('selects the token-bound instance, not a same-name root or URL declaration', async () => { + const target = harness(); + try { + await expect(target.handler({}, uri, 'attach', undefined, target.token)).resolves.toMatchObject({ kind: 'image' }); + expect(target.ledger.ghostCanRead).toHaveBeenCalledExactlyOnceWith(hash, '_ns__acme__helper'); + expect(target.findAvailable).not.toHaveBeenCalled(); + } finally { ghostMediaHandoverTargetTracker.revoke(target.token); } + }); + + it.each([ + ['attach', undefined, 'unknown-token'], + ['menu', undefined, 'registered'], + ['attach', 'helper', 'registered'], + ['attach', undefined, ''], + ])('rejects %s with instance %s and token %s without a legacy fallback', async (purpose, instanceId, token) => { + const target = harness(); + try { + await expect(target.handler({}, uri, purpose, instanceId, token === 'registered' ? target.token : token)).rejects.toMatchObject({ code: 'NOT_FOUND' }); + expect(target.findAvailable).not.toHaveBeenCalled(); + expect(target.ledger.ghostCanRead).not.toHaveBeenCalled(); + } finally { ghostMediaHandoverTargetTracker.revoke(target.token); } + }); + + it.each(['token revoked', 'same-version receipt replaced'])('cancels when %s while the ledger lookup is pending', async (change) => { + const target = harness(); + let release: (granted: boolean) => void = () => {}; + target.ledger.ghostCanRead.mockImplementation(() => new Promise((resolve) => { release = resolve; })); + const pending = target.handler({}, uri, 'attach', undefined, target.token); + if (change === 'token revoked') ghostMediaHandoverTargetTracker.revoke(target.token); + else { + const installed = target.installed.get('_ns__acme__helper')!; + target.installed.set('_ns__acme__helper', { ...installed, approval: { state: 'approved', revision: 'replacement-receipt' } }); + } + release(true); + await expect(pending).rejects.toMatchObject({ code: 'NOT_FOUND' }); + ghostMediaHandoverTargetTracker.revoke(target.token); + }); + + it('preserves an old root controller without instance or source token', async () => { + const target = harness(); + try { + target.installed.delete('_ns__acme__helper'); + target.ledger.ghostCanRead.mockResolvedValue(true); + await expect(target.handler({}, uri)).resolves.toMatchObject({ kind: 'image' }); + expect(target.ledger.ghostCanRead).toHaveBeenCalledExactlyOnceWith(hash, 'helper'); + } finally { ghostMediaHandoverTargetTracker.revoke(target.token); } + }); +}); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostMediaHandoverTargetTracker.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostMediaHandoverTargetTracker.test.ts new file mode 100644 index 00000000000..80cfee88d26 --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostMediaHandoverTargetTracker.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { GhostMediaHandoverTargetTracker } from '../ghostMediaHandoverTargetTracker'; +import { resolveGhostPanelMedia } from '../previewGate'; + +const hash = 'a'.repeat(64); +const uri = 'cindy-ghost://helper/media/' + hash + '.png'; + +describe('Ghost media handover targets', () => { + it.each(['media', 'preview'])('keeps same-name root and organization %s drags isolated through a cascade', async (shape) => { + const tracker = new GhostMediaHandoverTargetTracker(); + const sources = ['helper', '_ns__acme__helper', '_ns__other__helper']; + const tokens = sources.map((instanceId) => tracker.register({ ghostId: 'helper', instanceId, isCurrent: () => true })); + expect(new Set(tokens).size).toBe(3); + const dragUri = 'cindy-ghost://helper/' + shape + '/' + hash + '.png'; + for (const position of [1, 0, 2, 1]) { + const target = tracker.resolve(tokens[position], dragUri); + expect(target).toEqual({ ghostId: 'helper', instanceId: sources[position] }); + const ghostCanRead = vi.fn(async (_hash: string, instanceId: string) => instanceId === sources[position]); + await expect(resolveGhostPanelMedia(dragUri, 'attach', { + ghostCanRead, + getBlobInfo: async () => ({ ext: '.png', mimeType: 'image/png' }), + blobUrl: () => 'cindy-media://blobs/image.png', + blobAbsPath: () => '/blobs/image.png', statSize: async () => 42, + }, target!)).resolves.toMatchObject({ kind: 'image' }); + expect(ghostCanRead).toHaveBeenCalledWith(hash, sources[position]); + } + }); + + it('never grants a sibling instance ledger reference', async () => { + const tracker = new GhostMediaHandoverTargetTracker(); + const token = tracker.register({ ghostId: 'helper', instanceId: '_ns__acme__helper', isCurrent: () => true }); + const target = tracker.resolve(token, uri); + const ghostCanRead = vi.fn(async (_hash: string, instanceId: string) => instanceId === 'helper'); + await expect(resolveGhostPanelMedia(uri, 'attach', { + ghostCanRead, + getBlobInfo: async () => ({ ext: '.png', mimeType: 'image/png' }), + blobUrl: () => 'cindy-media://blobs/image.png', + blobAbsPath: () => '/blobs/image.png', statSize: async () => 42, + }, target!)).resolves.toBeNull(); + expect(ghostCanRead).toHaveBeenCalledExactlyOnceWith(hash, '_ns__acme__helper'); + }); + + it('revokes a detached guest without revoking its same-name sibling', () => { + const tracker = new GhostMediaHandoverTargetTracker(); + const root = tracker.register({ ghostId: 'helper', instanceId: 'helper', isCurrent: () => true }); + const organization = tracker.register({ ghostId: 'helper', instanceId: '_ns__acme__helper', isCurrent: () => true }); + tracker.revoke(organization); + expect(tracker.resolve(organization, uri)).toBeNull(); + expect(tracker.resolve(root, uri)?.instanceId).toBe('helper'); + }); + + it.each(['owner generation', 'receipt replacement', 'guest destruction', 'host destruction'])('does not revive after %s changes', () => { + const tracker = new GhostMediaHandoverTargetTracker(); + let current = true; + const token = tracker.register({ ghostId: 'helper', instanceId: 'helper', isCurrent: () => current }); + current = false; + expect(tracker.resolve(token, uri)).toBeNull(); + current = true; + expect(tracker.resolve(token, uri)).toBeNull(); + }); + + it('rejects unknown tokens, self-reported instances and another URL host', () => { + const tracker = new GhostMediaHandoverTargetTracker(); + const token = tracker.register({ ghostId: 'helper', instanceId: '_ns__acme__helper', isCurrent: () => true }); + for (const invalid of [undefined, null, '', '_ns__acme__helper', {}, 'a'.repeat(1024)]) { + expect(tracker.resolve(invalid, uri)).toBeNull(); + } + expect(tracker.resolve(token, uri.replace('helper', 'other'))).toBeNull(); + expect(tracker.resolve(token, uri + '?instanceId=helper')).toBeNull(); + expect(tracker.resolve(token, uri)?.instanceId).toBe('_ns__acme__helper'); + }); +}); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostNamespaceMigration.manager.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostNamespaceMigration.manager.test.ts new file mode 100644 index 00000000000..f1525a3f125 --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostNamespaceMigration.manager.test.ts @@ -0,0 +1,965 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { InstalledGhost } from '../../../shared/ghost.js'; +import { createOrganizationPrefixStore } from '../../plugin-market/organizationPrefixStore.js'; +import { PluginMarketLedger } from '../../plugin-market/ledger.js'; +import { GhostManager, type GhostManagerOptions } from '../GhostManager.js'; +import { + classifyNamespaceMigration, + readNamespaceMigrationInstallOrigin, + readNamespaceMigrationMarketRecord, +} from '../ghostNamespaceMigration.js'; +import { + assertManagedPluginParentSync, + GhostInstallReceiptStore, + createGhostInstallReceipt, + hashApprovedSkillContent, +} from '../ghostInstallReceipt.js'; +import { runGhostSnapshotWorkerRequest } from '../ghostSnapshotWorkerProcess.js'; +import { writeTestCindyPackage } from './cindyPackageFixture.js'; + +let workDir: string; +let rootDir: string; +let manager: GhostManager; + +const mutateSnapshot: NonNullable = async ({ parentDir, ...request }) => { + await runGhostSnapshotWorkerRequest(request, parentDir); +}; + +function createManager(options: Omit = {}): GhostManager { + return new GhostManager({ getRootDir: () => rootDir, ...options }); +} + +function receiptStore(mutation?: GhostManagerOptions['mutateSnapshot']): GhostInstallReceiptStore { + const stateRoot = path.join(workDir, 'ghosts-install-state'); + return new GhostInstallReceiptStore(() => stateRoot, mutation); +} + +beforeEach(async () => { + workDir = fs.realpathSync.native( + await fs.promises.mkdtemp(path.join(os.tmpdir(), 'cindy-ns-mig-mgr-')), + ); + rootDir = path.join(workDir, 'ghosts'); + manager = createManager({ mutateSnapshot }); +}); + +afterEach(async () => { + await fs.promises.rm(workDir, { recursive: true, force: true }); +}); + +function manifest(id = 'hello'): Record { + return { + schemaVersion: 2, + id, + name: 'Hello', + version: '1.0.0', + kind: 'chip', + entry: 'main.js', + slots: ['tool'], + tools: [{ name: 'do_thing', description: 'do' }], + }; +} + +it.each(['_ns', '_root'])('rejects linked %s parents for content recovery and state journals', async (reservedRoot) => { + const outside = path.join(workDir, 'outside'); + const contentNs = path.join(rootDir, reservedRoot); + const relId = reservedRoot === '_ns' ? '_ns/acme/hello' : '_root/hello'; + const stateRoot = path.join(workDir, 'ghosts-install-state'); + await fs.promises.mkdir(outside, { recursive: true }); + await fs.promises.mkdir(rootDir, { recursive: true }); + await fs.promises.writeFile(path.join(outside, 'sentinel'), 'keep'); + await fs.promises.symlink(outside, contentNs, 'dir'); + expect(() => assertManagedPluginParentSync(rootDir, relId)).toThrow(); + + const store = new GhostInstallReceiptStore(() => stateRoot); + await fs.promises.mkdir(stateRoot, { recursive: true }); + await fs.promises.symlink(outside, path.join(stateRoot, reservedRoot), 'dir'); + await expect(store.writePendingMutation(relId, { + kind: 'install', packageSha256: 'a'.repeat(64), + })).rejects.toThrow(); + expect(store.readPendingMutationSync(relId).state).toBe('unreadable'); + if (reservedRoot === '_root') expect(store.listPendingMutationIdsSync().state).toBe('unreadable'); + expect(fs.readFileSync(path.join(outside, 'sentinel'), 'utf8')).toBe('keep'); +}); + +it('does not recover an interrupted namespaced uninstall through a linked content parent', async () => { + const outside = path.join(workDir, 'outside'); + const stateRoot = path.join(workDir, 'ghosts-install-state'); + await fs.promises.mkdir(path.join(outside, 'acme', 'hello'), { recursive: true }); + await fs.promises.writeFile(path.join(outside, 'acme', 'hello', 'sentinel'), 'keep'); + await fs.promises.mkdir(rootDir, { recursive: true }); + await fs.promises.symlink(outside, path.join(rootDir, '_ns'), 'dir'); + const store = new GhostInstallReceiptStore(() => stateRoot); + await store.writePendingMutation('_ns/acme/hello', { kind: 'uninstall' }); + manager = createManager({ getStateDir: () => stateRoot }); + expect(fs.readFileSync(path.join(outside, 'acme', 'hello', 'sentinel'), 'utf8')).toBe('keep'); + expect(store.readPendingMutationSync('_ns/acme/hello').state).toBe('valid'); +}); + +it('refuses to publish a namespaced receipt through a linked approval parent', async () => { + await plantLegacyInstall('hello'); + const stateRoot = path.join(workDir, 'ghosts-install-state'); + const outside = path.join(workDir, 'outside'); + await fs.promises.mkdir(outside); + await fs.promises.writeFile(path.join(outside, 'sentinel'), 'keep'); + await fs.promises.symlink(outside, path.join(stateRoot, '_ns'), 'dir'); + const store = new GhostInstallReceiptStore(() => stateRoot); + const approval = store.read('hello'); + expect(approval.state).toBe('approved'); + if (approval.state !== 'approved') return; + await expect(store.write({ ...approval.receipt, namespace: 'acme' }, { + relId: '_ns/acme/hello', requireSkillSnapshot: false, + })).rejects.toThrow('parent is not a real directory'); + expect(fs.readFileSync(path.join(outside, 'sentinel'), 'utf8')).toBe('keep'); +}); + +async function plantLegacyInstall( + id: string, + withSkill = false, + installOrigin?: 'agent-forge', +): Promise { + const dir = path.join(rootDir, id); + await fs.promises.mkdir(dir, { recursive: true }); + const declared = { + ...manifest(id), + ...(withSkill ? { + slots: ['tool', 'skill'], + skill: { items: [{ dir: 'skills/demo', name: 'demo', description: 'Demo skill' }] }, + } : {}), + }; + await fs.promises.writeFile(path.join(dir, 'ghost.json'), JSON.stringify(declared)); + await fs.promises.writeFile(path.join(dir, 'main.js'), '// ok\n'); + if (withSkill) { + await fs.promises.mkdir(path.join(dir, 'skills', 'demo'), { recursive: true }); + await fs.promises.writeFile(path.join(dir, 'skills', 'demo', 'SKILL.md'), + '---\nname: demo\ndescription: Demo skill\n---\n\nDemo\n'); + } + const store = receiptStore(mutateSnapshot); + const approvedManifest = { + ...declared, + } as InstalledGhost['manifest']; + await store.write( + createGhostInstallReceipt({ + manifest: approvedManifest, + localeResources: {}, + enabled: true, + trust: { + level: 'unverified', + publisherSigned: false, + publisherVerified: false, + reviewed: false, + }, + skillContentSha256: await hashApprovedSkillContent(approvedManifest, dir), + ...(installOrigin ? { installOrigin } : {}), + }), + { skillSourceDir: dir }, + ); +} + +async function makeCindy(id: string): Promise { + return writeTestCindyPackage(path.join(workDir, `${id}.cindy`), manifest(id)); +} + +async function stampLegacyOrganizationInstall(id = 'hello', withSkill = false): Promise { + await plantLegacyInstall(id, withSkill); + manager.list(); + await manager.commitPendingNamespace(id, 'acme', 'market-organization'); +} + +describe('GhostManager namespace migration census', () => { + it('retries a failed first directory scan instead of persisting an empty census', async () => { + await plantLegacyInstall('hello'); + const actualRead = fs.readdirSync; + const read = vi.spyOn(fs, 'readdirSync').mockImplementation(((directory: fs.PathLike, ...args: unknown[]) => { + if (String(directory) === rootDir) { + read.mockRestore(); + throw Object.assign(new Error('unavailable'), { code: 'EACCES' }); + } + return actualRead(directory, ...(args as [])); + }) as typeof fs.readdirSync); + try { + expect(manager.ensureNamespaceMigrationCensus()).toBeNull(); + } finally { + read.mockRestore(); + } + expect(manager.ensureNamespaceMigrationCensus()?.entries.hello?.status).toBe('pending'); + }); + it('keeps an old unstamped install unresolved when its census is unavailable', async () => { + await plantLegacyInstall('hello'); + fs.writeFileSync(path.join(workDir, 'ghosts-install-state', 'namespace-migration.v1.json'), '{'); + expect(manager.list()[0]?.namespaceMigration).toBe('pending'); + expect(manager.list()[0]?.namespace).toBeUndefined(); + }); + it('waits for approved origin and organization prefix before committing a Forge install', async () => { + await plantLegacyInstall('acme-tool', false, 'agent-forge'); + const prefixStore = createOrganizationPrefixStore(path.join(workDir, 'organization.v1.json')); + let receiptUnreadable = true; + manager = createManager({ + classifyPendingNamespace: (ghostId, marketSyncCompleted = false) => { + const prefix = prefixStore.lookup('org-acme'); + return classifyNamespaceMigration({ + ghostId, + builtin: false, + installOrigin: readNamespaceMigrationInstallOrigin(() => { + if (receiptUnreadable) throw new Error('receipt temporarily unreadable'); + return manager.readApprovedInstallOriginStrict(ghostId); + }), + marketSyncCompleted, + marketRecord: null, + currentOrganization: { + organizationId: 'org-acme', + orgSlug: 'acme', + pluginPrefix: prefix.kind === 'known' ? prefix.pluginPrefix : null, + }, + }); + }, + }); + + await manager.reconcilePendingRootNamespaces(true); + expect(manager.list()[0]).toMatchObject({ namespaceMigration: 'pending' }); + expect(manager.list()[0]?.namespace).toBeUndefined(); + + receiptUnreadable = false; + await manager.reconcilePendingRootNamespaces(true); + expect(manager.list()[0]).toMatchObject({ namespaceMigration: 'pending' }); + expect(manager.list()[0]?.namespace).toBeUndefined(); + + prefixStore.remember('org-acme', 'acme'); + await manager.reconcilePendingRootNamespaces(true); + expect(manager.list()[0]).toMatchObject({ namespace: 'acme' }); + expect(manager.list()[0]?.namespaceMigration).toBeUndefined(); + }); + + it('does not stamp an approval receipt when the market namespace stamp fails', async () => { + await plantLegacyInstall('hello'); + manager = createManager({ + beforeNamespaceCommit: () => { throw new Error('market namespace conflict'); }, + }); + await expect(manager.commitPendingNamespace('hello', 'acme', 'market-organization')) + .rejects.toThrow('market namespace conflict'); + const store = receiptStore(); + const approval = store.read('hello'); + expect(approval.state).toBe('approved'); + if (approval.state === 'approved') expect(approval.receipt.namespace).toBeUndefined(); + expect(manager.list()[0]?.namespaceMigration).toBe('pending'); + }); + + it('runs an approved legacy resident offline, then stops it before stamping and restarts after commit', async () => { + await plantLegacyInstall('hello'); + const events: string[] = []; + let runtimeBusy = false; + manager = createManager({ + classifyPendingNamespace: (_id, synced) => synced + ? { kind: 'commit', namespace: 'acme', basis: 'market-organization' } + : { kind: 'pending', reason: 'awaiting-market-facts' }, + isNamespaceMigrationBusy: () => runtimeBusy, + canResumePendingResidentOffline: () => true, + onResumePendingResidentOffline: (ghost) => { + expect(ghost.approval.state).toBe('approved'); + runtimeBusy = true; + events.push('started'); + }, + preparePendingResidentForMigration: async () => { + events.push('stopped'); + runtimeBusy = false; + return true; + }, + beforeNamespaceCommit: () => events.push('stamped'), + onNamespaceCommitted: () => events.push('restarted'), + }); + manager.resumePendingResidentsOffline(); + await manager.reconcilePendingRootNamespaces(false); + expect(events).toEqual(['started']); + expect(manager.list()[0]?.namespaceMigration).toBe('pending'); + await manager.reconcilePendingRootNamespaces(true); + expect(events).toEqual(['started', 'stopped', 'stamped', 'restarted']); + expect(manager.list()[0]).toMatchObject({ namespace: 'acme' }); + }); + + it('keeps a running offline resident pending when safe stop is deferred', async () => { + await plantLegacyInstall('hello'); + const stopped = vi.fn(async () => false); + const deferred = vi.fn(); + manager = createManager({ + classifyPendingNamespace: () => ({ kind: 'commit', namespace: 'acme', basis: 'market-organization' }), + isNamespaceMigrationBusy: () => true, + canResumePendingResidentOffline: () => true, + onResumePendingResidentOffline: vi.fn(), + preparePendingResidentForMigration: stopped, + onPendingResidentMigrationDeferred: deferred, + }); + manager.resumePendingResidentsOffline(); + await manager.reconcilePendingRootNamespaces(true); + expect(stopped).toHaveBeenCalledOnce(); + expect(deferred).toHaveBeenCalledWith('hello'); + expect(manager.list()[0]?.namespaceMigration).toBe('pending'); + expect(manager.list()[0]?.namespace).toBeUndefined(); + }); + + it('retries after in-flight work finishes, then stops, stamps, and restarts the offline resident', async () => { + await plantLegacyInstall('hello'); + vi.useFakeTimers(); + try { + let inFlight = true; + let runtimeBusy = false; + let retry: Promise | undefined; + const events: string[] = []; + const stop = vi.fn(async () => { + if (inFlight) return false; + runtimeBusy = false; + events.push('stopped'); + return true; + }); + manager = createManager({ + classifyPendingNamespace: () => ({ kind: 'commit', namespace: 'acme', basis: 'market-organization' }), + isNamespaceMigrationBusy: () => inFlight || runtimeBusy, + canResumePendingResidentOffline: () => true, + onResumePendingResidentOffline: () => { runtimeBusy = true; events.push('started'); }, + preparePendingResidentForMigration: stop, + onPendingResidentMigrationDeferred: () => { + setTimeout(() => { retry = manager.reconcilePendingRootNamespaces(true); }, 1000); + }, + beforeNamespaceCommit: () => events.push('stamped'), + onNamespaceCommitted: () => events.push('restarted'), + }); + manager.resumePendingResidentsOffline(); + await manager.reconcilePendingRootNamespaces(true); + expect(events).toEqual(['started']); + expect(manager.list()[0]?.namespaceMigration).toBe('pending'); + inFlight = false; + await vi.advanceTimersByTimeAsync(1000); + expect(retry).toBeDefined(); + await retry; + expect(stop).toHaveBeenCalledTimes(2); + expect(events).toEqual(['started', 'stopped', 'stamped', 'restarted']); + expect(manager.list()[0]?.namespace).toBe('acme'); + } finally { + vi.useRealTimers(); + } + }); + + it('defers a failed market namespace commit so the offline resident can retry', async () => { + await plantLegacyInstall('hello'); + const deferred = vi.fn(); + let failStamp = true; + manager = createManager({ + classifyPendingNamespace: () => ({ kind: 'commit', namespace: 'acme', basis: 'market-organization' }), + preparePendingResidentForMigration: async () => true, + onPendingResidentMigrationDeferred: deferred, + beforeNamespaceCommit: () => { + if (failStamp) throw new Error('market ledger unavailable'); + }, + }); + await expect(manager.reconcilePendingRootNamespaces(true)).rejects.toThrow('market ledger unavailable'); + expect(deferred).toHaveBeenCalledWith('hello'); + expect(manager.list()[0]?.namespaceMigration).toBe('pending'); + failStamp = false; + await manager.reconcilePendingRootNamespaces(true); + expect(manager.list()[0]?.namespace).toBe('acme'); + }); + + it('abandons an offline migration if its owner changes during safe stop', async () => { + await plantLegacyInstall('hello'); + let owner = 'original'; + let releaseStop: (() => void) | undefined; + const stopping = new Promise((resolve) => { releaseStop = resolve; }); + const committed = vi.fn(); + manager = createManager({ + getOwnerContextKey: () => owner, + classifyPendingNamespace: () => ({ kind: 'commit', namespace: 'acme', basis: 'market-organization' }), + canResumePendingResidentOffline: () => true, + onResumePendingResidentOffline: vi.fn(), + preparePendingResidentForMigration: async () => { await stopping; return true; }, + onNamespaceCommitted: committed, + }); + manager.resumePendingResidentsOffline(); + const migration = manager.reconcilePendingRootNamespaces(true); + owner = 'replacement'; + releaseStop?.(); + await migration; + expect(committed).not.toHaveBeenCalled(); + expect(manager.list()[0]?.namespaceMigration).toBe('pending'); + }); + + it('does not publish a namespace commit when its owner changes while writing the receipt', async () => { + await plantLegacyInstall('hello'); + let owner = 'original'; + let releaseWrite: (() => void) | undefined; + let writeStarted: (() => void) | undefined; + const writing = new Promise((resolve) => { writeStarted = resolve; }); + const blocked = new Promise((resolve) => { releaseWrite = resolve; }); + const write = vi.spyOn(GhostInstallReceiptStore.prototype, 'write').mockImplementation(async () => { + writeStarted?.(); + await blocked; + }); + const committed = vi.fn(); + try { + manager = createManager({ + getOwnerContextKey: () => owner, + onNamespaceCommitted: committed, + }); + manager.list(); + const migration = manager.commitPendingNamespace('hello', 'acme', 'market-organization'); + await writing; + owner = 'replacement'; + releaseWrite?.(); + await expect(migration).rejects.toThrow('owner changed'); + expect(committed).not.toHaveBeenCalled(); + expect(manager.list()[0]?.namespaceMigration).toBe('pending'); + } finally { + write.mockRestore(); + } + }); + + it('stops offline residency when the market responds but the organization slug is not yet known', async () => { + await plantLegacyInstall('hello'); + let slugKnown = false; + let busy = false; + const stopped = vi.fn(async () => { busy = false; return true; }); + manager = createManager({ + classifyPendingNamespace: () => slugKnown + ? { kind: 'commit', namespace: 'acme', basis: 'market-organization' } + : { kind: 'pending', reason: 'awaiting-organization-namespace' }, + isNamespaceMigrationBusy: () => busy, + canResumePendingResidentOffline: () => true, + onResumePendingResidentOffline: () => { busy = true; }, + preparePendingResidentForMigration: stopped, + }); + manager.resumePendingResidentsOffline(); + await manager.reconcilePendingRootNamespaces(true); + expect(stopped).toHaveBeenCalledOnce(); + expect(manager.list()[0]?.namespaceMigration).toBe('pending'); + slugKnown = true; + await manager.reconcilePendingRootNamespaces(true); + expect(manager.list()[0]?.namespace).toBe('acme'); + }); + + it('waits for a failed market read before committing an old organization install', async () => { + await plantLegacyInstall('hello'); + let readFails = true; + let commits = 0; + manager = createManager({ + classifyPendingNamespace: (ghostId, marketSyncCompleted = false) => classifyNamespaceMigration({ + ghostId, + builtin: false, + installOrigin: 'manual', + marketSyncCompleted, + marketRecord: readNamespaceMigrationMarketRecord(() => { + if (readFails) throw new Error('locked ledger'); + return [{ scope: 'organization', source: 'market', organizationId: 'org-acme' }]; + }), + currentOrganization: { organizationId: 'org-acme', orgSlug: 'acme', pluginPrefix: 'acme' }, + }), + onNamespaceCommitted: () => { commits += 1; }, + }); + + await manager.reconcilePendingRootNamespaces(true); + expect(manager.list()[0]).toMatchObject({ namespaceMigration: 'pending' }); + expect(manager.list()[0]?.namespace).toBeUndefined(); + expect(commits).toBe(0); + + readFails = false; + await manager.reconcilePendingRootNamespaces(true); + expect(manager.list()[0]).toMatchObject({ namespace: 'acme' }); + expect(manager.list()[0]?.namespaceMigration).toBeUndefined(); + expect(commits).toBe(1); + }); + + it('captures only verified recovered legacy installs after the initial empty census', async () => { + await fs.promises.mkdir(rootDir, { recursive: true }); + expect(manager.list()).toEqual([]); + await plantLegacyInstall('recovered'); + await plantLegacyInstall('unverified'); + expect(manager.list().find((ghost) => ghost.manifest.id === 'recovered')?.namespaceMigration).toBe('pending'); + manager.captureRecoveredLegacyNamespace(['recovered']); + expect(manager.list().find((ghost) => ghost.manifest.id === 'recovered')?.namespaceMigration).toBe('pending'); + expect(manager.list().find((ghost) => ghost.manifest.id === 'unverified')?.namespaceMigration).toBe('pending'); + const orgCindy = await makeCindy('recovered'); + await expect(manager.install(orgCindy, { namespace: 'acme' })).resolves.toMatchObject({ + rejection: { code: 'namespace-migration-pending' }, + }); + }); + + it('recovers an organization install whose approved namespace was erased by a downgraded client', async () => { + await plantLegacyInstall('hello'); + await manager.commitPendingNamespace('hello', 'acme', 'market-organization'); + const store = receiptStore(); + const approval = store.read('hello'); + expect(approval.state).toBe('approved'); + if (approval.state !== 'approved') return; + const { namespace: oldNamespace, ...downgradedReceipt } = approval.receipt; + expect(oldNamespace).toBe('acme'); + await store.write(downgradedReceipt, { relId: 'hello', requireSkillSnapshot: false }); + expect(manager.list()[0]).toMatchObject({ namespaceMigration: 'pending' }); + expect(manager.readDeliveryNamespace('hello')).toBeUndefined(); + const orgCindy = await makeCindy('hello'); + await expect(manager.install(orgCindy, { namespace: 'acme' })).resolves.toMatchObject({ + rejection: { code: 'namespace-migration-pending' }, + }); + + manager = createManager({ + mutateSnapshot, + recoverUnstampedOrganizationNamespace: () => 'acme', + classifyPendingNamespace: () => ({ kind: 'commit', namespace: 'acme', basis: 'market-organization' }), + }); + await manager.reconcilePendingRootNamespaces(false); + expect(manager.list()[0]?.namespaceMigration).toBe('pending'); + await manager.reconcilePendingRootNamespaces(true); + expect(manager.list()[0]).toMatchObject({ namespace: 'acme' }); + expect(manager.list()[0]?.namespaceMigration).toBeUndefined(); + expect(manager.readDeliveryNamespace('hello')).toBe('acme'); + }); + + it('does not claim a post-census downgrade install without verified market evidence', async () => { + await fs.promises.mkdir(rootDir, { recursive: true }); + manager.list(); + await plantLegacyInstall('hello'); + manager = createManager({ + recoverUnstampedOrganizationNamespace: () => null, + classifyPendingNamespace: () => ({ kind: 'commit', namespace: null, basis: 'manual-after-sync' }), + }); + await manager.reconcilePendingRootNamespaces(true); + expect(manager.list()[0]?.namespaceMigration).toBe('pending'); + expect(manager.readDeliveryNamespace('hello')).toBeUndefined(); + const orgCindy = await makeCindy('hello'); + await expect(manager.install(orgCindy, { namespace: 'acme' })).resolves.toMatchObject({ + rejection: { code: 'namespace-migration-pending' }, + }); + }); + + it('resumes a post-census unstamped resident offline without assigning it root identity', async () => { + await fs.promises.mkdir(rootDir, { recursive: true }); + manager.list(); + await plantLegacyInstall('hello'); + const resumed = vi.fn((ghost: InstalledGhost) => ghost.namespaceMigration); + manager = createManager({ + canResumePendingResidentOffline: () => true, + onResumePendingResidentOffline: resumed, + }); + manager.resumePendingResidentsOffline(); + expect(resumed).toHaveBeenCalledOnce(); + expect(resumed.mock.results[0]?.value).toBe('pending'); + }); + + it('finishes the captured downgrade recovery when receipt writing preceded ledger commit', async () => { + await fs.promises.mkdir(rootDir, { recursive: true }); + manager.list(); + await plantLegacyInstall('hello'); + let failOnce = true; + manager = createManager({ + mutateSnapshot, + recoverUnstampedOrganizationNamespace: () => 'acme', + classifyPendingNamespace: () => ({ kind: 'commit', namespace: 'acme', basis: 'market-organization' }), + onNamespaceCommitted: () => { + if (failOnce) throw new Error('ledger unavailable'); + }, + }); + await expect(manager.reconcilePendingRootNamespaces(true)).rejects.toThrow('ledger unavailable'); + expect(manager.list()[0]).toMatchObject({ namespace: 'acme', namespaceMigration: 'pending' }); + failOnce = false; + await manager.reconcilePendingRootNamespaces(true); + expect(manager.list()[0]).toMatchObject({ namespace: 'acme' }); + expect(manager.list()[0]?.namespaceMigration).toBeUndefined(); + }); + + it('keeps a receipt-stamped namespace pending until its market record is stamped', async () => { + await plantLegacyInstall('hello'); + manager.list(); + let shouldFail = true; + manager = createManager({ + mutateSnapshot, + onNamespaceCommitted: () => { + if (shouldFail) throw new Error('market ledger unavailable'); + }, + }); + await expect(manager.commitPendingNamespace('hello', 'acme', 'market-organization')) + .rejects.toThrow('market ledger unavailable'); + expect(manager.list()[0]?.namespaceMigration).toBe('pending'); + shouldFail = false; + await expect(manager.commitPendingNamespace('hello', 'acme', 'market-organization')) + .resolves.toEqual({ ok: true }); + expect(manager.list()[0]?.namespace).toBe('acme'); + expect(manager.list()[0]?.namespaceMigration).toBeUndefined(); + }); + + it('captures a pre-namespace root install as pending and does not treat a later install as pending', async () => { + await plantLegacyInstall('xd-feishu'); + const listed = manager.list(); + expect(listed).toEqual([ + expect.objectContaining({ + manifest: expect.objectContaining({ id: 'xd-feishu' }), + namespaceMigration: 'pending', + }), + ]); + expect(listed[0]?.namespace).toBeUndefined(); + + const planted = await makeCindy('helper'); + const installed = await manager.install(planted); + expect('ghost' in installed).toBe(true); + const helperGhost = (installed as { ghost: { manifest: { id: string }; namespace?: unknown } }).ghost; + expect(helperGhost.manifest.id).toBe('helper'); + expect(helperGhost).toHaveProperty('namespace', null); + const helper = manager.list().find((ghost) => ghost.manifest.id === 'helper'); + expect(helper).toBeDefined(); + expect(helper).toHaveProperty('namespace', null); + expect(helper?.namespaceMigration).toBeUndefined(); + }); + + it('commits a pending builtin-looking install as root without moving the directory', async () => { + await plantLegacyInstall('hello'); + manager.list(); + await expect(manager.commitPendingRootNamespace('hello', 'builtin')).resolves.toEqual({ ok: true }); + const ghost = manager.list()[0]; + expect(ghost).toMatchObject({ + manifest: { id: 'hello' }, + namespace: null, + }); + expect(ghost?.namespaceMigration).toBeUndefined(); + expect(ghost?.dir).toBe(path.join(rootDir, 'hello')); + }); + + it('lets a root reinstall proceed after uninstalling a pending legacy install', async () => { + await plantLegacyInstall('hello'); + manager.list(); + await expect(manager.uninstall('hello', { notify: false })).resolves.toEqual({ ok: true }); + const cindy = await makeCindy('hello'); + await expect(manager.install(cindy)).resolves.toMatchObject({ + ghost: { manifest: { id: 'hello' } }, + }); + }); + + it('keeps a pending census while the root directory is in an update backup', async () => { + await plantLegacyInstall('hello'); + manager.list(); + const live = path.join(rootDir, 'hello'); + const backup = path.join(rootDir, '.cindy-updating-hello-deadbeef'); + await fs.promises.rename(live, backup); + manager.list(); + await fs.promises.rename(backup, live); + const orgCindy = await makeCindy('hello'); + await expect(manager.install(orgCindy, { namespace: 'acme' })).resolves.toMatchObject({ + rejection: { code: 'namespace-migration-pending' }, + }); + }); + + it('captures the original receipt when the first census sees an update backup', async () => { + await plantLegacyInstall('hello'); + const receipts = receiptStore(); + const backupName = '.cindy-updating-hello-deadbeef'; + await receipts.writePendingMutation('hello', { + kind: 'update', + packageSha256: 'a'.repeat(64), + backupDirName: backupName, + phase: 'backed-up', + }); + await fs.promises.rename(path.join(rootDir, 'hello'), path.join(rootDir, backupName)); + expect(manager.ensureNamespaceMigrationCensus()?.entries.hello?.status).toBe('pending'); + await fs.promises.rename(path.join(rootDir, backupName), path.join(rootDir, 'hello')); + await receipts.clearPendingMutation('hello'); + expect(manager.list()[0]?.namespaceMigration).toBe('pending'); + }); + + it('blocks a same-name organization install while the root instance is still pending', async () => { + await plantLegacyInstall('hello'); + manager.list(); + const orgCindy = await makeCindy('hello'); + await expect(manager.install(orgCindy, { namespace: 'acme' })).resolves.toMatchObject({ + rejection: { code: 'namespace-migration-pending' }, + }); + expect(fs.existsSync(path.join(rootDir, '_ns', 'acme', 'hello'))).toBe(false); + }); + + it('defers a classified busy root and commits its identity before installing a sibling', async () => { + await plantLegacyInstall('hello'); + const marketLedger = new PluginMarketLedger(path.join(workDir, 'market', 'ledger.v1.json')); + marketLedger.upsertInstallation({ + pluginId: 'root-resource', ghostId: 'hello', releaseId: 'root-release', + version: '1.0.0', sha256: 'a'.repeat(64), scope: 'public', organizationId: null, + source: 'market', installed: true, updatedAt: '2026-09-30T00:00:00.000Z', + }); + let busy = true; + manager = createManager({ + isNamespaceMigrationBusy: () => busy, + classifyPendingNamespace: (ghostId) => classifyNamespaceMigration({ + ghostId, builtin: false, installOrigin: 'manual', marketSyncCompleted: true, + marketRecord: readNamespaceMigrationMarketRecord(() => marketLedger.installationsForGhost(ghostId)), + currentOrganization: null, + }), + beforeNamespaceCommit: (ghostId, namespace) => { + if (!marketLedger.stampNamespaceIfAbsent(ghostId, namespace)) throw new Error('stamp failed'); + }, + mutateSnapshot, + }); + manager.list(); + await manager.reconcilePendingRootNamespaces(true); + const orgCindy = await makeCindy('hello'); + await expect(manager.install(orgCindy, { namespace: 'acme' })).resolves.toMatchObject({ + rejection: { code: 'namespace-migration-pending' }, + }); + expect(marketLedger.installationsForGhost('hello')).toHaveLength(1); + expect(manager.list()[0]?.namespaceMigration).toBe('pending'); + expect(fs.existsSync(path.join(rootDir, '_ns', 'acme', 'hello'))).toBe(false); + busy = false; + await expect(manager.install(orgCindy, { namespace: 'acme' })).resolves.toMatchObject({ + ghost: { namespace: 'acme' }, + }); + expect(marketLedger.installationForPlugin({ ghostId: 'hello', namespace: null })).toMatchObject({ namespace: null }); + expect(manager.ensureNamespaceMigrationCensus()?.entries).toEqual({}); + expect(manager.list().find((ghost) => ghost.dir === path.join(rootDir, 'hello'))).toMatchObject({ namespace: null }); + }); + + it('keeps an already ambiguous multi-record pending install blocked without stamping either row', async () => { + await plantLegacyInstall('hello'); + const beforeCommit = vi.fn(); + manager = createManager({ + classifyPendingNamespace: (ghostId) => classifyNamespaceMigration({ + ghostId, builtin: false, installOrigin: 'manual', marketSyncCompleted: true, + marketRecord: readNamespaceMigrationMarketRecord(() => [ + { scope: 'public', source: 'market', organizationId: null, installed: true }, + { scope: 'organization', source: 'market', organizationId: 'org-acme', namespace: 'acme', installed: true }, + ]), + currentOrganization: null, + }), + beforeNamespaceCommit: beforeCommit, + }); + manager.list(); + await manager.reconcilePendingRootNamespaces(true); + await expect(manager.install(await makeCindy('hello'), { namespace: 'other' })).resolves.toMatchObject({ + rejection: { code: 'namespace-migration-pending' }, + }); + expect(beforeCommit).not.toHaveBeenCalled(); + expect(manager.list()[0]?.namespaceMigration).toBe('pending'); + }); + + it('allows the organization instance after the pending root install is classified', async () => { + await plantLegacyInstall('hello'); + manager.list(); + await manager.commitPendingRootNamespace('hello', 'market-public'); + const orgCindy = await makeCindy('hello'); + await expect(manager.install(orgCindy, { namespace: 'acme' })).resolves.toMatchObject({ + ghost: { namespace: 'acme', dir: path.join(rootDir, '_ns', 'acme', 'hello') }, + }); + expect(manager.list().map((ghost) => [ghost.namespace ?? null, ghost.manifest.id])).toEqual( + expect.arrayContaining([ + [null, 'hello'], + ['acme', 'hello'], + ]), + ); + }); + + it('commits an organization namespace in place without moving the directory or storage key', async () => { + await plantLegacyInstall('xd-feishu'); + manager.list(); + await expect(manager.commitPendingNamespace('xd-feishu', 'xd', 'market-organization')).resolves.toEqual({ + ok: true, + }); + expect(manager.ensureNamespaceMigrationCensus()?.entries).toEqual({}); + const ghost = manager.list()[0]; + expect(ghost).toMatchObject({ + manifest: { id: 'xd-feishu' }, + namespace: 'xd', + dir: path.join(rootDir, 'xd-feishu'), + }); + expect(ghost?.namespaceMigration).toBeUndefined(); + expect(fs.existsSync(path.join(rootDir, '_ns', 'xd', 'xd-feishu'))).toBe(false); + const { installedGhostStoragePart } = await import('../../../shared/pluginIdentity.js'); + expect(installedGhostStoragePart(ghost!)).toBe('xd-feishu'); + }); + + it('finishes a receipt-first commit by removing the pending entry after a restart', async () => { + await plantLegacyInstall('hello'); + expect(manager.ensureNamespaceMigrationCensus()?.entries.hello?.status).toBe('pending'); + const stateRoot = path.join(workDir, 'ghosts-install-state'); + const receipts = receiptStore(mutateSnapshot); + const approval = receipts.read('hello'); + if (approval.state !== 'approved') throw new Error('expected approved receipt'); + await receipts.write({ ...approval.receipt, namespace: 'xd' }, { + relId: 'hello', skillSourceDir: path.join(rootDir, 'hello'), requireSkillSnapshot: false, + }); + manager = createManager({ getStateDir: () => stateRoot }); + await expect(manager.commitPendingNamespace('hello', null, 'market-public')).resolves.toEqual({ ok: true }); + expect(manager.ensureNamespaceMigrationCensus()?.entries).toEqual({}); + manager = createManager({ getStateDir: () => stateRoot }); + expect(manager.list()[0]).toMatchObject({ namespace: 'xd', dir: path.join(rootDir, 'hello') }); + expect(manager.list()[0]?.namespaceMigration).toBeUndefined(); + }); + + it('disables and uninstalls an in-place namespaced plugin without inventing _ns paths', async () => { + await plantLegacyInstall('xd-feishu'); + manager.list(); + await expect(manager.commitPendingNamespace('xd-feishu', 'xd', 'market-organization')).resolves.toEqual({ + ok: true, + }); + await expect(manager.setEnabled('_ns/xd/xd-feishu', false)).resolves.toEqual({ ok: true }); + expect(manager.list()[0]?.enabled).toBe(false); + expect(fs.existsSync(path.join(rootDir, 'xd-feishu'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_ns', 'xd', 'xd-feishu'))).toBe(false); + await expect(manager.setEnabled('xd-feishu', true)).resolves.toEqual({ ok: true }); + expect(manager.list()[0]?.enabled).toBe(true); + await expect(manager.uninstall('_ns/xd/xd-feishu', { notify: false })).resolves.toEqual({ ok: true }); + expect(fs.existsSync(path.join(rootDir, 'xd-feishu'))).toBe(false); + expect(manager.list()).toEqual([]); + }); + + it('installs a same-name root without moving or stopping an approved legacy organization', async () => { + await stampLegacyOrganizationInstall(); + const before = receiptStore(mutateSnapshot).read('hello'); + const busy = vi.fn(() => true); + manager = createManager({ mutateSnapshot, isNamespaceMigrationBusy: busy }); + const rootCindy = await makeCindy('hello'); + await expect(manager.install(rootCindy)).resolves.toMatchObject({ + ghost: { namespace: null, dir: path.join(rootDir, '_root', 'hello') }, + }); + expect(busy).not.toHaveBeenCalled(); + expect(receiptStore(mutateSnapshot).read('hello')).toEqual(before); + expect(fs.existsSync(path.join(rootDir, 'hello', 'ghost.json'))).toBe(true); + expect(fs.existsSync(path.join(rootDir, '_ns', 'acme', 'hello'))).toBe(false); + expect(manager.list().map((ghost) => [ghost.namespace, ghost.dir])).toEqual(expect.arrayContaining([ + ['acme', path.join(rootDir, 'hello')], [null, path.join(rootDir, '_root', 'hello')], + ])); + const { installedGhostStoragePart } = await import('../../../shared/pluginIdentity.js'); + expect(manager.list().map(installedGhostStoragePart).sort()).toEqual(['_root__hello', 'hello']); + manager = createManager({ mutateSnapshot }); + expect(manager.list()).toHaveLength(2); + await expect(manager.setEnabled('_root/hello', false)).resolves.toEqual({ ok: true }); + expect(manager.list().find((ghost) => ghost.namespace === 'acme')?.enabled).toBe(true); + await expect(manager.uninstall('_root/hello')).resolves.toEqual({ ok: true }); + expect(manager.list()).toEqual([expect.objectContaining({ namespace: 'acme', dir: path.join(rootDir, 'hello') })]); + }); + + it.each([null, 'acme'] as const)('updates a legacy %s installation in place', async (namespace) => { + await plantLegacyInstall('hello', true); + manager.list(); + await manager.commitPendingNamespace('hello', namespace, namespace === null ? 'explicit-root' : 'market-organization'); + const ghost = manager.list()[0]; + const { ghostInstallApprovalToken } = await import('../../../shared/ghost.js'); + await expect(manager.update(await makeCindy('hello'), { + namespace, expectedInstalledApproval: ghostInstallApprovalToken(ghost.approval), + })).resolves.toMatchObject({ ghost: { namespace, dir: path.join(rootDir, 'hello') } }); + expect(fs.existsSync(path.join(rootDir, '_root', 'hello'))).toBe(false); + expect(fs.existsSync(path.join(rootDir, '_ns', 'acme', 'hello'))).toBe(false); + }); + + it('does not let an absent new root instance mutate a same-name legacy root', async () => { + await plantLegacyInstall('hello'); + manager.list(); + await manager.commitPendingNamespace('hello', null, 'explicit-root'); + const before = receiptStore().read('hello'); + expect(await manager.setEnabled('_root/hello', false)).toMatchObject({ rejection: { code: 'not-installed' } }); + expect(await manager.uninstall('_root/hello')).toMatchObject({ rejection: { code: 'not-installed' } }); + expect(await manager.removeInstallApproval('_root/hello')).toBe(true); + expect(receiptStore().read('hello')).toEqual(before); + expect(manager.list()).toEqual([expect.objectContaining({ enabled: true, dir: path.join(rootDir, 'hello') })]); + }); + + it.each(['_root', '_ns/acme', '_ns/acme/org-helper'])('keeps other installs visible when %s is temporarily unreadable', async (unreadable) => { + await manager.install(await makeCindy('hello')); + await manager.install(await makeCindy('org-helper'), { namespace: 'acme' }); + const target = path.join(rootDir, ...unreadable.split('/')); + const realLstat = fs.lstatSync; + const locked = vi.spyOn(fs, 'lstatSync').mockImplementation((filePath, options) => { + if (String(filePath) === target) throw Object.assign(new Error('locked'), { code: 'EACCES' }); + return realLstat(filePath, options as never); + }); + try { + expect(manager.list().map((ghost) => ghost.manifest.id)).toEqual([unreadable === '_root' ? 'org-helper' : 'hello']); + } finally { + locked.mockRestore(); + } + }); + + it('keeps the legacy organization approved when a new root package cannot be installed', async () => { + await stampLegacyOrganizationInstall('hello', true); + const before = receiptStore(mutateSnapshot).read('hello'); + const file = await writeTestCindyPackage(path.join(workDir, 'root-with-skill.cindy'), { + ...manifest('hello'), slots: ['tool', 'skill'], + skill: { items: [{ dir: 'skills/demo', name: 'demo', description: 'Demo skill' }] }, + }, { 'main.js': '// ok\n', 'skills/demo/SKILL.md': '---\nname: demo\ndescription: Demo skill\n---\n\nDemo\n' }); + manager = createManager({ mutateSnapshot: async () => { throw new Error('snapshot unavailable'); } }); + await expect(manager.install(file)).resolves.toMatchObject({ rejection: { code: 'io' } }); + expect(receiptStore(mutateSnapshot).read('hello')).toEqual(before); + expect(manager.list()).toEqual([expect.objectContaining({ namespace: 'acme', approval: expect.objectContaining({ state: 'approved' }) })]); + }); + + it('installs and verifies a namespaced skill snapshot under its physical identity', async () => { + const filePath = await writeTestCindyPackage(path.join(workDir, 'helper-skill.cindy'), { + ...manifest('helper'), + slots: ['tool', 'skill'], + skill: { items: [{ dir: 'skills/demo', name: 'demo', description: 'Demo skill' }] }, + }, { + 'main.js': '// ok\n', + 'skills/demo/SKILL.md': '---\nname: demo\ndescription: Demo skill\n---\n\nDemo\n', + }); + const result = await manager.install(filePath, { namespace: 'acme' }); + expect(result).toMatchObject({ ghost: { manifest: { id: 'helper' } } }); + if (!('ghost' in result)) return; + expect(result.ghost.approvedSkillRoot).toContain(path.join('_ns', 'acme', 'helper')); + await expect(manager.verifyApprovedSkillSnapshot(result.ghost)).resolves.toBe(true); + }); + + it('keeps a stamped skill approved beside a same-name new root install', async () => { + await stampLegacyOrganizationInstall('hello', true); + const rootCindy = await makeCindy('hello'); + await expect(manager.install(rootCindy)).resolves.toMatchObject({ ghost: { manifest: { id: 'hello' } } }); + const org = manager.list().find((ghost) => ghost.namespace === 'acme'); + expect(org).toBeDefined(); + await expect(manager.verifyApprovedSkillSnapshot(org!)).resolves.toBe(true); + }); + + it('treats a later install of the same organization identity as already installed', async () => { + await stampLegacyOrganizationInstall(); + const orgCindy = await makeCindy('hello'); + await expect(manager.install(orgCindy, { namespace: 'acme' })).resolves.toMatchObject({ + rejection: { code: 'already-installed' }, + }); + expect(fs.existsSync(path.join(rootDir, '_ns', 'acme', 'hello'))).toBe(false); + }); + + it('recovers a half-written commit from the receipt instead of reclassifying', async () => { + await plantLegacyInstall('xd-feishu'); + manager.list(); + const stateRoot = path.join(workDir, 'ghosts-install-state'); + const store = new GhostInstallReceiptStore( + () => stateRoot, + async ({ parentDir, targetName, operation }) => { + if (operation === 'remove') { + await fs.promises.rm(path.join(parentDir, targetName), { recursive: true, force: true }); + } + }, + ); + const current = store.read('xd-feishu'); + expect(current.state).toBe('approved'); + if (current.state !== 'approved') return; + await store.write( + { ...current.receipt, namespace: 'xd' }, + { skillSourceDir: path.join(rootDir, 'xd-feishu'), requireSkillSnapshot: false, relId: 'xd-feishu' }, + ); + await expect(manager.commitPendingNamespace('xd-feishu', null, 'builtin')).resolves.toEqual({ + ok: true, + }); + expect(manager.list()[0]).toMatchObject({ + manifest: { id: 'xd-feishu' }, + namespace: 'xd', + }); + expect(manager.list()[0]?.namespaceMigration).toBeUndefined(); + }); + + it('skips the first namespace stamp while the plugin is busy', async () => { + await plantLegacyInstall('hello'); + const busyManager = createManager({ + isNamespaceMigrationBusy: () => true, + mutateSnapshot, + }); + busyManager.list(); + await expect(busyManager.commitPendingNamespace('hello', null, 'builtin')).resolves.toEqual({ + ok: false, + reason: 'busy', + }); + expect(busyManager.list()[0]?.namespaceMigration).toBe('pending'); + }); + + +}); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostNamespaceMigration.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostNamespaceMigration.test.ts new file mode 100644 index 00000000000..49a886f2aac --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostNamespaceMigration.test.ts @@ -0,0 +1,307 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + captureRecoveredNamespaceEntry, + censusNamespaceMigration, + dropNamespaceMigrationEntry, + classifyNamespaceMigration, + createNamespaceMigrationStore, + isCensusCandidate, + isPendingNamespaceGhost, + parseNamespaceMigrationLedger, + pendingNamespaceGhostIds, + planNamespaceCommit, + readNamespaceMigrationInstallOrigin, + readNamespaceMigrationMarketRecord, + resolveInstallAgainstPending, + type ClassifyNamespaceMigrationInput, + type NamespaceCensusCandidate, +} from '../ghostNamespaceMigration.js'; + +const NOW = '2026-09-22T12:00:00.000Z'; + +function candidate(ghostId: string, identitySource?: object): NamespaceCensusCandidate { + return { ghostId, relId: ghostId, ...(identitySource ? { identitySource } : {}) }; +} + +function classify( + partial: Partial & Pick, +) { + return classifyNamespaceMigration({ + builtin: false, + installOrigin: 'manual', + marketSyncCompleted: false, + marketRecord: null, + currentOrganization: null, + ...partial, + }); +} + +describe('isCensusCandidate', () => { + it('accepts root-dir installs whose identity has not recorded namespace', () => { + expect(isCensusCandidate(candidate('xd-feishu'))).toBe(true); + expect(isCensusCandidate(candidate('hello', { id: 'hello' }))).toBe(true); + expect(isCensusCandidate(candidate('hello', { namespace: null }))).toBe(false); + expect(isCensusCandidate(candidate('hello', { namespace: 'acme' }))).toBe(false); + expect(isCensusCandidate({ ghostId: 'hello', relId: '_ns/acme/hello' })).toBe(false); + expect(isCensusCandidate({ ghostId: 'BAD', relId: 'BAD' })).toBe(false); + }); +}); + +describe('censusNamespaceMigration', () => { + it('captures only legacy root installs the first time, then closes the door', () => { + const created = censusNamespaceMigration( + { kind: 'missing' }, + [ + candidate('xd-feishu'), + candidate('hello', { namespace: null }), + { ghostId: 'helper', relId: '_ns/acme/helper' }, + ], + NOW, + ); + expect(created.kind).toBe('created'); + if (created.kind !== 'created') return; + expect(Object.keys(created.ledger.entries)).toEqual(['xd-feishu']); + expect(created.ledger.entries['xd-feishu']?.status).toBe('pending'); + + const again = censusNamespaceMigration( + { kind: 'ok', ledger: created.ledger }, + [candidate('xd-feishu'), candidate('new-plugin')], + '2026-09-23T00:00:00.000Z', + ); + expect(again).toEqual({ kind: 'unchanged', ledger: created.ledger }); + + const duringUpdateBackup = censusNamespaceMigration( + { kind: 'ok', ledger: created.ledger }, + [], + '2026-09-23T00:00:00.000Z', + ); + expect(duringUpdateBackup).toEqual({ kind: 'unchanged', ledger: created.ledger }); + expect(dropNamespaceMigrationEntry(created.ledger, 'xd-feishu').entries).toEqual({}); + }); + + it('does not mistake inherited object keys for pending plugin ids', () => { + const created = censusNamespaceMigration({ kind: 'missing' }, [], NOW); + if (created.kind !== 'created') throw new Error('expected census'); + expect(isPendingNamespaceGhost(created.ledger, 'constructor')).toBe(false); + expect(dropNamespaceMigrationEntry(created.ledger, 'constructor')).toBe(created.ledger); + const captured = captureRecoveredNamespaceEntry(created.ledger, candidate('constructor'), NOW); + expect(isPendingNamespaceGhost(captured, 'constructor')).toBe(true); + }); + + it('does not recensus a corrupt or unreadable ledger', () => { + for (const kind of ['corrupt', 'unreadable'] as const) { + expect(censusNamespaceMigration({ kind }, [candidate('hello')], NOW)) + .toEqual({ kind: 'blocked', reason: kind }); + } + }); +}); + +describe('classifyNamespaceMigration', () => { + it('commits builtin and public/personal/custom market installs as root', () => { + const cases: [Partial & { ghostId: string }, string][] = [ + [{ ghostId: 'cindy-art', builtin: true }, 'builtin'], + [{ ghostId: 'helper', marketRecord: { scope: 'public', source: 'market', organizationId: null } }, 'market-public'], + [{ ghostId: 'helper', marketRecord: { scope: 'personal', source: 'market', organizationId: 'user-1' } }, 'market-personal'], + [{ ghostId: 'helper', marketRecord: { scope: 'public', source: 'git-market', organizationId: null } }, 'market-custom'], + ]; + for (const [input, basis] of cases) { + expect(classify(input)).toEqual({ kind: 'commit', namespace: null, basis }); + } + }); + + it('commits organization installs in place when orgSlug is a trusted current-org fact', () => { + const cases: [Partial & { ghostId: string }, ReturnType][] = [ + [{ ghostId: 'xd-feishu', marketRecord: { scope: 'organization', source: 'market', organizationId: 'org-xd', namespace: 'xd' }, + currentOrganization: { organizationId: 'org-xd', orgSlug: 'xd', pluginPrefix: 'xd' } }, + { kind: 'commit', namespace: 'xd', basis: 'market-organization' }], + [{ ghostId: 'helper', marketRecord: { scope: 'organization', source: 'market', organizationId: 'org-acme' }, + currentOrganization: { organizationId: 'org-acme', orgSlug: 'acme', pluginPrefix: 'acme' } }, + { kind: 'commit', namespace: 'acme', basis: 'market-organization' }], + [{ ghostId: 'helper', marketRecord: { scope: 'organization', source: 'market', organizationId: 'org-acme' }, + currentOrganization: { organizationId: 'org-other', orgSlug: 'other', pluginPrefix: 'oth' } }, + { kind: 'pending', reason: 'awaiting-organization-namespace' }], + ]; + for (const [input, expected] of cases) expect(classify(input)).toEqual(expected); + }); + + it('commits a known root namespace on the market record, and waits without market facts', () => { + expect(classify({ + ghostId: 'helper', + marketRecord: { scope: 'public', source: 'market', organizationId: null, namespace: null }, + })).toEqual({ kind: 'commit', namespace: null, basis: 'explicit-root' }); + expect(classify({ ghostId: 'xd-feishu' })).toEqual({ + kind: 'pending', + reason: 'awaiting-market-facts', + }); + }); + + it('commits explicit Forge self-tests to the current orgSlug', () => { + expect(classify({ + ghostId: 'acme-tool', installOrigin: 'agent-forge', + currentOrganization: { organizationId: 'org-acme', orgSlug: 'acme', pluginPrefix: 'acme' }, + })).toEqual({ kind: 'commit', namespace: 'acme', basis: 'forge-current-org' }); + }); + + it('commits unmatched manual installs only after a completed market sync', () => { + expect(classify({ ghostId: 'local-tool', marketSyncCompleted: false })).toEqual({ + kind: 'pending', + reason: 'awaiting-market-facts', + }); + expect(classify({ ghostId: 'local-tool', marketSyncCompleted: true })).toEqual({ + kind: 'commit', + namespace: null, + basis: 'manual-after-sync', + }); + }); + + it('keeps old installs pending when market records cannot be read or resolved', () => { + const organizationRecord = { scope: 'organization' as const, source: 'market' as const, organizationId: 'org-xd' }; + const unavailable = readNamespaceMigrationMarketRecord(() => { throw new Error('locked ledger'); }); + const ambiguous = readNamespaceMigrationMarketRecord(() => [organizationRecord, organizationRecord]); + expect(unavailable).toBeUndefined(); + expect(ambiguous).toBeUndefined(); + expect(readNamespaceMigrationMarketRecord(() => [])).toBeNull(); + for (const marketRecord of [unavailable, ambiguous]) { + expect(classify({ ghostId: 'xd-feishu', marketSyncCompleted: true, marketRecord })) + .toEqual({ kind: 'pending', reason: 'awaiting-market-facts' }); + } + expect(classify({ + ghostId: 'xd-feishu', + marketSyncCompleted: true, + marketRecord: readNamespaceMigrationMarketRecord(() => [organizationRecord]), + currentOrganization: { organizationId: 'org-xd', orgSlug: 'xd', pluginPrefix: 'xd' }, + })).toEqual({ kind: 'commit', namespace: 'xd', basis: 'market-organization' }); + }); + + it('does not treat a removed organization route as evidence for a manual replacement', () => { + const removed = { scope: 'organization' as const, source: 'market' as const, organizationId: 'org-acme', namespace: 'acme', installed: false }; + const record = readNamespaceMigrationMarketRecord(() => [removed]); + expect(classify({ + ghostId: 'helper', + marketRecord: record, + marketSyncCompleted: true, + installOrigin: 'manual', + currentOrganization: { organizationId: 'org-acme', orgSlug: 'acme', pluginPrefix: null }, + })).toEqual({ kind: 'commit', namespace: null, basis: 'manual-after-sync' }); + expect(classify({ + ghostId: 'helper', marketRecord: removed, marketSyncCompleted: true, installOrigin: 'manual', + })).toEqual({ kind: 'commit', namespace: null, basis: 'manual-after-sync' }); + }); + + it('does not turn an unreadable approved origin into a manual root install', () => { + const unavailable = readNamespaceMigrationInstallOrigin(() => { throw new Error('locked receipt'); }); + const currentOrganization = { organizationId: 'org-acme', orgSlug: 'acme', pluginPrefix: null }; + expect(unavailable).toBeUndefined(); + const cases: [Partial & { ghostId: string }, ReturnType][] = [ + [{ ghostId: 'acme-tool', installOrigin: unavailable }, { kind: 'pending', reason: 'awaiting-install-origin' }], + [{ ghostId: 'acme-tool', installOrigin: unavailable, builtin: true }, { kind: 'commit', namespace: null, basis: 'builtin' }], + [{ ghostId: 'acme-tool', installOrigin: unavailable, marketRecord: { scope: 'public', source: 'market', organizationId: null } }, + { kind: 'commit', namespace: null, basis: 'market-public' }], + [{ ghostId: 'acme-tool', installOrigin: readNamespaceMigrationInstallOrigin(() => 'agent-forge'), currentOrganization }, + { kind: 'pending', reason: 'awaiting-market-facts' }], + [{ ghostId: 'local-tool', installOrigin: readNamespaceMigrationInstallOrigin(() => 'manual') }, + { kind: 'commit', namespace: null, basis: 'manual-after-sync' }], + ]; + for (const [input, expected] of cases) { + expect(classify({ marketSyncCompleted: true, ...input })).toEqual(expected); + } + }); +}); + +describe('commit and install conflict', () => { + it('removes only captured pending ids after their receipts are committed', () => { + const created = censusNamespaceMigration({ kind: 'missing' }, [candidate('hello')], NOW); + if (created.kind !== 'created') throw new Error('expected census'); + const committed = dropNamespaceMigrationEntry(created.ledger, 'hello'); + expect(committed.entries).toEqual({}); + expect(pendingNamespaceGhostIds(committed)).toEqual([]); + expect(isPendingNamespaceGhost(committed, 'hello')).toBe(false); + expect(censusNamespaceMigration( + { kind: 'ok', ledger: committed }, [candidate('hello')], NOW, + )).toEqual({ kind: 'unchanged', ledger: committed }); + }); + + it('reads old committed entries without reopening the one-shot census', () => { + const parsed = parseNamespaceMigrationLedger({ + schemaVersion: 1, + censusedAt: NOW, + entries: { + hello: { ghostId: 'hello', relId: 'hello', capturedAt: NOW, status: 'committed', namespace: null, committedAt: NOW, basis: 'builtin' }, + helper: { ghostId: 'helper', relId: 'helper', capturedAt: NOW, status: 'pending' }, + }, + }); + expect(parsed?.entries).toEqual({ + helper: { ghostId: 'helper', relId: 'helper', capturedAt: NOW, status: 'pending' }, + }); + expect(censusNamespaceMigration({ kind: 'ok', ledger: parsed! }, [candidate('hello')], NOW).kind).toBe('unchanged'); + expect(parseNamespaceMigrationLedger({ + schemaVersion: 1, + censusedAt: NOW, + entries: { hello: { ghostId: 'hello', relId: 'hello', capturedAt: NOW, status: 'committed', namespace: 'INVALID', committedAt: NOW, basis: 'builtin' } }, + })).toBeNull(); + }); + + it('blocks a same-name org install until the pending identity is committed', () => { + type Input = Parameters[0]; + const waiting: Input['classification'][] = [ + { kind: 'pending', reason: 'awaiting-market-facts' }, + { kind: 'commit', namespace: null, basis: 'market-public' }, + ]; + for (const classification of waiting) { + expect(resolveInstallAgainstPending({ ghostId: 'hello', requestedNamespace: 'acme', pending: true, classification })) + .toMatchObject({ kind: 'wait' }); + } + const cases: [Omit, ReturnType][] = [ + [{ requestedNamespace: 'acme', pending: true, classification: { kind: 'commit', namespace: 'acme', basis: 'market-organization' } }, { kind: 'already-installed' }], + [{ requestedNamespace: null, pending: true, classification: null }, { kind: 'already-installed' }], + [{ requestedNamespace: 'acme', pending: false, classification: null }, { kind: 'proceed' }], + ]; + for (const [input, expected] of cases) { + expect(resolveInstallAgainstPending({ ghostId: 'hello', ...input })).toEqual(expected); + } + }); +}); + +describe('namespace migration store', () => { + let dir: string | null = null; + afterEach(() => { + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + dir = null; + }); + + it('round-trips a census and refuses to write over an unreadable path', () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ns-mig-')); + const filePath = path.join(dir, 'namespace-migration.v1.json'); + const store = createNamespaceMigrationStore(filePath); + expect(store.read()).toEqual({ kind: 'missing' }); + const created = censusNamespaceMigration({ kind: 'missing' }, [candidate('hello')], NOW); + if (created.kind !== 'created') throw new Error('expected census'); + store.write(created.ledger); + const read = store.read(); + expect(read.kind).toBe('ok'); + if (read.kind !== 'ok') return; + expect(parseNamespaceMigrationLedger(read.ledger)).toEqual(read.ledger); + expect(pendingNamespaceGhostIds(read.ledger)).toEqual(['hello']); + }); +}); + +describe('planNamespaceCommit', () => { + const cases: [string, Parameters[0], ReturnType][] = [ + ['recovers a receipt that already has namespace even when the plugin is busy', + { pending: true, busy: true, receiptNamespace: 'xd', requested: { namespace: null, basis: 'builtin' } }, + { kind: 'write-ledger-only', namespace: 'xd', basis: 'receipt-recovered' }], + ['blocks the first receipt write while the plugin is busy', + { pending: true, busy: true, requested: { namespace: 'acme', basis: 'market-organization' } }, + { kind: 'skip', reason: 'busy' }], + ['applies the requested namespace when the receipt is still legacy', + { pending: true, busy: false, requested: { namespace: 'acme', basis: 'market-organization' } }, + { kind: 'write-receipt-and-ledger', namespace: 'acme', basis: 'market-organization' }], + ]; + it.each(cases)('%s', (_name, input, expected) => { + expect(planNamespaceCommit(input)).toEqual(expected); + }); +}); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostOauthAccounts.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostOauthAccounts.test.ts index 9ec70b01365..73f9603cd91 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/ghostOauthAccounts.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostOauthAccounts.test.ts @@ -29,6 +29,12 @@ const DECL: GhostOauthDecl = { identity: { url: 'https://api.example.com/userinfo', labelPath: 'email' }, }; +/** §4.4: 特权不再随官方前缀默认放行,测试夹具显式授予。 */ +const FIRST_PARTY_HOST = { + isTokenBrokerAuthorized: () => true, + isHostPrimitiveAuthorized: () => true, +} as const; + function memoryVault( seed?: Record, ): GhostOauthVault & { data: Map } { @@ -154,6 +160,33 @@ describe('插件 OAuth clientId 迁移', () => { }); }); + it('migrates accounts stored under a namespaced vault id', () => { + const vaultId = '_ns__xd__xd-feishu'; + const vault = memoryVault(); + vault.store( + vaultId, + `${KEY}-accounts`, + JSON.stringify({ + defaultAccountId: 'acc-1', + accounts: [{ id: 'acc-1', label: 'a@b.com', status: 'connected', createdAt: 1 }], + }), + ); + const mgr = new GhostOauthAccountManager({ + vault, + fetchImpl: vi.fn() as unknown as typeof fetch, + openExternal: vi.fn(), + }); + expect( + mgr.expireAccountsForChangedClients( + oauthManifest('old-client'), + oauthManifest('new-client'), + vaultId, + ), + ).toBe(1); + expect(mgr.listAccounts(vaultId, KEY)[0]?.status).toBe('expired'); + expect(mgr.listAccounts(GHOST, KEY)).toEqual([]); + }); + it('clientId 未变化或用户使用自定义 clientId 时不改变账号状态', () => { const unchangedVault = memoryVault({ [`${KEY}-accounts`]: JSON.stringify({ @@ -753,6 +786,183 @@ describe('missingAuthScopes(快照推断)', () => { }); describe('connectAccount', () => { + it.each([ + ['same declaration on a replacement root instance', 'token'], + ['physical relocation during identity lookup', 'identity'], + ['approval replacement before the mutation lock', 'mutation'], + ['owner invalidation before the mutation lock', 'owner'], + ] as const)('rejects %s without committing tokens', async (_name, transition) => { + const ghostId = 'helper'; + const vault = memoryVault(); + const originalTarget = { ownerGeneration: 1, revision: 'approved-org', storagePart: ghostId }; + let currentTarget: typeof originalTarget | null = originalTarget; + let insideMutationLock = false; + const captureConnectTarget = vi.fn(() => currentTarget); + const isConnectTargetCurrent = vi.fn(( + _ghostId: string, _secretKey: string, _decl: GhostOauthDecl, expected?: unknown, + ) => expected === undefined || expected === currentTarget); + const decl = { ...DECL, clientId: 'fake-public-client' }; + const deps = { + vault, + captureConnectTarget, + isConnectTargetCurrent, + openExternal: autoBrowser(), + fetchImpl: (async (input) => { + if (String(input) === decl.tokenUrl) { + if (transition === 'token') { + currentTarget = { ownerGeneration: 1, revision: 'approved-root', storagePart: ghostId }; + } + return jsonResponse({ access_token: 'fake-org-access', refresh_token: 'fake-org-refresh', expires_in: 3600 }); + } + if (transition === 'identity') { + currentTarget = { ownerGeneration: 1, revision: 'approved-org', storagePart: '_ns__acme__helper' }; + } + return jsonResponse({ email: 'org@example.com' }); + }) as typeof fetch, + withMutationLock: async (_ghostId: string, task: () => Promise | Result) => { + insideMutationLock = true; + if (transition === 'mutation') { + currentTarget = { ownerGeneration: 1, revision: 'approved-new', storagePart: ghostId }; + } + if (transition === 'owner') currentTarget = null; + return task(); + }, + }; + const manager = new GhostOauthAccountManager(deps); + + await expect(manager.connectAccount(ghostId, KEY, decl)).resolves.toMatchObject({ + ok: false, error: 'INVALID_CONFIG', + }); + expect(captureConnectTarget).toHaveBeenCalledExactlyOnceWith(ghostId); + expect(isConnectTargetCurrent).toHaveBeenLastCalledWith(ghostId, KEY, decl, originalTarget); + expect(insideMutationLock).toBe(transition !== 'token'); + expect(vault.data.size).toBe(0); + expect(manager.listAccounts(ghostId, KEY)).toHaveLength(0); + await expect(manager.getFreshAccessToken(ghostId, KEY, decl)).resolves.toMatchObject({ + ok: false, error: 'NO_ACCOUNT', + }); + }); + + it.each([ + { revision: 'legacy-approval', storagePart: 'helper' }, + { namespace: null, revision: 'root-approval', storagePart: '_root__helper' }, + { namespace: 'acme', revision: 'relocated-approval', storagePart: '_ns__acme__helper' }, + ])('keeps an unchanged approved target usable: $revision', async (target) => { + const vault = memoryVault(); + let insideMutationLock = false; + const captureConnectTarget = vi.fn(() => target); + const isConnectTargetCurrent = vi.fn(( + _ghostId: string, _secretKey: string, _decl: GhostOauthDecl, expected?: unknown, + ) => expected === undefined || expected === target); + const decl = { ...DECL, clientId: 'fake-public-client' }; + const fetchImpl = vi.fn(async (input: string | URL | Request) => String(input) === decl.tokenUrl + ? jsonResponse({ access_token: 'fake-access', refresh_token: 'fake-refresh', expires_in: 3600 }) + : jsonResponse({ email: 'test@example.com' })); + const deps = { + vault, captureConnectTarget, isConnectTargetCurrent, fetchImpl: fetchImpl as typeof fetch, + openExternal: autoBrowser(), + withMutationLock: async (_ghostId: string, task: () => Promise | Result) => { + insideMutationLock = true; + return task(); + }, + }; + const manager = new GhostOauthAccountManager(deps); + const result = await manager.connectAccount(target.storagePart, KEY, decl); + + expect(result.ok).toBe(true); + expect(captureConnectTarget).toHaveBeenCalledExactlyOnceWith(target.storagePart); + expect(isConnectTargetCurrent).toHaveBeenLastCalledWith(target.storagePart, KEY, decl, target); + expect(insideMutationLock).toBe(true); + expect(manager.listAccounts(target.storagePart, KEY)).toHaveLength(1); + if (!result.ok) throw new Error('Expected the unchanged target to connect'); + expect(vault.read(target.storagePart, KEY + '-rt-' + result.account.id)).toBe('fake-refresh'); + const fetchCount = fetchImpl.mock.calls.length; + await expect(manager.getFreshAccessToken(target.storagePart, KEY, decl)).resolves.toMatchObject({ + ok: true, accessToken: 'fake-access', + }); + expect(fetchImpl).toHaveBeenCalledTimes(fetchCount); + }); + + it.each([null, undefined])('refuses an unavailable captured target (%s) before authorization', async (target) => { + const vault = memoryVault(); + const openExternal = vi.fn(autoBrowser()); + const fetchImpl = vi.fn(async (input: string | URL | Request) => String(input) === DECL.tokenUrl + ? jsonResponse({ access_token: 'fake-access', refresh_token: 'fake-refresh', expires_in: 3600 }) + : jsonResponse({ email: 'test@example.com' })); + const deps = { + vault, openExternal, fetchImpl: fetchImpl as typeof fetch, + captureConnectTarget: () => target, + isConnectTargetCurrent: () => true, + }; + const manager = new GhostOauthAccountManager(deps); + await expect(manager.connectAccount('helper', KEY, { ...DECL, clientId: 'fake-client' })).resolves.toMatchObject({ + ok: false, error: 'INVALID_CONFIG', + }); + expect(openExternal).not.toHaveBeenCalled(); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(vault.data.size).toBe(0); + }); + + it('does not recapture a replacement instance when the endpoint provides its original target', async () => { + const originalTarget = { revision: 'approved-org', storagePart: 'helper' }; + const replacementTarget = { revision: 'approved-root', storagePart: 'helper' }; + const captureConnectTarget = vi.fn(() => replacementTarget); + const openExternal = vi.fn(); + const vault = memoryVault(); + const deps = { + vault, captureConnectTarget, openExternal, + fetchImpl: vi.fn() as unknown as typeof fetch, + isConnectTargetCurrent: ( + _ghostId: string, _secretKey: string, _decl: GhostOauthDecl, expected?: unknown, + ) => expected === replacementTarget, + }; + const manager = new GhostOauthAccountManager(deps); + await expect(manager.connectAccount('helper', KEY, { ...DECL, clientId: 'fake-client' }, { + expectedConnectTarget: originalTarget, + })).resolves.toMatchObject({ ok: false, error: 'INVALID_CONFIG' }); + expect(captureConnectTarget).not.toHaveBeenCalled(); + expect(openExternal).not.toHaveBeenCalled(); + expect(vault.data.size).toBe(0); + }); + + it('fails closed when a captured target has no lifecycle verifier', async () => { + const vault = memoryVault(); + const openExternal = vi.fn(); + const deps = { + vault, openExternal, + fetchImpl: vi.fn() as unknown as typeof fetch, + captureConnectTarget: () => ({ revision: 'approved-org' }), + }; + const manager = new GhostOauthAccountManager(deps); + await expect(manager.connectAccount('helper', KEY, { ...DECL, clientId: 'fake-client' })).resolves.toMatchObject({ + ok: false, error: 'INVALID_CONFIG', + }); + expect(openExternal).not.toHaveBeenCalled(); + expect(vault.data.size).toBe(0); + }); + + it('keeps refresh token comparison independent of connect target capture', async () => { + const vault = seededVault(); + const captureConnectTarget = vi.fn(() => null); + const isConnectTargetCurrent = vi.fn(() => true); + const fetchImpl = vi.fn(async () => { + vault.remove(GHOST, KEY + '-rt-acc-1'); + return jsonResponse({ access_token: 'fake-stale-access', refresh_token: 'fake-stale-refresh', expires_in: 3600 }); + }); + const deps = { + vault, captureConnectTarget, isConnectTargetCurrent, + openExternal: vi.fn(), fetchImpl: fetchImpl as unknown as typeof fetch, + }; + const manager = new GhostOauthAccountManager(deps); + await expect(manager.getFreshAccessToken(GHOST, KEY, DECL)).resolves.toMatchObject({ + ok: false, error: 'AUTH_EXPIRED', + }); + expect(captureConnectTarget).not.toHaveBeenCalled(); + expect(isConnectTargetCurrent).toHaveBeenCalledWith(GHOST, KEY, DECL); + expect(vault.read(GHOST, KEY + '-rt-acc-1')).toBeNull(); + expect([...vault.data.values()]).not.toContain('fake-stale-refresh'); + }); + it.each(['boundary', 'policy'] as const)('does not commit OAuth tokens when %s becomes invalid during identity lookup', async (reason) => { const vault = memoryVault({ [`${KEY}-client-id`]: 'cid' }); const before = new Map(vault.data); @@ -791,21 +1001,22 @@ describe('connectAccount', () => { redirectPort: heldPort, }; const reclaimPort = vi.fn(async () => false); - const mkMgr = (): GhostOauthAccountManager => + const mkMgr = (hostPrimitive: boolean): GhostOauthAccountManager => new GhostOauthAccountManager({ vault: memoryVault(), fetchImpl: vi.fn() as unknown as typeof fetch, openExternal: vi.fn(), reclaimPort, + isHostPrimitiveAuthorized: () => hostPrimitive, }); // 第三方 id:门控挡住,占用直接报错,回收器(杀进程)绝不能被调用。 - await expect(mkMgr().connectAccount('evil-tools', KEY, decl)).resolves.toMatchObject({ + await expect(mkMgr(false).connectAccount('evil-tools', KEY, decl)).resolves.toMatchObject({ ok: false, error: 'LISTEN_FAILED', }); expect(reclaimPort).not.toHaveBeenCalled(); - // 官方前缀 id:回收器放行被调用(此处回收失败仍 LISTEN_FAILED,只验门控)。 - await expect(mkMgr().connectAccount('cindy-google', KEY, decl)).resolves.toMatchObject({ + // 经第一方宿主原语授权后才调用回收器(此处回收失败仍 LISTEN_FAILED,只验门控)。 + await expect(mkMgr(true).connectAccount('cindy-google', KEY, decl)).resolves.toMatchObject({ ok: false, error: 'LISTEN_FAILED', }); @@ -934,6 +1145,7 @@ describe('connectAccount', () => { vault, fetchImpl: fetchImpl as unknown as typeof fetch, openExternal: autoBrowser(), + ...FIRST_PARTY_HOST, }); const result = await mgr.connectAccount(GHOST, KEY, avatarDecl); @@ -1014,6 +1226,7 @@ describe('connectAccount', () => { vault: memoryVault({ [`${KEY}-client-id`]: 'cid' }), fetchImpl: fetchImpl as unknown as typeof fetch, openExternal: autoBrowser(), + ...FIRST_PARTY_HOST, }); const result = await mgr.connectAccount(GHOST, KEY, avatarDecl); expect(result.ok).toBe(true); @@ -1257,6 +1470,154 @@ describe('connectAccount', () => { }); }); +describe('invalidateGhost', () => { + it.each(['success', 'invalid-grant', 'missing-token'] as const)('blocks stale %s mutations after waiting for the OAuth lock', async (outcome) => { + const vault = seededVault('fake-same-refresh'); + if (outcome === 'missing-token') vault.remove(GHOST, KEY + '-rt-acc-1'); + const before = new Map(vault.data); + const manager: GhostOauthAccountManager = new GhostOauthAccountManager({ + vault, openExternal: vi.fn(), sleep: instantSleep, + fetchImpl: vi.fn(async () => outcome === 'success' + ? jsonResponse({ access_token: 'fake-stale-access', refresh_token: 'fake-stale-rotated', expires_in: 3600 }) + : jsonResponse({ error: 'invalid_grant' }, 400)) as unknown as typeof fetch, + withMutationLock: async (_ghostId, task) => { + manager.invalidateGhost(GHOST); + return task(); + }, + }); + await expect(manager.getFreshAccessToken(GHOST, KEY, DECL)).resolves.toMatchObject({ + ok: false, error: 'AUTH_EXPIRED', + }); + expect(vault.data).toEqual(before); + }); + + it('does not write old asynchronous identity backfill into restored credentials', async () => { + const vault = seededVault('fake-same-refresh'); + const before = new Map(vault.data); + let releaseIdentity!: (response: Response) => void; + const identityResponse = new Promise((resolve) => { releaseIdentity = resolve; }); + const fetchImpl = vi.fn(async (input: string | URL | Request) => String(input) === DECL.tokenUrl + ? jsonResponse({ access_token: 'fake-access', expires_in: 3600 }) + : identityResponse); + const manager = new GhostOauthAccountManager({ + vault, openExternal: vi.fn(), fetchImpl: fetchImpl as typeof fetch, + }); + const backfillTarget = manager as unknown as { + backfillIdentityExtras: (...parameters: unknown[]) => Promise; + }; + const runBackfill = backfillTarget.backfillIdentityExtras.bind(manager); + let backfillTask: Promise | null = null; + vi.spyOn(backfillTarget, 'backfillIdentityExtras').mockImplementation((...parameters) => { + backfillTask = runBackfill(...parameters); + return backfillTask; + }); + const decl = { ...DECL, identity: { ...DECL.identity!, displayTemplate: '{email}' } }; + await expect(manager.getFreshAccessToken(GHOST, KEY, decl)).resolves.toMatchObject({ ok: true }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(backfillTask).not.toBeNull(); + manager.invalidateGhost(GHOST); + releaseIdentity(jsonResponse({ email: 'old-owner@example.com' })); + await backfillTask; + expect(vault.data).toEqual(before); + }); + + it.each(['token', 'identity', 'mutation'] as const)('retires a connect during %s and permits a fresh connect after rollback', async (transition) => { + const vault = memoryVault(); + const target = { revision: 'approved-original' }; + const decl = { ...DECL, clientId: 'fake-client' }; + let shouldInvalidate = true; + const manager: GhostOauthAccountManager = new GhostOauthAccountManager({ + vault, openExternal: autoBrowser(), captureConnectTarget: () => target, + isConnectTargetCurrent: () => true, + fetchImpl: (async (input) => { + const tokenRequest = String(input) === decl.tokenUrl; + if (shouldInvalidate && transition === (tokenRequest ? 'token' : 'identity')) { + manager.invalidateGhost?.(GHOST); + } + return tokenRequest + ? jsonResponse({ access_token: 'fake-access', refresh_token: 'fake-refresh', expires_in: 3600 }) + : jsonResponse({ email: 'test@example.com' }); + }) as typeof fetch, + withMutationLock: async (_ghostId, task) => { + if (shouldInvalidate && transition === 'mutation') manager.invalidateGhost?.(GHOST); + return task(); + }, + }); + await expect(manager.connectAccount(GHOST, KEY, decl)).resolves.toMatchObject({ + ok: false, error: 'INVALID_CONFIG', + }); + expect(vault.data.size).toBe(0); + shouldInvalidate = false; + await expect(manager.connectAccount(GHOST, KEY, decl)).resolves.toMatchObject({ ok: true }); + expect(manager.listAccounts(GHOST, KEY)).toHaveLength(1); + }); + + it('clears only the invalidated ghost cache even when restored credentials are identical', async () => { + const vault = seededVault('fake-same-refresh'); + const otherGhostId = '_ns__acme__' + GHOST; + for (const [key, value] of [...vault.data]) { + vault.data.set(otherGhostId + key.slice(GHOST.length), value); + } + let fetchCount = 0; + const fetchImpl = vi.fn(async () => { + fetchCount += 1; + return jsonResponse({ access_token: 'fake-access-' + fetchCount, expires_in: 3600 }); + }); + const manager = new GhostOauthAccountManager({ + vault, openExternal: vi.fn(), fetchImpl: fetchImpl as unknown as typeof fetch, + }); + await expect(manager.getFreshAccessToken(GHOST, KEY, DECL)).resolves.toMatchObject({ accessToken: 'fake-access-1' }); + await expect(manager.getFreshAccessToken(otherGhostId, KEY, DECL)).resolves.toMatchObject({ accessToken: 'fake-access-2' }); + manager.invalidateGhost?.(GHOST); + await expect(manager.getFreshAccessToken(otherGhostId, KEY, DECL)).resolves.toMatchObject({ accessToken: 'fake-access-2' }); + await expect(manager.getFreshAccessToken(GHOST, KEY, DECL)).resolves.toMatchObject({ accessToken: 'fake-access-3' }); + expect(fetchImpl).toHaveBeenCalledTimes(3); + }); + + it('rejects an old refresh with an identical token and does not delete the new generation single flight', async () => { + const vault = seededVault('fake-same-refresh'); + let releaseOld!: (response: Response) => void; + let releaseNew!: (response: Response) => void; + const oldResponse = new Promise((resolve) => { releaseOld = resolve; }); + const newResponse = new Promise((resolve) => { releaseNew = resolve; }); + const fetchImpl = vi.fn().mockReturnValueOnce(oldResponse).mockReturnValueOnce(newResponse); + const manager = new GhostOauthAccountManager({ + vault, openExternal: vi.fn(), fetchImpl: fetchImpl as unknown as typeof fetch, + }); + const oldFlow = manager.getFreshAccessToken(GHOST, KEY, DECL); + manager.invalidateGhost?.(GHOST); + const newFlow = manager.getFreshAccessToken(GHOST, KEY, DECL); + expect(fetchImpl).toHaveBeenCalledTimes(2); + releaseOld(jsonResponse({ access_token: 'fake-old-access', refresh_token: 'fake-old-rotated', expires_in: 3600 })); + await expect(oldFlow).resolves.toMatchObject({ ok: false, error: 'AUTH_EXPIRED' }); + expect(vault.read(GHOST, KEY + '-rt-acc-1')).toBe('fake-same-refresh'); + const joinedNewFlow = manager.getFreshAccessToken(GHOST, KEY, DECL); + expect(fetchImpl).toHaveBeenCalledTimes(2); + releaseNew(jsonResponse({ access_token: 'fake-new-access', refresh_token: 'fake-new-rotated', expires_in: 3600 })); + await expect(newFlow).resolves.toMatchObject({ ok: true, accessToken: 'fake-new-access' }); + await expect(joinedNewFlow).resolves.toMatchObject({ ok: true, accessToken: 'fake-new-access' }); + expect(vault.read(GHOST, KEY + '-rt-acc-1')).toBe('fake-new-rotated'); + }); + + it('does not delete or expire restored credentials after an old invalid_grant', async () => { + const vault = seededVault('fake-same-refresh'); + const before = new Map(vault.data); + let releaseOld!: (response: Response) => void; + const response = new Promise((resolve) => { releaseOld = resolve; }); + const onAccountStatusChanged = vi.fn(); + const manager = new GhostOauthAccountManager({ + vault, openExternal: vi.fn(), sleep: instantSleep, onAccountStatusChanged, + fetchImpl: vi.fn(() => response) as unknown as typeof fetch, + }); + const oldFlow = manager.getFreshAccessToken(GHOST, KEY, DECL); + manager.invalidateGhost?.(GHOST); + releaseOld(jsonResponse({ error: 'invalid_grant' }, 400)); + await expect(oldFlow).resolves.toMatchObject({ ok: false, error: 'AUTH_EXPIRED' }); + expect(vault.data).toEqual(before); + expect(onAccountStatusChanged).not.toHaveBeenCalled(); + }); +}); + describe('内置 client 回落链', () => { const BAKED: GhostOauthDecl = { ...DECL, clientId: 'baked-cid', clientSecret: 'baked-sec' }; @@ -1637,6 +1998,7 @@ describe('多实例共库的 RT 轮换竞态(invalid_grant 防误删)', () => { openExternal: vi.fn(), broker: { exchange: vi.fn(), refresh }, sleep: instantSleep, + ...FIRST_PARTY_HOST, }); const brokerDecl: GhostOauthDecl = { authorizeUrl: 'https://auth.example.com/authorize', @@ -1769,6 +2131,7 @@ describe('tokenBroker 模式', () => { autoBrowser('c-bk')(url); }, broker: { exchange, refresh: vi.fn() }, + ...FIRST_PARTY_HOST, }); const result = await mgr.connectAccount(GHOST, KEY, BROKER_DECL); @@ -1780,6 +2143,21 @@ describe('tokenBroker 模式', () => { expect(fetchImpl.mock.calls.map((c) => String(c[0]))).not.toContain(BROKER_DECL.tokenUrl); }); + it('connect: missing first-party grant denies even an official-looking id', async () => { + const openExternal = vi.fn(); + const mgr = new GhostOauthAccountManager({ + vault: memoryVault(), + fetchImpl: vi.fn() as unknown as typeof fetch, + openExternal, + broker: { exchange: vi.fn(), refresh: vi.fn() }, + }); + await expect(mgr.connectAccount(GHOST, KEY, BROKER_DECL)).resolves.toMatchObject({ + ok: false, + error: 'BROKER_FORBIDDEN', + }); + expect(openExternal).not.toHaveBeenCalled(); + }); + it('clientConfigured:brokered + 内置 clientId 恒 true,与保险库无关', () => { const mgr = new GhostOauthAccountManager({ vault: memoryVault(), @@ -1812,6 +2190,7 @@ describe('tokenBroker 模式', () => { })), refresh: vi.fn(), }, + ...FIRST_PARTY_HOST, }); await expect( mgr.connectAccount(GHOST, KEY, BROKER_DECL, { clientId: 'global-cid' }), @@ -1824,6 +2203,7 @@ describe('tokenBroker 模式', () => { fetchImpl: vi.fn() as unknown as typeof fetch, openExternal: blockedOpenExternal, broker: { exchange: vi.fn(), refresh: vi.fn() }, + ...FIRST_PARTY_HOST, }); await expect( blocked.connectAccount(GHOST, KEY, BROKER_DECL, { clientId: 'foreign-cid' }), @@ -1851,6 +2231,7 @@ describe('tokenBroker 模式', () => { openExternal: vi.fn(), broker: { exchange: vi.fn(), refresh }, sleep: instantSleep, + ...FIRST_PARTY_HOST, }); await expect(mgr.getFreshAccessToken(GHOST, KEY, BROKER_DECL)).resolves.toMatchObject({ ok: false, @@ -1943,6 +2324,7 @@ describe('brokerBounce(双地址弹跳回调)', () => { fetchImpl: vi.fn() as unknown as typeof fetch, openExternal: openExternal1, broker: { exchange: vi.fn(), refresh: vi.fn() }, + ...FIRST_PARTY_HOST, }); await expect( mgrNoResolver.connectAccount(GHOST, KEY, bounceDecl(53699)), @@ -1960,6 +2342,7 @@ describe('brokerBounce(双地址弹跳回调)', () => { openExternal: openExternal2, broker: { exchange: vi.fn(), refresh: vi.fn() }, resolveBrokerPublicUrl: vi.fn(() => null), + ...FIRST_PARTY_HOST, }); await expect( mgrNullResolver.connectAccount(GHOST, KEY, bounceDecl(53699)), @@ -2014,6 +2397,7 @@ describe('brokerBounce(双地址弹跳回调)', () => { }, broker: { exchange, refresh: vi.fn() }, resolveBrokerPublicUrl, + ...FIRST_PARTY_HOST, }); const result = await mgr.connectAccount(GHOST, KEY, bounceDecl(freePort)); expect(result).toMatchObject({ ok: true }); @@ -2335,6 +2719,7 @@ describe('identity.avatarPath 头像回填', () => { vault, fetchImpl: avatarFetch() as unknown as typeof fetch, openExternal: vi.fn(), + ...FIRST_PARTY_HOST, }); await expect(mgr.getFreshAccessToken(GHOST, KEY, AVATAR_DECL)).resolves.toMatchObject({ ok: true, @@ -2419,6 +2804,7 @@ describe('identity.avatarPath 头像回填', () => { vault, fetchImpl: fetchImpl as unknown as typeof fetch, openExternal: vi.fn(), + ...FIRST_PARTY_HOST, }); await expect(mgr.getFreshAccessToken(GHOST, KEY, AVATAR_DECL)).resolves.toMatchObject({ ok: true, diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostPreferenceRelocation.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostPreferenceRelocation.test.ts new file mode 100644 index 00000000000..1d34ada056a --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostPreferenceRelocation.test.ts @@ -0,0 +1,353 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const owner = vi.hoisted(() => ({ root: '', id: 'owner-a', generation: 1, pending: false })); + +vi.mock('../../appSessionState.js', () => ({ + ownerScopedUserDataPath: (...parts: string[]) => path.join(owner.root, owner.id, ...parts), + activeOwnerScopeKey: () => `${owner.id}:${owner.generation}`, + isAppSessionBoundaryPending: () => owner.pending, +})); +vi.mock('../../maker-host/logger-adapter.js', () => ({ + desktopMakerLogger: { child: () => ({ info: vi.fn(), warn: vi.fn() }) }, +})); + +import * as locks from '../../device-link/crossProcessLock.js'; +import { + relocateGhostCindyPrefs, readGhostCindyOverrides, readGhostCindyInflightLimit, + writeGhostCindyOverride, writeGhostCindyInflightLimit, +} from '../cindyPrefsStore.js'; +import { + relocateGhostErrandPrefs, readGhostErrandConfig, readGhostErrandSessionId, + writeGhostErrandConfig, writeGhostErrandSessionId, +} from '../errandPrefsStore.js'; +import { relocateGhostPickedDirs, isGhostPickedDir, recordGhostPickedDir } from '../pickGrantsStore.js'; +import { relocateGhostUnread, readGhostUnread, markGhostUnread, clearGhostUnread } from '../ghostUnreadStore.js'; +import { updateGhostPrefsForRelocation } from '../ghostPreferenceRelocation.js'; + +const from = 'helper'; +const to = '_ns__acme__helper'; +const resources = [ + { + name: 'Cindy overrides and limits', + file: 'ghost-cindy-prefs.json', + relocate: relocateGhostCindyPrefs, + source: { overrides: { [from]: { 'image.generate': 'model', future: { value: 1 } } }, inflightLimits: { [from]: 2 } }, + destination: { overrides: { [to]: { 'image.generate': 'model', future: { value: 1 } } }, inflightLimits: { [to]: 2 } }, + collision: { overrides: { [from]: { 'image.generate': 'model' } }, inflightLimits: { [to]: 3 } }, + invalid: { overrides: [] }, + }, + { + name: 'errand configuration and all session keys', + file: 'ghost-errand-prefs.json', + relocate: relocateGhostErrandPrefs, + source: { + errand: { [from]: { permissionMode: 'acceptEdits', workingDir: '/project', future: true } }, + sessions: { [from]: 'shared-session', [`${from}#daily`]: 'daily-session', 'helper-other#daily': 'other-session' }, + }, + destination: { + errand: { [to]: { permissionMode: 'acceptEdits', workingDir: '/project', future: true } }, + sessions: { [to]: 'shared-session', [`${to}#daily`]: 'daily-session', 'helper-other#daily': 'other-session' }, + }, + collision: { errand: { [from]: { permissionMode: 'auto' } }, sessions: { [`${to}#other`]: 'other-session' } }, + invalid: { sessions: null }, + }, + { + name: 'picked directory grants', + file: 'ghost-pick-grants.json', + relocate: relocateGhostPickedDirs, + source: { grants: { [from]: ['/project'], unrelated: ['/other'] } }, + destination: { grants: { [to]: ['/project'], unrelated: ['/other'] } }, + collision: { grants: { [from]: ['/project'], [to]: ['/project'] } }, + invalid: { grants: 'unreadable' }, + }, + { + name: 'unread entries', + file: 'ghost-unread.json', + relocate: relocateGhostUnread, + source: { entries: { [from]: { summary: 'new activity', at: 100, future: true } } }, + destination: { entries: { [to]: { summary: 'new activity', at: 100, future: true } } }, + collision: { entries: { [from]: { at: 100 }, [to]: { at: 100 } } }, + invalid: { entries: [] }, + }, +]; + +function write(file: string, value: unknown, ownerId = owner.id): string { + const target = path.join(owner.root, ownerId, file); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, JSON.stringify(value)); + return target; +} + +const writers = [ + { + name: 'Cindy override', resource: resources[0], + prime: () => readGhostCindyOverrides(from), + write: (id: string) => writeGhostCindyOverride(id, 'image.generate', 'next-model'), + }, + { + name: 'Cindy limit', resource: resources[0], + prime: () => readGhostCindyInflightLimit(from), + write: (id: string) => writeGhostCindyInflightLimit(id, 4), + }, + { + name: 'errand config', resource: resources[1], + prime: () => readGhostErrandConfig(from), + write: (id: string) => writeGhostErrandConfig(id, { permissionMode: 'auto' }), + }, + ...[undefined, 'daily'].map((sessionKey) => ({ + name: 'errand session ' + (sessionKey ?? 'shared'), resource: resources[1], + prime: () => readGhostErrandSessionId(from, sessionKey), + write: (id: string) => writeGhostErrandSessionId(id, 'next-session', sessionKey), + })), + { + name: 'picked directory', resource: resources[2], + prime: () => isGhostPickedDir(from, '/project'), + write: (id: string) => recordGhostPickedDir(id, '/next-project'), + }, + { + name: 'unread mark', resource: resources[3], + prime: () => readGhostUnread(from), + write: (id: string) => markGhostUnread(id, 'next activity', 200), + }, + { + name: 'unread clear', resource: resources[3], + prime: () => readGhostUnread(from), + write: (id: string) => clearGhostUnread(id), + }, +]; + +describe.each(writers)('$name relocation write gate', ({ resource, prime, write: mutate }) => { + it.each([from, 'another-plugin'])('blocks %s during actual lock release, even with unchanged mtime', async (id) => { + const file = write(resource.file, resource.source); + const time = new Date('2000-01-01T00:00:00Z'); + fs.utimesSync(file, time, time); + prime(); + const rename = fs.promises.rename; + let attempted = false; + let failure: unknown; + vi.spyOn(fs.promises, 'rename').mockImplementation(async (source, destination) => { + if (String(source) === file + '.lock' && String(destination).startsWith(file + '.lock.release-')) { + attempted = true; + fs.utimesSync(file, time, time); + try { mutate(id); } catch (error) { failure = error; } + } + await rename(source, destination); + }); + await resource.relocate(from, to); + expect(attempted).toBe(true); + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toMatch(/relocating/); + expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toEqual(resource.destination); + expect(() => mutate('another-plugin')).not.toThrow(); + const contents = fs.readFileSync(file, 'utf8'); + expect(contents).not.toContain('"' + from + '"'); + expect(contents).toContain('"' + to + '"'); + }); + + it.each(['write', 'release'])('recovers after a %s failure and refreshes caches before unblocking', async (stage) => { + const file = write(resource.file, resource.source); + const time = new Date('2000-01-01T00:00:00Z'); + fs.utimesSync(file, time, time); + prime(); + if (stage === 'write') { + const rename = fs.renameSync; + vi.spyOn(fs, 'renameSync').mockImplementation((source, destination) => { + if (String(destination) === file) throw new Error('simulated write failure'); + rename(source, destination); + }); + } else { + const withLock = locks.withCrossProcessLock; + vi.spyOn(locks, 'withCrossProcessLock').mockImplementationOnce(async (target, options, task, signal) => { + await withLock(target, options, task, signal); + fs.utimesSync(file, time, time); + throw new Error('simulated release failure'); + }); + } + await expect(resource.relocate(from, to)).rejects.toThrow('simulated ' + stage + ' failure'); + vi.restoreAllMocks(); + expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toEqual(stage === 'write' ? resource.source : resource.destination); + expect(() => mutate('another-plugin')).not.toThrow(); + if (stage === 'release') { + const committed = fs.readFileSync(file, 'utf8'); + expect(committed).not.toContain('"' + from + '"'); + expect(committed).toContain('"' + to + '"'); + } + await resource.relocate(from, to); + const contents = fs.readFileSync(file, 'utf8'); + expect(contents).not.toContain('"' + from + '"'); + expect(contents).toContain('"' + to + '"'); + }); + + it('allows another owner to write during release without changing the relocating owner', async () => { + const file = write(resource.file, resource.source); + const otherFile = write(resource.file, resource.source, 'owner-b'); + prime(); + const withLock = locks.withCrossProcessLock; + let attempted = false; + vi.spyOn(locks, 'withCrossProcessLock').mockImplementationOnce(async (target, options, task, signal) => { + const result = await withLock(target, options, task, signal); + owner.id = 'owner-b'; + try { + mutate(from); + attempted = true; + } finally { + owner.id = 'owner-a'; + } + return result; + }); + await resource.relocate(from, to); + expect(attempted).toBe(true); + expect(fs.existsSync(otherFile)).toBe(true); + expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toEqual(resource.destination); + expect(() => mutate('another-plugin')).not.toThrow(); + }); +}); + +it('keeps the gate through cache reset, rejects overlap, and clears it if the callback fails', async () => { + write('ghost-cindy-prefs.json', resources[0].source); + const reset = vi.fn(() => { + expect(() => writeGhostCindyInflightLimit(from, 4)).toThrow(/relocating/); + throw new Error('simulated cache reset failure'); + }); + const withLock = locks.withCrossProcessLock; + vi.spyOn(locks, 'withCrossProcessLock').mockImplementationOnce(async (target, options, task, signal) => { + await expect(relocateGhostCindyPrefs(from, to)).rejects.toThrow(/relocating/); + expect(() => writeGhostCindyInflightLimit(from, 4)).toThrow(/relocating/); + expect(() => writeGhostErrandSessionId(from, 'other-file-session')).not.toThrow(); + return withLock(target, options, task, signal); + }); + await expect(updateGhostPrefsForRelocation('ghost-cindy-prefs.json', () => ({}), reset)) + .rejects.toThrow(/simulated cache reset failure/); + expect(reset).toHaveBeenCalledOnce(); + expect(() => writeGhostCindyInflightLimit(from, 4)).not.toThrow(); + await relocateGhostCindyPrefs(from, to); +}); + +beforeEach(() => { + owner.root = fs.mkdtempSync(path.join(os.tmpdir(), 'cindy-plugin-prefs-relocation-')); + owner.id = 'owner-a'; + owner.generation = 1; + owner.pending = false; +}); + +afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(owner.root, { recursive: true, force: true }); +}); + +describe.each(resources)('$name relocation', (resource) => { + it('moves the complete identity, preserves unknown fields, replays and reverses', async () => { + const extra = { futureTopLevel: { enabled: true } }; + const file = write(resource.file, { ...resource.source, ...extra }); + await resource.relocate(from, to); + expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toEqual({ ...resource.destination, ...extra }); + await resource.relocate(from, to); + expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toEqual({ ...resource.destination, ...extra }); + await resource.relocate(to, from); + await resource.relocate(to, from); + expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toEqual({ ...resource.source, ...extra }); + }); + + it('refuses a destination collision without merging identities or grants', async () => { + const file = write(resource.file, resource.collision); + const before = fs.readFileSync(file, 'utf8'); + await expect(resource.relocate(from, to)).rejects.toThrow(/collision/); + expect(fs.readFileSync(file, 'utf8')).toBe(before); + }); + + it.each(['{invalid json', '[]'])('preserves an unreadable document: %s', async (contents) => { + const file = write(resource.file, {}); + fs.writeFileSync(file, contents); + await expect(resource.relocate(from, to)).rejects.toThrow(/unreadable/); + expect(fs.readFileSync(file, 'utf8')).toBe(contents); + }); + + it('refuses a malformed identity map', async () => { + const file = write(resource.file, resource.invalid); + const before = fs.readFileSync(file, 'utf8'); + await expect(resource.relocate(from, to)).rejects.toThrow(/unreadable/); + expect(fs.readFileSync(file, 'utf8')).toBe(before); + }); + + it('does not mutate another owner or recreate absent data', async () => { + const otherFile = write(resource.file, resource.source, 'owner-b'); + const before = fs.readFileSync(otherFile, 'utf8'); + await resource.relocate(from, to); + expect(fs.readFileSync(otherFile, 'utf8')).toBe(before); + expect(fs.existsSync(path.join(owner.root, owner.id, resource.file))).toBe(false); + }); + + it('rejects owner generation changes while acquiring the write lock', async () => { + const file = write(resource.file, resource.source); + const before = fs.readFileSync(file, 'utf8'); + vi.spyOn(locks, 'withCrossProcessLock').mockImplementationOnce(async (_file, _options, task) => { + owner.generation += 1; + return task({ held: true }); + }); + await expect(resource.relocate(from, to)).rejects.toThrow(/scope changed/); + expect(fs.readFileSync(file, 'utf8')).toBe(before); + }); + + it('does not write when the lock is unavailable', async () => { + const file = write(resource.file, resource.source); + const before = fs.readFileSync(file, 'utf8'); + vi.spyOn(locks, 'withCrossProcessLock').mockImplementationOnce(async (_file, _options, task) => + task({ held: false, reason: 'unavailable' }), + ); + await expect(resource.relocate(from, to)).rejects.toThrow(/busy/); + expect(fs.readFileSync(file, 'utf8')).toBe(before); + }); +}); + +it('moves directory authorization without letting a newly installed root inherit the grant', async () => { + write('ghost-pick-grants.json', { grants: { [from]: ['/project'] } }); + expect(isGhostPickedDir(from, '/project')).toBe(true); + await relocateGhostPickedDirs(from, to); + expect(isGhostPickedDir(from, '/project')).toBe(false); + expect(isGhostPickedDir(to, '/project')).toBe(true); +}); + +describe.each([ + { + resource: resources[0], + read: (id: string) => [readGhostCindyOverrides(id), readGhostCindyInflightLimit(id)], + empty: [{}, null], + }, + { + resource: resources[1], + read: (id: string) => [readGhostErrandConfig(id), readGhostErrandSessionId(id), readGhostErrandSessionId(id, 'daily')], + empty: [{}, null, null], + }, + { + resource: resources[2], + read: (id: string) => isGhostPickedDir(id, '/project'), + empty: false, + }, + { + resource: resources[3], + read: (id: string) => { + const entry = readGhostUnread(id); + return entry ? { summary: entry.summary, at: entry.at } : null; + }, + empty: null, + }, +])('$resource.name live reader', ({ resource, read, empty }) => { + it('does not expose the old identity even when the replacement has the same mtime', async () => { + const file = write(resource.file, resource.source); + const time = new Date('2026-09-01T00:00:00Z'); + fs.utimesSync(file, time, time); + const previous = read(from); + expect(previous).not.toEqual(empty); + await resource.relocate(from, to); + fs.utimesSync(file, time, time); + expect(read(from)).toEqual(empty); + expect(read(to)).toEqual(previous); + await resource.relocate(to, from); + fs.utimesSync(file, time, time); + expect(read(to)).toEqual(empty); + expect(read(from)).toEqual(previous); + }); +}); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostProductionCallbacksFixture.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostProductionCallbacksFixture.ts new file mode 100644 index 00000000000..be56509a695 --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostProductionCallbacksFixture.ts @@ -0,0 +1,54 @@ +import fs from 'node:fs'; +import ts from 'typescript'; + +const source = fs.readFileSync(new URL('../index.ts', import.meta.url), 'utf8'); +const ast = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, true); + +export function createGhostProductionCallbacks(options: { + functions?: string[]; + variables?: string[]; + callbacks?: Record; + initialize?: string; + transformCallback?: (source: string) => string; +}): (deps: Record) => Result { + const { functions = [], variables = [], callbacks = {} } = options; + const names = [...functions, ...variables, ...Object.keys(callbacks)]; + if (new Set(names).size !== names.length) throw new Error('Duplicate production export names'); + const declarations = new Map(); + const record = (name: string, declaration: string) => { + if (declarations.has(name)) throw new Error('Duplicate production declaration: ' + name); + declarations.set(name, declaration); + }; + const visit = (node: ts.Node): void => { + if (ts.isFunctionDeclaration(node) && node.name && functions.includes(node.name.text)) { + record(node.name.text, node.getText(ast).replace(/^export /, '')); + } + if (ts.isVariableDeclaration(node) && variables.includes(node.name.getText(ast))) { + record(node.name.getText(ast), 'const ' + node.getText(ast) + ';'); + } + if (ts.isCallExpression(node)) { + for (const [name, [callee, channel]] of Object.entries(callbacks)) { + if (node.expression.getText(ast) !== callee) continue; + const firstArgument = node.arguments[0]; + if (channel !== undefined && (!firstArgument || !ts.isStringLiteral(firstArgument) || + firstArgument.text !== channel)) continue; + const callback = node.arguments[channel === undefined ? 0 : 1]; + if (!callback) throw new Error('Production callback missing: ' + name); + const text = callback.getText(ast); + record(name, 'const ' + name + ' = ' + (options.transformCallback?.(text) ?? text) + ';'); + } + } + ts.forEachChild(node, visit); + }; + visit(ast); + const missing = names.filter((name) => !declarations.has(name)); + if (missing.length) throw new Error('Production declarations missing: ' + missing.join(', ')); + const compiled = ts.transpileModule( + (options.initialize ?? '') + '\n' + [...declarations.values()].join('\n'), + { compilerOptions: { target: ts.ScriptTarget.ES2022 } }, + ).outputText; + return (deps) => new Function( + 'deps', 'const {' + Object.keys(deps).join(',') + '} = deps;' + compiled + + ';return {' + names.join(',') + '};', + )(deps) as Result; +} diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostRecentUsageStore.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostRecentUsageStore.test.ts index e0c86ac52aa..1b45257f68c 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/ghostRecentUsageStore.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostRecentUsageStore.test.ts @@ -9,8 +9,16 @@ import { normalizeGhostRecentIds } from '../ghostRecentUsageStore'; describe('ghostRecentUsageStore', () => { it('keeps valid ids newest-first while removing invalid and duplicate values', () => { expect( - normalizeGhostRecentIds(['cindy-github', '', 'bad id', 'xd-mivo', 'cindy-github', 42]), - ).toEqual(['cindy-github', 'xd-mivo']); + normalizeGhostRecentIds([ + 'cindy-github', + '', + 'bad id', + 'xd-mivo', + '_ns__acme__helper', + 'cindy-github', + 42, + ]), + ).toEqual(['cindy-github', 'xd-mivo', '_ns__acme__helper']); }); it('bounds persisted history', () => { diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostRecommendations.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostRecommendations.test.ts index 8302ab99788..80538bf0358 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/ghostRecommendations.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostRecommendations.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import path from 'node:path'; import type { InstalledGhost } from '../../../shared/ghost'; import { validateGhostManifest } from '../../../shared/ghost'; import { buildGhostRecommendationSnapshot } from '../ghostRecommendationSnapshot'; @@ -6,18 +7,22 @@ import { buildGhostRecommendationSnapshot } from '../ghostRecommendationSnapshot const state = vi.hoisted(() => ({ owner: 'owner-a', buckets: new Map>(), + failRecentWrite: false, + failRecommendationRead: false, })); -vi.mock('../../appSessionState.js', () => ({ ownerScopedUserDataPath: () => state.owner })); +vi.mock('../../appSessionState.js', () => ({ ownerScopedUserDataPath: (...parts: string[]) => path.join(state.owner, ...parts) })); vi.mock('electron-store', () => ({ default: class { - constructor(private options: { cwd: string; defaults: Record }) { + constructor(private options: { cwd: string; name: string; defaults: Record }) { if (!state.buckets.has(options.cwd)) state.buckets.set(options.cwd, structuredClone(options.defaults)); } get(key: string) { + if (state.failRecommendationRead && this.options.name === 'ghost-recommendations') throw new Error('read unavailable'); return state.buckets.get(this.options.cwd)?.[key]; } set(key: string, value: unknown) { + if (state.failRecentWrite && this.options.name === 'ghost-recent-usage') throw new Error('disk unavailable'); state.buckets.get(this.options.cwd)![key] = structuredClone(value); } }, @@ -36,8 +41,10 @@ const ghost = { } as unknown as InstalledGhost; beforeEach(() => { state.owner = 'owner-a'; - state.buckets.set('owner-a', { entries: [] }); - state.buckets.set('owner-b', { entries: [] }); + state.failRecentWrite = false; + state.failRecommendationRead = false; + state.buckets.set('owner-a', { entries: [], ids: [] }); + state.buckets.set('owner-b', { entries: [], ids: [] }); }); describe('plugin recommendation state', () => { it('replaces, withdraws, preserves install priority and isolates owners', () => { @@ -62,6 +69,23 @@ describe('plugin recommendation state', () => { forgetGhostRecommendations('example'); expect(readGhostRecommendationEntries()).toEqual([]); }); + it('accepts namespaced instance ids used by the pipe binding', () => { + expect(replaceGhostRecommendations('_ns__xd__helper', [item])).toEqual({ ok: true }); + expect(readGhostRecommendationEntries()[0].id).toBe('_ns__xd__helper'); + }); + it('keeps same-name root and organization recommendations and history separate', () => { + const root = { ...ghost, manifest: { ...ghost.manifest, id: 'helper', name: 'Root' }, dir: '/ghosts/helper', namespace: null }; + const org = { ...ghost, manifest: { ...ghost.manifest, id: 'helper', name: 'Org' }, dir: '/ghosts/_ns/acme/helper', namespace: 'acme' }; + replaceGhostRecommendations('helper', [{ ...item, id: 'root' }]); + replaceGhostRecommendations('_ns__acme__helper', [{ ...item, id: 'org' }]); + markGhostRecommendationInstalled('_ns__acme__helper'); + const snapshot = buildGhostRecommendationSnapshot('owner-a', [root, org], readGhostRecommendationEntries(), ['_ns__acme__helper']); + expect(snapshot.sources.map((source) => [source.ghostId, source.items?.[0]?.id])).toEqual([ + ['helper', 'root'], ['_ns__acme__helper', 'org'], + ]); + expect(snapshot.recentIds).toEqual(['_ns__acme__helper']); + expect(snapshot.newlyInstalledId).toBe('_ns__acme__helper'); + }); it('rejects invalid replacement without losing previous tasks', () => { replaceGhostRecommendations('example', [item]); expect(replaceGhostRecommendations('example', [{ ...item, pluginId: 'other' }]).ok).toBe(false); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostSetupCoordinator.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostSetupCoordinator.test.ts index 719d0f43664..1c864c8099b 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/ghostSetupCoordinator.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostSetupCoordinator.test.ts @@ -194,7 +194,10 @@ function requiredNavigation(revision = 0): GhostSetupAssessment { }; } -function harness(initial: GhostSetupAssessment) { +function harness( + initial: GhostSetupAssessment, + extras?: { resolveStoreId?: (ghostId: string) => string }, +) { const changeBus = new GhostSetupChangeBus(); const broadcast = vi.fn(); const bridge = new GhostSetupInteractionBridge({ broadcast }); @@ -225,6 +228,7 @@ function harness(initial: GhostSetupAssessment) { createRequestId: () => `request-${++requestNumber}`, timeoutMs: 5_000, terminalGraceMs: 0, + ...(extras?.resolveStoreId ? { resolveStoreId: extras.resolveStoreId } : {}), }); return { bridge, @@ -451,6 +455,24 @@ describe('GhostSetupCoordinator', () => { await waiting; }); + it('wakes setup waiters from namespaced store ids emitted by plugin settings', async () => { + const h = harness(required(), { + resolveStoreId: (ghostId) => (ghostId === 'gmail' ? '_ns__xd__gmail' : ghostId), + }); + const waiting = h.coordinator.ensureReady({ + sessionId: 'session-1', + ghostId: 'gmail', + tool: 'search', + }); + await vi.waitFor(() => expect(h.bridge.pendingSnapshots()).toHaveLength(1)); + h.setAssessment(ready(4)); + h.changeBus.emit('_ns__xd__gmail', { source: 'oauth', ref: 'google' }); + await expect(waiting).resolves.toMatchObject({ + ok: true, + assessment: { state: 'ready', revision: 4 }, + }); + }); + it('submits inline Secret per request, re-assesses on change, and never snapshots the value', async () => { const h = harness(requiredInline()); const waiting = h.coordinator.ensureReady({ diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostSetupManifestTracker.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostSetupManifestTracker.test.ts index 69ef8a36105..d30a7acea0b 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/ghostSetupManifestTracker.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostSetupManifestTracker.test.ts @@ -28,6 +28,44 @@ function ghost( } describe('GhostSetupManifestTracker', () => { + it('tracks colliding ids independently by physical instance', () => { + const bus = new GhostSetupChangeBus(); + const onRoot = vi.fn(); + const onOrg = vi.fn(); + bus.subscribe('helper', onRoot); + bus.subscribe('_ns__acme__helper', onOrg); + const tracker = new GhostSetupManifestTracker(bus, () => true); + const root = ghost('helper'); + const org = { ...ghost('helper'), namespace: 'acme', dir: '/plugins/_ns/acme/helper' }; + tracker.seed([root, org]); + expect(tracker.note([{ ...root, enabled: false }, org])).toEqual(['helper']); + expect(onRoot).toHaveBeenCalledTimes(1); + expect(onOrg).not.toHaveBeenCalled(); + expect(tracker.note([{ ...root, enabled: false }, { ...org, enabled: false }])).toEqual(['_ns__acme__helper']); + expect(onOrg).toHaveBeenCalledTimes(1); + expect(tracker.note([{ ...root, enabled: false }])).toEqual(['_ns__acme__helper']); + expect(onOrg).toHaveBeenCalledTimes(2); + }); + + it('keeps the bare physical id for an in-place namespace stamp', () => { + const bus = new GhostSetupChangeBus(); + const listener = vi.fn(); + bus.subscribe('helper', listener); + const tracker = new GhostSetupManifestTracker(bus, () => true); + const org = { ...ghost('helper'), namespace: 'acme' }; + tracker.seed([org]); + expect(tracker.note([{ ...org, enabled: false }])).toEqual(['helper']); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('checks availability against each installed physical id', () => { + const availability = vi.fn((_instanceId: string) => true); + const tracker = new GhostSetupManifestTracker(new GhostSetupChangeBus(), availability); + tracker.seed([ghost('helper'), { ...ghost('helper'), namespace: 'acme', dir: '/plugins/_ns/acme/helper' }]); + expect(availability.mock.calls.map(([instanceId]) => instanceId)).toEqual([ + 'helper', '_ns__acme__helper', + ]); + }); it('emits only for install, update, enable/disable, and uninstall diffs', () => { const bus = new GhostSetupChangeBus(); const listener = vi.fn(); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostTrustedXdTarget.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostTrustedXdTarget.test.ts new file mode 100644 index 00000000000..b194d9fcc53 --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostTrustedXdTarget.test.ts @@ -0,0 +1,297 @@ +import { describe, expect, it } from 'vitest'; +import type { InstalledGhost } from '../../../shared/ghost.js'; +import type { GhostFirstPartyFactsLoad } from '../ghostFirstPartyFacts.js'; +import type { + GhostFirstPartyCurrentOrganization, + GhostFirstPartyFacts, + GhostFirstPartyMarketRecord, +} from '../ghostFirstPartyPrivilege.js'; +import { isTrustedXdGhostForScriptTarget } from '../ghostTrustedXdTarget.js'; + +const packageSha256 = 'a'.repeat(64); +const organization: GhostFirstPartyCurrentOrganization = { + organizationId: 'org-xd', + orgSlug: 'xd', + pluginPrefix: 'xd', +}; +const marketRecord: GhostFirstPartyMarketRecord = { + scope: 'organization', + organizationId: organization.organizationId, + source: 'market', + installed: true, + sha256: packageSha256, + approvedPackageSha256: packageSha256, +}; + +function installed(overrides: Partial = {}): InstalledGhost { + const ghost: InstalledGhost = { + manifest: { + schemaVersion: 2, + id: 'xd-feishu', + name: 'Feishu', + version: '1.0.0', + kind: 'chip', + entry: 'main.js', + }, + dir: '/tmp/brain/_ns/xd/xd-feishu', + namespace: 'xd', + enabled: true, + approval: { state: 'approved', revision: 'trusted-revision' }, + ...overrides, + }; + if (ghost.namespace === undefined) delete ghost.namespace; + return ghost; +} + +function ready(overrides: Partial = {}): GhostFirstPartyFactsLoad { + return { + kind: 'ready', + facts: { + ghostId: 'xd-feishu', + namespace: 'xd', + builtin: false, + marketRecord, + currentOrganization: organization, + installOrigin: 'manual', + ...overrides, + }, + }; +} + +function builtin(namespace: string | null): GhostFirstPartyFactsLoad { + return ready({ + namespace, + builtin: true, + marketRecord: null, + approvedPackageSha256: packageSha256, + trustedSource: { kind: 'builtin-official', ghostId: 'xd-feishu', namespace, packageSha256 }, + }); +} + +describe('trusted XD script target', () => { + it.each(['xd', undefined, null])('accepts verified XD namespace with orgSlug %s', (orgSlug) => { + expect( + isTrustedXdGhostForScriptTarget( + installed(), + ready({ + currentOrganization: { organizationId: organization.organizationId, orgSlug }, + }), + false, + ), + ).toBe(true); + }); + + it.each(['xd', undefined, null])( + 'accepts approved pending legacy with current XD identity %s', + (orgSlug) => { + expect( + isTrustedXdGhostForScriptTarget( + installed({ namespace: undefined }), + ready({ + namespace: null, + currentOrganization: { ...organization, orgSlug }, + }), + true, + ), + ).toBe(true); + }, + ); + + it.each(['xd', undefined])( + 'accepts pending trusted builtin with current XD identity %s', + (orgSlug) => { + const load = builtin(null); + if (load.kind === 'ready') load.facts.currentOrganization = { ...organization, orgSlug }; + expect(isTrustedXdGhostForScriptTarget(installed({ namespace: undefined }), load, true)).toBe( + true, + ); + }, + ); + + it('accepts trusted builtin evidence for explicit XD namespace', () => { + expect(isTrustedXdGhostForScriptTarget(installed(), builtin('xd'), false)).toBe(true); + }); + + it.each(['xd', undefined])( + 'rejects known root even with pending argument and slug %s', + (orgSlug) => { + expect( + isTrustedXdGhostForScriptTarget( + installed({ namespace: null }), + ready({ + namespace: null, + currentOrganization: { ...organization, orgSlug }, + }), + true, + ), + ).toBe(false); + }, + ); + + it('rejects unknown namespace outside the pending transition', () => { + expect( + isTrustedXdGhostForScriptTarget( + installed({ namespace: undefined }), + ready({ namespace: null }), + false, + ), + ).toBe(false); + }); + + it('does not treat an explicit undefined delivery field as missing legacy namespace', () => { + const ghost = installed({ namespace: undefined }); + ghost.namespace = undefined; + expect(isTrustedXdGhostForScriptTarget(ghost, ready({ namespace: null }), true)).toBe(false); + }); + + it.each([false, true])( + 'rejects explicit foreign slug despite prefix XD, pending %s', + (pending) => { + expect( + isTrustedXdGhostForScriptTarget( + installed({ namespace: pending ? undefined : 'xd' }), + ready({ + namespace: pending ? null : 'xd', + currentOrganization: { ...organization, orgSlug: 'foreign' }, + }), + pending, + ), + ).toBe(false); + }, + ); + + it.each([undefined, null, 'foreign'])( + 'rejects pending legacy without affirmative XD identity, prefix %s', + (pluginPrefix) => { + expect( + isTrustedXdGhostForScriptTarget( + installed({ namespace: undefined }), + ready({ + namespace: null, + currentOrganization: { organizationId: organization.organizationId, pluginPrefix }, + }), + true, + ), + ).toBe(false); + }, + ); + + it.each(['invalid', 'legacy-unapproved'] as const)( + 'rejects pending legacy approval %s', + (state) => { + expect( + isTrustedXdGhostForScriptTarget( + installed({ namespace: undefined, approval: { state } }), + ready({ namespace: null }), + true, + ), + ).toBe(false); + }, + ); + + it('rejects foreign delivery namespace even with trusted current-org market evidence', () => { + expect( + isTrustedXdGhostForScriptTarget( + installed({ namespace: 'foreign' }), + ready({ namespace: 'foreign' }), + true, + ), + ).toBe(false); + }); + + it.each([ + { organizationId: 'foreign-org' }, + { scope: 'public', organizationId: null }, + { source: 'local-market' }, + { source: 'git-market' }, + { source: 'legacy-adopted' }, + { installed: false }, + { sha256: 'b'.repeat(64) }, + { approvedPackageSha256: null }, + { sha256: 'invalid', approvedPackageSha256: 'invalid' }, + ] satisfies Partial[])( + 'rejects untrusted market evidence %j', + (overrides) => { + expect( + isTrustedXdGhostForScriptTarget( + installed(), + ready({ + marketRecord: { ...marketRecord, ...overrides }, + }), + false, + ), + ).toBe(false); + }, + ); + + it.each([false, true])('rejects new public XD name even on builtin roster %s', (builtin) => { + expect( + isTrustedXdGhostForScriptTarget( + installed({ namespace: undefined }), + ready({ + namespace: null, + builtin, + legacyFirstPartyEligible: true, + marketRecord: { ...marketRecord, scope: 'public', organizationId: null }, + }), + true, + ), + ).toBe(false); + }); + + it.each([ + { trustedSource: null }, + { approvedPackageSha256: 'b'.repeat(64) }, + { + trustedSource: { + kind: 'builtin-official', + ghostId: 'other-plugin', + namespace: 'xd', + packageSha256, + }, + }, + { + trustedSource: { + kind: 'builtin-official', + ghostId: 'xd-feishu', + namespace: null, + packageSha256, + }, + }, + ] satisfies Partial[])( + 'rejects builtin without matching receipt evidence %j', + (overrides) => { + const load = builtin('xd'); + if (load.kind === 'ready') Object.assign(load.facts, overrides); + expect(isTrustedXdGhostForScriptTarget(installed(), load, false)).toBe(false); + }, + ); + + it.each([false, true])('rejects anonymous identity with builtin evidence %s', (useBuiltin) => { + const load = useBuiltin ? builtin('xd') : ready(); + if (load.kind === 'ready') load.facts.currentOrganization = null; + expect(isTrustedXdGhostForScriptTarget(installed(), load, false)).toBe(false); + }); + + it.each([{ ghostId: 'other-plugin' }, { namespace: null }])( + 'rejects mismatched facts %j', + (overrides) => { + expect(isTrustedXdGhostForScriptTarget(installed(), ready(overrides), false)).toBe(false); + }, + ); + + it('rejects unavailable facts', () => { + expect( + isTrustedXdGhostForScriptTarget( + installed(), + { + kind: 'unavailable', + reason: 'market-installation-read-failed', + purpose: 'runtime', + action: 'load-without-privilege', + }, + false, + ), + ).toBe(false); + }); +}); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostUnreadProjection.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostUnreadProjection.test.ts index f028cf26086..dcb8d4dfc73 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/ghostUnreadProjection.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostUnreadProjection.test.ts @@ -15,7 +15,13 @@ import type { InstalledGhost } from '../../../shared/ghost'; function ghost( id: string, - opts: { enabled?: boolean; notify?: boolean; badge?: boolean } = {}, + opts: { + enabled?: boolean; + notify?: boolean; + badge?: boolean; + namespace?: string | null; + dir?: string; + } = {}, ): InstalledGhost { const badge = opts.badge ?? true; return { @@ -31,8 +37,9 @@ function ghost( ...(badge ? { badge: true } : {}), }, - dir: `/fake/${id}`, + dir: opts.dir ?? `/fake/${id}`, enabled: opts.enabled ?? true, + ...(Object.hasOwn(opts, 'namespace') ? { namespace: opts.namespace } : {}), } as InstalledGhost; } @@ -58,6 +65,31 @@ describe('ghostUnreadProjection', () => { expect(selectRevokedGhostUnreadIds(entries, [ghost('a', { enabled: false })])).toEqual([]); }); + it.each([true, false])('已迁移的组织插件按物理实例保留未读, enabled=%s', (enabled) => { + const installed = ghost('helper', { + enabled, namespace: 'acme', dir: '/fake/_ns/acme/helper', + }); + expect(selectRevokedGhostUnreadIds([{ ghostId: '_ns__acme__helper' }], [installed], true)) + .toEqual([]); + }); + + it.each([undefined, null, 'acme'])('未搬迁的实例按原物理键保留未读, namespace=%s', (namespace) => { + const installed = ghost('helper', { namespace }); + expect(selectRevokedGhostUnreadIds([{ ghostId: 'helper' }], [installed], true)).toEqual([]); + }); + + it('同名 root 与组织实例的权限分别撤销,不会互相保留或误删', () => { + const entries = [{ ghostId: 'helper' }, { ghostId: '_ns__acme__helper' }]; + const root = ghost('helper', { namespace: null }); + const organization = ghost('helper', { namespace: 'acme', dir: '/fake/_ns/acme/helper' }); + expect(selectRevokedGhostUnreadIds(entries, [ + ghost('helper', { namespace: null, badge: false }), organization, + ], true)).toEqual(['helper']); + expect(selectRevokedGhostUnreadIds(entries, [ + root, ghost('helper', { namespace: 'acme', dir: '/fake/_ns/acme/helper', badge: false }), + ], true)).toEqual(['_ns__acme__helper']); + }); + it('能力撤销进撤销名单:更新后不再声明 badge / 包已卸载', () => { const entries = [{ ghostId: 'revoked' }, { ghostId: 'noslot' }, { ghostId: 'gone' }, { ghostId: 'ok' }]; const ids = selectRevokedGhostUnreadIds(entries, [ diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostUserDataRelocation.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostUserDataRelocation.test.ts new file mode 100644 index 00000000000..31bc4e26796 --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostUserDataRelocation.test.ts @@ -0,0 +1,146 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; + +import type { GhostManifest } from '../../../shared/ghost.js'; +import type { GhostCardDb } from '../cardStoreDb.js'; +import { LayoutStore } from '../../layout/LayoutStore.js'; +import { createDefaultLayout, walkPanes } from '../../../shared/layoutTree.js'; +import { GhostManager } from '../GhostManager.js'; +import { createGhostInstallReceipt, GhostInstallReceiptStore, hashApprovedSkillContent } from '../ghostInstallReceipt.js'; +import { runGhostSnapshotWorkerRequest } from '../ghostSnapshotWorkerProcess.js'; +import { relocateGhostUserDataResources, type GhostUserDataRelocationResources } from '../ghostUserDataRelocation.js'; +import { writeTestCindyPackage } from './cindyPackageFixture.js'; + +const scope = vi.hoisted(() => ({ dir: '' })); +vi.mock('electron', () => ({ app: { getPath: () => scope.dir } })); +vi.mock('../../appSessionState.js', () => ({ + ownerScopedUserDataPath: (...parts: string[]) => path.join(scope.dir, ...parts), + activeOwnerScopeKey: () => scope.dir, + isAppSessionBoundaryPending: () => false, +})); +vi.mock('../../maker-host/logger-adapter.js', () => ({ + desktopMakerLogger: { child: () => ({ info: vi.fn(), warn: vi.fn() }) }, +})); + +const cindy = await import('../cindyPrefsStore.js'); +const errand = await import('../errandPrefsStore.js'); +const pick = await import('../pickGrantsStore.js'); +const workdir = await import('../ghostWorkdirPrefs.js'); +const unread = await import('../ghostUnreadStore.js'); +const cards = await import('../cardStoreDb.js'); +const schema = await import('../../localDb/schema.js'); +let rawDb: Database.Database; +let db: GhostCardDb; +let root: string; +let receipts: GhostInstallReceiptStore; +let layout: LayoutStore; +let resources: GhostUserDataRelocationResources; +const from = 'helper'; +const to = '_ns__acme__helper'; +const manifest = { + schemaVersion: 2, id: from, name: 'Helper', version: '1.0.0', + kind: 'chip', entry: 'main.js', slots: ['tool'], + tools: [{ name: 'do_thing', description: 'do' }], +} as GhostManifest; +const mutateSnapshot: NonNullable[1]> = async ({ parentDir, ...request }) => { + await runGhostSnapshotWorkerRequest(request, parentDir); +}; + +beforeEach(async () => { + scope.dir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'cindy-relocation-'))); + root = path.join(scope.dir, 'ghosts'); + receipts = new GhostInstallReceiptStore(() => path.join(scope.dir, 'ghosts-install-state'), mutateSnapshot); + rawDb = new Database(':memory:'); + rawDb.exec(fs.readFileSync(path.resolve(__dirname, '../../../../drizzle/0072_first_lightspeed.sql'), 'utf8')); + db = drizzle(rawDb, { schema }); + layout = new LayoutStore({ getFilePath: () => path.join(scope.dir, 'layout.json') }); + const initial = createDefaultLayout(); + if (initial.content.type !== 'split') throw new Error('expected default split'); + for (const child of initial.content.children) child.fraction = 0.4; + initial.content.children.push({ fraction: 0.2, node: { type: 'pane', id: 'org-placement', panelKind: 'ghost:helper' } }); + expect(layout.setLayout(initial)).toMatchObject({ persisted: true }); + const noOp = async () => {}; + resources = { + userDataPath: (...parts) => path.join(scope.dir, ...parts), + assertCurrent: () => {}, + secrets: noOp, libraryBinding: noOp, libraryMeta: noOp, + cindyPreferences: cindy.relocateGhostCindyPrefs, + errandPreferences: errand.relocateGhostErrandPrefs, + pickedDirectories: pick.relocateGhostPickedDirs, + workdirPreferences: workdir.relocateGhostWorkdirPrefs, + media: noOp, + cards: (source, destination) => cards.reassignGhostCards(source, destination, db), + unread: unread.relocateGhostUnread, + }; + fs.mkdirSync(path.join(root, from), { recursive: true }); + fs.writeFileSync(path.join(root, from, 'ghost.json'), JSON.stringify(manifest)); + fs.writeFileSync(path.join(root, from, 'main.js'), 'void 0;'); + await receipts.write(createGhostInstallReceipt({ + manifest, localeResources: {}, enabled: true, + trust: { level: 'unverified', publisherSigned: false, publisherVerified: false, reviewed: false }, + skillContentSha256: await hashApprovedSkillContent(manifest, path.join(root, from)), + }), { skillSourceDir: path.join(root, from) }); + fs.mkdirSync(path.join(scope.dir, 'ghost-kv')); + fs.writeFileSync(path.join(scope.dir, 'ghost-kv', from + '.json'), JSON.stringify({ private: 'org' })); + cindy.writeGhostCindyOverride(from, 'image.generate', 'org-model'); + errand.writeGhostErrandConfig(from, { permissionMode: 'auto', workingDir: '/org-workspace' }); + errand.writeGhostErrandSessionId(from, 'org-session'); + errand.writeGhostErrandSessionId(from, 'org-keyed-session', 'draft'); + pick.recordGhostPickedDir(from, '/org-picked'); + workdir.setGhostDisabledForWorkdir('/project', from, true); + unread.markGhostUnread(from, 'org activity', 100); + await cards.upsertGhostCard({ + callId: 'org-card', ghostId: from, sessionId: 'org-session', html: '

org

', + height: 240, v: 1, updatedAt: 1, + }, db); +}); + +afterEach(() => { rawDb.close(); fs.rmSync(scope.dir, { recursive: true, force: true }); }); + +it('installs a same-name root without changing legacy data or UI references', async () => { + const archive = vi.fn(); + const manager = new GhostManager({ getRootDir: () => root, onArchiveSourceState: archive }); + expect(await manager.commitPendingNamespace(from, 'acme', 'market-organization')).toEqual({ ok: true }); + const receiptBefore = fs.readFileSync(path.join(scope.dir, 'ghosts-install-state', from + '.json'), 'utf8'); + const packageFile = await writeTestCindyPackage(path.join(scope.dir, 'root.cindy'), manifest, { 'main.js': 'void 1;' }); + expect(await manager.install(packageFile)).toMatchObject({ ghost: { namespace: null, dir: path.join(root, '_root', from) } }); + expect(archive).not.toHaveBeenCalled(); + expect(fs.readFileSync(path.join(scope.dir, 'ghosts-install-state', from + '.json'), 'utf8')).toBe(receiptBefore); + expect(fs.readFileSync(path.join(scope.dir, 'ghost-kv', from + '.json'), 'utf8')).toBe(JSON.stringify({ private: 'org' })); + expect(fs.existsSync(path.join(scope.dir, 'ghost-kv', '_root__' + from + '.json'))).toBe(false); + expect(cindy.readGhostCindyOverrides(from)['image.generate']).toBe('org-model'); + expect(errand.readGhostErrandConfig(from)).toMatchObject({ permissionMode: 'auto', workingDir: '/org-workspace' }); + expect(errand.readGhostErrandSessionId(from, 'draft')).toBe('org-keyed-session'); + expect(unread.readGhostUnread(from)).toMatchObject({ summary: 'org activity', at: 100 }); + expect(await cards.getGhostCard('org-card', db)).toMatchObject({ ghostId: from }); + expect(walkPanes(layout.getLayout())).toContainEqual({ type: 'pane', id: 'org-placement', panelKind: 'ghost:helper' }); +}); + +it('archives and restores source-owned data without changing logical UI placement', async () => { + const archive = '_ns__cindy-archive-test__helper'; + await relocateGhostUserDataResources(from, archive, resources); + expect(errand.readGhostErrandSessionId(from)).toBeNull(); + expect(await cards.getGhostCard('org-card', db)).toMatchObject({ ghostId: archive }); + expect(unread.readGhostUnread(from)).toBeNull(); + expect(unread.readGhostUnread(archive)).toMatchObject({ summary: 'org activity', at: 100 }); + expect(walkPanes(layout.getLayout())).toContainEqual({ type: 'pane', id: 'org-placement', panelKind: 'ghost:helper' }); + await relocateGhostUserDataResources(archive, from, resources); + expect(errand.readGhostErrandSessionId(from, 'draft')).toBe('org-keyed-session'); + expect(await cards.getGhostCard('org-card', db)).toMatchObject({ ghostId: from }); + expect(unread.readGhostUnread(from)).toMatchObject({ summary: 'org activity', at: 100 }); +}); + +it('stops at an owner change after an asynchronous resource instead of writing later references', async () => { + let current = true; + const binding = vi.fn(); + const guarded = { ...resources, secrets: () => { current = false; }, libraryBinding: binding, + assertCurrent: () => { if (!current) throw new Error('owner changed'); }, + }; + await expect(relocateGhostUserDataResources(from, to, guarded)).rejects.toThrow('owner changed'); + expect(binding).not.toHaveBeenCalled(); + expect(errand.readGhostErrandSessionId(from)).toBe('org-session'); +}); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostVisibility.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostVisibility.test.ts new file mode 100644 index 00000000000..e528e8107e7 --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostVisibility.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; + +import type { InstalledGhost } from '../../../shared/ghost.js'; +import { classifyGhostVisibility, classifyInstalledGhostVisibility } from '../ghostVisibility.js'; + +function ghost(id: string, namespace?: string | null, enabled = true): InstalledGhost { + return { + manifest: { schemaVersion: 2, id, name: id, version: '1.0.0', kind: 'chip', entry: 'index.js', slots: [] }, + dir: namespace ? `/tmp/_ns/${namespace}/${id}` : `/tmp/${id}`, + enabled, + approval: { state: 'approved', revision: '00000000-0000-4000-8000-000000000001' }, + ...(namespace !== undefined ? { namespace } : {}), + } as InstalledGhost; +} + +describe('classifyGhostVisibility namespace', () => { + const root = ghost('helper', null); + const enterprise = ghost('helper', 'acme'); + const deps = { + listGhosts: () => [root, enterprise], + isAvailableForActiveSession: () => true, + isDisabledForWorkdir: () => false, + }; + + it('returns GHOST_AMBIGUOUS when the same ghostId has two instances and namespace is omitted', () => { + const result = classifyGhostVisibility('helper', null, deps); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errorCode).toBe('GHOST_AMBIGUOUS'); + expect(result.candidates).toEqual([ + { ghostId: 'helper', namespace: null }, + { ghostId: 'helper', namespace: 'acme' }, + ]); + }); + + it('selects the requested namespace when provided', () => { + expect(classifyGhostVisibility('helper', null, deps, 'acme')).toMatchObject({ + ok: true, + ghost: { dir: '/tmp/_ns/acme/helper' }, + }); + expect(classifyGhostVisibility('helper', null, deps, null)).toMatchObject({ + ok: true, + ghost: { dir: '/tmp/helper' }, + }); + }); + + it('checks workdir disable against the physical storage part', () => { + const org = ghost('helper', 'acme'); + const inPlace = { ...ghost('xd-feishu', 'xd'), dir: '/tmp/xd-feishu' }; + const disabled = new Set(['_ns__acme__helper', 'xd-feishu']); + const workdirDeps = { + listGhosts: () => [org, inPlace], + isAvailableForActiveSession: () => true, + isDisabledForWorkdir: (id: string) => disabled.has(id), + }; + expect(classifyGhostVisibility('helper', '/proj', workdirDeps, 'acme')).toMatchObject({ + ok: false, + errorCode: 'GHOST_DISABLED_IN_WORKDIR', + }); + expect(classifyGhostVisibility('xd-feishu', '/proj', workdirDeps, 'xd')).toMatchObject({ + ok: false, + errorCode: 'GHOST_DISABLED_IN_WORKDIR', + }); + }); + + it('resolves a storage-part instance id when namespace is omitted', () => { + expect(classifyGhostVisibility('_ns__acme__helper', null, deps)).toMatchObject({ + ok: true, + ghost: { dir: '/tmp/_ns/acme/helper' }, + }); + }); + + it.each(['pending', 'root', 'in-place', 'canonical', 'coexist'] as const)( + 'revalidates the selected physical instance in %s without erasing delivery state', (stage) => { + const selected = stage === 'pending' + ? { ...ghost('helper'), namespaceMigration: 'pending' as const } + : stage === 'root' ? root + : stage === 'in-place' ? { ...enterprise, dir: root.dir } : enterprise; + const others = stage === 'coexist' ? [root] : []; + const stageDeps = { ...deps, listGhosts: () => [selected, ...others] }; + expect(classifyInstalledGhostVisibility(selected, null, stageDeps)).toMatchObject({ ok: true, ghost: selected }); + const replacement = { ...selected, namespace: selected.namespace === null ? 'acme' : null }; + expect(classifyInstalledGhostVisibility(selected, null, { ...stageDeps, listGhosts: () => [replacement] })) + .toMatchObject({ ok: false, errorCode: 'GHOST_NOT_FOUND' }); + }, + ); +}); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostWebviewPartition.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostWebviewPartition.test.ts index 4734c046953..f9cf8b704e7 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/ghostWebviewPartition.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostWebviewPartition.test.ts @@ -7,6 +7,7 @@ vi.mock('../../appSessionState', () => ({ import { ghostPartition } from '../../../shared/ghost'; import { ownerScopedGhostPartition, + ownerScopedGhostPartitionForInstalledGhost, resolveGhostWebviewPartitionClaim, } from '../ghostWebviewPartition'; @@ -14,6 +15,14 @@ describe('ghost WebView Main partition', () => { const ownerA = { mode: 'cloud' as const, dataOwnerId: 'owner-a' }; const ownerB = { mode: 'cloud' as const, dataOwnerId: 'owner-b' }; + it('isolates approved receipts even when source version and panel HTML are unchanged', () => { + const original = { manifest: { id: 'source-change', version: '1.0.0' }, approval: { state: 'approved' as const, revision: 'receipt-a' } }; + const replacement = { ...original, approval: { state: 'approved' as const, revision: 'receipt-b' } }; + const originalPartition = ownerScopedGhostPartitionForInstalledGhost(original, ownerA); + expect(ownerScopedGhostPartitionForInstalledGhost(original, ownerA)).toBe(originalPartition); + expect(ownerScopedGhostPartitionForInstalledGhost(replacement, ownerA)).not.toBe(originalPartition); + }); + it('同 owner + ghost 稳定,不同 owner + 同 ghost 使用不同 session', () => { const partitionA = ownerScopedGhostPartition('same-ghost', ownerA); const partitionB = ownerScopedGhostPartition('same-ghost', ownerB); @@ -51,4 +60,27 @@ describe('ghost WebView Main partition', () => { ).toBeNull(); expect(resolveGhostWebviewPartitionClaim(undefined, ownerA)).toBeNull(); }); + + it('企业实例使用 storage part 作为 session 分区后缀', () => { + expect(ownerScopedGhostPartition('_ns__acme__helper', ownerA)).toBe( + 'cindy-ghost-owner:cloud:opaque-owner-a:_ns__acme__helper', + ); + expect(resolveGhostWebviewPartitionClaim(ghostPartition('_ns__acme__helper'), ownerA)).toEqual({ + ghostId: '_ns__acme__helper', + partition: 'cindy-ghost-owner:cloud:opaque-owner-a:_ns__acme__helper', + }); + expect(ownerScopedGhostPartition('_ns/acme/helper', ownerA)).toBeNull(); + }); + + it('迁移前的旧 root、原位企业实例与新 root 各自使用不同的会话', () => { + const legacy = { manifest: { id: 'helper' } }; + const organization = { manifest: { id: 'helper' }, namespace: 'acme' }; + const root = { manifest: { id: 'helper' }, namespace: null }; + expect(ownerScopedGhostPartitionForInstalledGhost(legacy, ownerA)) + .toBe('cindy-ghost-owner:cloud:opaque-owner-a:helper'); + expect(ownerScopedGhostPartitionForInstalledGhost(organization, ownerA)) + .toBe('cindy-ghost-owner:cloud:opaque-owner-a:_ns__acme__helper'); + expect(ownerScopedGhostPartitionForInstalledGhost(root, ownerA)) + .toBe('cindy-ghost-owner:cloud:opaque-owner-a:helper:root'); + }); }); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostWorkdirPrefs.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostWorkdirPrefs.test.ts index 07c586f2e33..81d04b44cf4 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/ghostWorkdirPrefs.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostWorkdirPrefs.test.ts @@ -4,14 +4,157 @@ * 兜底)见 mcp-integrations/__tests__/ghostWorkdirGate.test.ts。 */ -import { describe, it, expect, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; +import * as locks from '../../device-link/crossProcessLock.js'; + +const scope = vi.hoisted(() => ({ dir: '' })); vi.mock('electron', () => ({ app: { getPath: () => '/tmp/never-used-here' } })); vi.mock('../../maker-host/logger-adapter.js', () => ({ desktopMakerLogger: { child: () => ({ info: () => {}, warn: () => {}, error: () => {} }) }, })); +vi.mock('../../appSessionState.js', () => ({ + ownerScopedUserDataPath: (...parts: string[]) => path.join(scope.dir, ...parts), + activeOwnerScopeKey: () => scope.dir, + isAppSessionBoundaryPending: () => false, +})); + +const { + __testing, setGhostDisabledForWorkdir, listDisabledGhostIdsForWorkdir, + relocateGhostWorkdirPrefs, +} = await import('../ghostWorkdirPrefs'); + +describe('relocateGhostWorkdirPrefs', () => { + beforeEach(() => { + scope.dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cindy-ghost-prefs-relocate-')); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(scope.dir, { recursive: true, force: true }); + }); + + it('moves disabled projects to the namespaced identity without disabling a new root plugin', async () => { + setGhostDisabledForWorkdir('/project/a', 'helper', true); + setGhostDisabledForWorkdir('/project/b', 'helper', true); + await relocateGhostWorkdirPrefs('helper', '_ns__acme__helper'); + expect(listDisabledGhostIdsForWorkdir('/project/a')).toEqual(['_ns__acme__helper']); + expect(listDisabledGhostIdsForWorkdir('/project/b')).toEqual(['_ns__acme__helper']); + await relocateGhostWorkdirPrefs('helper', '_ns__acme__helper'); + expect(listDisabledGhostIdsForWorkdir('/project/a')).toEqual(['_ns__acme__helper']); + }); + + it('refuses existing destination disables without merging plugin preferences', async () => { + setGhostDisabledForWorkdir('/project/a', 'helper', true); + setGhostDisabledForWorkdir('/project/a', 'another', true); + setGhostDisabledForWorkdir('/project/a', '_ns__acme__helper', true); + setGhostDisabledForWorkdir('/project/b', '_ns__acme__helper', true); + await expect(relocateGhostWorkdirPrefs('helper', '_ns__acme__helper')).rejects.toThrow(/collision/); + expect(listDisabledGhostIdsForWorkdir('/project/a')).toEqual(['_ns__acme__helper', 'another', 'helper']); + expect(listDisabledGhostIdsForWorkdir('/project/b')).toEqual(['_ns__acme__helper']); + }); -const { __testing } = await import('../ghostWorkdirPrefs'); + it('replays the inverse without relying on an in-memory rollback closure', async () => { + setGhostDisabledForWorkdir('/project/a', 'helper', true); + setGhostDisabledForWorkdir('/project/b', 'helper', true); + await relocateGhostWorkdirPrefs('helper', '_ns__acme__helper'); + await relocateGhostWorkdirPrefs('_ns__acme__helper', 'helper'); + await relocateGhostWorkdirPrefs('_ns__acme__helper', 'helper'); + expect(listDisabledGhostIdsForWorkdir('/project/a')).toEqual(['helper']); + expect(listDisabledGhostIdsForWorkdir('/project/b')).toEqual(['helper']); + }); + + it('does not overwrite unreadable preferences; retry succeeds once repaired', async () => { + setGhostDisabledForWorkdir('/project/a', 'helper', true); + const file = path.join(scope.dir, 'ghost-workdir-prefs.json'); + fs.writeFileSync(file, '{bad json'); + await expect(relocateGhostWorkdirPrefs('helper', '_ns__acme__helper')).rejects.toThrow(/unreadable/); + expect(fs.readFileSync(file, 'utf8')).toBe('{bad json'); + fs.writeFileSync(file, JSON.stringify({ disabledByWorkdir: { '/project/a': ['helper'] } })); + await relocateGhostWorkdirPrefs('helper', '_ns__acme__helper'); + expect(listDisabledGhostIdsForWorkdir('/project/a')).toEqual(['_ns__acme__helper']); + }); + + it.each(['helper', 'another-plugin'])('blocks %s writes during lock release with unchanged mtime', async (id) => { + setGhostDisabledForWorkdir('/project/a', 'helper', true); + const file = path.join(scope.dir, 'ghost-workdir-prefs.json'); + const time = new Date('2000-01-01T00:00:00Z'); + fs.utimesSync(file, time, time); + listDisabledGhostIdsForWorkdir('/project/a'); + const rename = fs.promises.rename; + let attempted = false; + let failure: unknown; + vi.spyOn(fs.promises, 'rename').mockImplementation(async (source, destination) => { + if (String(source) === file + '.lock' && String(destination).startsWith(file + '.lock.release-')) { + attempted = true; + fs.utimesSync(file, time, time); + try { setGhostDisabledForWorkdir('/project/a', id, true); } catch (error) { failure = error; } + } + await rename(source, destination); + }); + await relocateGhostWorkdirPrefs('helper', '_ns__acme__helper'); + expect(attempted).toBe(true); + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toMatch(/relocating/); + expect(listDisabledGhostIdsForWorkdir('/project/a')).toEqual(['_ns__acme__helper']); + setGhostDisabledForWorkdir('/project/a', 'another-plugin', true); + expect(listDisabledGhostIdsForWorkdir('/project/a')).toEqual(['_ns__acme__helper', 'another-plugin']); + }); + + it.each(['write', 'release'])('recovers after %s failure without restoring stale cache', async (stage) => { + setGhostDisabledForWorkdir('/project/a', 'helper', true); + const file = path.join(scope.dir, 'ghost-workdir-prefs.json'); + const time = new Date('2000-01-01T00:00:00Z'); + fs.utimesSync(file, time, time); + listDisabledGhostIdsForWorkdir('/project/a'); + if (stage === 'write') { + const rename = fs.renameSync; + vi.spyOn(fs, 'renameSync').mockImplementation((source, destination) => { + if (String(destination) === file) throw new Error('simulated write failure'); + rename(source, destination); + }); + } else { + const withLock = locks.withCrossProcessLock; + vi.spyOn(locks, 'withCrossProcessLock').mockImplementationOnce(async (target, options, task, signal) => { + await withLock(target, options, task, signal); + fs.utimesSync(file, time, time); + throw new Error('simulated release failure'); + }); + } + await expect(relocateGhostWorkdirPrefs('helper', '_ns__acme__helper')) + .rejects.toThrow('simulated ' + stage + ' failure'); + vi.restoreAllMocks(); + setGhostDisabledForWorkdir('/project/a', 'another-plugin', true); + if (stage === 'release') { + expect(listDisabledGhostIdsForWorkdir('/project/a')).toEqual(['_ns__acme__helper', 'another-plugin']); + } + await relocateGhostWorkdirPrefs('helper', '_ns__acme__helper'); + expect(listDisabledGhostIdsForWorkdir('/project/a')).toEqual(['_ns__acme__helper', 'another-plugin']); + }); + + it('allows another owner to write while this owner releases its lock', async () => { + setGhostDisabledForWorkdir('/project/a', 'helper', true); + const originalDir = scope.dir; + const withLock = locks.withCrossProcessLock; + vi.spyOn(locks, 'withCrossProcessLock').mockImplementationOnce(async (target, options, task, signal) => { + const result = await withLock(target, options, task, signal); + scope.dir = path.join(originalDir, 'owner-b'); + try { + expect(setGhostDisabledForWorkdir('/project/b', 'helper', true)).toEqual(['helper']); + } finally { + scope.dir = originalDir; + } + return result; + }); + await relocateGhostWorkdirPrefs('helper', '_ns__acme__helper'); + expect(listDisabledGhostIdsForWorkdir('/project/a')).toEqual(['_ns__acme__helper']); + expect(JSON.parse(fs.readFileSync(path.join(originalDir, 'owner-b', 'ghost-workdir-prefs.json'), 'utf8'))) + .toEqual({ disabledByWorkdir: { '/project/b': ['helper'] } }); + }); +}); describe('normalizeWorkdirKey(纯字符串归一化,不碰 fs)', () => { it('Windows 形态:统一反斜杠、去尾分隔符、小写折叠', () => { diff --git a/apps/desktop/src/main/cindy-brain/__tests__/iosSimulatorPluginGate.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/iosSimulatorPluginGate.test.ts index d1b9e7230c6..63cdf05d78e 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/iosSimulatorPluginGate.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/iosSimulatorPluginGate.test.ts @@ -60,6 +60,39 @@ describe('iOS Simulator plugin Host gate', () => { ).toEqual({ allowed: true }); }); + it('denies a namespaced provider disabled for the working directory', () => { + const enterprise = { ...ghost('ios-simulator', true), namespace: 'xd' }; + expect(resolve([enterprise], { disabledInWorkdirIds: ['_ns__xd__ios-simulator'] })).toMatchObject({ + allowed: false, + errorCode: 'IOS_SIMULATOR_DISABLED', + data: { reason: 'disabled-in-workdir' }, + }); + }); + + it('keeps namespace available when only root is disabled for the working directory', () => { + const root = { ...ghost('ios-simulator', true), namespace: null }; + const enterprise = { ...ghost('ios-simulator', true), namespace: 'xd' }; + expect(resolve([root, enterprise], { disabledInWorkdirIds: ['ios-simulator'] })).toEqual({ allowed: true }); + }); + + it('checks session availability using the namespaced instance', () => { + const enterprise = { ...ghost('ios-simulator', true), namespace: 'xd' }; + expect(resolve([enterprise], { unavailableIds: ['_ns__xd__ios-simulator'] })).toMatchObject({ + allowed: false, + data: { reason: 'session-unavailable' }, + }); + expect(resolve([enterprise], { unavailableIds: ['ios-simulator'] })).toEqual({ allowed: true }); + }); + + it('keeps the physical key for an in-place migrated provider', () => { + const enterprise = { ...ghost('ios-simulator', true), namespace: 'xd', dir: '/fake/ghosts/ios-simulator' }; + expect(resolve([enterprise], { disabledInWorkdirIds: ['ios-simulator'] })).toMatchObject({ + allowed: false, + data: { reason: 'disabled-in-workdir' }, + }); + expect(resolve([enterprise], { disabledInWorkdirIds: ['_ns__xd__ios-simulator'] })).toEqual({ allowed: true }); + }); + it('does not treat an unrelated enabled plugin as a capability provider', () => { expect(resolve([ghost('ordinary-plugin', true, false)])).toMatchObject({ allowed: false, diff --git a/apps/desktop/src/main/cindy-brain/__tests__/legacyGhostRecoveryOrchestration.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/legacyGhostRecoveryOrchestration.test.ts index eeaf3ecb23d..8a0f04f88e0 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/legacyGhostRecoveryOrchestration.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/legacyGhostRecoveryOrchestration.test.ts @@ -61,5 +61,9 @@ describe('legacy Ghost recovery acknowledgement orchestration', () => { expect(acknowledgementBlock).toContain( 'recoveredLegacyIds.filter((id) => !pending.has(id) && !failed.has(id))', ); + expect(acknowledgementBlock.indexOf('captureRecoveredLegacyNamespace(')).toBeGreaterThan(0); + expect(acknowledgementBlock.indexOf('captureRecoveredLegacyNamespace(')).toBeLessThan( + acknowledgementBlock.indexOf('await acknowledgeRecoveredLegacyGhosts('), + ); }); }); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryBinding.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryBinding.test.ts index 9fadc3e5c9b..789c4df8c47 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryBinding.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryBinding.test.ts @@ -3,12 +3,12 @@ * 候选位置校验(受管根排斥/UNC 拒/云盘警告/可写探针)、损坏文件回落默认。 * 注入 deps + os.tmpdir,零 Electron。identity 用例带平台能力探针。 */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { LibraryBindingStore, validateLibraryCandidateLocation, type LibraryBindingDeps } from '../libraryBinding.js'; +import { LibraryBindingStore, assertLibraryMetaOwner, relocateLibraryMetaOwner, validateLibraryCandidateLocation, type LibraryBindingDeps } from '../libraryBinding.js'; const GHOST_ID = 'mivo-canvas'; @@ -36,9 +36,231 @@ describe('LibraryBindingStore', () => { }); afterEach(async () => { + vi.restoreAllMocks(); await fs.promises.rm(tmp, { recursive: true, force: true }); }); + async function interruptRelocation(stage: 'before-folder' | 'folder' | 'binding'): Promise { + const store = new LibraryBindingStore(deps); + await store.setBinding('hello', candidate); + await fs.promises.mkdir(path.join(candidate, 'hello')); + await fs.promises.writeFile(path.join(candidate, 'hello', 'keep.txt'), 'original'); + const rename = fs.promises.rename.bind(fs.promises); + let bindingWrites = 0; + const interrupted = vi.spyOn(fs.promises, 'rename').mockImplementation(async (from, to) => { + if (stage === 'before-folder' && path.basename(String(to)) === '_ns__acme__hello') { + throw new Error('simulated process interruption'); + } + await rename(from, to); + if (to === bindingFile) bindingWrites += 1; + if ((stage === 'folder' && path.basename(String(to)) === '_ns__acme__hello') || + (stage === 'binding' && to === bindingFile && bindingWrites === 2)) { + throw new Error('simulated process interruption'); + } + }); + await expect(store.relocateBinding('hello', '_ns__acme__hello')).rejects.toThrow('interruption'); + interrupted.mockRestore(); + } + + it('recovers a durable custom relocation after rename but before binding commit', async () => { + await interruptRelocation('folder'); + const restarted = new LibraryBindingStore(deps); + await expect(restarted.assertCanRelocateBinding('hello', '_ns__acme__hello')).resolves.toBeUndefined(); + await restarted.relocateBinding('hello', '_ns__acme__hello'); + expect(await restarted.getBinding('hello')).toBeNull(); + expect((await restarted.getBinding('_ns__acme__hello'))?.generation).toBe(1); + expect(await fs.promises.readFile(path.join(candidate, '_ns__acme__hello', 'keep.txt'), 'utf8')).toBe('original'); + expect(JSON.parse(await fs.promises.readFile(bindingFile, 'utf8')).pendingRelocation).toBeUndefined(); + }); + + it('recovers binding committed before marker cleanup and supports reverse rollback', async () => { + await interruptRelocation('binding'); + const interrupted = JSON.parse(await fs.promises.readFile(bindingFile, 'utf8')); + expect(interrupted.bindings.hello).toBeUndefined(); + expect(interrupted.bindings._ns__acme__hello.generation).toBe(1); + expect(interrupted.pendingRelocation).toBeDefined(); + const restarted = new LibraryBindingStore(deps); + await restarted.relocateBinding('hello', '_ns__acme__hello'); + expect(await restarted.getBinding('hello')).toBeNull(); + expect((await restarted.getBinding('_ns__acme__hello'))?.generation).toBe(1); + await restarted.relocateBinding('_ns__acme__hello', 'hello'); + expect((await restarted.getBinding('hello'))?.generation).toBe(1); + expect(await restarted.getBinding('_ns__acme__hello')).toBeNull(); + expect(await fs.promises.readFile(path.join(candidate, 'hello', 'keep.txt'), 'utf8')).toBe('original'); + }); + + it('supports reverse rollback directly from an interrupted forward rename', async () => { + await interruptRelocation('folder'); + const restarted = new LibraryBindingStore(deps); + await restarted.relocateBinding('_ns__acme__hello', 'hello'); + expect((await restarted.getBinding('hello'))?.generation).toBe(1); + expect(await restarted.getBinding('_ns__acme__hello')).toBeNull(); + expect(await fs.promises.readFile(path.join(candidate, 'hello', 'keep.txt'), 'utf8')).toBe('original'); + }); + + it('rejects a foreign target even with an unfinished relocation journal and retries after restoration', async () => { + await interruptRelocation('folder'); + const target = path.join(candidate, '_ns__acme__hello'); + const original = path.join(candidate, 'saved-original'); + await fs.promises.rename(target, original); + await fs.promises.mkdir(target); + await fs.promises.writeFile(path.join(target, 'foreign.txt'), 'foreign'); + const restarted = new LibraryBindingStore(deps); + await expect(restarted.relocateBinding('hello', '_ns__acme__hello')).rejects.toThrow('directory identity changed'); + await expect(restarted.getBinding('_ns__acme__hello')).rejects.toThrow('directory identity changed'); + expect(await fs.promises.readFile(path.join(target, 'foreign.txt'), 'utf8')).toBe('foreign'); + expect(JSON.parse(await fs.promises.readFile(bindingFile, 'utf8')).bindings.hello.generation).toBe(1); + await fs.promises.rm(target, { recursive: true }); + await fs.promises.rename(original, target); + await restarted.relocateBinding('hello', '_ns__acme__hello'); + expect(await fs.promises.readFile(path.join(target, 'keep.txt'), 'utf8')).toBe('original'); + }); + + it('rejects a changed parent identity without rewriting the binding or marker', async () => { + await interruptRelocation('folder'); + const contents = await fs.promises.readFile(bindingFile, 'utf8'); + const saved = path.join(tmp, 'saved-parent'); + await fs.promises.rename(candidate, saved); + await fs.promises.mkdir(candidate); + const restarted = new LibraryBindingStore(deps); + await expect(restarted.relocateBinding('hello', '_ns__acme__hello')).rejects.toThrow('root identity changed'); + expect(await fs.promises.readFile(bindingFile, 'utf8')).toBe(contents); + await fs.promises.rm(candidate, { recursive: true }); + await fs.promises.rename(saved, candidate); + await restarted.relocateBinding('hello', '_ns__acme__hello'); + expect((await restarted.getBinding('_ns__acme__hello'))?.generation).toBe(1); + }); + + it('rejects a replaced source identity before a recorded rename', async () => { + await interruptRelocation('before-folder'); + const source = path.join(candidate, 'hello'); + const original = path.join(candidate, 'saved-original'); + await fs.promises.rename(source, original); + await fs.promises.mkdir(source); + await fs.promises.writeFile(path.join(source, 'foreign.txt'), 'foreign'); + const restarted = new LibraryBindingStore(deps); + await expect(restarted.relocateBinding('hello', '_ns__acme__hello')).rejects.toThrow('directory identity changed'); + expect(await fs.promises.readFile(path.join(source, 'foreign.txt'), 'utf8')).toBe('foreign'); + expect(fs.existsSync(path.join(candidate, '_ns__acme__hello'))).toBe(false); + await fs.promises.rm(source, { recursive: true }); + await fs.promises.rename(original, source); + await restarted.relocateBinding('hello', '_ns__acme__hello'); + expect(await fs.promises.readFile(path.join(candidate, '_ns__acme__hello', 'keep.txt'), 'utf8')).toBe('original'); + }); + + it.each(['generation', 'owner', 'malformed', 'schema'] as const)('rejects %s journal drift', async (drift) => { + await interruptRelocation('folder'); + const data = JSON.parse(await fs.promises.readFile(bindingFile, 'utf8')); + if (drift === 'generation') data.bindings.hello.generation += 1; + if (drift === 'owner') data.pendingRelocation.ownerFile = path.join(tmp, 'other-owner', 'libraries-binding.json'); + if (drift === 'malformed') data.pendingRelocation.libraryIdentity = { dev: 1, ino: 0 }; + if (drift === 'schema') data.version = 2; + const contents = JSON.stringify(data); + await fs.promises.writeFile(bindingFile, contents); + await expect(new LibraryBindingStore(deps).relocateBinding('hello', '_ns__acme__hello')).rejects.toThrow( + drift === 'generation' ? 'generation changed' : 'journal is invalid', + ); + expect(await fs.promises.readFile(bindingFile, 'utf8')).toBe(contents); + expect(await fs.promises.readFile(path.join(candidate, '_ns__acme__hello', 'keep.txt'), 'utf8')).toBe('original'); + }); + + it('retains intent after a rename error and recovers only the recorded source', async () => { + const store = new LibraryBindingStore(deps); + await store.setBinding('hello', candidate); + await fs.promises.mkdir(path.join(candidate, 'hello')); + await fs.promises.writeFile(path.join(candidate, 'hello', 'keep.txt'), 'original'); + const rename = fs.promises.rename.bind(fs.promises); + const failure = vi.spyOn(fs.promises, 'rename').mockImplementation(async (from, to) => { + if (path.basename(String(to)) === '_ns__acme__hello') throw new Error('rename unavailable'); + await rename(from, to); + }); + await expect(store.relocateBinding('hello', '_ns__acme__hello')).rejects.toThrow('rename unavailable'); + failure.mockRestore(); + expect(JSON.parse(await fs.promises.readFile(bindingFile, 'utf8')).pendingRelocation).toBeDefined(); + const restarted = new LibraryBindingStore(deps); + expect((await restarted.getBinding('_ns__acme__hello'))?.generation).toBe(1); + expect(await fs.promises.readFile(path.join(candidate, '_ns__acme__hello', 'keep.txt'), 'utf8')).toBe('original'); + }); + + it('rejects a symlink target rather than following the original directory identity', async () => { + await interruptRelocation('folder'); + const target = path.join(candidate, '_ns__acme__hello'); + const original = path.join(candidate, 'saved-original'); + await fs.promises.rename(target, original); + await fs.promises.symlink(original, target, 'junction'); + await expect(new LibraryBindingStore(deps).relocateBinding('hello', '_ns__acme__hello')) + .rejects.toThrow('directory identity unavailable'); + expect(await fs.promises.readFile(path.join(original, 'keep.txt'), 'utf8')).toBe('original'); + }); + + it('does not recover an owner journal through a different owner-scoped binding file', async () => { + await interruptRelocation('folder'); + const otherFile = path.join(tmp, 'owners', 'k2', 'libraries-binding.json'); + await fs.promises.mkdir(path.dirname(otherFile), { recursive: true }); + const contents = await fs.promises.readFile(bindingFile, 'utf8'); + await fs.promises.writeFile(otherFile, contents); + const otherStore = new LibraryBindingStore({ ...deps, getFile: () => otherFile }); + await expect(otherStore.getBinding('_ns__acme__hello')).rejects.toThrow('journal is invalid'); + expect(await fs.promises.readFile(bindingFile, 'utf8')).toBe(contents); + expect(await fs.promises.readFile(otherFile, 'utf8')).toBe(contents); + }); + + it('preserves a never-opened custom binding without creating a library directory', async () => { + const store = new LibraryBindingStore(deps); + await store.setBinding('hello', candidate); + await store.relocateBinding('hello', '_ns__acme__hello'); + expect(await store.getBinding('hello')).toBeNull(); + expect(await store.getBinding('_ns__acme__hello')).toMatchObject({ generation: 1, libraryReady: false }); + expect(fs.existsSync(path.join(candidate, '_ns__acme__hello'))).toBe(false); + await store.relocateBinding('_ns__acme__hello', 'hello'); + expect(await store.getBinding('hello')).toMatchObject({ generation: 1, libraryReady: false }); + }); + + it('rejects a dangling foreign target before recording a relocation intent', async () => { + const store = new LibraryBindingStore(deps); + await store.setBinding('hello', candidate); + const target = path.join(candidate, '_ns__acme__hello'); + await fs.promises.symlink(path.join(tmp, 'missing-foreign'), target, 'junction'); + await expect(store.assertCanRelocateBinding('hello', '_ns__acme__hello')).rejects.toThrow('destination already exists'); + await expect(store.relocateBinding('hello', '_ns__acme__hello')).rejects.toThrow('destination already exists'); + expect(JSON.parse(await fs.promises.readFile(bindingFile, 'utf8')).pendingRelocation).toBeUndefined(); + expect((await fs.promises.lstat(target)).isSymbolicLink()).toBe(true); + }); + + it('rejects an owner change while reading a pending relocation without touching either owner', async () => { + await interruptRelocation('folder'); + const contents = await fs.promises.readFile(bindingFile, 'utf8'); + const originalFile = bindingFile; + const readFile = fs.promises.readFile.bind(fs.promises); + const changed = vi.spyOn(fs.promises, 'readFile').mockImplementation(async (...args) => { + const value = await readFile(...args); + bindingFile = path.join(tmp, 'owners', 'k2', 'libraries-binding.json'); + return value; + }); + await expect(new LibraryBindingStore(deps).getBinding('_ns__acme__hello')).rejects.toThrow('owner changed'); + changed.mockRestore(); + expect(fs.existsSync(bindingFile)).toBe(false); + bindingFile = originalFile; + expect(await fs.promises.readFile(bindingFile, 'utf8')).toBe(contents); + }); + + it('moves a library owner exactly once without losing other metadata or reassigning another plugin', async () => { + const root = path.join(defaultRootBase, 'hello'); + const file = path.join(root, '.cindy-library', 'meta.json'); + await fs.promises.mkdir(path.dirname(file), { recursive: true }); + await fs.promises.writeFile(file, JSON.stringify({ version: 1, ghostId: 'hello', createdAt: 1, orphaned: { at: 2, name: 'old' } })); + await expect(assertLibraryMetaOwner(root, 'other')).rejects.toThrow('different plugin'); + await expect(assertLibraryMetaOwner(root, 'hello')).resolves.toBeUndefined(); + expect(await relocateLibraryMetaOwner(root, 'hello', '_ns__acme__hello')).toBe(true); + await expect(assertLibraryMetaOwner(root, '_ns__acme__hello')).resolves.toBeUndefined(); + expect(await relocateLibraryMetaOwner(root, 'hello', '_ns__acme__hello')).toBe(false); + expect(JSON.parse(await fs.promises.readFile(file, 'utf8'))).toEqual({ + version: 1, ghostId: '_ns__acme__hello', createdAt: 1, orphaned: { at: 2, name: 'old' }, + }); + await expect(relocateLibraryMetaOwner(root, 'other', 'hello')).rejects.toThrow('different plugin'); + expect(await relocateLibraryMetaOwner(path.join(defaultRootBase, 'missing'), 'hello', '_ns__acme__hello')).toBe(false); + }); + it('无 binding → 默认根;绑定后解析到 /', async () => { const store = new LibraryBindingStore(deps); const before = await store.resolveLibraryRoot(GHOST_ID); @@ -67,6 +289,98 @@ describe('LibraryBindingStore', () => { expect(reread.kind).toBe('custom'); }); + it('企业实例 storage part 可绑定且与 root 分键;斜杠 id 非法', async () => { + const store = new LibraryBindingStore(deps); + const orgId = '_ns__acme__mivo-canvas'; + const set = await store.setBinding(orgId, candidate); + expect(set.ok).toBe(true); + const resolved = await store.resolveLibraryRoot(orgId); + expect(resolved.kind).toBe('custom'); + if (resolved.kind === 'custom' && resolved.root !== null) { + expect(resolved.root).toBe(path.join(await fs.promises.realpath(candidate), orgId)); + } + const rootResolved = await store.resolveLibraryRoot(GHOST_ID); + expect(rootResolved.kind).toBe('default'); + const bad = await store.setBinding('_ns/acme/mivo-canvas', candidate); + expect(bad.ok).toBe(false); + if (!bad.ok) expect(bad.errorCode).toBe('PATH_INVALID'); + }); + + it('relocates a custom binding key and folder after an in-place instance moves', async () => { + const store = new LibraryBindingStore(deps); + const fromId = 'hello'; + const toId = '_ns__acme__hello'; + const set = await store.setBinding(fromId, candidate); + expect(set.ok).toBe(true); + const fromRoot = path.join(await fs.promises.realpath(candidate), fromId); + await fs.promises.mkdir(fromRoot, { recursive: true }); + await fs.promises.writeFile(path.join(fromRoot, 'keep.txt'), 'org'); + await store.relocateBinding(fromId, toId); + expect(await store.getBinding(fromId)).toBeNull(); + expect(await store.getBinding(toId)).not.toBeNull(); + const resolved = await store.resolveLibraryRoot(toId); + expect(resolved.kind).toBe('custom'); + if (resolved.kind === 'custom' && resolved.root !== null) { + expect(resolved.root).toBe(path.join(await fs.promises.realpath(candidate), toId)); + await expect(fs.promises.readFile(path.join(resolved.root, 'keep.txt'), 'utf8')).resolves.toBe('org'); + } + }); + + it('refuses to relocate a binding onto an existing destination', async () => { + const store = new LibraryBindingStore(deps); + await store.setBinding('hello', candidate); + await store.setBinding('_ns__acme__hello', candidate); + await expect(store.relocateBinding('hello', '_ns__acme__hello')).rejects.toThrow( + 'library binding destination already exists', + ); + expect(await store.getBinding('hello')).not.toBeNull(); + }); + + it('detects a binding conflict before moving the plugin directory', async () => { + const store = new LibraryBindingStore(deps); + await store.setBinding('hello', candidate); + await store.setBinding('_ns__acme__hello', candidate); + await expect(store.assertCanRelocateBinding('hello', '_ns__acme__hello')).rejects.toThrow( + 'library binding destination already exists', + ); + expect(await store.getBinding('hello')).not.toBeNull(); + }); + + + it('refuses to relocate onto an existing custom library folder without deleting source', async () => { + const store = new LibraryBindingStore(deps); + await store.setBinding('hello', candidate); + const fromRoot = path.join(await fs.promises.realpath(candidate), 'hello'); + const toRoot = path.join(await fs.promises.realpath(candidate), '_ns__acme__hello'); + await fs.promises.mkdir(fromRoot, { recursive: true }); + await fs.promises.writeFile(path.join(fromRoot, 'keep.txt'), 'org'); + await fs.promises.mkdir(toRoot, { recursive: true }); + await fs.promises.writeFile(path.join(toRoot, 'old.txt'), 'orphan'); + await expect(store.relocateBinding('hello', '_ns__acme__hello')).rejects.toThrow( + 'library custom root destination already exists', + ); + expect(await store.getBinding('hello')).not.toBeNull(); + await expect(fs.promises.readFile(path.join(fromRoot, 'keep.txt'), 'utf8')).resolves.toBe('org'); + await expect(fs.promises.readFile(path.join(toRoot, 'old.txt'), 'utf8')).resolves.toBe('orphan'); + }); + + it('does not claim an existing destination folder when the source folder is missing', async () => { + const store = new LibraryBindingStore(deps); + await store.setBinding('hello', candidate); + const destination = path.join(await fs.promises.realpath(candidate), '_ns__acme__hello'); + await fs.promises.mkdir(destination); + await fs.promises.writeFile(path.join(destination, 'keep.txt'), 'other'); + await expect(store.assertCanRelocateBinding('hello', '_ns__acme__hello')).rejects.toThrow( + 'library custom root destination already exists', + ); + await expect(store.relocateBinding('hello', '_ns__acme__hello')).rejects.toThrow( + 'library custom root destination already exists', + ); + expect(await store.getBinding('hello')).not.toBeNull(); + expect(await store.getBinding('_ns__acme__hello')).toBeNull(); + await expect(fs.promises.readFile(path.join(destination, 'keep.txt'), 'utf8')).resolves.toBe('other'); + }); + it('重新绑定 generation 递增;撤销后回落默认', async () => { const store = new LibraryBindingStore(deps); await store.setBinding(GHOST_ID, candidate); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryExtraDirGrantContract.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryExtraDirGrantContract.test.ts index 94c787265ab..4359fcfb46b 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryExtraDirGrantContract.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryExtraDirGrantContract.test.ts @@ -29,7 +29,7 @@ describe('library extraDirs grant wiring', () => { it('slot sync refuses to no-op-success when the opener is not library-capable', () => { expect(body).toContain('async function syncMivoLibraryExtraDirFromSlot('); - expect(body).toContain('if (root !== null && !isLibraryCapableGhost(findAvailableGhost(ghostId)))'); + expect(body).toContain('if (root !== null && !isLibraryCapableGhost(findGhostForInstanceId(ghostId)))'); expect(body).toContain('if (libraryExtraDirOwnerGhostId !== null && libraryExtraDirOwnerGhostId !== ghostId)'); expect(body).toContain("if (result === 'granted') libraryExtraDirOwnerGhostId = ghostId;"); expect(body).toContain('return result;'); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts index 11ffee7bd14..a37287bb982 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts @@ -8,6 +8,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import Database from 'better-sqlite3'; +import JSZip from 'jszip'; import { GhostLibrarySlot, @@ -20,14 +21,19 @@ import { } from '../librarySlot.js'; import { createHash } from 'node:crypto'; import { crc32 } from 'node:zlib'; -import { LibraryBindingStore } from '../libraryBinding.js'; +import { LibraryBindingStore, relocateLibraryMetaOwner } from '../libraryBinding.js'; import { LibraryVault } from '../libraryVault.js'; import { initCustomLibraryTree, openExistingCustomLibrary } from '../libraryDirFd.js'; import { createLibraryDbCore, type SqliteDatabaseConstructor } from '../libraryDbCore.js'; import { LibrarySqlService } from '../librarySqlService.js'; +import { GhostManager } from '../GhostManager.js'; +import { findInstalledGhostByInstanceId, installedGhostStoragePart } from '../../../shared/pluginIdentity.js'; +import { GhostInstallReceiptStore } from '../ghostInstallReceipt.js'; +import { runGhostSnapshotWorkerRequest } from '../ghostSnapshotWorkerProcess.js'; import { classifyGhostLibraryOperationSupport, GHOST_LIBRARY_CAPABILITIES_V1, + ghostInstallApprovalToken, type InstalledGhost, } from '../../../shared/ghost.js'; @@ -70,6 +76,7 @@ describe('GhostLibrarySlot', () => { let resolveLibraryRoot: ReturnType; let createVault: ReturnType; let createSqlService: ReturnType; + let getGhost: GhostLibrarySlotDeps['getGhost']; let clock: number; beforeEach(async () => { @@ -82,6 +89,7 @@ describe('GhostLibrarySlot', () => { await fs.promises.mkdir(candidate, { recursive: true }); ghost = makeGhost(true); ghosts = new Map([[GHOST_ID, ghost]]); + getGhost = (id) => ghosts.get(id) ?? null; bindingStore = new LibraryBindingStore({ getFile: () => bindingFile, getManagedRoots: () => [path.join(tmp, 'managed')], @@ -100,7 +108,7 @@ describe('GhostLibrarySlot', () => { }), ); const deps: GhostLibrarySlotDeps = { - getGhost: (id) => ghosts.get(id) ?? null, + getGhost: (id) => getGhost(id), bindingStore, getDefaultRoot: (id) => path.join(defaultRootBase, id), captureOwnerScope: () => captureOwnerScope(), @@ -304,6 +312,336 @@ describe('GhostLibrarySlot', () => { expect(backups.some((f) => f.startsWith('pre-migrate-'))).toBe(true); }); + async function relocateLibraryState( + fromPart: string, + toPart: string, + afterRelocate?: () => Promise, + ): Promise { + slot.setRelocating(fromPart, true); + slot.setRelocating(toPart, true); + try { + await slot.disposeGhost(fromPart); + await slot.disposeGhost(toPart); + const binding = await bindingStore.getBinding(fromPart); + let destination: string; + if (binding) { + await bindingStore.relocateBinding(fromPart, toPart); + destination = path.join(binding.realPathAtGrant, toPart); + } else { + destination = path.join(defaultRootBase, toPart); + if (fs.existsSync(destination)) throw new Error('Library relocation destination already exists'); + await fs.promises.rename(path.join(defaultRootBase, fromPart), destination); + } + await relocateLibraryMetaOwner(destination, fromPart, toPart); + await afterRelocate?.(); + } finally { + try { + await slot.disposeGhost(fromPart); + await slot.disposeGhost(toPart); + } finally { + slot.setRelocating(fromPart, false); + slot.setRelocating(toPart, false); + } + } + } + + async function seedSourceDatabase(value: string, instanceId = GHOST_ID): Promise { + expect(await slot.handleLibraryRequest(instanceId, { op: 'db.open', dbPath: 'library.sqlite' })) + .toMatchObject({ ok: true, op: 'db.open' }); + expect(await slot.handleLibraryRequest(instanceId, { + op: 'db.exec', dbPath: 'library.sqlite', sql: 'CREATE TABLE source_state (value TEXT)', + })).toMatchObject({ ok: true, op: 'db.exec' }); + expect(await slot.handleLibraryRequest(instanceId, { + op: 'db.exec', dbPath: 'library.sqlite', sql: 'INSERT INTO source_state VALUES (?)', params: [value], + })).toMatchObject({ ok: true, op: 'db.exec' }); + expect(await slot.handleLibraryRequest(instanceId, { + op: 'db.exec', dbPath: 'library.sqlite', sql: 'SELECT value FROM source_state', + })).toMatchObject({ ok: true, op: 'db.exec', rows: [{ value }] }); + } + + it.each(['default', 'custom'] as const)('source archive wrapper isolates the real SQLite %s Library from a replacement source', async (location) => { + if (location === 'custom') { + expect((await bindingStore.setBinding(GHOST_ID, candidate)).ok).toBe(true); + } + await seedSourceDatabase('old-source'); + const archivePart = '_ns__cindy-archive-00000000-0000-4000-8000-000000000002__mivo-canvas'; + await relocateLibraryState(GHOST_ID, archivePart); + ghosts.set(GHOST_ID, { + ...makeGhost(true), + approval: { state: 'approved', revision: '00000000-0000-4000-8000-000000000003' }, + }); + expect(await slot.handleLibraryRequest(GHOST_ID, { op: 'open' })).toMatchObject({ ok: true, state: 'ready' }); + expect(await slot.handleLibraryRequest(GHOST_ID, { op: 'db.open', dbPath: 'library.sqlite' })) + .toMatchObject({ ok: true, op: 'db.open' }); + expect(await slot.handleLibraryRequest(GHOST_ID, { + op: 'db.exec', dbPath: 'library.sqlite', + sql: "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'source_state'", + })).toMatchObject({ ok: true, op: 'db.exec', rows: [] }); + await seedSourceDatabase('new-source'); + const archivedRoot = location === 'custom' ? candidate : defaultRootBase; + const archived = new Database(path.join(archivedRoot, archivePart, 'library.sqlite'), { readonly: true }); + try { + expect(archived.prepare('SELECT value FROM source_state').all()).toEqual([{ value: 'old-source' }]); + } finally { + archived.close(); + } + expect(await slot.handleLibraryRequest(GHOST_ID, { + op: 'db.exec', dbPath: 'library.sqlite', sql: 'SELECT value FROM source_state', + })).toMatchObject({ ok: true, op: 'db.exec', rows: [{ value: 'new-source' }] }); + }); + + it.each(['default', 'custom'] as const)('source archive wrapper restores the real SQLite %s Library on inverse after failure', async (location) => { + if (location === 'custom') { + expect((await bindingStore.setBinding(GHOST_ID, candidate)).ok).toBe(true); + } + await seedSourceDatabase('original-source'); + const initialService = createSqlService.mock.results[0].value as LibrarySqlService; + const archivePart = '_ns__cindy-archive-00000000-0000-4000-8000-000000000002__mivo-canvas'; + await expect(relocateLibraryState(GHOST_ID, archivePart, async () => { + expect(await slot.handleLibraryRequest(GHOST_ID, { op: 'db.open', dbPath: 'library.sqlite' })) + .toMatchObject({ ok: false, errorCode: 'LIBRARY_READONLY' }); + throw new Error('failure before source receipt commit'); + })).rejects.toThrow('failure before source receipt commit'); + expect(await initialService.exec(path.join(defaultRootBase, GHOST_ID, 'library.sqlite'), 'SELECT 1')) + .toMatchObject({ ok: false, code: 'DB_ERROR' }); + const originalRoot = location === 'custom' ? candidate : defaultRootBase; + expect(fs.existsSync(path.join(originalRoot, GHOST_ID))).toBe(false); + await relocateLibraryState(archivePart, GHOST_ID); + expect(await slot.handleLibraryRequest(GHOST_ID, { op: 'db.open', dbPath: 'library.sqlite' })) + .toMatchObject({ ok: true, op: 'db.open' }); + expect(await slot.handleLibraryRequest(GHOST_ID, { + op: 'db.exec', dbPath: 'library.sqlite', sql: 'SELECT value FROM source_state', + })).toMatchObject({ ok: true, op: 'db.exec', rows: [{ value: 'original-source' }] }); + expect(await slot.handleLibraryRequest(GHOST_ID, { + op: 'db.exec', dbPath: 'library.sqlite', sql: 'INSERT INTO source_state VALUES (?)', params: ['after-rollback'], + })).toMatchObject({ ok: true, op: 'db.exec', changes: 1 }); + expect(fs.existsSync(path.join(originalRoot, archivePart))).toBe(false); + const restored = new Database(path.join(originalRoot, GHOST_ID, 'library.sqlite'), { readonly: true }); + try { + expect(restored.prepare('SELECT value FROM source_state ORDER BY rowid').all()) + .toEqual([{ value: 'original-source' }, { value: 'after-rollback' }]); + } finally { + restored.close(); + } + }); + + it.each(['default', 'custom'] as const)('delayed session creation does not block real Manager %s source archive rollback', async (location) => { + const manager = new GhostManager({ + getRootDir: () => path.join(tmp, 'ghosts'), + onArchiveSourceState: relocateLibraryState, + mutateSnapshot: async ({ parentDir, ...request }) => runGhostSnapshotWorkerRequest(request, parentDir), + }); + getGhost = (id) => findInstalledGhostByInstanceId(manager.list(), id) ?? null; + const pack = async (version: string): Promise => { + const zip = new JSZip(); + zip.file('ghost.json', JSON.stringify({ + schemaVersion: 2, id: GHOST_ID, name: 'Library regression', version, + kind: 'chip', entry: 'main.js', slots: ['library'], + })); + const file = path.join(tmp, version + '.cindy'); + await fs.promises.writeFile(file, await zip.generateAsync({ type: 'nodebuffer' })); + return file; + }; + expect(await manager.install(await pack('1.0.0'))).toHaveProperty('ghost'); + const instanceId = installedGhostStoragePart(manager.list()[0]); + if (location === 'custom') expect((await bindingStore.setBinding(instanceId, candidate)).ok).toBe(true); + await seedSourceDatabase('original-source', instanceId); + await slot.disposeGhost(instanceId); + let resume!: () => void; + let entered!: () => void; + const gate = new Promise((resolve) => { resume = resolve; }); + const started = new Promise((resolve) => { entered = resolve; }); + const original = LibraryBindingStore.prototype.resolveLibraryRoot.bind(bindingStore); + resolveLibraryRoot.mockImplementationOnce(async (id: string) => { + const resolution = await original(id); + entered(); + await gate; + return resolution; + }); + const pending = slot.handleLibraryRequest(instanceId, { op: 'db.open', dbPath: 'late.sqlite' }); + await started; + const store = (manager as unknown as { receiptStore: GhostInstallReceiptStore }).receiptStore; + const write = vi.spyOn(store, 'write').mockImplementationOnce(async () => { + expect(manager.list()[0]?.approval.state).toBe('invalid'); + resume(); + await pending; + throw new Error('simulated receipt write failure'); + }); + const archivePart = '_ns__cindy-archive-00000000-0000-4000-8000-000000000002__mivo-canvas'; + let result; + try { + result = await manager.update(await pack('2.0.0'), { + expectedInstalledApproval: ghostInstallApprovalToken(manager.list()[0]?.approval), + sourceStateArchiveId: archivePart, + }); + } finally { + resume(); + write.mockRestore(); + } + expect(await pending).toMatchObject({ ok: false, errorCode: 'LIBRARY_UNAVAILABLE', reason: 'CANCELLED' }); + expect(result).toMatchObject({ rejection: { code: 'io', reason: 'simulated receipt write failure' } }); + expect(result).not.toHaveProperty('rejection.rollbackFailed'); + await manager.retryInterruptedMutationsAfterDbReady(); + expect(manager.list()[0]).toMatchObject({ manifest: { version: '1.0.0' }, approval: { state: 'approved' } }); + expect(store.readPendingMutationSync('_root/' + GHOST_ID).state).toBe('missing'); + const restoredRoot = path.join(location === 'custom' ? candidate : defaultRootBase, instanceId); + expect(fs.existsSync(path.join(restoredRoot, 'late.sqlite'))).toBe(false); + expect(fs.existsSync(path.join(location === 'custom' ? candidate : defaultRootBase, archivePart))).toBe(false); + const restored = new Database(path.join(restoredRoot, 'library.sqlite'), { readonly: true }); + try { + expect(restored.prepare('SELECT value FROM source_state').all()).toEqual([{ value: 'original-source' }]); + } finally { + restored.close(); + } + expect(await slot.handleLibraryRequest(instanceId, { op: 'db.open', dbPath: 'library.sqlite' })).toMatchObject({ ok: true }); + }); + + it.each(['open', 'db.open'] as const)('delayed %s and queued writes stay cancelled after relocation flags clear', async (op) => { + await seedSourceDatabase('original-source'); + await slot.disposeGhost(GHOST_ID); + let resume!: () => void; + let entered!: () => void; + const gate = new Promise((resolve) => { resume = resolve; }); + const started = new Promise((resolve) => { entered = resolve; }); + const original = LibraryBindingStore.prototype.resolveLibraryRoot.bind(bindingStore); + resolveLibraryRoot.mockImplementationOnce(async (id: string) => { + const resolution = await original(id); + entered(); + await gate; + return resolution; + }); + const pending = slot.handleLibraryRequest(GHOST_ID, { op, dbPath: 'late.sqlite' }); + await started; + const queued = op === 'db.open' + ? slot.handleLibraryRequest(GHOST_ID, { op: 'db.open', dbPath: 'queued.sqlite' }) + : undefined; + const archivePart = '_ns__cindy-archive-00000000-0000-4000-8000-000000000002__mivo-canvas'; + try { + await relocateLibraryState(GHOST_ID, archivePart); + } finally { + resume(); + } + expect(await pending).toMatchObject({ ok: false, reason: 'CANCELLED' }); + if (queued) expect(await queued).toMatchObject({ ok: false, reason: 'CANCELLED' }); + expect(fs.existsSync(path.join(defaultRootBase, GHOST_ID))).toBe(false); + await relocateLibraryState(archivePart, GHOST_ID); + expect(await slot.handleLibraryRequest(GHOST_ID, { op: 'db.open', dbPath: 'library.sqlite' })).toMatchObject({ ok: true }); + }); + + it.each(['receipt', 'unapproved', 'directory'] as const)('delayed session creation rejects a changed %s target without relocation', async (change) => { + let resume!: () => void; + let entered!: () => void; + const gate = new Promise((resolve) => { resume = resolve; }); + const started = new Promise((resolve) => { entered = resolve; }); + const original = LibraryBindingStore.prototype.resolveLibraryRoot.bind(bindingStore); + resolveLibraryRoot.mockImplementationOnce(async (id: string) => { + const resolution = await original(id); + entered(); + await gate; + return resolution; + }); + const pending = slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + await started; + ghosts.set(GHOST_ID, { + ...ghost, + ...(change === 'directory' ? { dir: '/tmp/replaced-install' } : { + approval: change === 'unapproved' + ? { state: 'invalid' } + : { state: 'approved', revision: '00000000-0000-4000-8000-000000000003' }, + }), + }); + resume(); + expect(await pending).toMatchObject({ ok: false, reason: 'CANCELLED' }); + expect(createVault).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(defaultRootBase, GHOST_ID))).toBe(false); + }); + + it('locale projection changes keep the same approved Library request and session current', async () => { + const original = LibraryBindingStore.prototype.resolveLibraryRoot.bind(bindingStore); + resolveLibraryRoot.mockImplementationOnce(async (id: string) => { + const resolution = await original(id); + ghosts.set(GHOST_ID, { + ...ghost, manifest: { ...ghost.manifest, name: 'Localized Library', resolvedLocale: 'en' }, + }); + return resolution; + }); + expect(await slot.handleLibraryRequest(GHOST_ID, { op: 'open' })).toMatchObject({ ok: true, state: 'ready' }); + ghosts.set(GHOST_ID, { ...ghost, manifest: { ...ghost.manifest, resolvedLocale: 'zh-CN' } }); + expect(await slot.handleLibraryRequest(GHOST_ID, { op: 'db.open', dbPath: 'library.sqlite' })).toMatchObject({ ok: true }); + expect(createVault).toHaveBeenCalledTimes(1); + expect(createSqlService).toHaveBeenCalledTimes(1); + }); + + it('queued vault open cannot publish a disposed session after archive', async () => { + await seedSourceDatabase('original-source'); + await slot.disposeGhost(GHOST_ID); + let resume!: () => void; + let entered!: () => void; + const gate = new Promise((resolve) => { resume = resolve; }); + const started = new Promise((resolve) => { entered = resolve; }); + const original = LibraryVault.prototype.open; + const open = vi.spyOn(LibraryVault.prototype, 'open').mockImplementationOnce(async function (this: LibraryVault) { + entered(); + await gate; + return original.call(this); + }); + const pending = slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + await started; + const archivePart = '_ns__cindy-archive-00000000-0000-4000-8000-000000000002__mivo-canvas'; + try { + await relocateLibraryState(GHOST_ID, archivePart); + } finally { + resume(); + open.mockRestore(); + } + expect(await pending).toMatchObject({ ok: false, reason: 'CANCELLED' }); + expect(fs.existsSync(path.join(defaultRootBase, GHOST_ID))).toBe(false); + expect(syncAgentReadonlyExtraDir).not.toHaveBeenCalledWith(GHOST_ID, path.join(defaultRootBase, GHOST_ID)); + await relocateLibraryState(archivePart, GHOST_ID); + expect(await slot.handleLibraryRequest(GHOST_ID, { op: 'open' })).toMatchObject({ ok: true, state: 'ready' }); + }); + + it('delayed db path resolution cannot recreate the original root after relocation', async () => { + await seedSourceDatabase('original-source'); + syncAgentReadonlyExtraDir.mockClear(); + let resume!: () => void; + let entered!: () => void; + const gate = new Promise((resolve) => { resume = resolve; }); + const started = new Promise((resolve) => { entered = resolve; }); + const original = LibraryVault.prototype.resolveDbTarget; + const resolve = vi.spyOn(LibraryVault.prototype, 'resolveDbTarget').mockImplementationOnce(async function (this: LibraryVault, relativePath: string) { + const target = await original.call(this, relativePath); + entered(); + await gate; + return target; + }); + const pending = slot.handleLibraryRequest(GHOST_ID, { op: 'db.open', dbPath: 'late.sqlite' }); + await started; + const archivePart = '_ns__cindy-archive-00000000-0000-4000-8000-000000000002__mivo-canvas'; + try { + await relocateLibraryState(GHOST_ID, archivePart); + } finally { + resume(); + resolve.mockRestore(); + } + expect(await pending).toMatchObject({ ok: false, reason: 'CANCELLED' }); + expect(fs.existsSync(path.join(defaultRootBase, GHOST_ID))).toBe(false); + await relocateLibraryState(archivePart, GHOST_ID); + expect(await slot.handleLibraryRequest(GHOST_ID, { op: 'db.open', dbPath: 'library.sqlite' })).toMatchObject({ ok: true }); + }); + + it('open during relocation cannot create an empty default root', async () => { + slot.setRelocating(GHOST_ID, true); + try { + expect(await slot.handleLibraryRequest(GHOST_ID, { op: 'open' })).toMatchObject({ ok: false }); + expect(createVault).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(defaultRootBase, GHOST_ID))).toBe(false); + } finally { + slot.setRelocating(GHOST_ID, false); + } + expect(await slot.handleLibraryRequest(GHOST_ID, { op: 'open' })).toMatchObject({ ok: true, state: 'ready' }); + }); + it('dbPath 非法/越界 → PATH_INVALID + INVALID_REQUEST', async () => { await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); const missing = await slot.handleLibraryRequest(GHOST_ID, { op: 'db.open' }); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryStaging.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryStaging.test.ts index 9c045c8b9f4..bfd3d3cf076 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryStaging.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryStaging.test.ts @@ -9,7 +9,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { createHash, randomUUID } from 'node:crypto'; -import { LibraryStagingStore, DEFAULT_LIBRARY_STAGING_LIMITS } from '../libraryStaging.js'; +import { LibraryStagingStore, DEFAULT_LIBRARY_STAGING_LIMITS, relocateLibraryStagingOwner } from '../libraryStaging.js'; import { LibraryVault, DEFAULT_LIBRARY_LIMITS } from '../libraryVault.js'; const sha256Of = (s: string | Buffer): string => createHash('sha256').update(s).digest('hex'); @@ -93,6 +93,64 @@ describe('LibraryStagingStore 故障恢复', () => { return { stagingId: begin.stagingId, digest, bytes: buf.byteLength }; } + it('relocates durable manifests and unfinished intents without losing upload identity or bytes', async () => { + const originalRoot = path.join(tmp, 'library-staging', ghostId); + const original = makeStore(originalRoot); + const durable = await beginChunk(original, 'durable', body); + expect((await original.commit({ ghostId, stagingId: durable.stagingId })).ok).toBe(true); + const unfinished = await beginChunk(original, 'unfinished', 'pending'); + await original.dispose(); + const destinationId = '_ns__xd__mivo-canvas'; + const destinationRoot = path.join(tmp, 'library-staging', destinationId); + await fs.promises.rename(originalRoot, destinationRoot); + const before = await fs.promises.readFile(path.join(destinationRoot, 'tasks', durable.stagingId, 'manifest.json'), 'utf8'); + await relocateLibraryStagingOwner(destinationRoot, ghostId, destinationId, 'local:owner-a:1', () => {}); + await relocateLibraryStagingOwner(destinationRoot, ghostId, destinationId, 'local:owner-a:1', () => {}); + const after = JSON.parse(await fs.promises.readFile(path.join(destinationRoot, 'tasks', durable.stagingId, 'manifest.json'), 'utf8')); + expect(after).toEqual({ ...JSON.parse(before), ghostId: destinationId }); + const intent = JSON.parse(await fs.promises.readFile(path.join(destinationRoot, 'tasks', unfinished.stagingId, 'intent.json'), 'utf8')); + expect(intent).toMatchObject({ ghostId: destinationId, taskId: 'unfinished' }); + const restored = new LibraryStagingStore({ + rootDir: destinationRoot, ghostId: destinationId, ownerScopeKey: 'local:owner-a:1', + captureOwnerScope: () => scope, getDiskFreeBytes: async () => 1024 ** 4, + }); + expect(await restored.list({ ghostId: destinationId })).toMatchObject({ + ok: true, items: [expect.objectContaining({ stagingId: durable.stagingId, sha256: durable.digest })], + }); + expect(await restored.read({ ghostId: destinationId, stagingId: durable.stagingId })).toMatchObject({ + ok: true, content: Buffer.from(body).toString('base64'), + }); + expect(await restored.begin({ + ghostId: destinationId, taskId: 'new', sourceRevision: 'rev-1', + totalBytes: body.length, sha256: sha, mime: 'image/png', recovery, + })).toMatchObject({ ok: true }); + await restored.dispose(); + }); + + it.each(['foreign-plugin', 'foreign-owner', 'corrupt'] as const)('refuses %s task metadata and can resume after repairing it', async (fault) => { + const { store, stagingId } = await commitOne(); + await store.dispose(); + const root = path.join(tmp, 'library-staging', ghostId); + const file = path.join(root, 'tasks', stagingId, 'manifest.json'); + const original = await fs.promises.readFile(file, 'utf8'); + const changed = fault === 'corrupt' ? '{' : JSON.stringify({ + ...JSON.parse(original), ...(fault === 'foreign-plugin' ? { ghostId: 'other' } : { ownerScopeKey: 'local:owner-b:1' }), + }); + await fs.promises.writeFile(file, changed); + await expect(relocateLibraryStagingOwner(root, ghostId, '_ns__xd__mivo-canvas', 'local:owner-a:1', () => {})).rejects.toThrow(); + expect(await fs.promises.readFile(file, 'utf8')).toBe(changed); + expect(await fs.promises.readFile(path.join(root, 'tasks', stagingId, 'blob.bin'), 'utf8')).toBe(body); + await fs.promises.writeFile(file, original); + await relocateLibraryStagingOwner(root, ghostId, '_ns__xd__mivo-canvas', 'local:owner-a:1', () => {}); + expect(JSON.parse(await fs.promises.readFile(file, 'utf8'))).toMatchObject({ ghostId: '_ns__xd__mivo-canvas' }); + }); + + it('does not create a missing staging root', async () => { + const root = path.join(tmp, 'missing'); + await relocateLibraryStagingOwner(root, ghostId, '_ns__xd__mivo-canvas', 'local:owner-a:1', () => {}); + await expect(fs.promises.stat(root)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + async function commitOne( store = makeStore(), taskId = 'task-1', diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts index 6ad9228aa50..0a421c9caf7 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts @@ -20,6 +20,7 @@ import { type LibraryReadHandle, } from '../libraryVault.js'; import { initCustomLibraryTree, openExistingCustomLibrary, parseExistingStdout, PROVABLE_STAGING_NAME } from '../libraryDirFd.js'; +import { relocateLibraryMetaOwner } from '../libraryBinding.js'; const sha256Of = (s: string): string => createHash('sha256').update(s).digest('hex'); @@ -109,6 +110,38 @@ describe('LibraryVault', () => { }); describe('open / status / meta', () => { + it('does not open a default library whose persisted owner differs from the plugin', async () => { + const previous = makeVault({ ghostId: 'other-plugin' }); + expect(await previous.open()).toMatchObject({ ok: true, state: 'ready' }); + await fs.promises.writeFile(path.join(libraryRoot, 'keep.txt'), 'private'); + const current = makeVault(); + expect(await current.open()).toMatchObject({ ok: true, state: 'unavailable', reason: 'corrupt' }); + expect(await fs.promises.readFile(path.join(libraryRoot, 'keep.txt'), 'utf8')).toBe('private'); + }); + + it('opens a physically relocated library after its owner meta is migrated', async () => { + const original = path.join(tmpRoot, 'libraries', 'hello'); + const previous = makeVault({ rootDir: () => original, ghostId: 'hello' }); + expect(await previous.open()).toMatchObject({ ok: true, state: 'ready' }); + await fs.promises.writeFile(path.join(original, 'keep.txt'), 'org'); + const destination = path.join(tmpRoot, 'libraries', '_ns__acme__hello'); + await fs.promises.rename(original, destination); + expect(await relocateLibraryMetaOwner(destination, 'hello', '_ns__acme__hello')).toBe(true); + const current = makeVault({ rootDir: () => destination, ghostId: '_ns__acme__hello' }); + expect(await current.open()).toMatchObject({ ok: true, state: 'ready' }); + expect(await fs.promises.readFile(path.join(destination, 'keep.txt'), 'utf8')).toBe('org'); + }); + + it('does not open a custom library whose persisted owner differs from the plugin', async () => { + const parent = path.join(tmpRoot, 'picked-owner'); + const custom = path.join(parent, 'test-ghost'); + await fs.promises.mkdir(parent, { recursive: true }); + const previous = makeVault({ rootDir: () => custom, locationKind: 'custom', ghostId: 'other-plugin' }); + expect(await previous.open()).toMatchObject({ ok: true, state: 'ready' }); + const current = makeVault({ rootDir: () => custom, locationKind: 'custom' }); + expect(await current.open()).toMatchObject({ ok: true, state: 'unavailable', reason: 'binding-moved' }); + }); + it('open 建骨架并写 meta;重复 open 幂等', async () => { const vault = makeVault(); const first = await vault.open(); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/marketGhostSessionBoundary.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/marketGhostSessionBoundary.test.ts index cfe8e456c5e..314b487efe7 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/marketGhostSessionBoundary.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/marketGhostSessionBoundary.test.ts @@ -148,10 +148,49 @@ describe('market Ghost session boundary', () => { const body = source.slice(start, end); expect(body).toContain('if (isAppSessionBoundaryPending()) return [];'); expect(source).toContain( - 'return availableGhosts().find((ghost) => ghost.manifest.id === id) ?? null;', + 'const resolved = resolveInstalledGhost(availableGhosts(), id, namespace);', + ); + const resolverStart = source.indexOf('export function findGhostForInstanceId('); + const resolverBody = source.slice(resolverStart, source.indexOf('\n}', resolverStart)); + expect(resolverBody.match(/availableGhosts\(\)/g)).toHaveLength(1); + expect(resolverBody).toContain('findInstalledGhostByInstanceId(ghosts, id)'); + expect(resolverBody).toContain('resolveInstalledGhost(ghosts, id, namespace)'); + expect(source.match(/getGhost: findAvailableGhost/g)?.length ?? 0).toBe(0); + expect(source.match(/getGhost: findGhostForInstanceId/g)?.length ?? 0).toBeGreaterThanOrEqual(12); + expect(resolverBody).toContain('if (namespace === undefined) {'); + expect(resolverBody).toContain( + "return resolved.status === 'unique' ? resolved.ghost : null;", + ); + expect(source).toContain('availableByInstanceId.get(entry.ghostId)'); + expect(source).toContain('hasCardSlot: (ghostId) => {'); + expect(source).toContain('const g = findGhostForInstanceId(ghostId);'); + expect(source).toContain( + 'export async function getGhostLibraryOverview(ghostId: string): Promise {', + ); + const overviewStart = source.indexOf( + 'export async function getGhostLibraryOverview(ghostId: string)', + ); + expect(source.slice(overviewStart, overviewStart + 280)).toContain( + 'const ghost = findGhostForInstanceId(ghostId);', ); - expect(source.match(/getGhost: findAvailableGhost/g)?.length ?? 0).toBeGreaterThanOrEqual(3); - expect(source).toContain('return findAvailableGhost(id)?.manifest.name ?? null;'); + const assessStart = source.indexOf( + 'export function getGhostSetupAssessment(ghostId: string)', + ); + const assessBody = source.slice(assessStart, assessStart + 1600); + expect(assessBody).toContain('const ghost = findGhostForInstanceId(ghostId);'); + expect(assessBody).toContain('const storeId = installedGhostStoragePart(ghost);'); + expect(assessBody).toContain('oauthManager.listAccounts(storeId, key)'); + expect(assessBody).not.toContain('oauthManager.listAccounts(ghostId, key)'); + expect(source).toContain('return findGhostForInstanceId(id)?.manifest.name ?? null;'); + }); + + it('blocks relocation and source archival while a private filesystem request is in flight', () => { + const start = source.indexOf('function assertGhostRelocationIdle(part: string): void {'); + const guard = source.slice(start, source.indexOf('\n}', start)); + expect(guard).toContain('fsSlotSingleton?.hasInFlightRequests(part)'); + expect(source).toContain('assertGhostRelocationIdle(fromPart);'); + expect(source).toContain('if (sourceChanged) assertGhostRelocationIdle(installedGhostStoragePart(previousGhost));'); + expect(source).toContain('if (expected.sourceChanged) assertGhostRelocationIdle(installedGhostStoragePart(installed));'); }); it('allows explicit local replacement and detaches market routing before landing', () => { @@ -171,7 +210,7 @@ describe('market Ghost session boundary', () => { const helperBody = source.slice(helperStart, helperEnd); const ledgerReadIndex = helperBody.indexOf( - 'marketLedger.installationForGhost(inspected.manifest.id)', + '= readLocalGhostUpdateSource(', ); const captureIndex = updateBody.indexOf('const mutationOwner = captureGhostMutationOwner();'); const inspectIndex = updateBody.indexOf('await manager.inspect(lizFilePath)'); @@ -181,16 +220,18 @@ describe('market Ghost session boundary', () => { const detachDecisionIndex = helperBody.indexOf( 'const detachMarketRecord = Boolean(marketRecord?.installed)', ); - const runtimeStopIndex = helperBody.indexOf('runtime.stop(inspected.manifest.id)'); + const runtimeStopIndex = helperBody.indexOf( + 'runtime.stop(previousGhost ? installedGhostStoragePart(previousGhost) : inspected.manifest.id)', + ); const stopAndWaitIndex = helperBody.indexOf( - 'await getGhostNodeRuntimeBroker().stopAndWait(inspected.manifest.id);', + 'await getGhostNodeRuntimeBroker().stopAndWait(previousGhost ? installedGhostStoragePart(previousGhost) : inspected.manifest.id);', ); const oauthLockIndex = helperBody.indexOf( - 'result = await withActiveOwnerGhostOauthMutationLock(inspected.manifest.id', + 'result = await withActiveOwnerGhostOauthMutationLock(', ); const managerUpdateIndex = helperBody.indexOf('manager.update(cindyFilePath,'); const detachIndex = helperBody.indexOf( - 'marketLedger.markRemoved(inspected.manifest.id, null)', + 'marketLedger.markRemovedRecord(marketRecord, null)', ); expect(captureIndex).toBeGreaterThan(-1); @@ -199,7 +240,8 @@ describe('market Ghost session boundary', () => { expect(helperCallIndex).toBeGreaterThan(leaseIndex); expect(ledgerBindIndex).toBeGreaterThan(-1); expect(runtimeStopIndex).toBeGreaterThan(ledgerBindIndex); - expect(ledgerReadIndex).toBeGreaterThan(stopAndWaitIndex); + expect(ledgerReadIndex).toBeGreaterThan(ledgerBindIndex); + expect(ledgerReadIndex).toBeLessThan(runtimeStopIndex); expect(detachDecisionIndex).toBeGreaterThan(ledgerReadIndex); expect(stopAndWaitIndex).toBeGreaterThan(runtimeStopIndex); // 只有确认旧进程退出,才切断旧市场的自动更新路由;等待失败时保留原路由, @@ -210,6 +252,7 @@ describe('market Ghost session boundary', () => { expect(oauthLockIndex).toBeGreaterThan(detachIndex); expect(managerUpdateIndex).toBeGreaterThan(oauthLockIndex); expect(helperBody).toContain('marketLedger.restoreInstallation('); + expect(helperBody).toContain('...(previousGhost ? deliveryNamespaceFields(previousGhost) : {})'); expect(helperBody).not.toContain('marketLedger.isDefaultInstallSuppressed('); expect(helperBody).not.toContain('marketInstallSubject'); expect(helperBody).toContain('用户显式卸载,不得产生 default-install opt-out'); @@ -238,16 +281,24 @@ describe('market Ghost session boundary', () => { expect(body.match(/expected\.beforeCommitInLock\?\.\(\);/g)).toHaveLength(1); const waitIndex = body.indexOf( - 'await getGhostNodeRuntimeBroker().stopAndWait(expected.ghostId);', + 'await getGhostNodeRuntimeBroker().stopAndWait(', ); const oauthLockIndex = body.indexOf( - 'await withActiveOwnerGhostOauthMutationLock(expected.ghostId', + 'await withActiveOwnerGhostOauthMutationLock(installedGhostStoragePart(installed)', ); const updateIndex = body.indexOf('manager.update(cindyFilePath,'); expect(waitIndex).toBeGreaterThan(-1); expect(waitIndex).toBeLessThan(oauthLockIndex); expect(oauthLockIndex).toBeLessThan(updateIndex); + expect(body).toContain('runtime.stop(installedGhostStoragePart(installed));'); + expect(body).toContain( + 'await getGhostNodeRuntimeBroker().stopAndWait(installedGhostStoragePart(installed));', + ); + expect(body).toContain('runtime.resetFuse(installedGhostStoragePart(result.ghost));'); + expect(body).not.toContain( + 'pluginStoragePart(createPluginLogicalIdentity(expected.namespace ?? null, expected.ghostId))', + ); const restoreIndex = body.indexOf('spawnIfResident(installed);'); expect(restoreIndex).toBeGreaterThan(updateIndex); }); @@ -264,10 +315,10 @@ describe('market Ghost session boundary', () => { const helperBody = source.slice(helperStart, helperEnd); const waitIndex = helperBody.indexOf( - 'await getGhostNodeRuntimeBroker().stopAndWait(inspected.manifest.id);', + 'await getGhostNodeRuntimeBroker().stopAndWait(previousGhost ? installedGhostStoragePart(previousGhost) : inspected.manifest.id);', ); const oauthLockIndex = helperBody.indexOf( - 'result = await withActiveOwnerGhostOauthMutationLock(inspected.manifest.id', + 'result = await withActiveOwnerGhostOauthMutationLock(', ); const updateIndex = helperBody.indexOf('manager.update(cindyFilePath'); const restoreIndex = helperBody.indexOf( @@ -284,7 +335,12 @@ describe('market Ghost session boundary', () => { // stopAndWait (rollback if provenance check fails). expect(restoreIndex).toBeGreaterThan(waitIndex); expect(updateBody).toContain('finally {\n releaseMutation();'); - expect(helperBody).toContain("throwIpcError('INTERNAL', 'Unable to verify the installed Plugin source');"); + const provenanceStart = source.indexOf('function readLocalGhostUpdateSource('); + const provenanceBody = source.slice(provenanceStart, helperStart); + expect(provenanceBody).toContain("throwIpcError('INTERNAL', 'Unable to verify the installed Plugin source');"); + expect(provenanceBody).toContain('installationForPlugin({'); + expect(provenanceBody).toContain('...deliveryNamespaceFields(previousGhost)'); + expect(helperBody.indexOf('= readLocalGhostUpdateSource(')).toBeLessThan(waitIndex); expect(helperBody).toContain("throwIpcError('INTERNAL', 'Unable to detach the installed Plugin source');"); }); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/networkSlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/networkSlot.test.ts index b5865dd5934..e0df902c960 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/networkSlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/networkSlot.test.ts @@ -1559,6 +1559,21 @@ describe('networkSlot · GitHub CLI 优先凭证(source:gh-cli)', () => { expect(readGhCliToken).not.toHaveBeenCalled(); expect(fetchImpl).not.toHaveBeenCalled(); }); + + it('组织同名或尚未确认 root 的 cindy-github 不能借用 gh-cli token', async () => { + for (const identity of [{ namespace: 'acme' }, { namespaceMigration: 'pending' }] as const) { + const readGhCliToken = vi.fn(async () => 'gho_should_not_be_read'); + const { slot, fetchImpl } = makeGithubSlot({ + getGhost: () => ({ ...fakeGhost({ id: 'cindy-github', network: githubNetwork, + trust: { level: 'cindy-official', publisherSigned: true, publisherVerified: true, + reviewed: true, publisherName: 'Cindy Plugin Market' } }), ...identity }), + readGhCliToken, + }); + expect((await slot.handleFetchRequest('web-search', { url: GITHUB_URL })).ok).toBe(false); + expect(readGhCliToken).not.toHaveBeenCalled(); + expect(fetchImpl).not.toHaveBeenCalled(); + } + }); }); describe('networkSlot · 目录上传(uploadDir,过户票据)', () => { diff --git a/apps/desktop/src/main/cindy-brain/__tests__/nodeRuntimeBroker.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/nodeRuntimeBroker.test.ts index 618b193ee1d..aa747a85d16 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/nodeRuntimeBroker.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/nodeRuntimeBroker.test.ts @@ -545,6 +545,36 @@ describe('nodeRuntimeBroker · 进程生命周期', () => { broker.destroyAll(); }); + + it('同 ghostId 的 root 与企业实例可同时跑 Node 进程', async () => { + const rootGhost = fakeGhost(); + const orgGhost: InstalledGhost = { ...fakeGhost(), namespace: 'acme', dir: '/fake/_ns/acme/node-ghost' }; + const children: FakeNodeProcess[] = []; + const broker = new GhostNodeRuntimeBroker({ + getGhost: (id) => { + if (id === '_ns__acme__node-ghost') return orgGhost; + if (id === 'node-ghost') return rootGhost; + return null; + }, + spawnProcess: () => { + const child = makeAutoReplyProcess(); + children.push(child); + return child as unknown as NodeWorkerProcess; + }, + }); + + expect(await broker.handleRequest('node-ghost', rpcRequest('root'))).toMatchObject({ ok: true }); + expect(await broker.handleRequest('_ns__acme__node-ghost', rpcRequest('org'))).toMatchObject({ + ok: true, + }); + expect(children).toHaveLength(2); + expect(broker.stateOf('node-ghost')).toBe('running'); + expect(broker.stateOf('_ns__acme__node-ghost')).toBe('running'); + broker.stop('node-ghost'); + expect(broker.stateOf('node-ghost')).toBe('off'); + expect(broker.stateOf('_ns__acme__node-ghost')).toBe('running'); + broker.destroyAll(); + }); it('停用式 stop 立即拒绝在途请求并关闭进程', async () => { const ghost = fakeGhost(); const child = new FakeNodeProcess(); // 不回 response,保持在途 diff --git a/apps/desktop/src/main/cindy-brain/__tests__/nodeRuntimeCancellation.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/nodeRuntimeCancellation.test.ts index 7b37fd49b32..8a4cedaf84f 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/nodeRuntimeCancellation.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/nodeRuntimeCancellation.test.ts @@ -42,14 +42,18 @@ afterEach(() => { for (const b of brokers.splice(0)) b.destroyAll(); }); -function harness(autoSpawn = true, extra: Partial = {}) { +function harness( + autoSpawn = true, + extra: Partial = {}, + identity?: { ghost?: InstalledGhost; requestId?: string }, +) { const worker = new Worker(); const children: Worker[] = []; const calls = new Map([ ['call-a', new AbortController()], ['call-b', new AbortController()], ]); - const ghost = { + const ghost = identity?.ghost ?? { manifest: { id: 'test-plugin', name: 'Test', @@ -65,11 +69,12 @@ function harness(autoSpawn = true, extra: Partial = dir: path.resolve('test-plugin'), enabled: true, } as InstalledGhost; + const requestId = identity?.requestId ?? ghost.manifest.id; const broker = new GhostNodeRuntimeBroker({ ...extra, - getGhost: () => ghost, - getCallSignal: (id, callId) => - id === 'test-plugin' ? (calls.get(callId)?.signal ?? null) : null, + getGhost: extra.getGhost ?? (() => ghost), + getCallSignal: extra.getCallSignal ?? ((id, callId) => + id === requestId ? (calls.get(callId)?.signal ?? null) : null), spawnProcess: () => { queueMicrotask(() => worker.emit('spawn')); return worker as NodeWorkerProcess; @@ -82,7 +87,7 @@ function harness(autoSpawn = true, extra: Partial = }); brokers.push(broker); const request = (callId?: string) => - broker.handleRequest('test-plugin', { + broker.handleRequest(requestId, { type: 'node-request', method: 'tools/call', params: { name: 'login' }, @@ -368,6 +373,61 @@ describe('Node private authorization bridge', () => { ); await second; }); + + it('looks up remote authorization by physical instance id, not manifest ghostId', async () => { + const storagePart = '_ns__acme__test-plugin'; + const orgGhost = { + manifest: { + id: 'test-plugin', + name: 'Test', + version: '1.0.0', + network: { hosts: ['provider.example'] }, + node: { + entry: 'worker.cjs', + entries: ['child.cjs'], + protocol: 'json-rpc-stdio', + childSpawn: true, + }, + }, + namespace: 'acme', + dir: '/fake/_ns/acme/test-plugin', + enabled: true, + } as InstalledGhost; + let input!: Parameters>[0]; + const open = vi.fn((value: typeof input) => { + input = value; + return { opened: Promise.resolve(), finish: vi.fn(), dispose: vi.fn() }; + }); + const h = harness( + true, + { + getGhost: (id) => (id === storagePart ? orgGhost : null), + getCallSessionId: (g, id) => + g === storagePart && id === 'call-a' ? 'trusted-session' : null, + openDeviceAuthorization: open, + }, + { ghost: orgGhost, requestId: storagePart }, + ); + const result = h.request('call-a'); + await until(() => h.worker.requests.length === 1); + h.worker.listener?.({ + type: 'device-authorize', + reqId: 'authorize-ns', + rpcId: h.worker.requests[0].id, + url: 'https://provider.example/device', + }); + await until(() => h.worker.controls.some((x) => x.reqId === 'authorize-ns')); + expect(open).toHaveBeenCalledTimes(1); + expect(h.worker.controls.find((x) => x.reqId === 'authorize-ns')).toMatchObject({ ok: true }); + expect(input.ghost.id).toBe(storagePart); + expect(input.sessionId).toBe('trusted-session'); + input.assertCurrent(); + h.worker.stdout.write( + JSON.stringify({ jsonrpc: '2.0', id: h.worker.requests[0].id, result: { ok: true } }) + '\n', + ); + expect(await result).toEqual({ ok: true, result: { ok: true } }); + }); + it.each([true, false])( 'finishes the authorization before invalidating its binding (CLI ok=%s)', async (ok) => { diff --git a/apps/desktop/src/main/cindy-brain/__tests__/pickSlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/pickSlot.test.ts index 5e0084e394f..6715387d87d 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/pickSlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/pickSlot.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { InstalledGhost } from '../../../shared/ghost'; +import { installedGhostMutationTargetToken } from '../../../shared/pluginIdentity'; import { GhostPickSlot, type PickSlotDeps } from '../pickSlot'; function pickGhost(options: { pick?: boolean; node?: boolean; enabled?: boolean } = {}): InstalledGhost { @@ -28,6 +29,7 @@ function makeSlot(overrides: Partial = {}) { let clock = 0; const deps: PickSlotDeps = { getGhost: () => pickGhost(), + getMutationTarget: () => 'installed-target', showDirectoryDialog: vi.fn(async () => '/Users/me/projects'), depositDir: vi.fn(() => ({ ok: true as const, @@ -154,6 +156,54 @@ describe('pickSlot · 授权 = 用户亲选', () => { }); }); +describe('pickSlot · 晚到的选择结果', () => { + it.each(['root-reuse', 'source-switch', 'owner-switch', 'disabled', 'unchanged'])( + '%s 不把旧授权交给新目标', + async (change) => { + let current = { + ...pickGhost(), + namespace: 'acme' as string | null, + approval: { state: 'approved' as const, revision: 'receipt-1' }, + }; + let owner = 'owner-1'; + let release!: (value: string | null) => void; + const dialog = new Promise((resolve) => { release = resolve; }); + const recordPickedDir = vi.fn(); + const { slot, deps } = makeSlot({ + getGhost: () => current, + getMutationTarget: () => installedGhostMutationTargetToken(current, owner), + showDirectoryDialog: () => dialog, + recordPickedDir, + }); + const pending = slot.handleRequest('pick-ghost', { mode: 'directory', deposit: true }); + if (change === 'root-reuse') current = { ...current, namespace: null }; + if (change === 'source-switch') { + current = { ...current, approval: { state: 'approved', revision: 'receipt-2' } }; + } + if (change === 'owner-switch') owner = 'owner-2'; + if (change === 'disabled') current = { ...current, enabled: false }; + release('/Users/me/projects'); + if (change === 'unchanged') { + expect(await pending).toMatchObject({ ok: true }); + expect(recordPickedDir).toHaveBeenCalledOnce(); + expect(deps.depositDir).toHaveBeenCalledOnce(); + } else { + expect(await pending).toMatchObject({ ok: false, errorCode: 'PERMISSION_DENIED' }); + expect(recordPickedDir).not.toHaveBeenCalled(); + expect(deps.depositDir).not.toHaveBeenCalled(); + } + }, + ); + + it('无法绑定目标时不打开选择框', async () => { + const { slot, deps } = makeSlot({ getMutationTarget: () => null }); + expect(await slot.handleRequest('pick-ghost', { mode: 'directory' })).toMatchObject({ + ok: false, errorCode: 'PERMISSION_DENIED', + }); + expect(deps.showDirectoryDialog).not.toHaveBeenCalled(); + }); +}); + describe('pickSlot · 骚扰钳制', () => { it('同插件两次请求间隔不足 = RATE_LIMITED(按尝试记账)', async () => { let clock = 0; diff --git a/apps/desktop/src/main/cindy-brain/__tests__/pluginInstanceAssembly.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/pluginInstanceAssembly.test.ts new file mode 100644 index 00000000000..d99a8e5bf17 --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/__tests__/pluginInstanceAssembly.test.ts @@ -0,0 +1,88 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { createGhostProductionCallbacks } from './ghostProductionCallbacksFixture.js'; +import { describe, expect, it, vi } from 'vitest'; +import type { InstalledGhost } from '../../../shared/ghost.js'; +import { ghostExternalLinkUrls } from '../../../shared/ghost.js'; +import { findInstalledGhostByInstanceId, installedGhostStoragePart, resolveInstalledGhost } from '../../../shared/pluginIdentity.js'; +import { PluginDownloadSlot } from '../downloadSlot.js'; +import { GhostExternalLinkGate } from '../previewGate.js'; +import { runGhostExternalLinkNavigation } from '../ghostExternalLinkNavigation.js'; + +const productionAssembly = createGhostProductionCallbacks<{ + pluginDownloads: PluginDownloadSlot; + handleGhostExternalLinkNavigation: (...args: unknown[]) => void; +}>({ + functions: ['findAvailableGhost', 'findGhostForInstanceId', 'getGhostExternalLinkGate', 'handleGhostExternalLinkNavigation'], + variables: ['pluginDownloads'], + initialize: 'let externalLinkGateSingleton = null;', +}); + +function installed(namespace?: string | null, inPlace = false): InstalledGhost { + return { + manifest: { + schemaVersion: 2, id: 'helper', name: 'Helper', version: '1.0.0', kind: 'chip', entry: 'main.js', + node: { entry: 'node.js' }, network: { + hosts: ['example.invalid'], secrets: [{ key: 'account', label: 'Account', url: 'https://example.invalid/control' }], + }, + }, + dir: namespace && !inPlace ? '/plugins/_ns/' + namespace + '/helper' : '/plugins/helper', + enabled: true, + approval: { state: 'approved', revision: '00000000-0000-4000-8000-000000000001' }, + ...(namespace === undefined ? { namespaceMigration: 'pending' } : { namespace }), + } as InstalledGhost; +} + +describe('production plugin instance assembly', () => { + it.each(['S1', 'S2-off', 'S2-on', 'S2-coexist'])( + 'downloads and external navigation keep the selected instance in %s', async (stage) => { + const selected = stage === 'S1' ? installed() + : stage === 'S2-off' ? installed('acme', true) : installed('acme'); + const other = { ...installed(null), enabled: false }; + const ghosts = stage === 'S2-coexist' ? [other, selected] : [selected]; + const tmp = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'cindy-instance-assembly-')); + const send = vi.fn(); + const openExternal = vi.fn(async () => {}); + const showMessageBox = vi.fn(async () => ({ response: 0 })); + const host = { isDestroyed: () => false }; + const guest = { isDestroyed: () => false, isFocused: () => true, hostWebContents: host }; + const download = vi.fn(async (options) => { + await fs.promises.writeFile(options.targetPath, 'ok'); + return { path: options.targetPath, size: 2, sha256: options.sha256, fromCache: false, durationMs: 1, resumedFromBytes: 0 }; + }); + const assembly = productionAssembly({ + availableGhosts: () => ghosts, resolveInstalledGhost, findInstalledGhostByInstanceId, + PluginDownloadSlot, GhostExternalLinkGate, ghostExternalLinkUrls, runGhostExternalLinkNavigation, + ownerScopedUserDataPath: (...parts: string[]) => path.join(tmp, ...parts), + activeOwnerScopeKey: () => 'owner', getActiveAppSession: () => ({ dataOwnerId: 'owner' }), + anonymousDownloadRoots: new Map(), sendToGhostLogic: send, createDownloader: () => download, + BrowserWindow: { fromWebContents: () => ({ isDestroyed: () => false }) }, dialog: { showMessageBox }, + shell: { openExternal }, t: (key: string) => key, + log: { debug: vi.fn(), warn: vi.fn() }, + }); + const instanceId = installedGhostStoragePart(selected); + try { + expect(await assembly.pluginDownloads.handle(instanceId, { + kind: 'start', id: 'artifact', url: 'https://example.invalid/file', bytes: 2, + sha256: createHash('sha256').update('ok').digest('hex'), + })).toMatchObject({ ok: true }); + expect(download).toHaveBeenCalledTimes(1); + expect(send.mock.calls.every(([id]) => id === instanceId)).toBe(true); + assembly.handleGhostExternalLinkNavigation('helper', 'https://example.invalid/control', host, guest, () => true, instanceId); + await vi.waitFor(() => expect(openExternal).toHaveBeenCalledWith('https://example.invalid/control')); + expect(showMessageBox).not.toHaveBeenCalled(); + selected.enabled = false; + expect(await assembly.pluginDownloads.handle(instanceId, { + kind: 'start', id: 'denied', url: 'https://example.invalid/file', bytes: 2, + sha256: 'a'.repeat(64), + })).toMatchObject({ ok: false }); + expect(download).toHaveBeenCalledTimes(1); + } finally { + await assembly.pluginDownloads.stopAndWait(); + await fs.promises.rm(tmp, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/previewGate.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/previewGate.test.ts index 66cceccb958..0d4fb2a89f8 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/previewGate.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/previewGate.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { GHOST_EXTERNAL_LINK_MIN_INTERVAL_MS, @@ -366,6 +366,54 @@ describe('parseGhostPanelMediaUrl(右键菜单形状:图片 + 视频)', () => { }); }); +describe('GhostPreviewGate instance isolation', () => { + it('keeps same-name root and organization preview grants and rate limits independent', async () => { + const ghostCanRead = vi.fn(async (_hash: string, instanceId: string) => + ['art', '_ns__acme__art', '_ns__other__art'].includes(instanceId)); + const gate = new GhostPreviewGate({ + ghostCanRead, + getBlobInfo: async () => ({ ext: '.png', mimeType: 'image/png' }), + blobUrl: () => 'cindy-media://blobs/' + HASH + '.png', + now: () => 1000, + }); + for (const instanceId of ['art', '_ns__acme__art', '_ns__other__art']) { + await expect(gate.request({ + ghostId: 'art', instanceId, url: URL_OK, isPanelFocused: () => true, + })).resolves.toMatchObject({ ok: true }); + expect(ghostCanRead).toHaveBeenLastCalledWith(HASH, instanceId); + await expect(gate.request({ + ghostId: 'art', instanceId, url: URL_OK, isPanelFocused: () => true, + })).resolves.toEqual({ ok: false, reason: 'rate-limited' }); + } + }); + + it('never falls back to the root grant when the organization does not own the image', async () => { + const ghostCanRead = vi.fn(async (_hash: string, instanceId: string) => instanceId === 'art'); + const gate = new GhostPreviewGate({ + ghostCanRead, + getBlobInfo: async () => ({ ext: '.png', mimeType: 'image/png' }), + blobUrl: () => 'cindy-media://blobs/' + HASH + '.png', + }); + await expect(gate.request({ + ghostId: 'art', instanceId: '_ns__acme__art', url: URL_OK, isPanelFocused: () => true, + })).resolves.toEqual({ ok: false, reason: 'not-owned' }); + expect(ghostCanRead).toHaveBeenCalledExactlyOnceWith(HASH, '_ns__acme__art'); + }); + + it('rejects a mismatched instance before querying the ledger', async () => { + const ghostCanRead = vi.fn(async () => true); + const gate = new GhostPreviewGate({ + ghostCanRead, + getBlobInfo: async () => ({ ext: '.png', mimeType: 'image/png' }), + blobUrl: () => 'cindy-media://blobs/' + HASH + '.png', + }); + await expect(gate.request({ + ghostId: 'art', instanceId: '_ns__acme__other', url: URL_OK, isPanelFocused: () => true, + })).resolves.toEqual({ ok: false, reason: 'bad-url' }); + expect(ghostCanRead).not.toHaveBeenCalled(); + }); +}); + describe('resolveGhostPanelMedia(换发闸:attach / menu 两用途)', () => { const DEPS = { ghostCanRead: async () => true, @@ -374,6 +422,76 @@ describe('resolveGhostPanelMedia(换发闸:attach / menu 两用途)', () => { blobAbsPath: (hash: string, ext: string) => `/blobs/${hash.slice(0, 2)}/${hash}${ext}`, statSize: async () => 1234, }; + + it.each([ + ['menu', 'media'], + ['menu', 'preview'], + ['attach', 'media'], + ['attach', 'preview'], + ] as const)('%s resolves %s through the verified namespaced instance', async (purpose, shape) => { + const ghostCanRead = vi.fn(async (_hash: string, instanceId: string) => instanceId === '_ns__acme__art'); + await expect(resolveGhostPanelMedia( + 'cindy-ghost://art/' + shape + '/' + HASH + '.png', + purpose, + { ...DEPS, ghostCanRead }, + { ghostId: 'art', instanceId: '_ns__acme__art' }, + )).resolves.toEqual({ url: 'cindy-media://blobs/' + HASH + '.png', kind: 'image' }); + expect(ghostCanRead).toHaveBeenCalledWith(HASH, '_ns__acme__art'); + }); + + it('does not borrow a root or another organization media grant', async () => { + const ghostCanRead = vi.fn(async (_hash: string, instanceId: string) => instanceId !== '_ns__acme__art'); + await expect(resolveGhostPanelMedia( + 'cindy-ghost://art/media/' + HASH + '.png', + 'menu', + { ...DEPS, ghostCanRead }, + { ghostId: 'art', instanceId: '_ns__acme__art' }, + )).resolves.toBeNull(); + expect(ghostCanRead).toHaveBeenCalledExactlyOnceWith(HASH, '_ns__acme__art'); + }); + + it('rejects a URL host that differs from the verified installed manifest', async () => { + const ghostCanRead = vi.fn(async () => true); + await expect(resolveGhostPanelMedia( + 'cindy-ghost://other/media/' + HASH + '.png', + 'attach', + { ...DEPS, ghostCanRead }, + { ghostId: 'art', instanceId: '_ns__acme__art' }, + )).resolves.toBeNull(); + expect(ghostCanRead).not.toHaveBeenCalled(); + }); + + it('keeps the legacy root call on the physical root grant', async () => { + const ghostCanRead = vi.fn(async (_hash: string, instanceId: string) => instanceId === 'art'); + await expect(resolveGhostPanelMedia( + 'cindy-ghost://art/media/' + HASH + '.png', + 'attach', + { ...DEPS, ghostCanRead }, + )).resolves.toEqual({ url: 'cindy-media://blobs/' + HASH + '.png', kind: 'image' }); + expect(ghostCanRead).toHaveBeenCalledWith(HASH, 'art'); + }); + + it.each(['', '../art', '_ns/acme/art', '_ns__acme__other'])('rejects invalid or mismatched storage identity %s', async (instanceId) => { + const ghostCanRead = vi.fn(async () => true); + await expect(resolveGhostPanelMedia( + 'cindy-ghost://art/media/' + HASH + '.png', + 'attach', + { ...DEPS, ghostCanRead }, + { ghostId: 'art', instanceId }, + )).resolves.toBeNull(); + expect(ghostCanRead).not.toHaveBeenCalled(); + }); + + it('keeps the legacy physical key after an in-place namespace stamp', async () => { + const ghostCanRead = vi.fn(async (_hash: string, instanceId: string) => instanceId === 'art'); + await expect(resolveGhostPanelMedia( + 'cindy-ghost://art/media/' + HASH + '.png', + 'menu', + { ...DEPS, ghostCanRead }, + { ghostId: 'art', instanceId: 'art' }, + )).resolves.toEqual({ url: 'cindy-media://blobs/' + HASH + '.png', kind: 'image' }); + expect(ghostCanRead).toHaveBeenCalledExactlyOnceWith(HASH, 'art'); + }); /** HASH = 'a'×64 的视频换发预期(路径引用元数据齐全)。 */ const VIDEO_RESOLVED = { url: `cindy-media://blobs/${HASH}.mp4`, @@ -385,6 +503,17 @@ describe('resolveGhostPanelMedia(换发闸:attach / menu 两用途)', () => { mimeType: 'video/mp4', }; + it.each(['menu', 'attach'] as const)('%s resolves namespaced video without changing its public filename', async (purpose) => { + const ghostCanRead = vi.fn(async (_hash: string, instanceId: string) => instanceId === '_ns__acme__art'); + await expect(resolveGhostPanelMedia( + 'cindy-ghost://art/media/' + HASH + '.mp4', + purpose, + { ...DEPS, ghostCanRead, getBlobInfo: async () => ({ ext: '.mp4', mimeType: 'video/mp4' }) }, + { ghostId: 'art', instanceId: '_ns__acme__art' }, + )).resolves.toEqual(VIDEO_RESOLVED); + expect(ghostCanRead).toHaveBeenCalledExactlyOnceWith(HASH, '_ns__acme__art'); + }); + it('menu:图片换发成功并回传 kind=image', async () => { await expect( resolveGhostPanelMedia(`cindy-ghost://art/media/${HASH}.png`, 'menu', DEPS), diff --git a/apps/desktop/src/main/cindy-brain/__tests__/residentGhost.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/residentGhost.test.ts index b6a23f77cc7..a61e08bdf96 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/residentGhost.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/residentGhost.test.ts @@ -46,8 +46,22 @@ it('preserves disabled/unavailable gates and ordinary on-demand startup behavior const ghost = { enabled: true, manifest: checked.manifest } as InstalledGhost; const deps = { isAvailable: () => true, startNode: vi.fn(), spawnBrowser: vi.fn(), warn: vi.fn() }; spawnResidentGhost({ ...ghost, enabled: false }, deps); + spawnResidentGhost({ ...ghost, namespaceMigration: 'pending' }, deps); spawnResidentGhost(ghost, { ...deps, isAvailable: () => false }); spawnResidentGhost({ ...ghost, manifest: { ...ghost.manifest, routineEvents: undefined } }, deps); expect(deps.startNode).not.toHaveBeenCalled(); expect(deps.spawnBrowser).not.toHaveBeenCalled(); }); + +it('starts a pending approved legacy resident only with an explicit offline recovery grant', () => { + const checked = validateGhostManifest(manifest); + if (!checked.ok) throw new Error(checked.reason); + const ghost = { enabled: true, manifest: checked.manifest, namespaceMigration: 'pending' } as InstalledGhost; + const deps = { isAvailable: () => true, startNode: vi.fn(), spawnBrowser: vi.fn(async () => ({ ok: true })), warn: vi.fn() }; + spawnResidentGhost(ghost, deps); + expect(deps.spawnBrowser).not.toHaveBeenCalled(); + spawnResidentGhost(ghost, { ...deps, allowPendingLegacy: true }); + expect(deps.spawnBrowser).toHaveBeenCalledOnce(); + spawnResidentGhost({ ...ghost, enabled: false }, { ...deps, allowPendingLegacy: true }); + expect(deps.spawnBrowser).toHaveBeenCalledOnce(); +}); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/skillSlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/skillSlot.test.ts index b1d64c8b1ae..07816aadac8 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/skillSlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/skillSlot.test.ts @@ -134,6 +134,46 @@ describe('skillSlot · checkSkillMdConsistency', () => { }); describe('skillSlot · reconcileGhostSkillLinks', () => { + it('projects root and organization skills separately and reclaims a namespaced dangling link', async () => { + const root = ghost('my-ghost', [{ dir: 'skills/foo', name: 'foo' }]); + const organization = { + ...ghost('my-ghost', [{ dir: 'skills/foo', name: 'foo' }]), + namespace: 'acme', + dir: path.join(brainRoot, '_ns', 'acme', 'my-ghost'), + approvedSkillRoot: path.join(approvalStateRoot, 'skill-snapshots', '_ns', 'acme', 'my-ghost', 'revision'), + }; + await writeSkillDir('my-ghost', 'skills/foo', 'foo'); + await fs.promises.mkdir(path.join(organization.approvedSkillRoot, 'skills', 'foo'), { recursive: true }); + await fs.promises.writeFile(path.join(organization.approvedSkillRoot, 'skills', 'foo', 'SKILL.md'), + '---\nname: foo\ndescription: 说明\n---\n正文\n'); + await reconcileGhostSkillLinks({ ghosts: [root, organization], brainRoot, approvalStateRoot, homeDir }); + const rootLink = path.join(sharedDir(), ghostSkillLinkName('my-ghost', 'foo')); + const orgLink = path.join(sharedDir(), ghostSkillLinkName('_ns__acme__my-ghost', 'foo')); + expect(sameRealPath(rootLink, path.join(brainRoot, 'my-ghost', 'skills', 'foo'))).toBe(true); + expect(sameRealPath(orgLink, path.join(organization.approvedSkillRoot, 'skills', 'foo'))).toBe(true); + await fs.promises.rm(organization.approvedSkillRoot, { recursive: true }); + await reconcileGhostSkillLinks({ ghosts: [root], brainRoot, approvalStateRoot, homeDir }); + await expect(fs.promises.lstat(orgLink)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('reclaims a pre-namespace-link-named orphan under a namespaced snapshot path', async () => { + const snapshot = path.join(approvalStateRoot, 'skill-snapshots', '_ns', 'acme', 'my-ghost', 'revision', 'skills', 'foo'); + const legacyLink = path.join(sharedDir(), ghostSkillLinkName('my-ghost', 'foo')); + await fs.promises.mkdir(sharedDir(), { recursive: true }); + await fs.promises.symlink(snapshot, legacyLink, process.platform === 'win32' ? 'junction' : 'dir'); + await reconcileGhostSkillLinks({ ghosts: [], brainRoot, approvalStateRoot, homeDir }); + await expect(fs.promises.lstat(legacyLink)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('does not reclaim a namespaced dangling link to another namespace snapshot', async () => { + const snapshot = path.join(approvalStateRoot, 'skill-snapshots', '_ns', 'other', 'my-ghost', 'revision', 'skills', 'foo'); + const externalLink = path.join(sharedDir(), ghostSkillLinkName('_ns__acme__my-ghost', 'foo')); + await fs.promises.mkdir(sharedDir(), { recursive: true }); + await fs.promises.symlink(snapshot, externalLink, process.platform === 'win32' ? 'junction' : 'dir'); + await reconcileGhostSkillLinks({ ghosts: [], brainRoot, approvalStateRoot, homeDir }); + expect(await fs.promises.readlink(externalLink)).toBe(snapshot); + }); + it('启用插件 → 建链进共享根并扇出 .claude;二次对账幂等', async () => { await writeSkillDir('my-ghost', 'skills/foo', 'foo'); const ghosts = [ghost('my-ghost', [{ dir: 'skills/foo', name: 'foo' }])]; @@ -710,7 +750,7 @@ describe('skillSlot · 全链路(打包 → 装入 → 对账 → 双端可见)' await fs.promises.readFile(path.join(sharedDir(), linkName, 'SKILL.md'), 'utf8'), ).toContain('演示技能'); await fs.promises.writeFile( - path.join(brainRoot, 'e2e-ghost', 'skills', 'demo', 'SKILL.md'), + path.join(manager.list()[0].dir, 'skills', 'demo', 'SKILL.md'), '---\nname: demo\ndescription: 演示技能\n---\n\n篡改后的指令\n', ); expect( diff --git a/apps/desktop/src/main/cindy-brain/__tests__/subscriptionGateway.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/subscriptionGateway.test.ts index d2b032bfe81..49bd20047b6 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/subscriptionGateway.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/subscriptionGateway.test.ts @@ -33,6 +33,7 @@ import { type GhostPipeEventPush, type InstalledGhost, } from '../../../shared/ghost'; +import { installedGhostStoragePart } from '../../../shared/pluginIdentity.js'; describe('did-session-switched primary session resolution', () => { it('allows ordinary sessions and Orca leads, but never workers or background sessions', () => { @@ -280,7 +281,7 @@ function makeGateway(overrides: Partial = {}) { listGhosts: () => [ghost('a', { topics: ['turn'] })], isRunning: (id) => running.has(id), wake: vi.fn(async (g: InstalledGhost) => { - running.add(g.manifest.id); + running.add(installedGhostStoragePart(g)); }), sendToGhost: (ghostId, payload) => { sent.push({ ghostId, payload }); @@ -323,6 +324,36 @@ describe('subscriptionGateway owner boundary', () => { const TURN_DATA = { sessionId: 's1', agent: 'claude-code' }; describe('did- 旁听扇出', () => { + it('does not deliver an old buffered event after its physical key has been reused', async () => { + let release!: () => void; + const pending = new Promise((resolve) => { release = resolve; }); + const { gw, sent, running } = makeGateway({ wake: () => pending }); + gw.publish('turn', 'did-turn-start', TURN_DATA); + gw.dropGhost('a'); + running.add('a'); + release(); + await pending; + await Promise.resolve(); + expect(sent).toHaveLength(0); + gw.publish('turn', 'did-turn-start', TURN_DATA); + expect(sent).toHaveLength(1); + expect((sent[0]!.payload as { seq: number }).seq).toBe(1); + }); + it('isolates same-name subscriber queues and never sends organization events to root', () => { + const root = ghost('helper', undefined); + const organization = { ...ghost('helper', { topics: ['turn'] }), namespace: 'acme', dir: '/fake/_ns/acme/helper' }; + const { gw, sent, running } = makeGateway({ listGhosts: () => [root, organization] }); + running.add('helper'); + running.add('_ns__acme__helper'); + gw.publish('turn', 'did-turn-start', TURN_DATA); + expect(sent.map((event) => event.ghostId)).toEqual(['_ns__acme__helper']); + root.manifest.subscribe = { topics: ['turn'] }; + gw.publish('turn', 'did-turn-start', TURN_DATA); + expect(sent.map((event) => [event.ghostId, (event.payload as { seq: number }).seq])).toEqual([ + ['_ns__acme__helper', 1], ['helper', 1], ['_ns__acme__helper', 2], + ]); + }); + it('只投声明了该 topic 的启用意识;seq 单调', async () => { const { gw, sent, running } = makeGateway({ listGhosts: () => [ @@ -506,6 +537,21 @@ describe('GhostActivityTracker', () => { }); describe('will- 拦截', () => { + it.each(['will-user-message', 'will-assistant-message'] as const)('wakes and routes %s only to its selected organization instance', async (hook) => { + const root = ghost('helper', undefined); + const org = { ...ghost('helper', { hooks: [hook] }), namespace: 'acme', dir: '/fake/_ns/acme/helper' }; + const { gw, sent, running } = makeGateway({ listGhosts: () => [root, org] }); + const screening = hook === 'will-user-message' + ? gw.screenUserMessage({ sessionId: 's', text: 'input' }) + : gw.screenAssistantMessage({ sessionId: 's', text: 'input' }); + await vi.waitFor(() => expect(sent).toHaveLength(1)); + expect(running).toEqual(new Set(['_ns__acme__helper'])); + expect(sent[0]!.ghostId).toBe('_ns__acme__helper'); + const hookId = (sent[0]!.payload as unknown as { hookId: string }).hookId; + gw.handleVerdict('helper', { type: 'event-verdict', hookId, action: 'rewrite', text: 'wrong instance' }); + gw.handleVerdict('_ns__acme__helper', { type: 'event-verdict', hookId, action: 'rewrite', text: 'organization' }); + expect(await screening).toMatchObject({ action: 'rewrite', ghostId: '_ns__acme__helper', text: 'organization' }); + }); beforeEach(() => { vi.useFakeTimers(); }); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/workspaceSlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/workspaceSlot.test.ts index 828635c2420..cd1a7971bf8 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/workspaceSlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/workspaceSlot.test.ts @@ -41,6 +41,7 @@ function makeSlot( let clock = 0; const deps: WorkspaceSlotDeps = { getGhost: () => workspaceGhost(), + getMutationTarget: () => 'original-installation', showDirectoryDialog: vi.fn(async () => '/Users/me/projects/demo'), resolveCallContext: vi.fn(() => ({ ghostId: 'ws-ghost', sessionId: 'sess-1' })), getSessionDirInfo: vi.fn(async () => ({ @@ -66,6 +67,130 @@ const DIR_REQ = { callId: 'call-1', } as const; +describe('workspace installed target boundary', () => { + it('does not grant a late selection after the plugin is disabled without a new receipt', async () => { + let enabled = true; + let finishDialog!: (directory: string) => void; + const service = makeService(); + const { slot } = makeSlot({ + getGhost: () => workspaceGhost({ enabled }), + showDirectoryDialog: () => new Promise((resolve) => { finishDialog = resolve; }), + }, service); + const pending = slot.handleRequest('ws-ghost', PICK_REQ); + enabled = false; + finishDialog('/Users/me/projects/demo'); + expect(await pending).toMatchObject({ ok: false, errorCode: 'CANCELLED' }); + expect(service.findActiveSessionByWorkdir).not.toHaveBeenCalled(); + }); + + it('rejects an unavailable installed target before asking for a directory', async () => { + const service = makeService(); + const { slot, deps } = makeSlot({ getMutationTarget: () => null }, service); + expect(await slot.handleRequest('ws-ghost', PICK_REQ)).toMatchObject({ ok: false, errorCode: 'PERMISSION_DENIED' }); + expect(deps.showDirectoryDialog).not.toHaveBeenCalled(); + expect(service.findActiveSessionByWorkdir).not.toHaveBeenCalled(); + }); + + it.each(['relocated-installation', 'replacement-source', 'new-owner', null])( + 'cancels a late directory selection when the target becomes %s', async (nextTarget) => { + let target: string | null = 'original-installation'; + let finishDialog!: (directory: string) => void; + const service = makeService(); + const { slot } = makeSlot({ + getMutationTarget: () => target, + showDirectoryDialog: () => new Promise((resolve) => { finishDialog = resolve; }), + }, service); + const pending = slot.handleRequest('ws-ghost', { ...PICK_REQ, focus: true }); + target = nextTarget; + finishDialog('/Users/me/projects/demo'); + expect(await pending).toMatchObject({ ok: false, errorCode: 'CANCELLED' }); + expect(service.findActiveSessionByWorkdir).not.toHaveBeenCalled(); + expect(service.createDraftSession).not.toHaveBeenCalled(); + expect(service.focusSession).not.toHaveBeenCalled(); + }, + ); + + it.each(['reuse', 'create'] as const)('does not %s after the target changes during lookup', async (outcome) => { + let target = 'original-installation'; + const service = makeService({ + findActiveSessionByWorkdir: vi.fn(async () => { + target = 'replacement-source'; + return outcome === 'reuse' ? 'existing-session' : null; + }), + }); + const { slot } = makeSlot({ getMutationTarget: () => target }, service); + expect(await slot.handleRequest('ws-ghost', { ...PICK_REQ, focus: true })).toMatchObject({ ok: false, errorCode: 'CANCELLED' }); + expect(service.createDraftSession).not.toHaveBeenCalled(); + expect(service.focusSession).not.toHaveBeenCalled(); + }); + + it('passes the installed target guard to draft creation and refuses late focus', async () => { + let target = 'original-installation'; + const service = makeService({ + createDraftSession: vi.fn(async ({ shouldContinue }) => { + expect(shouldContinue?.()).toBe(true); + target = 'replacement-source'; + expect(shouldContinue?.()).toBe(false); + return 'late-session'; + }), + }); + const { slot } = makeSlot({ getMutationTarget: () => target }, service); + expect(await slot.handleRequest('ws-ghost', { ...PICK_REQ, focus: true })).toMatchObject({ ok: false, errorCode: 'CANCELLED' }); + expect(service.focusSession).not.toHaveBeenCalled(); + }); + + it('rechecks the installed target when an authorized request leaves the ensure queue', async () => { + let secondCurrent = true; + let finishFirst!: (sessionId: string) => void; + const service = makeService({ + createDraftSession: vi.fn(async () => 'second-session') + .mockImplementationOnce(() => new Promise((resolve) => { finishFirst = resolve; })), + }); + const { slot, deps } = makeSlot({ + getMutationTarget: (id) => id === 'second-ghost' && !secondCurrent ? 'replacement-source' : id, + }, service); + const first = slot.handleRequest('first-ghost', PICK_REQ); + await vi.waitFor(() => expect(service.createDraftSession).toHaveBeenCalledOnce()); + const second = slot.handleRequest('second-ghost', { ...PICK_REQ, focus: true }); + await vi.waitFor(() => expect(deps.showDirectoryDialog).toHaveBeenCalledTimes(2)); + secondCurrent = false; + finishFirst('first-session'); + expect(await first).toMatchObject({ ok: true }); + expect(await second).toMatchObject({ ok: false, errorCode: 'CANCELLED' }); + expect(service.findActiveSessionByWorkdir).toHaveBeenCalledOnce(); + expect(service.createDraftSession).toHaveBeenCalledOnce(); + expect(service.focusSession).not.toHaveBeenCalled(); + }); + + it.each(['stat', 'session', 'review', 'confirm'] as const)( + 'stops a dir request when its installed target changes during %s', async (phase) => { + let target = 'original-installation'; + const service = makeService({ reviewPermissionAction: vi.fn(async () => { + if (phase === 'review') target = 'replacement-source'; + return { verdict: 'ask' as const }; + }) }); + const { slot, deps } = makeSlot({ + getMutationTarget: () => target, + resolveCallContext: () => ({ ghostId: 'ws-ghost', sessionId: 'sess-1', sessionInstanceId: 'instance-1' }), + isInsideWorkdir: () => false, + statDir: vi.fn(async () => { if (phase === 'stat') target = 'replacement-source'; return 'ok' as const; }), + getSessionDirInfo: vi.fn(async () => { + if (phase === 'session') target = 'replacement-source'; + return { workingDir: '/Users/me/projects/demo', remoteHostId: null }; + }), + confirmDir: vi.fn(async () => { target = 'replacement-source'; return { ok: true as const }; }), + }, service); + expect(await slot.handleRequest('ws-ghost', { ...DIR_REQ, focus: true })).toMatchObject({ ok: false, errorCode: 'CANCELLED' }); + expect(deps.getSessionDirInfo).toHaveBeenCalledTimes(phase === 'stat' ? 0 : 1); + expect(service.reviewPermissionAction).toHaveBeenCalledTimes(phase === 'stat' || phase === 'session' ? 0 : 1); + expect(deps.confirmDir).toHaveBeenCalledTimes(phase === 'confirm' ? 1 : 0); + expect(service.findActiveSessionByWorkdir).not.toHaveBeenCalled(); + expect(service.createDraftSession).not.toHaveBeenCalled(); + expect(service.focusSession).not.toHaveBeenCalled(); + }, + ); +}); + describe('workspaceSlot · 资格审与载荷校验', () => { it('未声明 workspace 能力 / 未启用 一律 PERMISSION_DENIED', async () => { const noSlot = makeSlot({ getGhost: () => workspaceGhost({ workspace: false }) }); @@ -117,6 +242,7 @@ describe('workspaceSlot · pick 流(亲选即授权)', () => { dirAbs: '/Users/me/projects/demo', title: '选择项目', ghostId: 'ws-ghost', + shouldContinue: expect.any(Function), }); expect(JSON.stringify(result)).not.toContain('/Users'); }); diff --git a/apps/desktop/src/main/cindy-brain/builtinGhostProvisioner.ts b/apps/desktop/src/main/cindy-brain/builtinGhostProvisioner.ts index f3591f34fec..6f5219067b8 100644 --- a/apps/desktop/src/main/cindy-brain/builtinGhostProvisioner.ts +++ b/apps/desktop/src/main/cindy-brain/builtinGhostProvisioner.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import crypto from 'node:crypto'; import os from 'node:os'; +import { authorDeclaredNamespaceReason } from '@cindy/plugin-protocol'; import { GHOST_MANIFEST_FILE, ghostIconMimeType, @@ -942,6 +943,11 @@ function readSeedManifest( }); return null; } + const reservedNamespace = authorDeclaredNamespaceReason(raw); + if (reservedNamespace) { + log?.warn('builtin seed skipped: invalid manifest', { seedDir, reason: reservedNamespace }); + return null; + } const v = validateGhostManifest(raw); if (!v.ok) { log?.warn('builtin seed skipped: invalid manifest', { seedDir, reason: v.reason }); diff --git a/apps/desktop/src/main/cindy-brain/cardRemoteResource.ts b/apps/desktop/src/main/cindy-brain/cardRemoteResource.ts index d3d9255b0e6..d03a0000f10 100644 --- a/apps/desktop/src/main/cindy-brain/cardRemoteResource.ts +++ b/apps/desktop/src/main/cindy-brain/cardRemoteResource.ts @@ -6,10 +6,32 @@ import { getGhostCard, upsertGhostCard, type GhostCardRecord } from './cardStore import { assertRemoteBotInvocationAllowed } from '../device-link/remoteBotSessionBoundary.js'; import { remoteResourceRegistry, RemoteResourceRegistryError, type RemoteResourceProvider } from '../device-link/remoteResourceRegistry.js'; import { captureDataOwnerBroadcastScope, isDataOwnerBroadcastScopeCurrent, tapWindowBroadcast, getSafeDataOwnerPushStamp } from '../device-link/broadcast-tap.js'; +import { isValidGhostId } from '../../shared/ghost.js'; +import { createPluginLogicalIdentity, findInstalledGhostByIdentity, findInstalledGhostByInstanceId, isGhostInstanceId } from '../../shared/pluginIdentity.js'; const COLLECTION = 'plugin-results'; const KIND = 'card'; +export function findGhostForRemotePluginIdentity( + ghosts: readonly T[], id: string, +): T | undefined { + if (isGhostInstanceId(id) && !isValidGhostId(id)) { + return findInstalledGhostByInstanceId(ghosts, id); + } + if (!id.startsWith('[')) { + const matches = ghosts.filter((ghost) => ghost.manifest.id === id); + return matches.length === 1 ? matches[0] : undefined; + } + try { + const parsed: unknown = JSON.parse(id); + if (!Array.isArray(parsed) || parsed.length !== 2 || + (parsed[0] !== null && typeof parsed[0] !== 'string') || !isValidGhostId(parsed[1])) return undefined; + return findInstalledGhostByIdentity(ghosts, createPluginLogicalIdentity(parsed[0], parsed[1])); + } catch { + return undefined; + } +} + function decodeText(value: string): string { return value.replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos|nbsp);/gi, (full, entity: string) => { const named: Record = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }; @@ -125,7 +147,9 @@ export function createPluginIdentityRemoteProvider(deps: { const valid = deps.captureScope(); let ids: unknown; try { ids = JSON.parse(request.ref.id); } catch { /* validated below */ } - if (!Array.isArray(ids) || ids.length !== 2 || !ids.every((id) => typeof id === 'string' && id.length > 0 && id.length <= 128)) { + if (!Array.isArray(ids) || ids.length !== 2 || + typeof ids[0] !== 'string' || ids[0].length === 0 || ids[0].length > 128 || + typeof ids[1] !== 'string' || ids[1].length === 0 || ids[1].length > 320) { throw new RemoteResourceRegistryError('NOT_FOUND', 'Plugin not found'); } await deps.authorize(ids[0]); diff --git a/apps/desktop/src/main/cindy-brain/cardService.ts b/apps/desktop/src/main/cindy-brain/cardService.ts index 1b15493e998..03d5be1bbb7 100644 --- a/apps/desktop/src/main/cindy-brain/cardService.ts +++ b/apps/desktop/src/main/cindy-brain/cardService.ts @@ -98,6 +98,8 @@ export interface GhostCardRow { export interface GhostCardPush { callId: string; ghostId: string; + /** 物理键与逻辑身份不同时,供进行中调用按 namespace 精确配对。 */ + logicalGhostId?: string; /** agent 侧 tool_use id(claude 路径有,codex 为 null → renderer 走启发式锚定)。 */ toolUseId: string | null; /** 静态版(settle 后 / 历史回放用;与落库内容一致)。 */ @@ -151,6 +153,7 @@ const SWEEP_MIN_INTERVAL_MS = 30_000; interface CallEntry { ghostId: string; + logicalGhostId?: string; toolUseId: string | null; sessionId: string | null; sessionInstanceId?: string; @@ -200,6 +203,7 @@ interface CallEntry { /** 卡片供片服务(单例装配见 cindy-brain/index.ts)。 */ export class GhostCardService { private readonly calls = new Map(); + private readonly pendingWrites = new Map, string>(); private lastSweepAt = 0; constructor(private readonly deps: GhostCardServiceDeps) {} @@ -237,6 +241,7 @@ export class GhostCardService { callId: string, info: { ghostId: string; + logicalGhostId?: string; toolUseId: string | null; sessionId: string | null; sessionInstanceId?: string; @@ -253,6 +258,7 @@ export class GhostCardService { this.sweep(); this.calls.set(callId, { ghostId: info.ghostId, + ...(info.logicalGhostId !== undefined ? { logicalGhostId: info.logicalGhostId } : {}), toolUseId: info.toolUseId, sessionId: info.sessionId, sessionInstanceId: info.sessionInstanceId, @@ -292,6 +298,17 @@ export class GhostCardService { return this.calls.get(callId)?.ghostId ?? null; } + async relocateGhost(fromPart: string, toPart: string): Promise { + for (const entry of this.calls.values()) { + if (entry.ghostId === fromPart) entry.ghostId = toPart; + } + await Promise.all( + [...this.pendingWrites] + .filter(([, ghostId]) => ghostId === fromPart) + .map(([pending]) => pending), + ); + } + /** * 内存条目全息查询(card-action 派发用):除归属外带出 sessionId—— * 铸衍生卡位(spawnCallId)登记时要续上会话归属,历史回放才能按会话 @@ -442,15 +459,17 @@ export class GhostCardService { updatedAt: now, }; // 落库失败不阻断推送:活卡先见,历史回放缺卡由 renderer missing 降级兜底。 - void this.deps.persist(row).catch((err) => { + const pending = this.deps.persist(row).catch((err) => { this.deps.log?.warn('ghost card persist failed', { callId: p.callId, error: err instanceof Error ? err.message : String(err), }); - }); + }).finally(() => this.pendingWrites.delete(pending)); + this.pendingWrites.set(pending, senderGhostId); this.deps.broadcast({ callId: p.callId, ghostId: senderGhostId, + ...(entry.logicalGhostId !== undefined ? { logicalGhostId: entry.logicalGhostId } : {}), toolUseId: entry.toolUseId, html: sanitized.html, animatedHtml: sanitized.animatedHtml ?? null, diff --git a/apps/desktop/src/main/cindy-brain/cindyPrefsStore.ts b/apps/desktop/src/main/cindy-brain/cindyPrefsStore.ts index 6273751d99a..0cf7cf17662 100644 --- a/apps/desktop/src/main/cindy-brain/cindyPrefsStore.ts +++ b/apps/desktop/src/main/cindy-brain/cindyPrefsStore.ts @@ -18,9 +18,16 @@ import { desktopMakerLogger } from '../maker-host/logger-adapter.js'; import { createOverrideSettingsFile } from '../maker-host/override-settings-file.js'; import { ownerScopedUserDataPath } from '../appSessionState.js'; +import { assertGhostPrefsWritable, relocateGhostPreferenceMaps } from './ghostPreferenceRelocation.js'; const log = desktopMakerLogger.child('cindy-prefs-store'); +export async function relocateGhostCindyPrefs(from: string, to: string): Promise { + await relocateGhostPreferenceMaps('ghost-cindy-prefs.json', ['overrides', 'inflightLimits'], from, to, () => { + store = createStore(); + }); +} + /** cindy 槽能力键(类目.动作;与身份卡详单同一词汇表,当前包含 video)。 */ // text.oneshot(快问快答)与图像/视频同表:每项覆盖记的都是一组供应商×模型。 // 文本类的取值是轻量任务模型链的档位键(codex-gpt-5.4-mini 等),不是媒体目录模型 id。 @@ -113,13 +120,18 @@ function normalize(raw: unknown): GhostCindyPrefs { return { overrides, inflightLimits }; } -const store = createOverrideSettingsFile({ - filePath: () => ownerScopedUserDataPath('ghost-cindy-prefs.json'), - defaults: DEFAULTS, - normalize, - log, - label: 'ghost-cindy-prefs', -}); +function createStore() { + return createOverrideSettingsFile({ + filePath: () => ownerScopedUserDataPath('ghost-cindy-prefs.json'), + defaults: DEFAULTS, + normalize, + log, + label: 'ghost-cindy-prefs', + preserveUnreadableFile: true, + }); +} + +let store = createStore(); /** 读某意识的全部覆盖(缺省空对象 = 全跟随默认)。 */ export function readGhostCindyOverrides(ghostId: string): Partial> { @@ -138,6 +150,7 @@ export function writeGhostCindyOverride( capability: CindyCapabilityKey, model: string | null, ): Partial> { + assertGhostPrefsWritable('ghost-cindy-prefs.json'); // 写前同样失效缓存:避免把用户刚手改的文件内容用旧缓存整体覆写掉。 store.invalidateIfChanged(); const overrides = { ...store.read().overrides }; @@ -163,6 +176,7 @@ export function readGhostCindyInflightLimit(ghostId: string): number | null { * 只收正整数,非法值直接抛(调用方应在入口校验,这里是最后防线)。 */ export function writeGhostCindyInflightLimit(ghostId: string, limit: number | null): void { + assertGhostPrefsWritable('ghost-cindy-prefs.json'); if (limit !== null && (!Number.isInteger(limit) || limit < 1)) { throw new Error(`inflight limit 必须是正整数或 null,收到:${String(limit)}`); } diff --git a/apps/desktop/src/main/cindy-brain/connectionAudienceResolver.ts b/apps/desktop/src/main/cindy-brain/connectionAudienceResolver.ts index 106a10023fe..f9e7727ac3a 100644 --- a/apps/desktop/src/main/cindy-brain/connectionAudienceResolver.ts +++ b/apps/desktop/src/main/cindy-brain/connectionAudienceResolver.ts @@ -11,13 +11,14 @@ */ import { isValidGhostId, isValidGhostNetworkHostPattern } from '../../shared/ghost.js'; import type { GhostManifest } from '../../shared/ghost.js'; +import { hasDeliveryNamespace, parsePluginInstanceId } from '../../shared/pluginIdentity.js'; import type { PluginMarketInstallationRecord } from '../plugin-market/ledger.js'; +import { matchesPendingLegacyForge } from './ghostFirstPartyPrivilege.js'; import { verifyInstalledMarketManifest, type InstalledMarketManifestIdentity, } from '../plugin-market/installedManifestIdentity.js'; import { PLUGIN_MEMBER_PUBLISHER_GHOST_ID } from '../plugin-publisher/types.js'; -import { PLUGIN_PREFIX_PATTERN } from '@cindy/plugin-protocol'; export interface ConnectionAudienceIdentity { membershipId: string; @@ -94,6 +95,9 @@ export interface LoadConnectionAudienceResolverOptions { ): PluginMarketInstallationRecord | MarketInstallationLookup | null; readApprovedPackageSha256?(ghostId: string): string | null; readInstallOrigin?(ghostId: string): 'manual' | 'agent-forge'; + /** Trusted receipt namespace. undefined means no delivery field. */ + readInstallNamespace?(ghostId: string): string | null | undefined; + isPendingLegacyForge?(ghostId: string): boolean; lookupOrganizationPrefix?( orgId: string, ): { kind: 'known'; pluginPrefix: string | null } | { kind: 'absent' } | { kind: 'unavailable' }; @@ -108,17 +112,19 @@ export function loadConnectionAudienceResolver( ): ConnectionAudienceResolver { return { resolve(ghostId, identity) { + const pluginSlug = parsePluginInstanceId(ghostId)?.ghostId ?? ghostId; const reject = (reason: string): null => { options.log?.warn('ghost Connection audience resolution rejected', { ghostId, + pluginSlug, reason, }); return null; }; - if (!isValidGhostId(ghostId) || !PLUGIN_SLUG_RE.test(ghostId)) { + if (!isValidGhostId(pluginSlug) || !PLUGIN_SLUG_RE.test(pluginSlug)) { return reject('plugin-id-invalid'); } - if (isReservedConnectionPluginSlug(ghostId)) { + if (isReservedConnectionPluginSlug(pluginSlug)) { return reject('plugin-id-reserved'); } if (identity.membershipKind !== 'org') return reject('membership-not-org'); @@ -131,16 +137,17 @@ export function loadConnectionAudienceResolver( const finish = (manifest: GhostManifest): ConnectionAudienceResolution | null => { const allowedHosts = declaredOidcTokenHosts(manifest); if (allowedHosts.length === 0) return reject('oidc-host-declaration-missing'); - const audience = `${identity.orgSlug}:${ghostId}`; + const audience = `${identity.orgSlug}:${pluginSlug}`; if (audience.length > 64) return reject('audience-too-long'); options.log?.info('ghost Connection audience resolved', { ghostId, + pluginSlug, allowedHostCount: allowedHosts.length, }); return { membershipId: identity.membershipId, audience, - pluginSlug: ghostId, + pluginSlug, allowedHosts, }; }; @@ -154,20 +161,34 @@ export function loadConnectionAudienceResolver( }; // 显式 ghost_forge_install 的企业作者自测分支,同时兼容升级前已有的 - // agent-forge receipt。个人身份、未知前缀与缺失批准包哈希均 fail closed。 + // agent-forge receipt。资格绑定当前组织身份,不再要求旧认领前缀。 const forgeOrigin = options.readInstallOrigin?.(ghostId); if (forgeOrigin === 'agent-forge') { - const prefixLookup = options.lookupOrganizationPrefix?.(identity.orgId); - const prefix = - prefixLookup && prefixLookup.kind === 'known' ? prefixLookup.pluginPrefix : null; - if (prefix && PLUGIN_PREFIX_PATTERN.test(prefix) && ghostId.startsWith(`${prefix}-`)) { + const parsedNamespace = parsePluginInstanceId(ghostId)?.namespace ?? null; + const recordedNamespace = options.readInstallNamespace?.(ghostId); + const namespace = recordedNamespace !== undefined ? recordedNamespace : parsedNamespace; + let pendingLegacyForCurrentOrg = false; + if (recordedNamespace === undefined && parsedNamespace === null) { + try { + const prefix = options.lookupOrganizationPrefix?.(identity.orgId); + pendingLegacyForCurrentOrg = prefix?.kind === 'known' && matchesPendingLegacyForge( + pluginSlug, + namespace, + options.isPendingLegacyForge?.(ghostId) === true, + prefix.pluginPrefix, + ); + } catch { + pendingLegacyForCurrentOrg = false; + } + } + if (identity.orgSlug && (namespace === identity.orgSlug || pendingLegacyForCurrentOrg)) { const approvedSha = options.readApprovedPackageSha256?.(ghostId) ?? null; if (!approvedSha || !/^[a-f0-9]{64}$/.test(approvedSha)) { return reject('forge-package-sha-missing'); } const identitySnapshot = readManifestIdentity(); if (!identitySnapshot) return reject('plugin-not-installed'); - if (identitySnapshot.manifest.id !== ghostId) return reject('plugin-id-mismatch'); + if (identitySnapshot.manifest.id !== pluginSlug) return reject('plugin-id-mismatch'); return finish(identitySnapshot.manifest); } } @@ -192,10 +213,10 @@ export function loadConnectionAudienceResolver( if (!installation) { // Named exception after the org gate and before market-missing reject. // Any persisted market row, including installed:false, still takes digest. - if (ghostId === LOCAL_OIDC_ALLOWLIST_GHOST_ID) { + if (pluginSlug === LOCAL_OIDC_ALLOWLIST_GHOST_ID) { const allowlisted = readManifestIdentity(); if (!allowlisted) return reject('plugin-not-installed'); - if (allowlisted.manifest.id !== ghostId) return reject('plugin-id-mismatch'); + if (allowlisted.manifest.id !== pluginSlug) return reject('plugin-id-mismatch'); const allowlistedHosts = declaredOidcTokenHosts(allowlisted.manifest); if ( allowlistedHosts.length !== 1 || @@ -215,6 +236,20 @@ export function loadConnectionAudienceResolver( if (installation.organizationId !== identity.orgId) { return reject('market-installation-org-mismatch'); } + if (hasDeliveryNamespace(installation)) { + let installedNamespace: string | null | undefined; + try { + installedNamespace = options.readInstallNamespace?.(ghostId); + } catch { + return reject('installed-namespace-unavailable'); + } + if ( + installedNamespace !== installation.namespace || + (installation.namespace !== null && installation.namespace !== identity.orgSlug) + ) { + return reject('market-installation-namespace-mismatch'); + } + } if (!installation.rawManifestSha256 && !installation.manifestDigest) { return reject('market-manifest-identity-missing'); } @@ -226,7 +261,7 @@ export function loadConnectionAudienceResolver( return reject('installed-manifest-read-failed'); } if (!identitySnapshot) return reject('plugin-not-installed'); - if (identitySnapshot.manifest.id !== ghostId) return reject('plugin-id-mismatch'); + if (identitySnapshot.manifest.id !== pluginSlug) return reject('plugin-id-mismatch'); if (!verifyInstalledMarketManifest(installation, identitySnapshot)) { return reject('installed-manifest-identity-mismatch'); } diff --git a/apps/desktop/src/main/cindy-brain/errandPrefsStore.ts b/apps/desktop/src/main/cindy-brain/errandPrefsStore.ts index 9c479fa335a..82167e34e6a 100644 --- a/apps/desktop/src/main/cindy-brain/errandPrefsStore.ts +++ b/apps/desktop/src/main/cindy-brain/errandPrefsStore.ts @@ -22,9 +22,16 @@ import { import { desktopMakerLogger } from '../maker-host/logger-adapter.js'; import { createOverrideSettingsFile } from '../maker-host/override-settings-file.js'; import { ownerScopedUserDataPath } from '../appSessionState.js'; +import { assertGhostPrefsWritable, relocateGhostPreferenceMaps } from './ghostPreferenceRelocation.js'; const log = desktopMakerLogger.child('errand-prefs-store'); +export async function relocateGhostErrandPrefs(from: string, to: string): Promise { + await relocateGhostPreferenceMaps('ghost-errand-prefs.json', ['errand', 'sessions'], from, to, () => { + store = createStore(); + }); +} + /** errand 会话可选的 agent 种类(与 sessions.agent_kind 同词汇表)。 */ export const GHOST_ERRAND_AGENT_KINDS = ['cc', 'codex', 'pi'] as const; export type GhostErrandAgentKind = (typeof GHOST_ERRAND_AGENT_KINDS)[number]; @@ -120,13 +127,18 @@ function normalize(raw: unknown): GhostErrandPrefs { return { errand, sessions }; } -const store = createOverrideSettingsFile({ - filePath: () => ownerScopedUserDataPath('ghost-errand-prefs.json'), - defaults: DEFAULTS, - normalize, - log, - label: 'ghost-errand-prefs', -}); +function createStore() { + return createOverrideSettingsFile({ + filePath: () => ownerScopedUserDataPath('ghost-errand-prefs.json'), + defaults: DEFAULTS, + normalize, + log, + label: 'ghost-errand-prefs', + preserveUnreadableFile: true, + }); +} + +let store = createStore(); /** 读某插件的 errand 配置(缺省空对象 = 全跟随默认)。 */ export function readGhostErrandConfig(ghostId: string): GhostErrandConfig { @@ -141,6 +153,7 @@ export function readGhostErrandConfig(ghostId: string): GhostErrandConfig { * 形状粗筛,逐字段值域清洗统一在这里(单一执法点)。返回清洗后的落盘值。 */ export function writeGhostErrandConfig(ghostId: string, config: unknown): GhostErrandConfig { + assertGhostPrefsWritable('ghost-errand-prefs.json'); store.invalidateIfChanged(); const errand = { ...store.read().errand }; const cfg = config === null ? {} : normalizeConfig(config); @@ -163,6 +176,7 @@ export function writeGhostErrandSessionId( sessionId: string | null, sessionKey?: string, ): void { + assertGhostPrefsWritable('ghost-errand-prefs.json'); store.invalidateIfChanged(); const key = sessionMapKey(ghostId, sessionKey); const sessions = { ...store.read().sessions }; diff --git a/apps/desktop/src/main/cindy-brain/exportGhostPackage.ts b/apps/desktop/src/main/cindy-brain/exportGhostPackage.ts index 9b806d22c6a..6ec48fde979 100644 --- a/apps/desktop/src/main/cindy-brain/exportGhostPackage.ts +++ b/apps/desktop/src/main/cindy-brain/exportGhostPackage.ts @@ -37,7 +37,8 @@ import path from 'node:path'; import JSZip from 'jszip'; -import { isValidGhostId, type InstalledGhost } from '../../shared/ghost.js'; +import { type InstalledGhost } from '../../shared/ghost.js'; +import { findInstalledGhostByInstanceId, isGhostInstanceId } from '../../shared/pluginIdentity.js'; import { MAX_BASIC_CINDY_FILE_BYTES, MAX_BASIC_UNCOMPRESSED_BYTES, @@ -529,10 +530,10 @@ export async function exportGhostPackage( id: unknown, deps: ExportGhostPackageDeps, ): Promise { - if (typeof id !== 'string' || !isValidGhostId(id)) { + if (typeof id !== 'string' || !isGhostInstanceId(id)) { return { status: 'invalid_id' }; } - const ghost = deps.listInstalled().find((candidate) => candidate.manifest.id === id); + const ghost = findInstalledGhostByInstanceId(deps.listInstalled(), id); if (!ghost) return { status: 'not_installed' }; // 双保险:dir 来自 GhostManager 扫描,这里再确认它是真实目录(lstat diff --git a/apps/desktop/src/main/cindy-brain/filoGoogleClientConfig.ts b/apps/desktop/src/main/cindy-brain/filoGoogleClientConfig.ts index 7c673bf75c6..9f2c9881aa4 100644 --- a/apps/desktop/src/main/cindy-brain/filoGoogleClientConfig.ts +++ b/apps/desktop/src/main/cindy-brain/filoGoogleClientConfig.ts @@ -1,11 +1,9 @@ import type { GhostManifest } from '../../shared/ghost.js'; -import { FILO_GOOGLE_GHOST_ID, FILO_GOOGLE_SECRET_KEY } from './googleAccountsMigration.js'; /** - * Filo Google 的 OAuth client 随插件清单分发(desktop 类 client 凭证按 - * Google 口径非机密,2026-07 维护者拍板回填 ghost.json)。本模块保留构建 - * 环境覆写通道:.env / 发布环境注入时优先于清单值,只在 main 内存里的出网 - * 声明上补值,不改磁盘 manifest,也不把覆写值广播给 renderer。 + * Filo Google 的 OAuth client 随插件清单分发。构建环境不再因为 id 叫 + * `filo-google` 就注入 client——名称不构成特权。保留此函数以免出网链 + * 接线处分叉,行为是恒等。 */ export interface FiloGoogleBuildClientConfig { clientId?: string; @@ -15,30 +13,7 @@ export interface FiloGoogleBuildClientConfig { /** 给 main 内部使用的 Filo Google manifest 补上构建环境里的 OAuth client。 */ export function withFiloGoogleBuildClientConfig( manifest: GhostManifest, - config: FiloGoogleBuildClientConfig, + _config: FiloGoogleBuildClientConfig, ): GhostManifest { - const clientId = config.clientId?.trim(); - if (manifest.id !== FILO_GOOGLE_GHOST_ID || !clientId || !manifest.network) return manifest; - - let changed = false; - const clientSecret = config.clientSecret?.trim(); - const secrets = manifest.network.secrets?.map((secret) => { - if ( - secret.key !== FILO_GOOGLE_SECRET_KEY || - secret.source !== 'oauth' || - !secret.oauth - ) { - return secret; - } - changed = true; - const oauth = { ...secret.oauth, clientId }; - delete oauth.clientSecret; - if (clientSecret) oauth.clientSecret = clientSecret; - return { - ...secret, - oauth, - }; - }); - if (!changed || !secrets) return manifest; - return { ...manifest, network: { ...manifest.network, secrets } }; + return manifest; } diff --git a/apps/desktop/src/main/cindy-brain/forge.ts b/apps/desktop/src/main/cindy-brain/forge.ts index cd604443597..9bfcf9c15ee 100644 --- a/apps/desktop/src/main/cindy-brain/forge.ts +++ b/apps/desktop/src/main/cindy-brain/forge.ts @@ -22,6 +22,7 @@ import { promisify } from 'node:util'; import JSZip from 'jszip'; import { + authorDeclaredNamespaceReason, PLUGIN_MEMBER_UPLOAD_MAX_ARCHIVE_BYTES, PLUGIN_MEMBER_UPLOAD_MAX_UNCOMPRESSED_BYTES, PLUGIN_MEMBER_UPLOAD_MAX_ZIP_ENTRIES, @@ -672,7 +673,25 @@ export async function scaffoldGhostDir( if (typeof manifestRaw !== 'string') { return { ok: false, errorCode: 'INTERNAL', message: 'scaffold manifest 必须是 JSON 字符串' }; } - const validation = validateGhostManifest(JSON.parse(manifestRaw)); + let scaffoldManifest: unknown; + try { + scaffoldManifest = JSON.parse(manifestRaw); + } catch { + return { + ok: false, + errorCode: 'INVALID_INPUT', + message: '插件信息不合格:ghost.json 不是合法 JSON', + }; + } + const reservedScaffoldNamespace = authorDeclaredNamespaceReason(scaffoldManifest); + if (reservedScaffoldNamespace) { + return { + ok: false, + errorCode: 'INVALID_INPUT', + message: `插件信息不合格:${reservedScaffoldNamespace}`, + }; + } + const validation = validateGhostManifest(scaffoldManifest); if (!validation.ok) { return { ok: false, @@ -882,6 +901,10 @@ async function buildGhostPackage( manifestBytes = Buffer.from(`${JSON.stringify(manifestRaw)}\n`, 'utf-8'); } } + const reservedNamespace = authorDeclaredNamespaceReason(manifestRaw); + if (reservedNamespace) { + return { ok: false, errorCode: 'MANIFEST_INVALID', message: `清单不合格:${reservedNamespace}` }; + } const v = validateGhostManifest(manifestRaw); if (!v.ok) { return { ok: false, errorCode: 'MANIFEST_INVALID', message: `清单不合格:${v.reason}` }; @@ -1948,7 +1971,7 @@ node 详单**不接受** \`command\` / \`args\` / \`shell\` / \`env\` 或其它 "extraAuthorizeParams": { "access_type": "offline", "prompt": "consent" }, // 可选 ≤8 条:服务商特有授权参数(协议保留参数禁写) "identity": { "url": "https://api.example.com/userinfo", "labelPath": "email", "displayTemplate": "{team} · {user}", "avatarPath": "data.avatar_thumb" }, // 可选:授权后拉一次身份端点给账号打标签(设置页"已连接为 xxx";url 域名须命中 hosts)。labelPath 应指向**唯一且稳定**字段(如邮箱 / user_id)——它是重复授权时的同身份合并判定键,选 name 这类可重名可改名字段会误合并。displayTemplate 可选:人类可读展示名模板,\`{点分路径}\` 占位符从同一份身份响应取值(至少一个占位符,≤200 字符),任一占位符取不到值整体降级为空、回落显示 labelPath 的值——labelPath 的稳定字段不可读(如 Slack 的 user_id)时声明它,设置页与账号工具展示的就是渲染后的名字(邮箱这类本身可读的服务商不需要)。avatarPath 可选:头像 URL 在身份响应里的点分路径(如飞书的 "data.avatar_thumb")——主机取 https 地址后**不带凭证**下载小图(仅 png/jpeg/webp/gif、≤256KB)转 data URL 存库,\`/oauth\` 回查里以 account.avatarDataUrl 给你的 settingsHtml 展示( 直接用)。**下载仅对第一方官方意识生效**(头像地址不受 hosts 白名单约束,第三方声明合法但恒降级 null)——所以页面必须能没头像也好看(如回落姓名首字圆片) "redirectPort": 53682, // 可选:loopback 回调固定端口(1024–65535);声明 tokenBroker 时必填。服务商要求回调 URI 与注册值精确匹配(如 Atlassian)时声明,回调恒为 http://127.0.0.1:<端口>/callback;非 broker 模式缺省 = 随机端口(Google 等允许任意 loopback 端口的服务商不用声明) - "tokenBroker": "jira", // 可选:三路资格:静态官方前缀照旧放行;当前组织的服务端 organization market 包满足来源/组织/前缀/整包 sha256 绑定;或企业作者用 ghost_forge_install 明确安装且 id 命中本组织已登记前缀。后两路只给 Broker 与 oidc-token,不给宿主原语;手动导入与个人身份不放行。声明时必须同时声明 redirectPort;code/refresh 交换经 Cindy 服务端 broker 完成(client secret 在服务端,不随包分发),与 clientSecret 互斥;设置页不再支持自填 client + "tokenBroker": "jira", // 可选:三路资格:随包官方种子或受信任公开市场的官方插件(名称本身不构成资格);当前组织的服务端 organization market 包满足来源/组织/namespace/整包 sha256 绑定;或企业作者用 ghost_forge_install 明确安装到当前组织 namespace。后两路只给 Broker 与 oidc-token,不给宿主原语;手动导入与个人身份不放行。声明时必须同时声明 redirectPort;code/refresh 交换经 Cindy 服务端 broker 完成(client secret 在服务端,不随包分发),与 clientSecret 互斥;设置页不再支持自填 client "brokerBounce": { "path": "/example/bounce", "callbackPath": "/example/callback" } // 可选:双地址弹跳回调(服务商后台只收 https redirect、不收 http loopback 时用)。必须与 tokenBroker、redirectPort 同时声明;报给服务商的 redirect_uri = broker 服务基地址 + path(主机运行时拼,清单不落域名),浏览器授权后由弹跳路由 302 回 http://127.0.0.1: } }], @@ -3111,8 +3134,8 @@ PAT。支持宿主管理连接入口时还返回可选的 \`hostManagedSetup:tru **Cindy 企业身份断言(source:"oidc-token",可选)**:适用于接入 Cindy Connection Auth 的企业服务。主机只在当前登录账号属于组织 Membership,且满足以下任一安装基座时 按需向 auth-server 换取短时 Connection JWT:①当前组织的 Plugin Market organization -安装记录(source 必须是服务端 \`market\`),且安装 manifest digest 与记录一致;②企业作者 -显式调用 \`ghost_forge_install\` 安装,插件 id 命中当前组织已登记前缀,且批准 receipt +安装记录(source 必须是服务端 \`market\`),且 namespace 属于当前组织、已批准整包 sha256 与记录一致;②企业作者 +显式调用 \`ghost_forge_install\` 安装到当前组织 namespace,且批准 receipt 保有本次包的完整 sha256。两路都要求清单声明目标服务域名。 audience 与组织身份由主机推导,插件清单和运行时代码都不能选择、读取或保存 audience/token;audience 固定为 \`\${orgSlug}:\${ghostId}\`,总长不得超过 64 字符。 @@ -3206,12 +3229,13 @@ identity.displayTemplate 时,\`/oauth\` 回查与连接结果里 account.label 即可;第一方官方内置意识会先自动结束占用进程并重试,第三方意识不享受此回收 ——请选一个不易撞车的端口)。声明 \`tokenBroker\` 时必须提供;非 broker 模式下, Google 这类允许任意 loopback 端口的服务商不用声明。 -- \`tokenBroker\`:资格有三路:①静态官方前缀命中,照旧放行;②当前组织的服务端 +- \`tokenBroker\`:资格有三路:①随包官方种子或受信任公开市场的官方插件,名称本身不构成资格;②当前组织的服务端 organization market 包已安装、source 为 \`market\`、organizationId 与当前组织一致, - id 命中本组织已登记前缀,且 release sha256 与批准 receipt 的 packageSha256 相等。 - ③企业作者通过 \`ghost_forge_install\` 明确安装,且 id 命中当前组织已登记前缀;是否已有 - 同 id 市场记录不影响这条自测路径。后两路不接受个人身份或别的组织前缀,且只给 Broker - 与 oidc-token,不给宿主原语;手动导入不属于 Forge 路径。 + namespace 与当前组织的可信身份一致,且 release sha256 与批准 receipt 的 packageSha256 相等。 + ③企业作者通过 \`ghost_forge_install\` 明确安装,Host 绑定当前组织 namespace;只有真实 + 存量待迁移安装仍使用本组织已登记前缀核对。是否已有同 id 市场记录不影响这条自测路径。 + 后两路不接受个人身份或别的组织身份;Forge 只给 Broker 与 oidc-token,不给宿主原语, + 可信 XD 企业市场安装的宿主能力另按可信组织身份核验,不依赖名称前缀。手动导入不属于 Forge 路径。 code/refresh 交换改经 Cindy 服务端 broker 完成,client secret 由服务端持有、不随包 分发,且要求用户已登录 Cindy。声明它时必须同时声明 redirectPort,并与 clientSecret 互斥;PKCE 缺省开(verifier @@ -4327,6 +4351,8 @@ if (!opened.ok) console.warn(opened.errorCode, opened.message); 以下只解释存量包的兼容形态,用于维护与迁移,**不要照抄到新插件**。存量插件装入且 启用后,主机仍会把每个技能目录链接进共享技能根 \`~/.agents/skills/<插件id>--<技能name>\`(Windows 用 junction),停用/卸载即撤链。 +已知企业 namespace 的实例用 \`_ns____<插件id>--<技能name>\`, +root 和存量未标记 namespace 的实例继续沿用旧链接名。 目录形态(每条 item 一个目录,内必须有 SKILL.md): @@ -4729,7 +4755,8 @@ Cindy 统一归类、随机选择与排序,同批每个场景和每个插件 产生的确切包:首次安装、以及权限比已装版本变多的更新,会先在任务里弹确认卡列出权限, 用户允许后才落位;用户拒绝返回 \`MUTATION_CANCELLED\`,不要重试,除非用户再次要求。 首次安装会启用,同 id 已安装时原位更新并保留启用状态、配置、 - 数据与面板位置,同版本也可覆盖。不要因为 scaffold 或 pack 成功就自动调用本工具。 + 数据与面板位置,同版本也可覆盖。同来源更新延续旧数据;从市场等不同来源切换到 Forge + 时旧账号、密钥及数据隔离保留,新来源需要重新连接。不要因为 scaffold 或 pack 成功就自动调用本工具。 企业身份下若清单声明 \`source:"oidc-token"\`,提交安装前会展示插件名、id 与精确请求 域名,并要求用户手输相同 id;取消不会安装。个人与企业身份下的明确 Forge 安装都会标记为 作者本地自测并受组织默认插件自动接管保护;但 Connection JWT 资格仍只来自当前企业身份、 @@ -4811,7 +4838,9 @@ Cindy 统一归类、随机选择与排序,同批每个场景和每个插件 - agent 详单格式错(background / errand / schedule 存在但不是 true;基础点击触发请写 \`agent: {}\`) - node 详单格式错(entry 不是包内 CommonJS .js/.cjs、protocol 不在 json-rpc-stdio / mcp-stdio、 resident 又写 idleTimeoutSeconds);未知 command/args/shell/env 只保留,不传给进程启动器 -- id 用了 \`cindy-\` / \`filo-\` / \`xd-\` 前缀(官方保留,正式版用户通道拒装;给自己的意识换个前缀) +- id 用了 \`cindy-\` 前缀(平台保留,正式版用户通道拒装)。\`filo-\` / \`xd-\` 只是普通名称,不授予官方资格 +- ghost.json 声明了 namespace(包括 null)。namespace 由可信交付及 Host 安装事实决定,作者不能自报 +- 企业发布遵循当前服务端准入。S1 或 S2 尚未开启时仍保留旧认领和前缀命名流程,不能把收到 namespace 当作自由命名已开放 - network 详单格式错(hosts 缺失/裸 TLD/IP/带端口/通配不在最左、secret 缺 inject、 inject.format 没有 {value} 占位、inject.header 用了 Host/Cookie 等协议关键头、 inject.hosts 不是 hosts 声明条目的子集、 diff --git a/apps/desktop/src/main/cindy-brain/fsSlot.ts b/apps/desktop/src/main/cindy-brain/fsSlot.ts index 01521732e35..3dfe3e78c73 100644 --- a/apps/desktop/src/main/cindy-brain/fsSlot.ts +++ b/apps/desktop/src/main/cindy-brain/fsSlot.ts @@ -67,6 +67,7 @@ import { type InstalledGhost, } from '../../shared/ghost.js'; import type { GhostGrantConfirmDecision, GhostGrantConfirmPayload } from './ghostGrantConfirmBridge.js'; +import { installedGhostMutationTargetToken } from '../../shared/pluginIdentity.js'; /** 会话快照:fs 槽 workdir 档守门要看的全部字段。 */ export interface FsSessionSnapshot { @@ -286,6 +287,7 @@ export class GhostFsSlot { * 纯内存、进程生命周期内,不落盘)。 */ private readonly workdirGrants = new Set(); + private readonly inFlightRequests = new Map(); private sessionSnapshotResolver: FsSlotDeps['getSessionSnapshot']; @@ -298,8 +300,13 @@ export class GhostFsSlot { this.sessionSnapshotResolver = resolve; } + hasInFlightRequests(ghostId: string): boolean { + return this.inFlightRequests.has(ghostId); + } + /** 处理一条 fs-request(ghost-pipe:send 的 invoke 返回值即本结果)。 */ async handleFsRequest(ghostId: string, payload: unknown): Promise { + this.inFlightRequests.set(ghostId, (this.inFlightRequests.get(ghostId) ?? 0) + 1); try { return await this.dispatch(ghostId, payload); } catch (err) { @@ -309,6 +316,10 @@ export class GhostFsSlot { error: err instanceof Error ? err.message : String(err), }); return fail('写文件失败(主机内部错误)'); + } finally { + const remaining = this.inFlightRequests.get(ghostId)! - 1; + if (remaining > 0) this.inFlightRequests.set(ghostId, remaining); + else this.inFlightRequests.delete(ghostId); } } @@ -641,7 +652,9 @@ export class GhostFsSlot { if (verdict === 'confirm') { const parentDir = path.dirname(target); const memoryKey = `${info.sessionId}${ghostId}${foldCase(parentDir)}`; - if (automaticReview || !this.workdirGrants.has(memoryKey)) { + const targetToken = installedGhostMutationTargetToken(ghost, ''); + const bindingKey = memoryKey + '\u0000' + targetToken; + if (automaticReview || targetToken === null || !this.workdirGrants.has(bindingKey)) { const decision = await this.deps.requestWriteConfirm(info.sessionId, { ghostId, ghostName: ghost.manifest.name || ghostId, @@ -655,7 +668,7 @@ export class GhostFsSlot { if (decision.reason === 'cancelled') return fail('用户拒绝了本次工作目录写入'); return fail('确认通道未就绪或会话已关闭,本次工作目录写入未执行'); } - if (!automaticReview) confirmedMemoryKey = memoryKey; + if (!automaticReview && targetToken !== null) confirmedMemoryKey = bindingKey; } } diff --git a/apps/desktop/src/main/cindy-brain/ghostFirstPartyFacts.ts b/apps/desktop/src/main/cindy-brain/ghostFirstPartyFacts.ts index 9a39d77637a..1b8648dc3ed 100644 --- a/apps/desktop/src/main/cindy-brain/ghostFirstPartyFacts.ts +++ b/apps/desktop/src/main/cindy-brain/ghostFirstPartyFacts.ts @@ -18,10 +18,17 @@ */ import type { PluginMarketInstallationRecord } from '../plugin-market/ledger.js'; import type { OrganizationPrefixLookup } from '../plugin-market/organizationPrefixStore.js'; +import { isTrustedPublicCindyResource } from './ghostFirstPartyPrivilege.js'; import type { GhostFirstPartyFacts, GhostFirstPartyMarketRecord, + GhostFirstPartyTrustedSource, } from './ghostFirstPartyPrivilege.js'; +import { + parsePluginInstallRelId, + parsePluginStoragePart, + pluginInstanceInstallRelId, +} from '../../shared/pluginIdentity.js'; export type GhostFirstPartyFactsPurpose = 'install' | 'runtime'; @@ -38,6 +45,7 @@ export type GhostFirstPartyFactsUnavailableReason = export interface GhostFirstPartyFactsIdentity { membershipKind: 'personal' | 'org'; orgId: string | null; + orgSlug?: string | null; } export type GhostFirstPartyFactsLoad = @@ -66,6 +74,15 @@ export interface LoadGhostFirstPartyFactsLoaderOptions { lookupOrganizationPrefix(orgId: string): OrganizationPrefixLookup; /** 显式 ghost_forge_install 返回 agent-forge;其它入口返回 manual。 */ readInstallOrigin(ghostId: string): 'manual' | 'agent-forge'; + /** + * Trusted receipt namespace. `undefined` means no delivery field; explicit + * `null` is root. Do not invent the current org slug here. + */ + readInstallNamespace?(ghostId: string): string | null | undefined; + isPendingLegacyForge?(ghostId: string): boolean; + isPendingLegacyNamespace?(installRelId: string): boolean; + readTrustedSource?(installRelId: string): GhostFirstPartyTrustedSource | null; + readLegacyFirstPartyEligible?(installRelId: string): boolean; } function actionFor(purpose: GhostFirstPartyFactsPurpose): GhostFirstPartyFactsUnavailableAction { @@ -106,6 +123,14 @@ export function bindPendingMarketRecordToInspectedPackage( export type GhostFirstPartyFactsOverrides = { installOrigin?: 'manual' | 'agent-forge'; marketRecord?: GhostFirstPartyMarketRecord | null; + /** + * Install-time delivery namespace. Runtime keeps parsing the storage part / + * install rel id; callers must not invent the current org slug at runtime. + * `undefined` means "parse from ghostId"; explicit `null` is root. + */ + namespace?: string | null; + trustedSource?: GhostFirstPartyTrustedSource | null; + legacyFirstPartyEligible?: boolean; }; export function loadGhostFirstPartyFactsLoader( @@ -113,6 +138,22 @@ export function loadGhostFirstPartyFactsLoader( ): GhostFirstPartyFactsLoader { return { load(ghostId, purpose, identity, overrides) { + const parsed = + parsePluginStoragePart(ghostId) ?? parsePluginInstallRelId(ghostId); + const logicalGhostId = parsed?.ghostId ?? ghostId; + // Receipts / origin live under install rel id (`helper` or `_ns/acme/helper`). + // Runtime oauth may pass a storage part (`_ns__acme__helper`). + const installRelId = pluginInstanceInstallRelId(ghostId) ?? ghostId; + const recordedNamespace = + overrides?.namespace !== undefined + ? overrides.namespace + : options.readInstallNamespace?.(installRelId); + const namespace = + recordedNamespace !== undefined ? recordedNamespace : (parsed?.namespace ?? null); + const legacyPendingForge = purpose === 'runtime' && recordedNamespace === undefined && + namespace === null && options.isPendingLegacyForge?.(installRelId) === true; + const legacyPendingNamespace = purpose === 'runtime' && recordedNamespace === undefined && + namespace === null && options.isPendingLegacyNamespace?.(installRelId) === true; const unavailable = ( reason: GhostFirstPartyFactsUnavailableReason, ): GhostFirstPartyFactsLoad => ({ @@ -122,6 +163,30 @@ export function loadGhostFirstPartyFactsLoader( action: actionFor(purpose), }); + let trustedSource: GhostFirstPartyTrustedSource | null = null; + let legacyFirstPartyEligible = false; + let approvedPackageSha256: string | null = null; + try { + trustedSource = overrides?.trustedSource !== undefined + ? overrides.trustedSource + : purpose === 'runtime' ? options.readTrustedSource?.(installRelId) ?? null : null; + legacyFirstPartyEligible = overrides?.legacyFirstPartyEligible !== undefined + ? overrides.legacyFirstPartyEligible === true + : purpose === 'runtime' && options.readLegacyFirstPartyEligible?.(installRelId) === true; + approvedPackageSha256 = overrides?.marketRecord !== undefined + ? overrides.marketRecord?.approvedPackageSha256 ?? null + : options.readApprovedPackageSha256(installRelId); + } catch { + trustedSource = null; + legacyFirstPartyEligible = false; + approvedPackageSha256 = null; + } + const privilegeEvidence = { + ...(legacyPendingNamespace ? { legacyPendingNamespace: true } : {}), + ...(trustedSource ? { trustedSource, approvedPackageSha256 } : {}), + ...(legacyFirstPartyEligible ? { legacyFirstPartyEligible: true } : {}), + }; + let builtin: boolean; try { builtin = options.readInstalledBuiltin(ghostId); @@ -134,7 +199,7 @@ export function loadGhostFirstPartyFactsLoader( installOrigin = overrides.installOrigin; } else { try { - installOrigin = options.readInstallOrigin(ghostId); + installOrigin = options.readInstallOrigin(installRelId); } catch { installOrigin = 'manual'; } @@ -147,12 +212,12 @@ export function loadGhostFirstPartyFactsLoader( try { const installation = options.readMarketInstallation(ghostId); marketRecord = installation - ? toMarketRecord(installation, options.readApprovedPackageSha256(ghostId)) + ? toMarketRecord(installation, approvedPackageSha256) : null; } catch { // Builtin official plugins and explicit Forge self-tests do not depend // on the ledger. A corrupt cache must not take either qualification away. - if (!builtin && installOrigin !== 'agent-forge') { + if (!builtin && !trustedSource && !legacyFirstPartyEligible && installOrigin !== 'agent-forge') { return unavailable('market-installation-read-failed'); } marketRecord = null; @@ -163,11 +228,14 @@ export function loadGhostFirstPartyFactsLoader( return { kind: 'ready', facts: { - ghostId, + ghostId: logicalGhostId, + namespace, builtin, marketRecord, currentOrganization: null, installOrigin, + ...(legacyPendingForge ? { legacyPendingForge: true } : {}), + ...privilegeEvidence, }, }; } @@ -187,16 +255,35 @@ export function loadGhostFirstPartyFactsLoader( * 哪天有人让优先级 1 开始读这两个字段,那条测试会红, * 提醒他这里的填充值会变成静默误报。 */ - const builtinOnlyFacts = (): GhostFirstPartyFactsLoad => ({ + const builtinOnlyFacts = (): Extract => ({ kind: 'ready', - facts: { ghostId, builtin, marketRecord, currentOrganization: null, installOrigin }, + facts: { ghostId: logicalGhostId, namespace, builtin, marketRecord, currentOrganization: null, installOrigin, ...(legacyPendingForge ? { legacyPendingForge: true } : {}), ...privilegeEvidence }, }); + if (installOrigin === 'manual' && isTrustedPublicCindyResource(builtinOnlyFacts().facts)) { + return builtinOnlyFacts(); + } + + if (identity.orgSlug && (namespace === identity.orgSlug || + (legacyPendingNamespace && installOrigin !== 'agent-forge')) || + (namespace !== null && identity.orgSlug == null && installOrigin !== 'agent-forge' && + marketRecord?.source === 'market' && marketRecord.scope === 'organization' && + marketRecord.organizationId === identity.orgId)) { + return { + kind: 'ready', + facts: { + ghostId: logicalGhostId, namespace, builtin, marketRecord, installOrigin, + currentOrganization: { organizationId: identity.orgId, orgSlug: identity.orgSlug }, + ...privilegeEvidence, + }, + }; + } + let lookup: OrganizationPrefixLookup; try { lookup = options.lookupOrganizationPrefix(identity.orgId); } catch { - if (builtin) return builtinOnlyFacts(); + if (builtin || trustedSource || legacyFirstPartyEligible) return builtinOnlyFacts(); return unavailable('organization-prefix-unavailable'); } @@ -204,21 +291,25 @@ export function loadGhostFirstPartyFactsLoader( return { kind: 'ready', facts: { - ghostId, + ghostId: logicalGhostId, + namespace, builtin, marketRecord, currentOrganization: { organizationId: identity.orgId, pluginPrefix: lookup.pluginPrefix, + orgSlug: identity.orgSlug ?? null, }, installOrigin, + ...(legacyPendingForge ? { legacyPendingForge: true } : {}), + ...privilegeEvidence, }, }; } // Prefix is required for non-builtin evaluation. Builtin still concludes // from `facts.builtin` and must not wait on a market-list cache fill. - if (builtin) return builtinOnlyFacts(); + if (builtin || trustedSource || legacyFirstPartyEligible) return builtinOnlyFacts(); return unavailable( lookup.kind === 'absent' diff --git a/apps/desktop/src/main/cindy-brain/ghostFirstPartyPrivilege.ts b/apps/desktop/src/main/cindy-brain/ghostFirstPartyPrivilege.ts index aa84f4a7189..83c100e59d3 100644 --- a/apps/desktop/src/main/cindy-brain/ghostFirstPartyPrivilege.ts +++ b/apps/desktop/src/main/cindy-brain/ghostFirstPartyPrivilege.ts @@ -5,14 +5,15 @@ * conclusion. Callers decide whether to refuse install or only withhold * privileges; this module does not refuse loading. * - * Call sites that previously used only `isBrokerEligibleGhostId` now ask this - * resolver **after** the static official-prefix hit (`cindy-` / `filo-` / `xd-`). - * Official-prefix plugins keep today's grant. Everything else is decided here. + * Call sites must not treat `cindy-` / `filo-` / `xd-` names as privileges. + * Official plugins still qualify through builtin seed or trusted public market + * facts; enterprise broker qualifies through current-organization market facts. * * Input priority: first evaluable of - * 1. builtin seed → static official table + * 1. approved builtin resource, or a captured legacy qualification * 2. explicit agent-forge install + current organization prefix - * 3. plugin-market ledger (source + scope + organizationId + Release sha256) + * 3. reserved Cindy public resources or current organization server packages + * in the plugin-market ledger (source + scope + organizationId + Release sha256) * paired with the approved receipt packageSha256 * 4. neither → fail-closed, no privilege * @@ -21,19 +22,19 @@ * (`plugin-market/service.ts`); that is not a trust statement. Server-market * public is only trusted when `source === 'market'`. * - * A matching official prefix is never a security proof by itself. The - * static table is only a criterion on the builtin branch and on trusted - * server-market public installs. + * A matching official prefix is never a security proof by itself. Cindy public + * resources require exact approved server-market package evidence; historical + * XD/Filo names require a captured qualification from a real old public install. * * `facts.builtin` is id-based, not byte-based: it means "this id is on the * bundled seed roster" (`listBuiltinSeedIds` / directory name), not "these * bytes came from the bundled seed". Byte-level guarantees live in - * provisioning content matching and `approveTrustedBundledInstall`. That is - * the same strength as today's `isOfficialGhostId(id)`. + * provisioning content matching and `approveTrustedBundledInstall`. A trusted + * source resource must additionally match the approved identity and content hash. */ -import { PLUGIN_PREFIX_PATTERN, type PluginScope } from '@cindy/plugin-protocol'; +import type { PluginScope } from '@cindy/plugin-protocol'; -import { isBrokerEligibleGhostId, isOfficialGhostId } from '../../shared/ghost.js'; +import { GHOST_OFFICIAL_ID_PREFIX, isOfficialGhostId } from '../../shared/ghost.js'; const PACKAGE_SHA256_RE = /^[a-f0-9]{64}$/; @@ -48,7 +49,8 @@ export type GhostFirstPartyBasis = | 'builtin-official' | 'market-public' | 'market-organization-current' - | 'forge-current-org-prefix' + | 'forge-current-org' + | 'legacy-existing-install' | 'denied-alias' | 'denied-foreign-org' | 'denied-unknown-origin'; @@ -72,26 +74,73 @@ export interface GhostFirstPartyMarketRecord { export interface GhostFirstPartyCurrentOrganization { organizationId: string; - pluginPrefix: string | null; + pluginPrefix?: string | null; + /** Permanent org slug; required to bind Forge self-test to the install namespace. */ + orgSlug?: string | null; } export interface GhostFirstPartyFacts { ghostId: string; + /** Install namespace. null is root; a string is the bound orgSlug. */ + namespace: string | null; /** True when the id is on the bundled seed roster (`InstalledGhost.builtin`). */ builtin: boolean; marketRecord: GhostFirstPartyMarketRecord | null; currentOrganization: GhostFirstPartyCurrentOrganization | null; /** 仅显式 ghost_forge_install 写入 agent-forge;其它入口均为 manual。 */ installOrigin: 'manual' | 'agent-forge'; + legacyPendingForge?: boolean; + legacyPendingNamespace?: boolean; + trustedSource?: GhostFirstPartyTrustedSource | null; + approvedPackageSha256?: string | null; + legacyFirstPartyEligible?: boolean; } -function matchesCurrentOrgPrefix( - ghostId: string, - currentOrganization: GhostFirstPartyCurrentOrganization | null, -): boolean { - const prefix = currentOrganization?.pluginPrefix; - if (!prefix || !PLUGIN_PREFIX_PATTERN.test(prefix)) return false; - return ghostId.startsWith(`${prefix}-`); +export interface GhostFirstPartyTrustedSource { + kind: 'builtin-official'; + ghostId: string; + namespace: string | null; + packageSha256: string; +} + +export function hasTrustedFirstPartySource(facts: GhostFirstPartyFacts): boolean { + const source = facts.trustedSource; + return source != null && source.ghostId === facts.ghostId && + source.namespace === facts.namespace && + PACKAGE_SHA256_RE.test(source.packageSha256) && + source.packageSha256 === facts.approvedPackageSha256; +} + +export function isTrustedPublicCindyResource(facts: GhostFirstPartyFacts): boolean { + const record = facts.marketRecord; + return facts.namespace === null && facts.ghostId.startsWith(GHOST_OFFICIAL_ID_PREFIX) && + record !== null && record.installed && record.source === 'market' && + record.scope === 'public' && record.organizationId === null && + record.approvedPackageSha256 !== null && PACKAGE_SHA256_RE.test(record.sha256) && + record.sha256 === record.approvedPackageSha256; +} + +export function captureLegacyFirstPartyEligibility(input: { + legacyExistingInstall: boolean; + ghostId: string; + namespace: string | null; + approved: boolean; + approvedPackageSha256: string | null; + marketRecord: (GhostFirstPartyMarketRecord & { ghostId: string; namespace?: string | null }) | null; + approvedOfficialTrust?: boolean; +}): boolean { + const record = input.marketRecord; + if (!input.legacyExistingInstall || !input.approved || input.namespace !== null || + !isOfficialGhostId(input.ghostId) || input.approvedPackageSha256 === null || + !PACKAGE_SHA256_RE.test(input.approvedPackageSha256)) return false; + if (input.approvedOfficialTrust === true) return true; + return record !== null && record.ghostId === input.ghostId && + record.namespace == null && + record.installed && record.source === 'market' && + record.scope === 'public' && record.organizationId === null && + record.approvedPackageSha256 !== null && PACKAGE_SHA256_RE.test(record.sha256) && + record.approvedPackageSha256 === input.approvedPackageSha256 && + record.sha256 === record.approvedPackageSha256; } function isCurrentOrganizationRecord( @@ -125,6 +174,21 @@ export function marketInstallationMatchesApprovedPackage( ); } +export function isTrustedMivoSecretAlias(facts: GhostFirstPartyFacts, pendingLegacy: boolean): boolean { + if (facts.ghostId !== 'xd-mivo') return false; + if (facts.builtin && facts.trustedSource?.kind === 'builtin-official' && + hasTrustedFirstPartySource(facts)) return true; + const record = facts.marketRecord; + const organization = facts.currentOrganization; + if (!record || record.source !== 'market' || record.scope !== 'organization' || + !record.installed || !organization || + !(organization.orgSlug === 'xd' || (organization.orgSlug == null && + (facts.namespace === 'xd' || organization.pluginPrefix === 'xd'))) || + record.organizationId !== organization.organizationId) return false; + return (facts.namespace === 'xd' || (facts.namespace === null && pendingLegacy)) && + marketInstallationMatchesApprovedPackage(record, organization); +} + function allow(basis: GhostFirstPartyBasis, hostPrimitiveEligible: boolean): GhostFirstPartyPrivilege { return { brokerEligible: true, hostPrimitiveEligible, basis }; } @@ -133,40 +197,69 @@ function deny(basis: Extract): GhostFi return { brokerEligible: false, hostPrimitiveEligible: false, basis }; } +export function matchesPendingLegacyForge( + ghostId: string, + namespace: string | null, + pending: boolean, + pluginPrefix: string | null, +): boolean { + return pending && namespace === null && !!pluginPrefix && ghostId.startsWith(pluginPrefix + '-'); +} + /** * Pure first-party privilege conclusion from already-collected facts. * Does not read disk, ledger, or Electron. */ export function resolveGhostFirstPartyPrivilege(facts: GhostFirstPartyFacts): GhostFirstPartyPrivilege { - if (facts.builtin) { - return isOfficialGhostId(facts.ghostId) - ? allow('builtin-official', true) - : deny('denied-unknown-origin'); + if (facts.builtin && facts.trustedSource?.kind === 'builtin-official' && + hasTrustedFirstPartySource(facts)) { + return allow('builtin-official', true); + } + + if (facts.installOrigin === 'manual' && isTrustedPublicCindyResource(facts)) { + return allow('market-public', true); } if ((FIRST_PARTY_ALIAS_GHOST_IDS as readonly string[]).includes(facts.ghostId)) { return deny('denied-alias'); } - // 企业作者的显式 Forge 自测资格来自本次安装来源与当前组织前缀,和市场账本 + if (facts.namespace === null && facts.installOrigin === 'manual' && + facts.legacyFirstPartyEligible === true && isOfficialGhostId(facts.ghostId) && + (facts.marketRecord === null || (facts.marketRecord.installed && + facts.marketRecord.source === 'market' && facts.marketRecord.scope === 'public' && + facts.marketRecord.organizationId === null && + PACKAGE_SHA256_RE.test(facts.marketRecord.sha256) && + facts.marketRecord.sha256 === facts.marketRecord.approvedPackageSha256))) { + return allow('legacy-existing-install', true); + } + + // 企业作者的显式 Forge 自测资格来自本次安装来源与当前组织身份,和市场账本 // 是否已有同 id、是否仍标记 installed 无关。它只开放 Broker / oidc-token, // 宿主原语仍保持拒绝。 - if ( - facts.installOrigin === 'agent-forge' && - matchesCurrentOrgPrefix(facts.ghostId, facts.currentOrganization) - ) { - return allow('forge-current-org-prefix', false); + if (facts.installOrigin === 'agent-forge') { + const orgSlug = facts.currentOrganization?.orgSlug; + const legacyMatchesCurrentOrg = matchesPendingLegacyForge( + facts.ghostId, + facts.namespace, + facts.legacyPendingForge === true, + facts.currentOrganization?.pluginPrefix ?? null, + ); + if (!orgSlug || (facts.namespace !== orgSlug && !legacyMatchesCurrentOrg)) { + return deny(facts.currentOrganization ? 'denied-foreign-org' : 'denied-unknown-origin'); + } + return allow('forge-current-org', false); } const record = facts.marketRecord; if (record !== null) { if (!record.installed) { // Uninstalled ledger rows stay denied. Explicit Forge self-test is - // decided above from origin + org prefix, before this market branch. + // decided above from origin + current organization, before this market branch. return deny('denied-unknown-origin'); } if (record.scope === 'public' && record.source === 'market') { - return isOfficialGhostId(facts.ghostId) + return isTrustedPublicCindyResource(facts) ? allow('market-public', true) : deny('denied-unknown-origin'); } @@ -179,16 +272,21 @@ export function resolveGhostFirstPartyPrivilege(facts: GhostFirstPartyFacts): Gh // that organization's server market". `git-market` / `local-market` rows // carry a placeholder scope, which is likewise not a trust statement. if (record.source !== 'market') return deny('denied-unknown-origin'); + if (facts.namespace === null && facts.legacyPendingNamespace !== true) { + return deny('denied-unknown-origin'); + } if (!isCurrentOrganizationRecord(record, facts.currentOrganization)) { return deny('denied-foreign-org'); } if (!marketInstallationMatchesApprovedPackage(record, facts.currentOrganization)) { return deny('denied-unknown-origin'); } - if (!matchesCurrentOrgPrefix(facts.ghostId, facts.currentOrganization)) { - return deny('denied-unknown-origin'); - } - return allow('market-organization-current', false); + const organization = facts.currentOrganization; + const xdOrganization = organization?.orgSlug === 'xd' || + (organization?.orgSlug == null && organization?.pluginPrefix === 'xd'); + return allow('market-organization-current', + (facts.namespace === 'xd' && (organization?.orgSlug == null || organization.orgSlug === 'xd')) || + (facts.namespace === null && facts.legacyPendingNamespace === true && xdOrganization)); } return deny('denied-unknown-origin'); } @@ -197,15 +295,22 @@ export function resolveGhostFirstPartyPrivilege(facts: GhostFirstPartyFacts): Gh } /** - * Incremental broker gate: official prefix (`cindy-` / `filo-` / `xd-`) keeps - * today's grant without consulting facts. Everything else asks the resolver. - * Unavailable facts are fail-closed (no broker). + * Broker gate from collected facts. Prefixes are not a grant. + * Unavailable facts are fail-closed. */ export function authorizeGhostTokenBroker( - ghostId: string, + _ghostId: string, load: { kind: 'ready'; facts: GhostFirstPartyFacts } | { kind: string }, ): boolean { - if (isBrokerEligibleGhostId(ghostId)) return true; if (load.kind !== 'ready' || !('facts' in load)) return false; return resolveGhostFirstPartyPrivilege(load.facts).brokerEligible; } + +/** Port reclaim / identity avatar download. Prefixes are not a grant. */ +export function authorizeGhostHostPrimitive( + _ghostId: string, + load: { kind: 'ready'; facts: GhostFirstPartyFacts } | { kind: string }, +): boolean { + if (load.kind !== 'ready' || !('facts' in load)) return false; + return resolveGhostFirstPartyPrivilege(load.facts).hostPrimitiveEligible; +} diff --git a/apps/desktop/src/main/cindy-brain/ghostInstallLock.ts b/apps/desktop/src/main/cindy-brain/ghostInstallLock.ts index 360b9ba8f51..485db312cba 100644 --- a/apps/desktop/src/main/cindy-brain/ghostInstallLock.ts +++ b/apps/desktop/src/main/cindy-brain/ghostInstallLock.ts @@ -21,6 +21,11 @@ */ import { AsyncLocalStorage } from 'node:async_hooks'; +import { + createPluginLogicalIdentity, + type PluginLogicalIdentity, +} from '../../shared/pluginIdentity.js'; + /** 当前异步上下文已持有的 ghostId 集合(重入判定依据)。 */ const heldIds = new AsyncLocalStorage>(); @@ -64,3 +69,28 @@ export async function withGhostInstallLock( export function resetGhostInstallLocksForTest(): void { locks.clear(); } + +/** + * Market paths that already have a logical identity should enter here. + * Install directories are still ghostId-addressed, so different namespaces of + * the same ghostId stay serial until those roots are split. + */ +export function withPluginInstallLock( + identity: PluginLogicalIdentity, + fn: () => Promise, +): Promise { + return withGhostInstallLock(identity.ghostId, fn); +} + +export function withPluginDeliveryInstallLock( + plugin: { ghostId: string; namespace?: string | null }, + fn: () => Promise, +): Promise { + if (Object.prototype.hasOwnProperty.call(plugin, "namespace")) { + return withPluginInstallLock( + createPluginLogicalIdentity(plugin.namespace ?? null, plugin.ghostId), + fn, + ); + } + return withGhostInstallLock(plugin.ghostId, fn); +} diff --git a/apps/desktop/src/main/cindy-brain/ghostInstallReceipt.ts b/apps/desktop/src/main/cindy-brain/ghostInstallReceipt.ts index 798605e257b..14f89fb70ed 100644 --- a/apps/desktop/src/main/cindy-brain/ghostInstallReceipt.ts +++ b/apps/desktop/src/main/cindy-brain/ghostInstallReceipt.ts @@ -2,7 +2,20 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { readBoundedFileNoFollowSync } from '../utils/readBoundedFile.js'; +import { isValidPluginNamespace } from '@cindy/plugin-protocol'; +import { + createPluginLogicalIdentity, + hasDeliveryNamespace, + isValidPluginInstallRelId, + parsePluginInstallRelId, + parsePluginStoragePart, + PLUGIN_NS_INSTALL_ROOT, + PLUGIN_ROOT_INSTALL_ROOT, + pluginInstallRelId, + pluginInstallStoragePart, + pluginStoragePart, +} from '../../shared/pluginIdentity.js'; import { GHOST_LOCALE_MAX_BYTES, GHOST_SKILL_MD_MAX_BYTES, @@ -38,6 +51,36 @@ const MAX_RECEIPT_BYTES = 2 * 1024 * 1024; const MAX_PENDING_MUTATION_BYTES = 64 * 1024; const MAX_ICON_DATA_URL_BYTES = 768 * 1024; const MAX_MIGRATION_LEDGER_BYTES = 64 * 1024; + +export function assertManagedPluginParentSync( + root: string, + relPath: string, + createMissing = false, +): boolean { + const segments = relPath.split('/'); + if (segments.some((segment) => !segment || segment === '.' || segment === '..' || + segment.includes('\\') || segment.includes(path.sep))) { + throw new Error('invalid managed plugin path'); + } + let parent = root; + for (const segment of segments.slice(0, -1)) { + parent = path.join(parent, segment); + try { + const kind = classifyGhostDirEntrySync(parent); + if (kind !== 'directory') throw new Error('managed plugin parent is not a real directory'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + if (!createMissing) return false; + try { + fs.mkdirSync(parent); + } catch (mkdirError) { + if ((mkdirError as NodeJS.ErrnoException).code !== 'EEXIST' || + classifyGhostDirEntrySync(parent) !== 'directory') throw mkdirError; + } + } + } + return true; +} /** * 受管 icon 快照的完整形态:声明的图片 mime + 严格 base64 载荷。载荷字符集也要 * 校验 —— 只认前缀会让被改写的 receipt 把任意字符串塞进 renderer 的 img src。 @@ -54,6 +97,8 @@ const ICON_DATA_URL_RE = /^data:image\/(?:png|jpeg|webp|gif);base64,[A-Za-z0-9+/ export interface GhostInstallReceipt { schemaVersion: typeof RECEIPT_SCHEMA_VERSION; id: string; + /** Missing is a pre-namespace receipt. null is root; a string is an organization. */ + namespace?: string | null; revision: string; manifest: GhostManifest; localeResources: Record; @@ -70,6 +115,7 @@ export interface GhostInstallReceipt { * receipt 中的同名值继续有效,避免升级后丢失既有企业作者自测资格。 */ installOrigin?: string; + legacyFirstPartyEligible?: boolean; /** * 按 skill item 目录钉住的固化字节指纹(`item.dir` → sha256)。声明了 skill 能力 * 时逐项必填,没声明时是空对象。 @@ -167,6 +213,7 @@ export type GhostPendingMutation = phase?: 'prepared' | 'backed-up' | 'published'; /** Hash of the previously approved bytes, when an approval existed. */ oldPackageSha256?: string; + sourceStateArchiveId?: string; } // uninstall 不带 packageSha256:它的提交信号不是"receipt 写到某版本",而是"receipt + // 内容目录都已移除"。恢复见到它就把两者删干净(顺序无关,幂等)。 @@ -178,6 +225,14 @@ export type GhostPendingMutationReadResult = | { state: 'invalid'; reason: string } | { state: 'unreadable'; reason: string }; +export function isValidGhostSourceStateArchiveId(value: unknown): value is string { + if (typeof value !== 'string') return false; + const identity = parsePluginStoragePart(value); + return identity !== null && pluginStoragePart(identity) === value && + identity.namespace !== null && identity.namespace.startsWith('cindy-archive-') && + isRevision(identity.namespace.slice('cindy-archive-'.length)); +} + export type GhostPendingMutationListResult = { state: 'ok'; ids: string[]; blocked: boolean } | { state: 'unreadable'; reason: string }; @@ -194,6 +249,24 @@ export class GhostInstallReceiptStore { return path.resolve(this.getRootDir()); } + private assertPathParentSync(absPath: string, createMissing = false): boolean { + const root = this.rootDir(); + return assertManagedPluginParentSync( + root, + path.relative(root, absPath).split(path.sep).join('/'), + createMissing, + ); + } + + private receiptRelId(receipt: { id: string; namespace?: string | null }): string { + return pluginInstallRelId( + createPluginLogicalIdentity( + hasDeliveryNamespace(receipt) ? receipt.namespace : null, + receipt.id, + ), + ); + } + private realRootDirSync(): string { return fs.realpathSync(this.rootDir()); } @@ -205,11 +278,41 @@ export class GhostInstallReceiptStore { return { state: 'invalid', reason: result.reason }; } + captureLegacyFirstPartyEligibilitySync(id: string, expectedRevision: string): void { + const identity = parsePluginInstallRelId(id); + const read = this.readForRecovery(id); + if (!identity || identity.namespace !== null || read.state !== 'approved' || + read.receipt.revision !== expectedRevision || hasDeliveryNamespace(read.receipt)) { + throw new Error('legacy qualification requires the exact censused root approval'); + } + const target = this.receiptPath(id); + this.assertPathParentSync(target); + const root = this.realRootDirSync(); + const temp = path.join(root, '.legacy-qualification-' + crypto.randomBytes(12).toString('hex') + '.tmp'); + const receipt = { + ...read.receipt, legacyFirstPartyEligible: true, + manifest: ghostManifestToAuthorFormat(read.receipt.manifest), + }; + try { + fs.writeFileSync(temp, JSON.stringify(receipt, null, 2) + '\n', { encoding: 'utf8', flag: 'wx', mode: 0o600 }); + fs.renameSync(temp, target); + } finally { + try { + fs.rmSync(temp, { force: true }); + } catch (error) { + void error; + } + } + } + /** Recovery must not confuse transient state-root IO with missing/corrupt approval state. */ readForRecovery(id: string): GhostInstallReceiptRecoveryReadResult { const receiptPath = this.receiptPath(id); let bytes: Buffer | null; try { + if (!this.assertPathParentSync(receiptPath)) { + return { state: 'missing' }; + } bytes = readBoundedFileNoFollowSync(receiptPath, MAX_RECEIPT_BYTES, { containWithin: this.realRootDirSync(), }); @@ -232,7 +335,8 @@ export class GhostInstallReceiptStore { } catch (error) { return { state: 'invalid', reason: error instanceof Error ? error.message : String(error) }; } - const validated = validateReceipt(parsed, id); + const expectedGhostId = parsePluginInstallRelId(id)?.ghostId ?? id; + const validated = validateReceipt(parsed, expectedGhostId); return validated.ok ? { state: 'approved', receipt: validated.receipt } : { state: 'invalid', reason: validated.reason }; @@ -248,16 +352,22 @@ export class GhostInstallReceiptStore { */ async write( receipt: GhostInstallReceipt, - options: { skillSourceDir?: string; requireSkillSnapshot?: boolean } = {}, + options: { skillSourceDir?: string; requireSkillSnapshot?: boolean; relId?: string } = {}, ): Promise { const validated = validateReceipt(receipt, receipt.id); if (!validated.ok) throw new Error(`refusing to write invalid ghost receipt: ${validated.reason}`); const root = this.rootDir(); - await fs.promises.mkdir(root, { recursive: true }); + const relId = options.relId ?? this.receiptRelId(receipt); + if (parsePluginInstallRelId(relId)?.ghostId !== receipt.id) { + throw new Error('ghost receipt relId does not match receipt id'); + } + const receiptFile = this.receiptPath(relId); + fs.mkdirSync(root, { recursive: true }); + this.assertPathParentSync(receiptFile, true); try { - await this.ensureSkillSnapshot(receipt, options.skillSourceDir); + await this.ensureSkillSnapshot(receipt, relId, options.skillSourceDir); } catch (error) { if (options.requireSkillSnapshot !== false) throw error; } @@ -277,12 +387,12 @@ export class GhostInstallReceiptStore { // writer may have observed it and committed the receipt; pathname-based // rollback could remove that install's only durable migration guard. if (!this.hasMigrationLedger()) { - await this.ensureMigrationMarker(receipt.id); + await this.ensureMigrationMarker(relId); } - const target = this.receiptPath(receipt.id); + const target = receiptFile; const temp = path.join( root, - `.${receipt.id}-${process.pid}-${crypto.randomBytes(6).toString('hex')}.tmp`, + `.${this.receiptRelId(receipt).replaceAll('/', '.')}-${process.pid}-${crypto.randomBytes(6).toString('hex')}.tmp`, ); const persistedReceipt = { ...validated.receipt, @@ -305,6 +415,7 @@ export class GhostInstallReceiptStore { async remove(id: string): Promise { const receiptPath = this.receiptPath(id); + if (!this.assertPathParentSync(receiptPath)) return; const receiptKind = await classifyCleanupEntry(receiptPath); if (receiptKind === 'missing') { // ENOENT is already-clean and must remain idempotent. @@ -318,7 +429,7 @@ export class GhostInstallReceiptStore { // and transient IO failures remain observable so the caller keeps its journal. const snapshotPath = await this.assertManagedSnapshotParent(id, { createMissing: false }); if (!snapshotPath) return; - const parentDir = path.join(this.rootDir(), 'skill-snapshots'); + const parentDir = path.dirname(snapshotPath); const parentStats = await fs.promises.lstat(parentDir, { bigint: true }); await this.mutateSnapshot({ parentDir, @@ -328,13 +439,14 @@ export class GhostInstallReceiptStore { ino: parentStats.ino, }, operation: 'remove', - targetName: id, + targetName: id.split('/').at(-1)!, }); } /** `remove` 的同步版:启动恢复(构造期同步)收尾未完成卸载用,判据同 `remove`。 */ removeSync(id: string): void { const receiptPath = this.receiptPath(id); + if (!this.assertPathParentSync(receiptPath)) return; const receiptKind = classifyCleanupEntrySync(receiptPath); if (receiptKind === 'missing') { // ENOENT is already-clean and must remain idempotent. @@ -352,10 +464,10 @@ export class GhostInstallReceiptStore { } skillSnapshotRoot(id: string, revision: string): string { - if (!isValidGhostId(id) || !isRevision(revision)) { + if (!isValidPluginInstallRelId(id) || !isRevision(revision)) { throw new Error('invalid ghost skill snapshot identity'); } - return path.join(this.rootDir(), 'skill-snapshots', id, revision); + return path.join(this.rootDir(), 'skill-snapshots', ...id.split('/'), revision); } /** @@ -376,10 +488,10 @@ export class GhostInstallReceiptStore { id: string, opts: { createMissing: boolean }, ): Promise { - if (!isValidGhostId(id)) throw new Error('invalid ghost id for snapshot path'); + if (!isValidPluginInstallRelId(id)) throw new Error('invalid ghost id for snapshot path'); const root = this.rootDir(); let current = root; - for (const segment of ['skill-snapshots', id]) { + for (const segment of ['skill-snapshots', ...id.split('/')]) { current = path.join(current, segment); let kind: Awaited> | null; try { @@ -414,10 +526,10 @@ export class GhostInstallReceiptStore { id: string, opts: { createMissing: boolean }, ): string | null { - if (!isValidGhostId(id)) throw new Error('invalid ghost id for snapshot path'); + if (!isValidPluginInstallRelId(id)) throw new Error('invalid ghost id for snapshot path'); const root = this.rootDir(); let current = root; - for (const segment of ['skill-snapshots', id]) { + for (const segment of ['skill-snapshots', ...id.split('/')]) { current = path.join(current, segment); let kind: ReturnType | null; try { @@ -535,8 +647,8 @@ export class GhostInstallReceiptStore { * 时用它区分"安装了 receipt 之前就是 legacy"与"新模型安装后 receipt 被删"。 */ private migrationMarkerPath(id: string): string { - if (!isValidGhostId(id)) throw new Error('invalid ghost id for migration marker path'); - return path.join(this.rootDir(), `.migrated-${id}`); + if (!isValidPluginInstallRelId(id)) throw new Error('invalid ghost id for migration marker path'); + return path.join(this.rootDir(), `.migrated-${id.replaceAll('/', '.')}`); } /** @@ -557,7 +669,7 @@ export class GhostInstallReceiptStore { const root = this.rootDir(); await fs.promises.mkdir(root, { recursive: true }); const target = this.migrationMarkerPath(id); - const temp = path.join(root, `.migrated-${id}-${process.pid}-${crypto.randomBytes(4).toString('hex')}.tmp`); + const temp = path.join(root, `.migrated-${id.replaceAll('/', '.')}-${process.pid}-${crypto.randomBytes(4).toString('hex')}.tmp`); try { await fs.promises.writeFile(temp, '', { encoding: 'utf8', flag: 'wx', mode: 0o600 }); // Publish via hard-link first so the operation is no-clobber: @@ -648,18 +760,29 @@ export class GhostInstallReceiptStore { /** 事务标记路径。点开头,不与 `.json` receipt 或 `.legacy-migration.json` 撞名。 */ private pendingMutationPath(id: string): string { - if (!isValidGhostId(id)) throw new Error('invalid ghost id for pending mutation path'); - return path.join(this.rootDir(), `.pending-${id}.json`); + const identity = parsePluginInstallRelId(id); + if (!identity) throw new Error('invalid ghost id for pending mutation path'); + return path.join( + this.rootDir(), + ...id.split('/').slice(0, -1), + `.pending-${identity.ghostId}.json`, + ); } /** 事务开始:装入/更新 rename 动盘**之前**落标记(原子 temp+rename;re-begin 覆盖)。 */ async writePendingMutation(id: string, entry: GhostPendingMutation): Promise { + if (entry.kind === 'update' && entry.sourceStateArchiveId !== undefined && + (!isValidGhostSourceStateArchiveId(entry.sourceStateArchiveId) || + entry.receiptRevision === undefined)) { + throw new Error('journal source state archive identity is invalid'); + } const root = this.rootDir(); - await fs.promises.mkdir(root, { recursive: true }); const target = this.pendingMutationPath(id); + fs.mkdirSync(root, { recursive: true }); + this.assertPathParentSync(target, true); const temp = path.join( root, - `.pending-${id}-${process.pid}-${crypto.randomBytes(6).toString('hex')}.tmp`, + `.pending-${id.replaceAll('/', '.')}-${process.pid}-${crypto.randomBytes(6).toString('hex')}.tmp`, ); try { await fs.promises.writeFile(temp, `${JSON.stringify({ version: 1, id, ...entry })}\n`, { @@ -675,12 +798,18 @@ export class GhostInstallReceiptStore { /** 事务提交:receipt 写成功后清标记。删不动只多留一份标记,下轮恢复幂等重判。 */ async clearPendingMutation(id: string): Promise { - await fs.promises.rm(this.pendingMutationPath(id), { force: true }); + const target = this.pendingMutationPath(id); + if (this.assertPathParentSync(target)) { + await fs.promises.rm(target, { force: true }); + } } /** 同步清标记(启动恢复在构造期同步跑,不能留 fire-and-forget 的异步删除)。 */ clearPendingMutationSync(id: string): void { - fs.rmSync(this.pendingMutationPath(id), { force: true }); + const target = this.pendingMutationPath(id); + if (this.assertPathParentSync(target)) { + fs.rmSync(target, { force: true }); + } } readPendingMutationSync(id: string): GhostPendingMutationReadResult { @@ -688,6 +817,9 @@ export class GhostInstallReceiptStore { const markerPath = this.pendingMutationPath(id); let bytes: Buffer | null; try { + if (!this.assertPathParentSync(markerPath)) { + return { state: 'missing' }; + } // Single-handle bounded read: the earlier lstat+readFileSync pair had a // TOCTOU window where a FIFO/symlink/huge file could replace the journal // between the two calls. readBoundedFileNoFollowSync opens, stats, and @@ -776,6 +908,11 @@ export class GhostInstallReceiptStore { } const oldPackageSha256 = raw.oldPackageSha256; const receiptRevision = raw.receiptRevision; + const sourceStateArchiveId = raw.sourceStateArchiveId; + if (sourceStateArchiveId !== undefined && + (!isValidGhostSourceStateArchiveId(sourceStateArchiveId) || receiptRevision === undefined)) { + return { state: 'invalid', reason: 'journal source state archive identity is invalid' }; + } if ( receiptRevision !== undefined && (typeof receiptRevision !== 'string' || !isRevision(receiptRevision)) @@ -797,6 +934,7 @@ export class GhostInstallReceiptStore { ...(receiptRevision !== undefined ? { receiptRevision } : {}), ...(phase !== undefined ? { phase } : {}), ...(oldPackageSha256 !== undefined ? { oldPackageSha256 } : {}), + ...(sourceStateArchiveId !== undefined ? { sourceStateArchiveId } : {}), }, }; } @@ -825,16 +963,60 @@ export class GhostInstallReceiptStore { if (isValidGhostId(match[1])) ids.push(match[1]); else blocked = true; } + try { + const nsRoot = path.join(this.rootDir(), PLUGIN_NS_INSTALL_ROOT); + for (const nsEntry of fs.readdirSync(nsRoot, { withFileTypes: true })) { + if (!nsEntry.isDirectory()) continue; + let pendingNames = []; + try { + pendingNames = fs.readdirSync(path.join(nsRoot, nsEntry.name)); + } catch (error) { + return { + state: 'unreadable', + reason: error instanceof Error ? error.message : String(error), + }; + } + for (const name of pendingNames) { + if (!name.startsWith('.pending-') || !name.endsWith('.json')) continue; + const ghostId = name.slice('.pending-'.length, -'.json'.length); + const relId = PLUGIN_NS_INSTALL_ROOT + '/' + nsEntry.name + '/' + ghostId; + if (isValidPluginInstallRelId(relId)) ids.push(relId); + else blocked = true; + } + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + return { + state: 'unreadable', + reason: error instanceof Error ? error.message : String(error), + }; + } + } + try { + const rootDir = path.join(this.rootDir(), PLUGIN_ROOT_INSTALL_ROOT); + if (this.assertPathParentSync(path.join(rootDir, '.pending-scan'))) { + for (const name of fs.readdirSync(rootDir)) { + const match = /^\.pending-(.+)\.json$/.exec(name); + if (!match) continue; + const relId = PLUGIN_ROOT_INSTALL_ROOT + '/' + match[1]; + if (isValidPluginInstallRelId(relId)) ids.push(relId); + else blocked = true; + } + } + } catch (error) { + return { state: 'unreadable', reason: error instanceof Error ? error.message : String(error) }; + } return { state: 'ok', ids, blocked }; } private receiptPath(id: string): string { - if (!isValidGhostId(id)) throw new Error('invalid ghost id for receipt path'); - return path.join(this.rootDir(), `${id}.json`); + if (!isValidPluginInstallRelId(id)) throw new Error('invalid ghost id for receipt path'); + return path.join(this.rootDir(), ...id.split('/').slice(0, -1), `${id.split('/').at(-1)}.json`); } private async ensureSkillSnapshot( receipt: GhostInstallReceipt, + relId: string, skillSourceDir: string | undefined, ): Promise { const items = receipt.manifest.skill?.items ?? []; @@ -843,17 +1025,20 @@ export class GhostInstallReceiptStore { try { await fs.promises.mkdir(snapshotsRoot, { recursive: false }); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; } - const rootStats = await fs.promises.lstat(snapshotsRoot, { bigint: true }); + const snapshotParent = await this.assertManagedSnapshotParent(relId, { createMissing: true }); + if (!snapshotParent) throw new Error('skill snapshot parent unavailable'); + const parentDir = path.dirname(snapshotParent); + const rootStats = await fs.promises.lstat(parentDir, { bigint: true }); if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) throw new Error('skill snapshot root unavailable'); await this.mutateSnapshot({ - parentDir: snapshotsRoot, + parentDir, expectedParent: { - realPath: await fs.promises.realpath(snapshotsRoot), + realPath: await fs.promises.realpath(parentDir), dev: rootStats.dev, ino: rootStats.ino, }, operation: 'ensure', - targetName: `${receipt.id}/${receipt.revision}`, + targetName: `${relId.split('/').at(-1)}/${receipt.revision}`, receipt, ...(skillSourceDir ? { sourceDir: skillSourceDir } : {}), }); @@ -961,7 +1146,8 @@ function isValidUniqueGhostIdArray(value: unknown): value is string[] { } function isManagedBackupDirName(id: string, name: string): boolean { - return new RegExp(`^\\.cindy-updating-${escapeRegExp(id)}-[0-9a-f]{8}$`).test(name); + if (!isValidPluginInstallRelId(id)) return false; + return new RegExp(`^\\.cindy-updating-${escapeRegExp(pluginInstallStoragePart(id))}-[0-9a-f]{8}$`).test(name); } function escapeRegExp(value: string): string { @@ -1030,6 +1216,8 @@ export function createGhostInstallReceipt(input: { revision?: string; iconDataUrl?: string; installOrigin?: string; + namespace?: string | null; + legacyFirstPartyEligible?: boolean; }): GhostInstallReceipt { if (input.installOrigin !== undefined && !isPersistableInstallOrigin(input.installOrigin)) { throw new Error('receipt installOrigin 不合法'); @@ -1037,6 +1225,9 @@ export function createGhostInstallReceipt(input: { return { schemaVersion: RECEIPT_SCHEMA_VERSION, id: input.manifest.id, + ...(Object.prototype.hasOwnProperty.call(input, 'namespace') + ? { namespace: input.namespace ?? null } + : {}), revision: input.revision ?? crypto.randomUUID(), manifest: input.manifest, localeResources: input.localeResources, @@ -1046,6 +1237,7 @@ export function createGhostInstallReceipt(input: { ...(input.packageSha256 ? { packageSha256: input.packageSha256 } : {}), ...(input.iconDataUrl ? { iconDataUrl: input.iconDataUrl } : {}), ...(input.installOrigin !== undefined ? { installOrigin: input.installOrigin } : {}), + ...(input.legacyFirstPartyEligible === true ? { legacyFirstPartyEligible: true } : {}), }; } @@ -1067,6 +1259,13 @@ export function effectiveInstallOrigin( return receipt.installOrigin === 'agent-forge' ? 'agent-forge' : 'manual'; } +export function legacyFirstPartyEligibilityAfterUpdate( + receipt: Pick, + sourceChanged: boolean, +): boolean { + return !sourceChanged && receipt.legacyFirstPartyEligible === true; +} + /** * 逐 skill item 目录算规范化内容指纹(排序后的相对路径 + 字节)。 * @@ -1198,6 +1397,17 @@ function validateReceipt( if (!validated.ok) return { ok: false, reason: `receipt locale 不合法:${localePath}` }; localeResources[localePath] = validated.resource; } + let namespace: string | null | undefined; + if (Object.prototype.hasOwnProperty.call(value, 'namespace')) { + if (value.namespace !== null && !isValidPluginNamespace(value.namespace)) { + return { ok: false, reason: 'receipt namespace 不合法' }; + } + namespace = value.namespace === null ? null : value.namespace; + } + if (value.legacyFirstPartyEligible !== undefined && + typeof value.legacyFirstPartyEligible !== 'boolean') { + return { ok: false, reason: 'receipt legacyFirstPartyEligible 不合法' }; + } let installOrigin: string | undefined; if (value.installOrigin !== undefined) { if ( @@ -1222,6 +1432,8 @@ function validateReceipt( ...(typeof value.packageSha256 === 'string' ? { packageSha256: value.packageSha256 } : {}), ...(typeof value.iconDataUrl === 'string' ? { iconDataUrl: value.iconDataUrl } : {}), ...(installOrigin !== undefined ? { installOrigin } : {}), + ...(value.legacyFirstPartyEligible === true ? { legacyFirstPartyEligible: true } : {}), + ...(namespace !== undefined ? { namespace } : {}), }, }; } diff --git a/apps/desktop/src/main/cindy-brain/ghostKvStore.ts b/apps/desktop/src/main/cindy-brain/ghostKvStore.ts index 4c2e3ddbc97..790f17b3ab8 100644 --- a/apps/desktop/src/main/cindy-brain/ghostKvStore.ts +++ b/apps/desktop/src/main/cindy-brain/ghostKvStore.ts @@ -1,7 +1,8 @@ /** * ghostKvStore —— 意识自定义参数的持久化真身(/kv 协议端点的存储层)。 * - * File: /.json(生产 rootDir = /ghost-kv/) + * File: /.json(生产 rootDir = /ghost-kv/; + * root = helper.json, org = _ns__acme__helper.json) * * 语义(docs/dev-rules/plugin-security-and-authoring.md / FORGE_GUIDE §4.8): * - 单意识单文件:损坏只伤一个意识,卸下清理 = unlink 一个文件; @@ -13,7 +14,7 @@ * 炸掉设置页; * - 写:tmp + rename 原子落盘(override-settings-file 同款),同步 IO * 天然串行,≤64KB 量级无阻塞之虞; - * - ghostId 过 isValidGhostId 双保险(调用方来自分区绑定,理论上已合法; + * - storage part 过 isValidPluginStoragePart 双保险(调用方来自分区绑定,理论上已合法; * 文件名安全不省这道)。 * * 与 Electron 解耦:rootDir 经工厂注入,单测直接用 os.tmpdir()(规范 14/23)。 @@ -22,7 +23,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { isValidGhostId } from '../../shared/ghost.js'; +import { isValidPluginStoragePart } from '../../shared/pluginIdentity.js'; /** 单意识 KV 序列化后的字节上限(64KB;超限写入拒 413)。 */ export const GHOST_KV_MAX_BYTES = 64 * 1024; @@ -127,7 +128,7 @@ export function createGhostKvStore(options: { const { getRootDir, log } = options; const fileFor = (ghostId: string): string => { - if (!isValidGhostId(ghostId)) { + if (!isValidPluginStoragePart(ghostId)) { throw new GhostKvError('INVALID_GHOST_ID', `非法 ghostId: ${String(ghostId)}`); } return path.join(getRootDir(), `${ghostId}.json`); diff --git a/apps/desktop/src/main/cindy-brain/ghostLocalUpdateSource.ts b/apps/desktop/src/main/cindy-brain/ghostLocalUpdateSource.ts new file mode 100644 index 00000000000..14ef5272963 --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/ghostLocalUpdateSource.ts @@ -0,0 +1,72 @@ +import type { GhostTrustInfo } from '../../shared/ghost.js'; +import type { GhostInstallReceipt } from './ghostInstallReceipt.js'; + +const PACKAGE_SHA256_RE = /^[a-f0-9]{64}$/; + +export interface GhostLocalUpdatePackageEvidence { + ghostId: string; + namespace: string | null; + packageSha256: string; + trust: GhostTrustInfo; +} + +export interface GhostLocalUpdateMarketSource { + resourceId: string; + ghostId: string; + namespace: string | null; +} + +export interface GhostLocalUpdateSourceInput { + existingSourceChanged: boolean; + previousApprovedReceipt: Pick | null; + inspectedPackage: GhostLocalUpdatePackageEvidence; + currentTrustedMarketSource?: GhostLocalUpdateMarketSource | null; + hostVerifiedRelease?: (GhostLocalUpdateMarketSource & { packageSha256: string }) | null; +} + +export interface GhostLocalUpdateSourceDecision { + sourceChanged: boolean; + legacyFirstPartyEligible: boolean; +} + +function verifiedPublisherKey(trust: GhostTrustInfo): string | null { + return trust.publisherSigned && trust.publisherVerified && + typeof trust.publisherKeyId === 'string' && trust.publisherKeyId.length > 0 + ? trust.publisherKeyId : null; +} + +export function classifyGhostLocalUpdateSource( + input: GhostLocalUpdateSourceInput, +): GhostLocalUpdateSourceDecision { + const previous = input.previousApprovedReceipt; + const next = input.inspectedPackage; + const identityMatches = previous !== null && previous.id === next.ghostId && + (previous.namespace ?? null) === next.namespace; + const previousHashValid = previous?.packageSha256 !== undefined && + PACKAGE_SHA256_RE.test(previous.packageSha256); + const nextHashValid = PACKAGE_SHA256_RE.test(next.packageSha256); + const source = input.currentTrustedMarketSource; + const release = input.hostVerifiedRelease; + const exactTrustedRelease = source != null && release != null && + source.resourceId.length > 0 && source.resourceId === release.resourceId && + source.ghostId === next.ghostId && release.ghostId === next.ghostId && + source.namespace === next.namespace && release.namespace === next.namespace && + PACKAGE_SHA256_RE.test(release.packageSha256) && + release.packageSha256 === next.packageSha256; + const previousPublisherKey = previous ? verifiedPublisherKey(previous.trust) : null; + const sameSourceProven = identityMatches && previousHashValid && nextHashValid && ( + previous.packageSha256 === next.packageSha256 || + (previousPublisherKey !== null && previousPublisherKey === verifiedPublisherKey(next.trust)) || + exactTrustedRelease + ); + const previousProtectedSource = previous?.legacyFirstPartyEligible === true || + previous?.trust.level === 'cindy-official' || previousPublisherKey !== null || source != null; + const sourceChanged = input.existingSourceChanged || !identityMatches || !nextHashValid || + (previousProtectedSource && !sameSourceProven); + return { + sourceChanged, + legacyFirstPartyEligible: !sourceChanged && previousHashValid && + previous?.legacyFirstPartyEligible === true, + }; +} diff --git a/apps/desktop/src/main/cindy-brain/ghostMediaHandoverTargetTracker.ts b/apps/desktop/src/main/cindy-brain/ghostMediaHandoverTargetTracker.ts new file mode 100644 index 00000000000..3ff5d46b224 --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/ghostMediaHandoverTargetTracker.ts @@ -0,0 +1,89 @@ +import { randomUUID } from 'node:crypto'; + +import { GHOST_MEDIA_HANDOVER_MIME, type GhostPanelMediaTarget } from '../../shared/ghost.js'; +import { parsePluginStoragePart } from '../../shared/pluginIdentity.js'; +import { parseGhostMediaHandoverUrl } from './previewGate.js'; + +interface GhostMediaHandoverSource extends GhostPanelMediaTarget { + instanceId: string; + isCurrent(): boolean; +} + +export class GhostMediaHandoverTargetTracker { + private readonly sources = new Map(); + + register(source: GhostMediaHandoverSource): string { + if (parsePluginStoragePart(source.instanceId)?.ghostId !== source.ghostId) { + throw new Error('Invalid ghost media handover source'); + } + const token = randomUUID(); + this.sources.set(token, { ...source }); + return token; + } + + revoke(token: string): void { + this.sources.delete(token); + } + + resolve(token: unknown, uri: unknown): GhostPanelMediaTarget | null { + if (typeof token !== 'string' || token.length !== 36 || typeof uri !== 'string') return null; + const source = this.sources.get(token); + if (!source) return null; + try { + if (!source.isCurrent()) { + this.revoke(token); + return null; + } + } catch { + this.revoke(token); + return null; + } + if (parseGhostMediaHandoverUrl(uri)?.ghostId !== source.ghostId) return null; + return { ghostId: source.ghostId, instanceId: source.instanceId }; + } +} + +export const ghostMediaHandoverTargetTracker = new GhostMediaHandoverTargetTracker(); + +export function resolveGhostMediaHandoverTarget(token: unknown, uri: unknown): GhostPanelMediaTarget | null { + return ghostMediaHandoverTargetTracker.resolve(token, uri); +} + +export function ghostMediaHandoverDragScript(sourceToken: string): string { + return '(' + installGhostMediaHandoverDrag.toString() + ')(' + + JSON.stringify(GHOST_MEDIA_HANDOVER_MIME) + ',' + JSON.stringify(sourceToken) + ')'; +} + +function installGhostMediaHandoverDrag(mime: string, sourceToken: string): void { + const handleDrag = (event: DragEvent) => { + if (!event.isTrusted || !event.dataTransfer) return; + const candidates: string[] = []; + for (const type of ['text/uri-list', 'text/plain']) { + const raw = event.dataTransfer.getData(type); + candidates.push(...raw.split(String.fromCharCode(10)).map((line) => line.trim())); + } + const target = event.target instanceof Element ? event.target : null; + const anchor = target?.closest('a'); + if (anchor instanceof HTMLAnchorElement) candidates.push(anchor.href); + const media = target?.closest('img,video'); + if (media instanceof HTMLImageElement || media instanceof HTMLVideoElement) { + candidates.push(media.currentSrc || media.src); + } + const uri = candidates.find((candidate) => { + try { + const parsed = new URL(candidate); + const parts = parsed.pathname.split('/'); + return parsed.protocol === 'cindy-ghost:' && parts.length === 3 + && ['media', 'preview'].includes(parts[1]) + && /^[a-f0-9]{64}[.](png|jpg|jpeg|gif|webp|mp4|webm)$/i.test(parts[2]) + && !parsed.search && !parsed.hash; + } catch { + return false; + } + }); + if (!uri) return; + event.dataTransfer.setData(mime, JSON.stringify({ uri, sourceToken })); + }; + window.addEventListener('dragstart', handleDrag, true); + window.addEventListener('dragstart', handleDrag, false); +} diff --git a/apps/desktop/src/main/cindy-brain/ghostNamespaceMigration.ts b/apps/desktop/src/main/cindy-brain/ghostNamespaceMigration.ts new file mode 100644 index 00000000000..a44b6c584d0 --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/ghostNamespaceMigration.ts @@ -0,0 +1,439 @@ +/** + * §5 旧安装 namespace 待迁移。 + * + * 升级时不能因为 receipt 暂时没有 namespace 就把旧企业插件当成 root, + * 也不能让升级后新装的包靠“缺字段”混进待迁移集合。待迁移实例保持原目录 + * 与运行资格。确认后在原目录/原 receipt 文件上写入 namespace,不搬到 + * `_ns//...`;存储键继续跟物理目录,避免 KV/OAuth 换键丢数据。 + */ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { isValidPluginNamespace, PLUGIN_PREFIX_PATTERN } from '@cindy/plugin-protocol'; + +import { isValidGhostId } from '../../shared/ghost.js'; +import { resolvePluginNamespaceState } from '../../shared/pluginIdentity.js'; + +export const NAMESPACE_MIGRATION_SCHEMA_VERSION = 1 as const; +export const NAMESPACE_MIGRATION_FILE = 'namespace-migration.v1.json'; + +export type NamespaceMigrationBasis = + | 'builtin' + | 'market-public' + | 'market-personal' + | 'market-custom' + | 'manual-after-sync' + | 'explicit-root' + | 'market-organization' + | 'forge-current-org' + | 'receipt-recovered'; + +export interface NamespaceMigrationEntry { + ghostId: string; + relId: string; + capturedAt: string; + status: 'pending'; + basis?: NamespaceMigrationBasis | 'awaiting-facts'; +} + +export interface NamespaceMigrationLedger { + schemaVersion: typeof NAMESPACE_MIGRATION_SCHEMA_VERSION; + censusedAt: string; + entries: Record; +} + +export type NamespaceMigrationLedgerRead = + | { kind: 'missing' } + | { kind: 'ok'; ledger: NamespaceMigrationLedger } + | { kind: 'corrupt' } + | { kind: 'unreadable' }; + +export type NamespaceClassification = + | { kind: 'commit'; namespace: string | null; basis: NamespaceMigrationBasis } + | { kind: 'pending'; reason: string }; + +export interface NamespaceCensusCandidate { + ghostId: string; + relId: string; + /** Receipt/ledger object; missing namespace field means pre-namespace. */ + identitySource?: object; +} + +export interface ClassifyNamespaceMigrationInput { + ghostId: string; + builtin: boolean; + installOrigin: 'manual' | 'agent-forge' | undefined; + marketSyncCompleted: boolean; + marketRecord: { + scope: 'public' | 'personal' | 'organization'; + source: 'market' | 'legacy-adopted' | 'git-market' | 'local-market'; + organizationId: string | null; + namespace?: string | null; + installed?: boolean; + } | null | undefined; + currentOrganization: { + organizationId: string; + orgSlug: string | null; + pluginPrefix: string | null; + } | null; +} + +export function readNamespaceMigrationMarketRecord( + readRecords: () => readonly NonNullable[], +): ClassifyNamespaceMigrationInput['marketRecord'] { + try { + const records = readRecords().filter((record) => record.installed !== false); + if (records.length === 0) return null; + return records.length === 1 ? records[0] : undefined; + } catch { + return undefined; + } +} + +export function readNamespaceMigrationInstallOrigin( + readApprovedOrigin: () => 'manual' | 'agent-forge', +): ClassifyNamespaceMigrationInput['installOrigin'] { + try { + return readApprovedOrigin(); + } catch { + return undefined; + } +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isIsoTimestamp(value: unknown): value is string { + return typeof value === 'string' && Number.isFinite(Date.parse(value)); +} + +function matchesOrgPrefix(ghostId: string, prefix: string | null): boolean { + if (!prefix || !PLUGIN_PREFIX_PATTERN.test(prefix)) return false; + return ghostId.startsWith(`${prefix}-`); +} + +export function isCensusCandidate(candidate: NamespaceCensusCandidate): boolean { + if (!isValidGhostId(candidate.ghostId)) return false; + if (candidate.relId !== candidate.ghostId) return false; + if (!candidate.identitySource) return true; + return resolvePluginNamespaceState(candidate.identitySource).kind === 'legacy'; +} + +export function censusNamespaceMigration( + existing: NamespaceMigrationLedgerRead, + candidates: readonly NamespaceCensusCandidate[], + now: string, +): { kind: 'unchanged'; ledger: NamespaceMigrationLedger } | { kind: 'created'; ledger: NamespaceMigrationLedger } | { kind: 'blocked'; reason: 'unreadable' | 'corrupt' } { + if (existing.kind === 'unreadable' || existing.kind === 'corrupt') { + return { kind: 'blocked', reason: existing.kind }; + } + if (existing.kind === 'ok') { + // Census is a one-shot capture. Do not drop pending just because the + // directory is missing on this scan: update moves it to a dotted backup, + // and list() is not serialized with that mutation. Uninstall drops pending + // explicitly via dropNamespaceMigrationEntry. + void candidates; + return { kind: 'unchanged', ledger: existing.ledger }; + } + const entries: Record = {}; + for (const candidate of candidates) { + if (!isCensusCandidate(candidate)) continue; + entries[candidate.ghostId] = { + ghostId: candidate.ghostId, + relId: candidate.relId, + capturedAt: now, + status: 'pending', + }; + } + return { + kind: 'created', + ledger: { + schemaVersion: NAMESPACE_MIGRATION_SCHEMA_VERSION, + censusedAt: now, + entries, + }, + }; +} + +export function dropNamespaceMigrationEntry( + ledger: NamespaceMigrationLedger, + ghostId: string, +): NamespaceMigrationLedger { + if (!Object.hasOwn(ledger.entries, ghostId)) return ledger; + const entries = { ...ledger.entries }; + delete entries[ghostId]; + return { ...ledger, entries }; +} + +export function captureRecoveredNamespaceEntry( + ledger: NamespaceMigrationLedger, + candidate: NamespaceCensusCandidate, + now: string, +): NamespaceMigrationLedger { + if (!isCensusCandidate(candidate) || Object.hasOwn(ledger.entries, candidate.ghostId)) return ledger; + return { + ...ledger, + entries: { + ...ledger.entries, + [candidate.ghostId]: { + ghostId: candidate.ghostId, + relId: candidate.relId, + capturedAt: now, + status: 'pending', + }, + }, + }; +} + +export function classifyNamespaceMigration( + input: ClassifyNamespaceMigrationInput, +): NamespaceClassification { + const record = input.marketRecord?.installed === false ? null : input.marketRecord; + if (record === undefined) { + return { kind: 'pending', reason: 'awaiting-market-facts' }; + } + if (record) { + const recordState = resolvePluginNamespaceState(record); + if (recordState.kind === 'known') { + if (recordState.namespace !== null) { + return { + kind: 'commit', + namespace: recordState.namespace, + basis: 'market-organization', + }; + } + return { kind: 'commit', namespace: null, basis: 'explicit-root' }; + } + if (record.source === 'git-market' || record.source === 'local-market') { + return { kind: 'commit', namespace: null, basis: 'market-custom' }; + } + if (record.scope === 'public') { + return { kind: 'commit', namespace: null, basis: 'market-public' }; + } + if (record.scope === 'personal') { + return { kind: 'commit', namespace: null, basis: 'market-personal' }; + } + if (record.scope === 'organization') { + const namespace = matchingOrganizationNamespace(record.organizationId, input.currentOrganization); + if (namespace) { + return { kind: 'commit', namespace, basis: 'market-organization' }; + } + return { kind: 'pending', reason: 'awaiting-organization-namespace' }; + } + } + + if (input.builtin) { + return { kind: 'commit', namespace: null, basis: 'builtin' }; + } + + if (input.installOrigin === undefined) { + return { kind: 'pending', reason: 'awaiting-install-origin' }; + } + + if ( + input.installOrigin === 'agent-forge' && + input.currentOrganization && + matchesOrgPrefix(input.ghostId, input.currentOrganization.pluginPrefix) + ) { + const namespace = input.currentOrganization.orgSlug; + if (namespace && isValidPluginNamespace(namespace)) { + return { kind: 'commit', namespace, basis: 'forge-current-org' }; + } + return { kind: 'pending', reason: 'awaiting-organization-namespace' }; + } + + if (input.marketSyncCompleted && input.installOrigin === 'manual' && record === null) { + return { kind: 'commit', namespace: null, basis: 'manual-after-sync' }; + } + + return { kind: 'pending', reason: 'awaiting-market-facts' }; +} + +function matchingOrganizationNamespace( + organizationId: string | null, + currentOrganization: ClassifyNamespaceMigrationInput['currentOrganization'], +): string | null { + if (!organizationId || !currentOrganization) return null; + if (currentOrganization.organizationId !== organizationId) return null; + const namespace = currentOrganization.orgSlug; + return namespace && isValidPluginNamespace(namespace) ? namespace : null; +} + +export type NamespaceCommitPlan = + | { kind: 'skip'; reason: 'not-pending' | 'busy' } + | { kind: 'write-ledger-only'; namespace: string | null; basis: 'receipt-recovered' } + | { + kind: 'write-receipt-and-ledger'; + namespace: string | null; + basis: NamespaceMigrationBasis; + }; + +/** + * Crash window: receipt may already carry namespace while the census ledger + * is still pending. Finish the ledger to match the receipt; do not reclassify. + * Busy only blocks a first-time receipt write, not ledger recovery. + */ +export function planNamespaceCommit(input: { + pending: boolean; + busy: boolean; + receiptNamespace?: string | null; + requested: { namespace: string | null; basis: NamespaceMigrationBasis }; +}): NamespaceCommitPlan { + if (!input.pending) return { kind: 'skip', reason: 'not-pending' }; + if (input.receiptNamespace !== undefined) { + return { + kind: 'write-ledger-only', + namespace: input.receiptNamespace, + basis: 'receipt-recovered', + }; + } + if (input.busy) return { kind: 'skip', reason: 'busy' }; + return { + kind: 'write-receipt-and-ledger', + namespace: input.requested.namespace, + basis: input.requested.basis, + }; +} + +export function pendingNamespaceGhostIds(ledger: NamespaceMigrationLedger): string[] { + return Object.keys(ledger.entries); +} + +export function isPendingNamespaceGhost( + ledger: NamespaceMigrationLedger | null, + ghostId: string, +): boolean { + return ledger !== null && Object.hasOwn(ledger.entries, ghostId); +} + +export function parseNamespaceMigrationLedger(raw: unknown): NamespaceMigrationLedger | null { + if (!isPlainObject(raw)) return null; + if (raw.schemaVersion !== NAMESPACE_MIGRATION_SCHEMA_VERSION) return null; + if (!isIsoTimestamp(raw.censusedAt)) return null; + if (!isPlainObject(raw.entries)) return null; + const entries: Record = {}; + for (const [key, value] of Object.entries(raw.entries)) { + const entry = parseEntry(value); + if (!entry || entry.ghostId !== key) return null; + if (entry.status === 'pending') entries[key] = entry; + } + return { + schemaVersion: NAMESPACE_MIGRATION_SCHEMA_VERSION, + censusedAt: raw.censusedAt, + entries, + }; +} + +function parseEntry(value: unknown): NamespaceMigrationEntry | { + ghostId: string; + status: 'committed'; +} | null { + if (!isPlainObject(value)) return null; + if (!isValidGhostId(value.ghostId) || typeof value.relId !== 'string') return null; + if (value.relId !== value.ghostId) return null; + if (!isIsoTimestamp(value.capturedAt)) return null; + if (value.status === 'pending') { + return { + ghostId: value.ghostId, + relId: value.relId, + capturedAt: value.capturedAt, + status: 'pending', + ...(typeof value.basis === 'string' ? { basis: value.basis as NamespaceMigrationEntry['basis'] } : {}), + }; + } + if (value.status !== 'committed') return null; + if (value.namespace !== null && !isValidPluginNamespace(value.namespace)) return null; + if (!isIsoTimestamp(value.committedAt)) return null; + if (!isCommittedBasis(value.basis)) return null; + return { ghostId: value.ghostId, status: 'committed' }; +} + +function isCommittedBasis(value: unknown): value is NamespaceMigrationBasis { + return ( + value === 'builtin' || + value === 'market-public' || + value === 'market-personal' || + value === 'market-custom' || + value === 'manual-after-sync' || + value === 'explicit-root' || + value === 'market-organization' || + value === 'forge-current-org' || + value === 'receipt-recovered' + ); +} + +export function resolveInstallAgainstPending(input: { + ghostId: string; + requestedNamespace: string | null; + pending: boolean; + classification: NamespaceClassification | null; +}): { kind: 'proceed' } | { kind: 'already-installed' } | { kind: 'wait'; reason: string } { + if (!input.pending) return { kind: 'proceed' }; + if (input.requestedNamespace === null) return { kind: 'already-installed' }; + if (!input.classification || input.classification.kind === 'pending') { + return { + kind: 'wait', + reason: `意识 ${input.ghostId} 仍在 namespace 待迁移,不能先装同名企业实例`, + }; + } + if (input.classification.namespace === input.requestedNamespace) { + return { kind: 'already-installed' }; + } + return { + kind: 'wait', + reason: '旧插件 namespace 尚未提交,请等待迁移完成后再安装同名企业实例', + }; +} + +export interface NamespaceMigrationStore { + read(): NamespaceMigrationLedgerRead; + write(ledger: NamespaceMigrationLedger): void; +} + +export function createNamespaceMigrationStore(filePath: string): NamespaceMigrationStore { + const read = (): NamespaceMigrationLedgerRead => { + let text: string; + try { + text = fs.readFileSync(filePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { kind: 'missing' }; + return { kind: 'unreadable' }; + } + try { + const ledger = parseNamespaceMigrationLedger(JSON.parse(text)); + if (!ledger) return { kind: 'corrupt' }; + return { kind: 'ok', ledger }; + } catch { + return { kind: 'corrupt' }; + } + }; + + return { + read, + write(ledger: NamespaceMigrationLedger): void { + const current = read(); + if (current.kind === 'unreadable') { + throw new Error('namespace migration ledger is unreadable'); + } + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const tempPath = `${filePath}.${crypto.randomUUID()}.tmp`; + try { + fs.writeFileSync(tempPath, `${JSON.stringify(ledger, null, 2)}\n`, 'utf8'); + fs.renameSync(tempPath, filePath); + } catch (error) { + try { + fs.unlinkSync(tempPath); + } catch { + // temp may not exist + } + throw error; + } + }, + }; +} + +export function namespaceMigrationFilePath(stateRoot: string): string { + return path.join(stateRoot, NAMESPACE_MIGRATION_FILE); +} diff --git a/apps/desktop/src/main/cindy-brain/ghostOauthAccounts.ts b/apps/desktop/src/main/cindy-brain/ghostOauthAccounts.ts index 925fe503e3e..41ffcfa5c4c 100644 --- a/apps/desktop/src/main/cindy-brain/ghostOauthAccounts.ts +++ b/apps/desktop/src/main/cindy-brain/ghostOauthAccounts.ts @@ -46,8 +46,6 @@ import { } from './ghostOauthFlow.js'; import { changedBuiltinOauthClientSecretKeys, - isBrokerEligibleGhostId, - isFirstPartyHostPrivilegeGhostId, type GhostManifest, type GhostSecretOauthDecl, } from '../../shared/ghost.js'; @@ -148,7 +146,13 @@ export interface GhostOauthAccountManagerDeps { * verifies that the plugin and the exact OAuth declaration still exist * before any callback result is persisted. */ - isConnectTargetCurrent?: (ghostId: string, secretKey: string, decl: GhostOauthDecl) => boolean; + captureConnectTarget?: (ghostId: string) => unknown; + isConnectTargetCurrent?: ( + ghostId: string, + secretKey: string, + decl: GhostOauthDecl, + expectedConnectTarget?: unknown, + ) => boolean; /** * Serialize the final declaration check and every related vault mutation * with plugin update migration. Browser authorization and identity requests @@ -158,10 +162,12 @@ export interface GhostOauthAccountManagerDeps { /** 延时器(仅 invalid_grant 轮换探测用;测试注入即时假体,生产缺省 setTimeout)。 */ sleep?: (ms: number) => Promise; /** - * tokenBroker 资格复核。官方前缀命中照今天放行;否则问 first-party 判据。 - * 缺省只认静态官方前缀,存量单测零行为变化。 + * tokenBroker 资格复核。按可信安装事实判定,名称前缀不放行。 + * 缺省拒绝。 */ isTokenBrokerAuthorized?: (ghostId: string) => boolean; + /** Port reclaim / identity avatar. Missing = deny. */ + isHostPrimitiveAuthorized?: (ghostId: string) => boolean; } /** @@ -375,6 +381,7 @@ export class GhostOauthAccountManager { private readonly tokenCache = new Map(); /** 刷新单飞:同键并发只跑一单,其余等结果。 */ private readonly refreshInflight = new Map>(); + private readonly ghostGenerations = new Map(); constructor(deps: GhostOauthAccountManagerDeps) { this.deps = deps; @@ -437,8 +444,13 @@ export class GhostOauthAccountManager { expireAccountsForChangedClients( previousManifest: GhostManifest, currentManifest: GhostManifest, + vaultId = currentManifest.id, ): number { - const migration = this.prepareAccountsForChangedClients(previousManifest, currentManifest); + const migration = this.prepareAccountsForChangedClients( + previousManifest, + currentManifest, + vaultId, + ); migration.commit(); return migration.expiredCount; } @@ -453,8 +465,9 @@ export class GhostOauthAccountManager { prepareAccountsForChangedClients( previousManifest: GhostManifest, currentManifest: GhostManifest, + vaultId = currentManifest.id, ): GhostOauthClientMigration { - const ghostId = currentManifest.id; + const ghostId = vaultId; const applied: Array<{ secretKey: string; beforeRaw: string; @@ -626,11 +639,14 @@ export class GhostOauthAccountManager { * the update-crash half state and incorrectly reusing that token for a third * client introduced by a later update. */ - reconcileAccountsForInstalledManifestWithResult(currentManifest: GhostManifest): { + reconcileAccountsForInstalledManifestWithResult( + currentManifest: GhostManifest, + vaultId = currentManifest.id, + ): { restored: number; retryPending: boolean; } { - const ghostId = currentManifest.id; + const ghostId = vaultId; let restoredCount = 0; let retryPending = false; for (const secret of currentManifest.network?.secrets ?? []) { @@ -688,8 +704,11 @@ export class GhostOauthAccountManager { } /** Compatibility wrapper for callers that only need the restored count. */ - reconcileAccountsForInstalledManifest(currentManifest: GhostManifest): number { - return this.reconcileAccountsForInstalledManifestWithResult(currentManifest).restored; + reconcileAccountsForInstalledManifest( + currentManifest: GhostManifest, + vaultId = currentManifest.id, + ): number { + return this.reconcileAccountsForInstalledManifestWithResult(currentManifest, vaultId).restored; } /** 返回仍未完成重新授权的 clientId 迁移账号数;普通撤销授权不计入。 */ @@ -846,7 +865,26 @@ export class GhostOauthAccountManager { * client 凭证未填直接拒;授权流程失败原样透传结构化错误(设置页据此提示)。 */ private isTokenBrokerAuthorized(ghostId: string): boolean { - return this.deps.isTokenBrokerAuthorized?.(ghostId) ?? isBrokerEligibleGhostId(ghostId); + return this.deps.isTokenBrokerAuthorized?.(ghostId) === true; + } + + captureConnectTarget(ghostId: string): unknown { + return this.deps.captureConnectTarget?.(ghostId); + } + + invalidateGhost(ghostId: string): void { + this.ghostGenerations.set(ghostId, this.ghostGeneration(ghostId) + 1); + const prefix = ghostId + ' '; + for (const key of this.tokenCache.keys()) { + if (key.startsWith(prefix)) this.tokenCache.delete(key); + } + for (const key of this.refreshInflight.keys()) { + if (key.startsWith(prefix)) this.refreshInflight.delete(key); + } + } + + private ghostGeneration(ghostId: string): number { + return this.ghostGenerations.get(ghostId) ?? 0; } async connectAccount( @@ -877,8 +915,26 @@ export class GhostOauthAccountManager { /** Main-only caller boundary, checked inside the credential mutation lock. */ assertCurrent?: () => void; beforeCommit?: () => Promise; + expectedConnectTarget?: unknown; }, ): Promise { + const generation = this.ghostGeneration(ghostId); + const targetProvided = Object.prototype.hasOwnProperty.call(opts ?? {}, 'expectedConnectTarget'); + const expectedConnectTarget = targetProvided + ? opts?.expectedConnectTarget + : this.captureConnectTarget(ghostId); + const targetCaptured = targetProvided || this.deps.captureConnectTarget !== undefined; + const isConnectTargetCurrent = (): boolean => this.ghostGeneration(ghostId) === generation && + (targetCaptured + ? expectedConnectTarget !== null && expectedConnectTarget !== undefined && + this.deps.isConnectTargetCurrent?.(ghostId, secretKey, decl, expectedConnectTarget) === true + : this.deps.isConnectTargetCurrent?.(ghostId, secretKey, decl) !== false); + const targetChanged: GhostOauthConnectResult = { + ok: false, + error: 'INVALID_CONFIG', + detail: '插件或授权声明已变更', + }; + if (targetCaptured && !isConnectTargetCurrent()) return targetChanged; if (decl.tokenBroker !== undefined && !this.isTokenBrokerAuthorized(ghostId)) { return { ok: false, @@ -934,17 +990,11 @@ export class GhostOauthAccountManager { // 回收 = 强杀占用进程,而"杀谁"由 redirectPort 决定——第三方 manifest // 可声明任意端口(如 5432),放开等于让任意意识借「连接账号」之手 // 强杀用户本地服务(Postgres 等),故第三方一律回落"占用即报错"。 - reclaimPort: isFirstPartyHostPrivilegeGhostId(ghostId) ? this.deps.reclaimPort : undefined, + reclaimPort: this.deps.isHostPrimitiveAuthorized?.(ghostId) ? this.deps.reclaimPort : undefined, }); if (!flow.ok) return { ok: false, error: flow.error, detail: flow.detail }; opts?.remote?.assertCurrent(); - if (this.deps.isConnectTargetCurrent?.(ghostId, secretKey, decl) === false) { - return { - ok: false, - error: 'INVALID_CONFIG', - detail: '插件或授权声明已变更', - }; - } + if (!isConnectTargetCurrent()) return targetChanged; // 身份标签:声明了 identity 才拉,失败降级 null(不阻断授权)。label 是 // 同身份合并的判定键;display 是展示名(declaration 有 displayTemplate 才有); @@ -969,7 +1019,7 @@ export class GhostOauthAccountManager { // 头像地址是身份端点响应里的任意 https,不受 hosts 白名单约束——放开 // 等于给第三方意识一个"主机代发 GET + 小图字节回沙箱"的 SSRF 读原语。 // 下载本身不带任何凭证(CDN 域名不在注入白名单);失败降级无头像。 - if (identity.avatarUrl !== null && isFirstPartyHostPrivilegeGhostId(ghostId)) { + if (identity.avatarUrl !== null && this.deps.isHostPrimitiveAuthorized?.(ghostId)) { avatar = await fetchGhostOauthAvatar({ url: identity.avatarUrl, fetchImpl: this.deps.fetchImpl, @@ -984,13 +1034,7 @@ export class GhostOauthAccountManager { // Identity/avatar fetches are asynchronous as well. Recheck inside the // same strict mutation lock as the first vault read/write so a package // update cannot replace the declaration between validation and commit. - if (this.deps.isConnectTargetCurrent?.(ghostId, secretKey, decl) === false) { - return { - ok: false, - error: 'INVALID_CONFIG', - detail: '插件或授权声明已变更', - }; - } + if (!isConnectTargetCurrent()) return targetChanged; // 清单在授权**之后**才读:授权流可长达数分钟,期间清单可能被并发写 // (断开其它账号 / 刷新 invalidGrant 标过期),以新鲜清单为准收窄 @@ -1198,9 +1242,11 @@ export class GhostOauthAccountManager { const inflight = this.refreshInflight.get(key); if (inflight) return inflight; - const task = this.refreshAccount(ghostId, secretKey, decl, resolvedId, key).finally(() => { - this.refreshInflight.delete(key); - }); + const generation = this.ghostGeneration(ghostId); + const task = this.refreshAccount(ghostId, secretKey, decl, resolvedId, key, generation) + .finally(() => { + if (this.refreshInflight.get(key) === task) this.refreshInflight.delete(key); + }); this.refreshInflight.set(key, task); return task; } @@ -1219,6 +1265,7 @@ export class GhostOauthAccountManager { decl: GhostOauthDecl, accountId: string, cacheKey: string, + generation: number, ): Promise { const config = this.readClientConfig(ghostId, secretKey, decl); if (!config) return { ok: false, error: 'NO_CLIENT_CONFIG' }; @@ -1226,6 +1273,7 @@ export class GhostOauthAccountManager { if (!refreshToken) { // 无 rt 且缓存已失效:只能重新授权。 await this.withMutationLock(ghostId, () => { + if (this.ghostGeneration(ghostId) !== generation) return; if (this.deps.isConnectTargetCurrent?.(ghostId, secretKey, decl) === false) return; this.markExpired(ghostId, secretKey, accountId); }); @@ -1236,6 +1284,7 @@ export class GhostOauthAccountManager { // 多实例共库纪律),探测到新 RT 就换它重试一轮;第二轮仍 invalid_grant // 才判真失效。 for (let attempt = 0; ; attempt += 1) { + if (this.ghostGeneration(ghostId) !== generation) return { ok: false, error: 'AUTH_EXPIRED' }; const result = await refreshGhostOauthToken({ config, refreshToken, @@ -1243,8 +1292,10 @@ export class GhostOauthAccountManager { broker: this.deps.broker, logger: this.deps.logger, }); + if (this.ghostGeneration(ghostId) !== generation) return { ok: false, error: 'AUTH_EXPIRED' }; if (result.ok) { const committed = await this.withMutationLock(ghostId, () => { + if (this.ghostGeneration(ghostId) !== generation) return false; if (this.deps.isConnectTargetCurrent?.(ghostId, secretKey, decl) === false) return false; const currentRefreshToken = this.deps.vault.read( ghostId, @@ -1274,7 +1325,7 @@ export class GhostOauthAccountManager { this.markConnected(ghostId, secretKey, accountId); return true; }); - if (!committed) return { ok: false, error: 'AUTH_EXPIRED' }; + if (!committed || this.ghostGeneration(ghostId) !== generation) return { ok: false, error: 'AUTH_EXPIRED' }; // 展示名/头像回填(fire-and-forget,不拖累令牌热路径):displayTemplate / // avatarPath 上线前连的老账号缺这些,借下一次令牌刷新顺路补上,无需重连。 void this.backfillIdentityExtras( @@ -1283,6 +1334,7 @@ export class GhostOauthAccountManager { decl, accountId, result.bundle.accessToken, + generation, ); return { ok: true, accessToken: result.bundle.accessToken, accountId }; } @@ -1300,6 +1352,7 @@ export class GhostOauthAccountManager { secretKey, accountId, refreshToken, + generation, ); if (rotated !== null) { this.deps.logger?.info( @@ -1314,6 +1367,7 @@ export class GhostOauthAccountManager { // 真失效:标 expired 引导重新授权。删除走 compare-and-delete——只删 // 仍等于自己最后用过的这枚;若期间有并发实例写入了更新的 RT,留给它。 await this.withMutationLock(ghostId, () => { + if (this.ghostGeneration(ghostId) !== generation) return; // 插件可能在 provider 请求期间换版。旧声明的 invalid_grant 不得 // 删除为包事务保留的旧 client token。 if (this.deps.isConnectTargetCurrent?.(ghostId, secretKey, decl) === false) return; @@ -1338,13 +1392,16 @@ export class GhostOauthAccountManager { secretKey: string, accountId: string, usedRefreshToken: string, + generation: number, ): Promise { + if (this.ghostGeneration(ghostId) !== generation) return null; const key = refreshTokenKey(secretKey, accountId); const immediate = this.deps.vault.read(ghostId, key); if (immediate !== null && immediate !== usedRefreshToken) return immediate; const sleep = this.deps.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); await sleep(GHOST_OAUTH_INVALID_GRANT_RECHECK_DELAY_MS); + if (this.ghostGeneration(ghostId) !== generation) return null; const delayed = this.deps.vault.read(ghostId, key); if (delayed !== null && delayed !== usedRefreshToken) return delayed; return null; @@ -1362,8 +1419,10 @@ export class GhostOauthAccountManager { decl: GhostOauthDecl, accountId: string, accessToken: string, + generation: number, ): Promise { try { + if (this.ghostGeneration(ghostId) !== generation) return; if (decl.identity === undefined) return; const template = decl.identity.displayTemplate; const before = parseManifest(this.deps.vault.read(ghostId, accountsKey(secretKey))); @@ -1373,7 +1432,7 @@ export class GhostOauthAccountManager { // 头像回填同样只对第一方官方意识放行(connectAccount 处的 SSRF 口径)。 const needAvatar = decl.identity.avatarPath !== undefined && - isFirstPartyHostPrivilegeGhostId(ghostId) && + this.deps.isHostPrimitiveAuthorized?.(ghostId) === true && this.readAvatar(ghostId, secretKey, accountId) === null; if (!needDisplay && !needAvatar) return; const identity = await fetchGhostOauthIdentity({ @@ -1384,10 +1443,12 @@ export class GhostOauthAccountManager { accessToken, fetchImpl: this.deps.fetchImpl, }); + if (this.ghostGeneration(ghostId) !== generation) return; if (needDisplay && identity.display !== null) { // 拉取期间清单可能被并发写(断开/设默认/新连接):用 patchAccount 做 // 定向字段写入——只改目标行的 displayLabel/label,不覆盖清单其它状态。 await this.withMutationLock(ghostId, () => { + if (this.ghostGeneration(ghostId) !== generation) return; if (this.deps.isConnectTargetCurrent?.(ghostId, secretKey, decl) === false) return; this.patchAccount(ghostId, secretKey, accountId, (fresh) => { if (fresh.displayLabel !== null) return false; @@ -1399,6 +1460,7 @@ export class GhostOauthAccountManager { this.deps.logger?.info('ghost oauth 账号展示名已回填', { ghostId, secretKey, accountId }); } if (needAvatar && identity.avatarUrl !== null) { + if (this.ghostGeneration(ghostId) !== generation) return; const avatar = await fetchGhostOauthAvatar({ url: identity.avatarUrl, fetchImpl: this.deps.fetchImpl, @@ -1406,6 +1468,7 @@ export class GhostOauthAccountManager { // 存前重验账号仍在清单(拉取期间可能被断开;断开后不再写孤儿头像键)。 if (avatar !== null) { await this.withMutationLock(ghostId, () => { + if (this.ghostGeneration(ghostId) !== generation) return; if (this.deps.isConnectTargetCurrent?.(ghostId, secretKey, decl) === false) return; const fresh = parseManifest(this.deps.vault.read(ghostId, accountsKey(secretKey))); if (fresh.accounts.some((a) => a.id === accountId)) { diff --git a/apps/desktop/src/main/cindy-brain/ghostOauthBroker.ts b/apps/desktop/src/main/cindy-brain/ghostOauthBroker.ts index 34d8bca2cd1..b69dc6dc135 100644 --- a/apps/desktop/src/main/cindy-brain/ghostOauthBroker.ts +++ b/apps/desktop/src/main/cindy-brain/ghostOauthBroker.ts @@ -1,8 +1,8 @@ /** * ghostOauthBroker.ts — tokenBroker 声明的 XDT server 授权 broker 调用器。 * --------------------------------------------------------------------------- - * oauth 详单声明 `tokenBroker: ""` 的意识。静态官方前缀照旧放行; - * 其余资格由装入来源与当前组织事实共同判定。校验层保持纯函数不感知装入语境, + * oauth 详单声明 `tokenBroker: ""` 的意识。资格按可信安装事实判定, + * 名称前缀不放行。校验层保持纯函数不感知装入语境, * 门控在装入闸与连接闸。符合资格后,code 换 token 与 refresh 不直连服务商 * tokenUrl,改经 XDT server * 的授权 broker 端点(`/api/integrations//oauth/exchange|refresh`,JWT diff --git a/apps/desktop/src/main/cindy-brain/ghostOauthFlow.ts b/apps/desktop/src/main/cindy-brain/ghostOauthFlow.ts index 16fc3ebbe78..d1d3aae42a7 100644 --- a/apps/desktop/src/main/cindy-brain/ghostOauthFlow.ts +++ b/apps/desktop/src/main/cindy-brain/ghostOauthFlow.ts @@ -76,9 +76,8 @@ export interface GhostOauthClientConfig { /** * 可选:XDT server token broker 的 provider slug(如 'jira')。声明后 * code 换 token 与 refresh 不直连 tokenUrl,改经注入的 broker 调用器 - * (client secret 在服务端,不随包分发)。静态官方前缀照旧放行;其余资格由装入 - * 来源与当前组织事实共同判定。校验层保持纯函数不感知装入语境,门控在运行时 - * 接线层。broker 模式兼容 + * (client secret 在服务端,不随包分发)。资格按可信安装事实判定,名称前缀不放行。 + * 校验层保持纯函数不感知装入语境,门控在运行时接线层。broker 模式兼容 * PKCE(pkce 缺省开):verifier 经 broker * exchange 透传到服务端,由 provider 决定是否消费(feishu 要、jira/slack * 显式声明 pkce:false)。 diff --git a/apps/desktop/src/main/cindy-brain/ghostPreferenceRelocation.ts b/apps/desktop/src/main/cindy-brain/ghostPreferenceRelocation.ts new file mode 100644 index 00000000000..863fa55a59c --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/ghostPreferenceRelocation.ts @@ -0,0 +1,88 @@ +import { + activeOwnerScopeKey, + isAppSessionBoundaryPending, + ownerScopedUserDataPath, +} from '../appSessionState.js'; +import { desktopMakerLogger } from '../maker-host/logger-adapter.js'; +import { createOverrideSettingsFile } from '../maker-host/override-settings-file.js'; + +const log = desktopMakerLogger.child('ghost-preference-relocation'); +const relocatingFiles = new Set(); + +export function assertGhostPrefsWritable(fileName: string): void { + if (relocatingFiles.has(ownerScopedUserDataPath(fileName))) { + throw new Error(fileName + ' preferences are relocating'); + } +} + +export async function updateGhostPrefsForRelocation( + fileName: string, + updater: (raw: Record) => Record, + onSettled?: () => void, +): Promise { + if (isAppSessionBoundaryPending()) throw new Error('ghost preferences owner scope is changing'); + assertGhostPrefsWritable(fileName); + const file = ownerScopedUserDataPath(fileName); + relocatingFiles.add(file); + try { + const relocationStore = createOverrideSettingsFile>({ + filePath: () => ownerScopedUserDataPath(fileName), + scopeKey: activeOwnerScopeKey, + defaults: {}, + normalize: (raw) => raw as Record, + preserveUnreadableFile: true, + logLoadedValue: false, + logReadErrorDetails: false, + log, + label: fileName, + }); + await relocationStore.updateAtomic(({ value }) => { + if (isAppSessionBoundaryPending()) throw new Error('ghost preferences owner scope is changing'); + return updater(value); + }, { preserveDefaults: true }); + } finally { + try { + onSettled?.(); + } finally { + relocatingFiles.delete(file); + } + } +} + +export async function relocateGhostPreferenceMaps( + fileName: string, + mapNames: readonly string[], + from: string, + to: string, + onSettled?: () => void, +): Promise { + if (from === to) return; + await updateGhostPrefsForRelocation(fileName, (raw) => { + const maps = mapNames.map((name) => { + const value = raw[name]; + if (value !== undefined && (value === null || typeof value !== 'object' || Array.isArray(value))) { + throw new Error(`${fileName} ${name} is unreadable`); + } + return { name, value: (value ?? {}) as Record }; + }); + const matches = (name: string, key: string, id: string) => + key === id || (name === 'sessions' && key.startsWith(`${id}#`)); + if (!maps.some(({ name, value }) => Object.keys(value).some((key) => matches(name, key, from)))) { + return {}; + } + if (maps.some(({ name, value }) => Object.keys(value).some((key) => matches(name, key, to)))) { + throw new Error(`${fileName} relocation destination collision`); + } + const patch: Record = {}; + for (const { name, value } of maps) { + const next = { ...value }; + for (const key of Object.keys(value)) { + if (!matches(name, key, from)) continue; + next[`${to}${key.slice(from.length)}`] = value[key]; + delete next[key]; + } + if (Object.keys(value).some((key) => matches(name, key, from))) patch[name] = next; + } + return patch; + }, onSettled); +} diff --git a/apps/desktop/src/main/cindy-brain/ghostRecentUsageStore.ts b/apps/desktop/src/main/cindy-brain/ghostRecentUsageStore.ts index e56d6c16678..03c375775ae 100644 --- a/apps/desktop/src/main/cindy-brain/ghostRecentUsageStore.ts +++ b/apps/desktop/src/main/cindy-brain/ghostRecentUsageStore.ts @@ -8,7 +8,7 @@ import Store from 'electron-store'; -import { isValidGhostId } from '../../shared/ghost.js'; +import { isGhostInstanceId } from '../../shared/pluginIdentity.js'; import { ownerScopedUserDataPath } from '../appSessionState.js'; interface GhostRecentUsageShape { @@ -41,7 +41,7 @@ export function normalizeGhostRecentIds(value: unknown): string[] { const seen = new Set(); const ids: string[] = []; for (const candidate of value) { - if (typeof candidate !== 'string' || !isValidGhostId(candidate) || seen.has(candidate)) { + if (typeof candidate !== 'string' || !isGhostInstanceId(candidate) || seen.has(candidate)) { continue; } seen.add(candidate); diff --git a/apps/desktop/src/main/cindy-brain/ghostRecommendationSnapshot.ts b/apps/desktop/src/main/cindy-brain/ghostRecommendationSnapshot.ts index cd565956749..853846a3b64 100644 --- a/apps/desktop/src/main/cindy-brain/ghostRecommendationSnapshot.ts +++ b/apps/desktop/src/main/cindy-brain/ghostRecommendationSnapshot.ts @@ -1,6 +1,7 @@ import type { InstalledGhost } from '../../shared/ghost.js'; import type { HomePluginRecommendationsSnapshot } from '../../shared/homePluginRecommendations.js'; import { validateGhostRecommendations, type GhostRecommendation } from '@cindy/plugin-protocol'; +import { installedGhostStoragePart } from '../../shared/pluginIdentity.js'; export function buildGhostRecommendationSnapshot( ownerId: string | null, @@ -9,14 +10,15 @@ export function buildGhostRecommendationSnapshot( recentIds: string[], ): HomePluginRecommendationsSnapshot { const sources = ghosts.map((g) => { + const instanceId = installedGhostStoragePart(g); // Keep legacy manifest/receipt content intact; invalid optional metadata must not disable a plugin. const candidates = - entries.find((e) => e.id === g.manifest.id)?.items ?? g.manifest.recommendations; + entries.find((e) => e.id === instanceId)?.items ?? g.manifest.recommendations; const validated = candidates === undefined ? undefined : validateGhostRecommendations(candidates); const items = validated?.ok ? validated.items : undefined; return { - ghostId: g.manifest.id, + ghostId: instanceId, name: g.manifest.name, enabled: g.enabled, ...(items !== undefined ? { items } : {}), diff --git a/apps/desktop/src/main/cindy-brain/ghostRecommendationStore.ts b/apps/desktop/src/main/cindy-brain/ghostRecommendationStore.ts index 99db76e364a..463028f79eb 100644 --- a/apps/desktop/src/main/cindy-brain/ghostRecommendationStore.ts +++ b/apps/desktop/src/main/cindy-brain/ghostRecommendationStore.ts @@ -1,7 +1,7 @@ import Store from 'electron-store'; import { validateGhostRecommendations, type GhostRecommendation } from '@cindy/plugin-protocol'; import { ownerScopedUserDataPath } from '../appSessionState.js'; -import { isValidGhostId } from '../../shared/ghost.js'; +import { isGhostInstanceId } from '../../shared/pluginIdentity.js'; interface Entry { id: string; @@ -31,7 +31,7 @@ export function readGhostRecommendationEntries(): Entry[] { const raw: unknown = store().get('entries'); if (!Array.isArray(raw)) return []; return raw.flatMap((entry): Entry[] => { - if (!entry || !isValidGhostId(entry.id)) return []; + if (!entry || !isGhostInstanceId(entry.id)) return []; const parsed = entry.items === undefined ? undefined : validateGhostRecommendations(entry.items); return [ @@ -47,7 +47,7 @@ export function readGhostRecommendationEntries(): Entry[] { } function update(id: string, patch: Partial): void { - if (!isValidGhostId(id)) throw new Error('Invalid plugin identity'); + if (!isGhostInstanceId(id)) throw new Error('Invalid plugin identity'); const entries = readGhostRecommendationEntries(); const previous = entries.find((e) => e.id === id); store().set('entries', [...entries.filter((e) => e.id !== id), { ...previous, ...patch, id }]); diff --git a/apps/desktop/src/main/cindy-brain/ghostSetupCoordinator.ts b/apps/desktop/src/main/cindy-brain/ghostSetupCoordinator.ts index 39220c60cc7..8f639e7ad3c 100644 --- a/apps/desktop/src/main/cindy-brain/ghostSetupCoordinator.ts +++ b/apps/desktop/src/main/cindy-brain/ghostSetupCoordinator.ts @@ -36,6 +36,7 @@ export type GhostSetupEnsureResult = | 'GHOST_NOT_FOUND' | 'GHOST_ASLEEP' | 'GHOST_DISABLED_IN_WORKDIR' + | 'GHOST_AMBIGUOUS' | 'TOOL_NOT_FOUND'; message: string; setup?: GhostSetupAssessment; @@ -46,8 +47,13 @@ export type GhostSetupTargetValidation = | { ok: false; errorCode: - 'GHOST_NOT_FOUND' | 'GHOST_ASLEEP' | 'GHOST_DISABLED_IN_WORKDIR' | 'TOOL_NOT_FOUND'; + | 'GHOST_NOT_FOUND' + | 'GHOST_ASLEEP' + | 'GHOST_DISABLED_IN_WORKDIR' + | 'GHOST_AMBIGUOUS' + | 'TOOL_NOT_FOUND'; message: string; + candidates?: Array<{ ghostId: string; namespace: string | null }>; }; export type GhostSetupActionResult = @@ -109,6 +115,8 @@ export interface GhostSetupCoordinatorDeps { logger?: { warn: (message: string, context?: Record) => void; }; + /** Map MCP/logical ghostId onto the credential/store instance id. */ + resolveStoreId?: (ghostId: string) => string; } export interface GhostSetupEnsureRequest { @@ -158,11 +166,12 @@ export class GhostSetupCoordinator { let assessmentDirty = false; const reconnectedActions = new Set(); let localConnectionAction: { id: string; ref: string; revision: number } | undefined; + const storeId = this.deps.resolveStoreId?.(request.ghostId) ?? request.ghostId; // Subscribe before the initial read. A committed settings write racing the // first assessment will then keep the read loop running until one complete // assessment observes a quiet revision. - unsubscribe = this.deps.changeBus.subscribe(request.ghostId, event => { + unsubscribe = this.deps.changeBus.subscribe(storeId, event => { if (localConnectionAction && event.source === 'connection' && event.ref === localConnectionAction.ref && event.revision > localConnectionAction.revision) { reconnectedActions.add(localConnectionAction.id); @@ -177,13 +186,13 @@ export class GhostSetupCoordinator { > => { for (;;) { assessmentDirty = false; - const startRevision = this.deps.changeBus.currentRevision(request.ghostId); + const startRevision = this.deps.changeBus.currentRevision(storeId); const target = this.deps.validateTarget(request.ghostId, request.tool, request.workingDir); if (!target.ok) return { ok: false, target }; const next = await this.deps.assess(request.ghostId); if ( !assessmentDirty && - this.deps.changeBus.currentRevision(request.ghostId) === startRevision + this.deps.changeBus.currentRevision(storeId) === startRevision ) { return { ok: true, assessment: next }; } diff --git a/apps/desktop/src/main/cindy-brain/ghostSetupManifestTracker.ts b/apps/desktop/src/main/cindy-brain/ghostSetupManifestTracker.ts index 76e492e24b9..89e1a75011d 100644 --- a/apps/desktop/src/main/cindy-brain/ghostSetupManifestTracker.ts +++ b/apps/desktop/src/main/cindy-brain/ghostSetupManifestTracker.ts @@ -7,6 +7,7 @@ */ import type { InstalledGhost } from '../../shared/ghost.js'; +import { installedGhostStoragePart } from '../../shared/pluginIdentity.js'; import type { GhostSetupChangeBus } from './ghostSetupChangeBus.js'; export class GhostSetupManifestTracker { @@ -41,10 +42,10 @@ export class GhostSetupManifestTracker { private snapshot(ghosts: InstalledGhost[]): Map { return new Map( ghosts.map((ghost) => [ - ghost.manifest.id, + installedGhostStoragePart(ghost), JSON.stringify({ enabled: ghost.enabled, - available: this.isAvailable(ghost.manifest.id), + available: this.isAvailable(installedGhostStoragePart(ghost)), manifest: ghost.manifest, }), ]), diff --git a/apps/desktop/src/main/cindy-brain/ghostSetupStatus.ts b/apps/desktop/src/main/cindy-brain/ghostSetupStatus.ts index 7484845c0e4..a1fdbfd2b7d 100644 --- a/apps/desktop/src/main/cindy-brain/ghostSetupStatus.ts +++ b/apps/desktop/src/main/cindy-brain/ghostSetupStatus.ts @@ -37,7 +37,8 @@ import type { GhostSetupStatus, GhostSetupStatusItem, } from '../../shared/ghost.js'; -import { GHOST_SECRET_VALUE_MAX_CHARS, isValidGhostId } from '../../shared/ghost.js'; +import { GHOST_SECRET_VALUE_MAX_CHARS } from '../../shared/ghost.js'; +import { isGhostInstanceId } from '../../shared/pluginIdentity.js'; import { throwIpcError } from '../utils/ipcValidate.js'; /** OAuth 凭证的分项状态(index.ts 由 GhostOauthAccountManager 现查)。 */ @@ -393,7 +394,7 @@ export function handleGhostSetupStatusRequest(args: { probesFor: (manifest: GhostManifest) => GhostSetupProbes; }): GhostSetupStatus { const { id } = args; - if (typeof id !== 'string' || !isValidGhostId(id)) { + if (typeof id !== 'string' || !isGhostInstanceId(id)) { throwIpcError('INVALID_PARAMS', 'id must be a valid Ghost id'); } const manifest = args.getRuntimeManifest(id); diff --git a/apps/desktop/src/main/cindy-brain/ghostSignature.ts b/apps/desktop/src/main/cindy-brain/ghostSignature.ts index 3915e94e1d8..d3da26e6f3f 100644 --- a/apps/desktop/src/main/cindy-brain/ghostSignature.ts +++ b/apps/desktop/src/main/cindy-brain/ghostSignature.ts @@ -14,6 +14,7 @@ import crypto from 'node:crypto'; import JSZip from 'jszip'; +import { authorDeclaredNamespaceReason } from '@cindy/plugin-protocol'; import { GHOST_MANIFEST_FILE, type GhostManifest, @@ -418,7 +419,10 @@ export async function signGhostPackage( zip.remove(`${prefix}${GHOST_SIGNATURE_FILE}`); const manifestEntry = zip.file(`${prefix}${GHOST_MANIFEST_FILE}`); if (!manifestEntry) throw new Error(`缺少 ${GHOST_MANIFEST_FILE}`); - const validation = validateGhostManifest(JSON.parse(await manifestEntry.async('text'))); + const rawManifest = JSON.parse(await manifestEntry.async('text')); + const reservedNamespace = authorDeclaredNamespaceReason(rawManifest); + if (reservedNamespace) throw new Error(reservedNamespace); + const validation = validateGhostManifest(rawManifest); if (!validation.ok) throw new Error(validation.reason); if (!options.publisherName.trim() || options.publisherName.length > 64) { throw new Error('publisherName 必须是 1–64 字符'); @@ -461,7 +465,10 @@ export async function reviewGhostPackage( const manifestEntry = zip.file(`${prefix}${GHOST_MANIFEST_FILE}`); const signatureEntry = zip.file(`${prefix}${GHOST_SIGNATURE_FILE}`); if (!manifestEntry || !signatureEntry) throw new Error('审核前必须已有发布者签名'); - const validation = validateGhostManifest(JSON.parse(await manifestEntry.async('text'))); + const rawManifest = JSON.parse(await manifestEntry.async('text')); + const reservedNamespace = authorDeclaredNamespaceReason(rawManifest); + if (reservedNamespace) throw new Error(reservedNamespace); + const validation = validateGhostManifest(rawManifest); if (!validation.ok) throw new Error(validation.reason); const verified = await verifyGhostZipSignatures(zip, prefix, validation.manifest); if (!verified.ok || !verified.document) { diff --git a/apps/desktop/src/main/cindy-brain/ghostSnapshotWorkerProcess.ts b/apps/desktop/src/main/cindy-brain/ghostSnapshotWorkerProcess.ts index 316f84bdfff..699249a9120 100644 --- a/apps/desktop/src/main/cindy-brain/ghostSnapshotWorkerProcess.ts +++ b/apps/desktop/src/main/cindy-brain/ghostSnapshotWorkerProcess.ts @@ -105,7 +105,10 @@ async function removeVerifiedDirectory( || movedStat.isSymbolicLink() || movedStat.dev !== targetStat.dev || movedStat.ino !== targetStat.ino - || !samePath(await fs.promises.realpath(quarantinePath), targetRealPath) + || !samePath( + await fs.promises.realpath(quarantinePath), + path.join(await fs.promises.realpath(path.dirname(quarantinePath)), path.basename(quarantinePath)), + ) ) { // Never recursively delete an unverified path. Leave the quarantined // directory for a later owner-checked cleanup pass. @@ -115,7 +118,9 @@ async function removeVerifiedDirectory( // owner-bound parent before recursive deletion; on failure the isolated // directory is intentionally left for a later guarded cleanup pass. await verifyParent(expectedParent, workingDir); - await verifyDirectory(workingDir, parentName); + if (!samePath(targetPath, path.join(workingDir, parentName))) { + await verifyDirectory(workingDir, parentName); + } await fs.promises.rm(quarantinePath, { recursive: true, force: true }); } async function copyDirectory(source: string, target: string): Promise { diff --git a/apps/desktop/src/main/cindy-brain/ghostTrustedXdTarget.ts b/apps/desktop/src/main/cindy-brain/ghostTrustedXdTarget.ts new file mode 100644 index 00000000000..9fd7a04b3ef --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/ghostTrustedXdTarget.ts @@ -0,0 +1,39 @@ +import type { InstalledGhost } from '../../shared/ghost.js'; +import { hasDeliveryNamespace } from '../../shared/pluginIdentity.js'; +import type { GhostFirstPartyFactsLoad } from './ghostFirstPartyFacts.js'; +import { + hasTrustedFirstPartySource, + marketInstallationMatchesApprovedPackage, +} from './ghostFirstPartyPrivilege.js'; + +export function isTrustedXdGhostForScriptTarget( + ghost: InstalledGhost, + load: GhostFirstPartyFactsLoad, + pendingLegacy: boolean, +): boolean { + if (load.kind !== 'ready' || ghost.approval.state !== 'approved') return false; + const facts = load.facts; + const organization = facts.currentOrganization; + if ( + !organization?.organizationId || + (organization.orgSlug != null && organization.orgSlug !== 'xd') || + facts.ghostId !== ghost.manifest.id || + facts.namespace !== (ghost.namespace ?? null) + ) + return false; + if (hasDeliveryNamespace(ghost)) { + if (ghost.namespace !== 'xd') return false; + } else if ( + !pendingLegacy || + (organization.orgSlug !== 'xd' && organization.pluginPrefix !== 'xd') + ) + return false; + const trustedMarket = + facts.marketRecord !== null && + marketInstallationMatchesApprovedPackage(facts.marketRecord, organization); + const trustedBuiltin = + facts.builtin && + facts.trustedSource?.kind === 'builtin-official' && + hasTrustedFirstPartySource(facts); + return trustedMarket || trustedBuiltin; +} diff --git a/apps/desktop/src/main/cindy-brain/ghostUnreadProjection.ts b/apps/desktop/src/main/cindy-brain/ghostUnreadProjection.ts index 4db90175af8..3673593dbf3 100644 --- a/apps/desktop/src/main/cindy-brain/ghostUnreadProjection.ts +++ b/apps/desktop/src/main/cindy-brain/ghostUnreadProjection.ts @@ -13,6 +13,7 @@ */ import type { InstalledGhost } from '../../shared/ghost.js'; +import { installedGhostStoragePart } from '../../shared/pluginIdentity.js'; /** * 该意识当前是否还持有未读角标能力(资格,与启用与否无关)。 @@ -46,7 +47,7 @@ export function selectRevokedGhostUnreadIds( if (entries.length === 0) return []; if (ghosts.length === 0 && !rosterAuthoritative) return []; const stillDeclared = new Set( - ghosts.filter(ghostDeclaresBadge).map((ghost) => ghost.manifest.id), + ghosts.filter(ghostDeclaresBadge).map(installedGhostStoragePart), ); return entries.map((entry) => entry.ghostId).filter((id) => !stillDeclared.has(id)); } diff --git a/apps/desktop/src/main/cindy-brain/ghostUnreadStore.ts b/apps/desktop/src/main/cindy-brain/ghostUnreadStore.ts index e03ce211d87..dbcebcd4f31 100644 --- a/apps/desktop/src/main/cindy-brain/ghostUnreadStore.ts +++ b/apps/desktop/src/main/cindy-brain/ghostUnreadStore.ts @@ -13,8 +13,10 @@ */ import Store from 'electron-store'; +import { assertGhostPrefsWritable, relocateGhostPreferenceMaps } from './ghostPreferenceRelocation.js'; -import { GHOST_BADGE_SUMMARY_MAX_CHARS, isValidGhostId } from '../../shared/ghost.js'; +import { GHOST_BADGE_SUMMARY_MAX_CHARS } from '../../shared/ghost.js'; +import { isGhostInstanceId } from '../../shared/pluginIdentity.js'; import { ownerScopedUserDataPath } from '../appSessionState.js'; /** 一条未读记录(ghostId → 最近一次点亮的摘要与时刻)。 */ @@ -36,6 +38,13 @@ const MAX_UNREAD_ENTRIES = 200; let storeInstance: Store | null = null; let storePath: string | null = null; +export async function relocateGhostUnread(from: string, to: string): Promise { + await relocateGhostPreferenceMaps('ghost-unread.json', ['entries'], from, to, () => { + storeInstance = null; + storePath = null; + }); +} + function getStore(): Store { const currentPath = ownerScopedUserDataPath(); if (!storeInstance || storePath !== currentPath) { @@ -60,7 +69,7 @@ export function normalizeGhostUnreadEntries(value: unknown): GhostUnreadEntry[] if (typeof value !== 'object' || value === null || Array.isArray(value)) return []; const entries: GhostUnreadEntry[] = []; for (const [ghostId, raw] of Object.entries(value as Record)) { - if (!isValidGhostId(ghostId)) continue; + if (!isGhostInstanceId(ghostId)) continue; if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) continue; const at = (raw as { at?: unknown }).at; if (typeof at !== 'number' || !Number.isFinite(at) || at <= 0) continue; @@ -114,6 +123,7 @@ export function markGhostUnread( summary: string | undefined, at: number, ): { entries: GhostUnreadEntry[]; evicted: string[] } { + assertGhostPrefsWritable('ghost-unread.json'); const result = applyGhostUnreadMark(loadGhostUnread(), { ghostId, ...(summary ? { summary } : {}), @@ -153,6 +163,7 @@ export function applyGhostUnreadMark( * "看见了哪一条"可言。 */ export function clearGhostUnread(ghostId: string, seenAt?: number): GhostUnreadEntry[] | null { + assertGhostPrefsWritable('ghost-unread.json'); const current = loadGhostUnread(); const entry = current.find((candidate) => candidate.ghostId === ghostId); if (!entry) return null; diff --git a/apps/desktop/src/main/cindy-brain/ghostUserDataRelocation.ts b/apps/desktop/src/main/cindy-brain/ghostUserDataRelocation.ts new file mode 100644 index 00000000000..6a6cbbf15f5 --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/ghostUserDataRelocation.ts @@ -0,0 +1,76 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const USER_DATA_PATHS = [ + ['ghost-kv', '.json'], + ['ghost-fs', ''], + ['libraries', ''], + ['library-staging', ''], +] as const; + +const USER_DATA_REFERENCES = [ + 'secrets', + 'libraryBinding', + 'libraryMeta', + 'workdirPreferences', + 'cindyPreferences', + 'errandPreferences', + 'pickedDirectories', + 'media', + 'cards', + 'unread', +] as const; + +type RelocateReference = (fromPart: string, toPart: string) => void | Promise; + +export type GhostUserDataRelocationResources = Record< + (typeof USER_DATA_REFERENCES)[number], + RelocateReference +> & { + userDataPath: (...parts: string[]) => string; + assertCurrent: () => void; +}; + +export function ghostUserDataRelocationPaths( + fromPart: string, + toPart: string, + userDataPath: GhostUserDataRelocationResources['userDataPath'], +): Array<{ from: string; to: string }> { + return USER_DATA_PATHS.map(([directory, suffix]) => ({ + from: userDataPath(directory, fromPart + suffix), + to: userDataPath(directory, toPart + suffix), + })); +} + +export function assertGhostUserDataPathsCanRelocate( + paths: Array<{ from: string; to: string }>, +): void { + for (const move of paths) { + if (fs.lstatSync(move.from, { throwIfNoEntry: false }) && + fs.lstatSync(move.to, { throwIfNoEntry: false })) { + throw new Error(`relocate destination already exists: ${move.to}`); + } + } +} + +export async function relocateGhostUserDataResources( + fromPart: string, + toPart: string, + resources: GhostUserDataRelocationResources, +): Promise { + if (fromPart === toPart) return; + resources.assertCurrent(); + const paths = ghostUserDataRelocationPaths(fromPart, toPart, resources.userDataPath); + assertGhostUserDataPathsCanRelocate(paths); + for (const move of paths) { + resources.assertCurrent(); + if (!fs.lstatSync(move.from, { throwIfNoEntry: false })) continue; + fs.mkdirSync(path.dirname(move.to), { recursive: true }); + fs.renameSync(move.from, move.to); + } + for (const resource of USER_DATA_REFERENCES) { + resources.assertCurrent(); + await resources[resource](fromPart, toPart); + } + resources.assertCurrent(); +} diff --git a/apps/desktop/src/main/cindy-brain/ghostVisibility.ts b/apps/desktop/src/main/cindy-brain/ghostVisibility.ts index 3c3fa9f993a..fce2d2859ae 100644 --- a/apps/desktop/src/main/cindy-brain/ghostVisibility.ts +++ b/apps/desktop/src/main/cindy-brain/ghostVisibility.ts @@ -10,14 +10,24 @@ */ import type { InstalledGhost } from '../../shared/ghost.js'; +import { isValidGhostId } from '../../shared/ghost.js'; +import { + findInstalledGhostByInstanceId, + formatInstalledGhostAmbiguity, + installedGhostStoragePart, + installedGhostLogicalIdentity, + deliveryNamespaceFields, + resolveInstalledGhost, +} from '../../shared/pluginIdentity.js'; import { t } from '../i18n.js'; export type GhostVisibilityResult = | { ok: true; ghost: InstalledGhost } | { ok: false; - errorCode: 'GHOST_NOT_FOUND' | 'GHOST_ASLEEP' | 'GHOST_DISABLED_IN_WORKDIR'; + errorCode: 'GHOST_NOT_FOUND' | 'GHOST_ASLEEP' | 'GHOST_DISABLED_IN_WORKDIR' | 'GHOST_AMBIGUOUS'; message: string; + candidates?: Array<{ ghostId: string; namespace: string | null }>; }; export interface GhostVisibilityDeps { @@ -30,16 +40,48 @@ export function classifyGhostVisibility( ghostId: string, workdir: string | null, deps: GhostVisibilityDeps, + namespace?: string | null, ): GhostVisibilityResult { - const ghost = deps.listGhosts().find((candidate) => candidate.manifest.id === ghostId); - if (!ghost) { + const listed = deps.listGhosts(); + // Plain ghostIds can be ambiguous across namespaces. Only encoded instance + // ids (`_ns__acme__helper`) skip the unique-ghostId compatibility path. + const byInstance = + namespace === undefined && !isValidGhostId(ghostId) + ? findInstalledGhostByInstanceId(listed, ghostId) + : undefined; + const resolved = byInstance + ? { status: 'unique' as const, ghost: byInstance } + : resolveInstalledGhost(listed, ghostId, namespace); + if (resolved.status === 'missing') { return { ok: false, errorCode: 'GHOST_NOT_FOUND', message: t('newChat.pluginSetup.targetNotFound'), }; } - if (!deps.isAvailableForActiveSession(ghostId)) { + if (resolved.status === 'ambiguous') { + return { + ok: false, + errorCode: 'GHOST_AMBIGUOUS', + message: formatInstalledGhostAmbiguity(ghostId, resolved.candidates), + candidates: resolved.candidates.map((candidate) => ({ + ghostId: candidate.manifest.id, + namespace: Object.prototype.hasOwnProperty.call(candidate, 'namespace') + ? candidate.namespace ?? null + : null, + })), + }; + } + return classifyGhostAvailability(resolved.ghost, workdir, deps); +} + +function classifyGhostAvailability( + ghost: InstalledGhost, + workdir: string | null, + deps: GhostVisibilityDeps, +): GhostVisibilityResult { + const instanceId = installedGhostStoragePart(ghost); + if (!deps.isAvailableForActiveSession(instanceId)) { return { ok: false, errorCode: 'GHOST_NOT_FOUND', @@ -48,7 +90,7 @@ export function classifyGhostVisibility( message: '该插件需要 Cindy 账号,未登录状态不可用;不要重试,改用本地可用方式。', }; } - if (deps.isDisabledForWorkdir(ghostId, workdir)) { + if (deps.isDisabledForWorkdir(instanceId, workdir)) { return { ok: false, errorCode: 'GHOST_DISABLED_IN_WORKDIR', @@ -64,3 +106,21 @@ export function classifyGhostVisibility( } return { ok: true, ghost }; } + +export function classifyInstalledGhostVisibility( + target: InstalledGhost, + workdir: string | null, + deps: GhostVisibilityDeps, +): GhostVisibilityResult { + const identity = installedGhostLogicalIdentity(target); + const ghost = findInstalledGhostByInstanceId(deps.listGhosts(), installedGhostStoragePart(target)); + if (!ghost || ghost.manifest.id !== identity.ghostId || + installedGhostLogicalIdentity(ghost).namespace !== identity.namespace || + ghost.dir !== target.dir || + JSON.stringify(ghost.approval) !== JSON.stringify(target.approval) || + JSON.stringify(deliveryNamespaceFields(ghost)) !== JSON.stringify(deliveryNamespaceFields(target)) || + installedGhostStoragePart(ghost) !== installedGhostStoragePart(target)) { + return { ok: false, errorCode: 'GHOST_NOT_FOUND', message: t('newChat.pluginSetup.targetNotFound') }; + } + return classifyGhostAvailability(ghost, workdir, deps); +} diff --git a/apps/desktop/src/main/cindy-brain/ghostWebviewPartition.ts b/apps/desktop/src/main/cindy-brain/ghostWebviewPartition.ts index 030466cc98a..6693163a33b 100644 --- a/apps/desktop/src/main/cindy-brain/ghostWebviewPartition.ts +++ b/apps/desktop/src/main/cindy-brain/ghostWebviewPartition.ts @@ -1,4 +1,6 @@ -import { GHOST_PARTITION_PREFIX, isValidGhostId, parseGhostPartition } from '../../shared/ghost.js'; +import { createHash } from 'node:crypto'; +import { GHOST_PARTITION_PREFIX, ghostInstallApprovalToken, parseGhostPartition, type GhostInstallApproval } from '../../shared/ghost.js'; +import { installedGhostLogicalIdentity, isValidPluginStoragePart, pluginStoragePart } from '../../shared/pluginIdentity.js'; import { dataOwnerStorageKey, type ActiveAppSession } from '../appSessionState.js'; const GHOST_OWNER_PARTITION_PREFIX = `${GHOST_PARTITION_PREFIX}owner:`; @@ -12,9 +14,26 @@ export interface ResolvedGhostWebviewPartition { export function ownerScopedGhostPartition( ghostId: string, owner: Pick, + knownRoot = false, ): string | null { - if (!isValidGhostId(ghostId) || owner.mode === 'signed-out' || !owner.dataOwnerId) return null; - return `${GHOST_OWNER_PARTITION_PREFIX}${owner.mode}:${dataOwnerStorageKey(owner.dataOwnerId)}:${ghostId}`; + if (!isValidPluginStoragePart(ghostId) || owner.mode === 'signed-out' || !owner.dataOwnerId) return null; + return `${GHOST_OWNER_PARTITION_PREFIX}${owner.mode}:${dataOwnerStorageKey(owner.dataOwnerId)}:${ghostId}${knownRoot ? ':root' : ''}`; +} + +export function ownerScopedGhostPartitionForInstalledGhost( + ghost: { manifest: { id: string }; namespace?: string | null; approval?: GhostInstallApproval; dir?: string }, + owner: Pick & Partial>, +): string | null { + const partition = ownerScopedGhostPartition( + pluginStoragePart(installedGhostLogicalIdentity(ghost)), + owner, + ghost.namespace === null, + ); + if (!partition || ghost.approval?.state !== 'approved') return partition; + const receipt = createHash('sha256') + .update(JSON.stringify([ghostInstallApprovalToken(ghost.approval), ghost.dir ?? null, owner.generation ?? null])) + .digest('hex'); + return partition + ':receipt:' + receipt; } /** diff --git a/apps/desktop/src/main/cindy-brain/ghostWorkdirPrefs.ts b/apps/desktop/src/main/cindy-brain/ghostWorkdirPrefs.ts index 654b28f7c43..08df2d409c6 100644 --- a/apps/desktop/src/main/cindy-brain/ghostWorkdirPrefs.ts +++ b/apps/desktop/src/main/cindy-brain/ghostWorkdirPrefs.ts @@ -27,6 +27,7 @@ import path from 'node:path'; import { desktopMakerLogger } from '../maker-host/logger-adapter.js'; import { createOverrideSettingsFile } from '../maker-host/override-settings-file.js'; import { ownerScopedUserDataPath } from '../appSessionState.js'; +import { assertGhostPrefsWritable, updateGhostPrefsForRelocation } from './ghostPreferenceRelocation.js'; const log = desktopMakerLogger.child('ghost-workdir-prefs'); @@ -71,13 +72,18 @@ function normalize(raw: unknown): GhostWorkdirPrefs { return { disabledByWorkdir }; } -const store = createOverrideSettingsFile({ - filePath: () => ownerScopedUserDataPath('ghost-workdir-prefs.json'), - defaults: DEFAULTS, - normalize, - log, - label: 'ghost-workdir-prefs', -}); +function createStore() { + return createOverrideSettingsFile({ + filePath: () => ownerScopedUserDataPath('ghost-workdir-prefs.json'), + defaults: DEFAULTS, + normalize, + log, + label: 'ghost-workdir-prefs', + preserveUnreadableFile: true, + }); +} + +let store = createStore(); function readPrefs(): GhostWorkdirPrefs { store.invalidateIfChanged(); @@ -97,6 +103,7 @@ export function isGhostDisabledForWorkdir(ghostId: string, workdir: string | nul /** 写入目录级例外;返回该目录写后的禁用列表(供 IPC 回包)。 */ export function setGhostDisabledForWorkdir(workdir: string, ghostId: string, disabled: boolean): string[] { + assertGhostPrefsWritable('ghost-workdir-prefs.json'); const key = normalizeWorkdirKey(workdir); if (key.length === 0) throw new Error('workdir must be a non-empty path'); const current = readPrefs().disabledByWorkdir; @@ -111,5 +118,35 @@ export function setGhostDisabledForWorkdir(workdir: string, ghostId: string, dis return next[key] ?? []; } +/** 原位组织插件改用 namespace 物理 ID 时,保留各项目的禁用例外。 */ +export async function relocateGhostWorkdirPrefs(fromPart: string, toPart: string): Promise { + if (fromPart === toPart) return; + await updateGhostPrefsForRelocation('ghost-workdir-prefs.json', (raw) => { + const map = raw.disabledByWorkdir; + if (map === undefined) return {}; + if (map === null || typeof map !== 'object' || Array.isArray(map)) { + throw new Error('ghost workdir preferences are unreadable'); + } + const entries = Object.entries(map); + if (entries.some(([, ids]) => !Array.isArray(ids) || ids.some((id) => typeof id !== 'string'))) { + throw new Error('ghost workdir preferences are unreadable'); + } + const disabled = map as Record; + if (!Object.values(disabled).some((ids) => ids.includes(fromPart))) return {}; + if (Object.values(disabled).some((ids) => ids.includes(toPart))) { + throw new Error('ghost workdir preferences relocation destination collision'); + } + return { + disabledByWorkdir: Object.fromEntries( + Object.entries(disabled).map(([workdir, ids]) => [ + workdir, ids.map((id) => id === fromPart ? toPart : id), + ]), + ), + }; + }, () => { + store = createStore(); + }); +} + /** 测试钩子(仅纯函数;读写链路由 IPC / 生效点测试覆盖)。 */ export const __testing = { normalize, normalizeWorkdirKey }; diff --git a/apps/desktop/src/main/cindy-brain/index.ts b/apps/desktop/src/main/cindy-brain/index.ts index 8db98dd3c41..fc0447b7acc 100644 --- a/apps/desktop/src/main/cindy-brain/index.ts +++ b/apps/desktop/src/main/cindy-brain/index.ts @@ -3,8 +3,15 @@ import { projectGhostAgentModels } from './ghostAgentModels.js'; import { getDesktopProviderService } from '../maker-host/createDesktopProviderService.js'; import { PluginDownloadSlot } from './downloadSlot.js'; import { createDownloader } from '../downloader/index.js'; -import { registerGhostCardRemoteProvider, persistGhostCardWithRemoteChange } from './cardRemoteResource.js'; -import { openDeviceAuthorizationCard, openPluginAuthorizationCard } from '../plugin-oauth/deviceCard.js'; +import { + registerGhostCardRemoteProvider, + persistGhostCardWithRemoteChange, + findGhostForRemotePluginIdentity, +} from './cardRemoteResource.js'; +import { + openDeviceAuthorizationCard, + openPluginAuthorizationCard, +} from '../plugin-oauth/deviceCard.js'; import { t as authorizationText } from '../i18n.js'; import { getBotAuthorizationService } from '../maker-ipc/botAuthorizationService.js'; import { isResidentBrowserGhost, spawnResidentGhost } from './residentGhost.js'; @@ -21,7 +28,7 @@ import { shell, type WebContents, } from 'electron'; -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { isDeepStrictEqual } from 'node:util'; @@ -61,10 +68,7 @@ import { unreviewedGhostPermissionItems, ghostWebviewEntryPaths, isCindyAccountGhostId, - isBrokerEligibleGhostId, - isOfficialGhostId, isUserInstallReservedGhostId, - isValidGhostId, layoutWithGhostPanel, type GhostHostNoticeKey, type GhostManifest, @@ -94,6 +98,7 @@ import { type AppSessionMode, } from '../appSessionState.js'; import { getLayoutStore } from '../layout/index.js'; +import { relocateGhostUserDataResources } from './ghostUserDataRelocation.js'; import { GhostManager, type GhostExclusiveMutation, @@ -110,6 +115,35 @@ import { invalidateForgePackTicketsForOwner, } from './forgePackStaging.js'; import { withGhostInstallLock } from './ghostInstallLock.js'; +import { + createPluginLogicalIdentity, + findConflictingGhostCommand, + findInstalledGhostByIdentity, + findInstalledGhostByInstanceId, + findInstalledGhostForLocalUpdate, + findInstalledGhostForDeliveryTarget, + installedGhostLogicalIdentity, + installedGhostPhysicalKeys, + installedGhostPhysicalRelId, + installedGhostStoragePart, + installedGhostMutationTargetToken, + isGhostInstanceId, + resolvePluginLibraryStorageKey, + parsePluginInstallRelId, + parsePluginInstanceId, + parsePluginStoragePart, + pluginInstallStoragePart, + isValidPluginInstallRelId, + pluginStoragePart, + resolveInstalledGhost, + hasDeliveryNamespace, + deliveryNamespaceFields, +} from '../../shared/pluginIdentity.js'; +import { + classifyNamespaceMigration, + readNamespaceMigrationInstallOrigin, + readNamespaceMigrationMarketRecord, +} from './ghostNamespaceMigration.js'; import { clearBuiltinTombstone, listEligibleBuiltinCommands, @@ -134,6 +168,7 @@ import { GhostRuntime } from './runtime/GhostRuntime.js'; import { electronSandboxAdapter, ensureGhostProtocolRegistered, + revokeLegacyGhostProtocolPartition, ghostIdForLogicWebContents, sendToGhostLogic, setGhostAppContextProvider, @@ -197,7 +232,6 @@ import { migrateFiloGoogleAccountsWithResult, type LegacyGoogleAccountRow, } from './googleAccountsMigration.js'; -import { withFiloGoogleBuildClientConfig } from './filoGoogleClientConfig.js'; import { LEGACY_JIRA_CONNECTION_FILE, LEGACY_JIRA_RT_FILE, @@ -259,13 +293,13 @@ import { } from './cindySlot.js'; import { GhostAgentSlot, type GhostAgentTurnRunner } from './agentSlot.js'; import { GhostErrandSlot, type GhostErrandRunner } from './errandSlot.js'; -import { readGhostErrandConfig, writeGhostErrandConfig } from './errandPrefsStore.js'; +import { readGhostErrandConfig, writeGhostErrandConfig, relocateGhostErrandPrefs } from './errandPrefsStore.js'; import { GhostNodeRuntimeBroker, type NodeRuntimeStartAttemptContext, } from './nodeRuntimeBroker.js'; import { GhostPickSlot } from './pickSlot.js'; -import { recordGhostPickedDir } from './pickGrantsStore.js'; +import { recordGhostPickedDir, relocateGhostPickedDirs } from './pickGrantsStore.js'; import { GhostPreviewSlot } from './previewSlot.js'; import { GhostScheduleSlot, isMainShellWindowUrl } from './scheduleSlot.js'; import { GhostWorkspaceSlot, type WorkspaceSessionService } from './workspaceSlot.js'; @@ -287,6 +321,7 @@ import { loadGhostUnread, markGhostUnread, readGhostUnread, + relocateGhostUnread, type GhostUnreadEntry, } from './ghostUnreadStore.js'; import { isGhostUnreadProjectable, selectRevokedGhostUnreadIds } from './ghostUnreadProjection.js'; @@ -327,15 +362,22 @@ import { type GhostFirstPartyPendingMarketRecord, type GhostFirstPartyFactsPurpose, } from './ghostFirstPartyFacts.js'; -import { authorizeGhostTokenBroker } from './ghostFirstPartyPrivilege.js'; +import { + authorizeGhostHostPrimitive, + authorizeGhostTokenBroker, + captureLegacyFirstPartyEligibility, + isTrustedMivoSecretAlias, +} from './ghostFirstPartyPrivilege.js'; +import { isTrustedXdGhostForScriptTarget } from './ghostTrustedXdTarget.js'; +import { classifyGhostLocalUpdateSource, type GhostLocalUpdateSourceDecision } from './ghostLocalUpdateSource.js'; import { ghostTokenBrokerInstallError } from './ghostTokenBrokerInstallError.js'; import { ghostBrokerRedirectPortInstallError } from './ghostBrokerRedirectPort.js'; import { ConnectionTokenProvider, type IssuedConnectionToken } from './connectionTokenProvider.js'; import { GhostFsSlot } from './fsSlot.js'; import { GhostLibrarySlot } from './librarySlot.js'; -import { LibraryBindingStore, validateLibraryCandidateLocation } from './libraryBinding.js'; +import { LibraryBindingStore, assertLibraryMetaOwner, relocateLibraryMetaOwner, validateLibraryCandidateLocation } from './libraryBinding.js'; import { LibraryVault, statfsFreeBytes, DEFAULT_LIBRARY_LIMITS } from './libraryVault.js'; -import { LibraryStagingStore } from './libraryStaging.js'; +import { LibraryStagingStore, relocateLibraryStagingOwner } from './libraryStaging.js'; import { LibrarySqlService, defaultLibraryDbWorkerPath } from './librarySqlService.js'; import { trashGhostLibrary } from './libraryTrash.js'; import { migrateGhostLibrary } from './libraryMigrate.js'; @@ -356,6 +398,7 @@ import { } from '../plugin-market/ledger.js'; import { installedMarketManifestIdentity, + verifiedUnstampedOrganizationNamespace, type InstalledMarketManifestIdentity, } from '../plugin-market/installedManifestIdentity.js'; import { createOrganizationPrefixStore } from '../plugin-market/organizationPrefixStore.js'; @@ -374,10 +417,15 @@ import { type GhostScreenResult, type MinimalAgentEvent, } from './subscriptionGateway.js'; -import { GhostExternalLinkGate, GhostPreviewGate, resolveGhostPanelMedia } from './previewGate.js'; +import { + GhostExternalLinkGate, + GhostPreviewGate, + parseGhostPanelMediaUrl, + resolveGhostPanelMedia, +} from './previewGate.js'; import { runGhostExternalLinkNavigation } from './ghostExternalLinkNavigation.js'; import { runGhostPreviewNavigation } from './ghostPreviewNavigation.js'; -import { resolveGhostWebviewPartitionClaim } from './ghostWebviewPartition.js'; +import { ownerScopedGhostPartitionForInstalledGhost, resolveGhostWebviewPartitionClaim } from './ghostWebviewPartition.js'; import { ghostSecretSaved, readGhostSecret, @@ -386,7 +434,9 @@ import { readCustomProviderKey, readGhostSecretTail, removeGhostSecret, + migrateGhostSecrets, removeGhostSecrets, + setMivoSecretAliasVerifier, storeGhostSecret, } from '../secrets/providerSecretStore.js'; import { getActiveCatalog, getXdGatewayModels } from '../maker-host/active-catalog.js'; @@ -429,12 +479,14 @@ import { readGhostCindyOverrides, readGhostCindyInflightLimit, writeGhostCindyOverride, + relocateGhostCindyPrefs, type CindyCapabilityKey, } from './cindyPrefsStore.js'; import { isCindyOverrideModelAllowed } from './cindyOverrideWhitelist.js'; import { isGhostDisabledForWorkdir, listDisabledGhostIdsForWorkdir, + relocateGhostWorkdirPrefs, setGhostDisabledForWorkdir, } from './ghostWorkdirPrefs.js'; import { @@ -472,6 +524,7 @@ import { ingestMedia, supportedMime } from '../cindy-media/ingest.js'; import { captureMediaRefCompensationScope } from '../cindy-media/refCompensationJournal.js'; import { sniffMediaMime } from '../cindy-media/sniffMediaMime.js'; import { recordGhostCallMedia } from './ghostMediaLedger.js'; +import { resolveGhostMediaHandoverTarget } from './ghostMediaHandoverTargetTracker.js'; import { MAKER_PUSH } from '../maker-ipc/channels.js'; import { ghostSetupNavigationForAction } from './ghostSetupNavigation.js'; import { assessGhostHostSetupRequirements } from './ghostHostSetupRequirements.js'; @@ -489,7 +542,7 @@ import { // delete require.cache[__filename] 不在 CJS 缓存里——跨 chunk require 会把整个 // 主进程 bundle 重新求值,启动副作用全量重跑直至 IPC 二次注册抛错,反复触发即 // 主进程 OOM(2026-07-12 实事故,详见 bootstrap-electron.ts 末尾的缓存自愈注释)。 -import { getDbClient } from '../localDb/client/current.js'; +import { getCurrentDbClientUserId, getDbClient } from '../localDb/client/current.js'; import * as localDbSchema from '../localDb/schema.js'; import { eq } from 'drizzle-orm'; import { requireAppCapability } from '../appCapabilities.js'; @@ -881,8 +934,8 @@ async function retryLegacyGhostRecoveryForActiveSession(): Promise !pending.has(id) && !failed.has(id)), + ); await acknowledgeRecoveredLegacyGhosts( expectedOwner.dataOwnerId, recoveredLegacyIds.filter((id) => !pending.has(id) && !failed.has(id)), @@ -946,7 +1002,9 @@ export function waitForGhostMutations(): Promise { /** Account-managed built-ins are unavailable outside a verified cloud session. */ export function isGhostAvailableForActiveSession(id: string): boolean { if (isAppSessionBoundaryPending()) return false; - return !isCindyAccountGhostId(id) || getAppCapabilities().canUseCindyAccountServices; + const identity = parsePluginStoragePart(id) ?? parsePluginInstallRelId(id); + const ghostId = identity?.ghostId ?? id; + return !isCindyAccountGhostId(ghostId) || getAppCapabilities().canUseCindyAccountServices; } /** Live Host capability gate shared by Agent transports and Renderer IPC. */ @@ -967,15 +1025,17 @@ function availableGhosts(): InstalledGhost[] { function projectGhostForRenderer(ghost: InstalledGhost): InstalledGhost { try { const runtimeManifest = withRuntimeFiloGoogleClient(ghost.manifest); + const storeId = installedGhostStoragePart(ghost); const oauthManager = getGhostOauthAccountManager(); const expiredAccountCount = (runtimeManifest.network?.secrets ?? []).reduce( (count, secret) => secret.source === 'oauth' && secret.oauth - ? count + oauthManager.clientMigrationExpiredAccountCount(runtimeManifest.id, secret.key) + ? count + + oauthManager.clientMigrationExpiredAccountCount(storeId, secret.key) : count, 0, ); - const suggest = getGhostOauthReauthSuggest(runtimeManifest); + const suggest = getGhostOauthReauthSuggest(runtimeManifest, storeId); return { ...ghost, ...(expiredAccountCount > 0 ? { oauthAuthorizationExpired: { expiredAccountCount } } : {}), @@ -998,19 +1058,46 @@ function projectGhostForRenderer(ghost: InstalledGhost): InstalledGhost { } } -function findAvailableGhost(id: string): InstalledGhost | null { - return availableGhosts().find((ghost) => ghost.manifest.id === id) ?? null; +function findAvailableGhost(id: string, namespace?: string | null): InstalledGhost | null { + const resolved = resolveInstalledGhost(availableGhosts(), id, namespace); + return resolved.status === 'unique' ? resolved.ghost : null; +} + +export function findGhostForInstanceId(id: string, namespace?: string | null): InstalledGhost | null { + const ghosts = availableGhosts(); + if (namespace === undefined) { + const byInstance = findInstalledGhostByInstanceId(ghosts, id); + if (byInstance) return byInstance; + } + const resolved = resolveInstalledGhost(ghosts, id, namespace); + return resolved.status === 'unique' ? resolved.ghost : null; +} + +function libraryStorageKeyFor(id: string): string | null { + return resolvePluginLibraryStorageKey(id, findGhostForInstanceId(id)); } /** Runtime-authorized lookup for integrations outside this module. */ -export function findAvailableGhostForAuthorization(id: string): InstalledGhost | null { - return findAvailableGhost(id); +export function findAvailableGhostForAuthorization( + id: string, + namespace?: string | null, +): InstalledGhost | null { + return findAvailableGhost(id, namespace); } export function listAvailableGhostsForAuthorization(): InstalledGhost[] { return availableGhosts(); } +export function findTrustedXdGhostForScript(id: string): InstalledGhost | null { + const ghost = findAvailableGhostForAuthorization(id, 'xd') ?? findAvailableGhostForAuthorization(id); + if (!ghost) return null; + const storagePart = installedGhostStoragePart(ghost); + const loaded = loadGhostFirstPartyFactsForGhost(storagePart, 'runtime'); + return isTrustedXdGhostForScriptTarget(ghost, loaded, getGhostManager().isPendingLegacyNamespace(storagePart)) + ? ghost : null; +} + function requireGhostAvailableForActiveSession(id: string): void { if (!isGhostAvailableForActiveSession(id)) { if (isAppSessionBoundaryPending()) { @@ -1296,6 +1383,84 @@ function migrateGhostKvOnRename(fromId: string, toId: string): void { } } +function assertGhostRelocationDbReady(): void { + const ownerId = getActiveAppSession().dataOwnerId; + if (!ownerId || getCurrentDbClientUserId() !== ownerId) { + throw new Error('Ghost relocation database owner is not ready'); + } + getDbClient(); +} + +function assertGhostRelocationIdle(part: string): void { + if (hasPendingGhostCalls(part) || hasRunningGhostErrand(part) || + hasRunningGhostCindyWork(part) || fsSlotSingleton?.hasInFlightRequests(part)) { + throw new Error('Ghost relocation is waiting for active work to finish'); + } +} + +async function relocateGhostUserData( + fromPart: string, toPart: string, +): Promise { + if (fromPart === toPart) return; + assertGhostRelocationIdle(fromPart); + const ownerKey = activeOwnerScopeKey(); + const assertCurrent = (): void => { + if (activeOwnerScopeKey() !== ownerKey || isAppSessionBoundaryPending()) { + throw new Error('Ghost relocation owner changed'); + } + assertGhostRelocationDbReady(); + }; + assertCurrent(); + getGhostOauthAccountManager().invalidateGhost(fromPart); + getGhostOauthAccountManager().invalidateGhost(toPart); + getBotAuthorizationService()?.invalidatePlugin(fromPart); + getBotAuthorizationService()?.invalidatePlugin(toPart); + subscriptionGatewaySingleton?.dropGhost(fromPart); + subscriptionGatewaySingleton?.dropGhost(toPart); + const slot = getGhostLibrarySlot(); + slot.setRelocating(fromPart, true); + slot.setRelocating(toPart, true); + try { + await slot.disposeGhost(fromPart); + await slot.disposeGhost(toPart); + await relocateGhostUserDataResources(fromPart, toPart, { + userDataPath: ownerScopedUserDataPath, + assertCurrent, + secrets: (from, to) => { migrateGhostSecrets(from, to); }, + libraryBinding: (from, to) => getGhostLibraryBindingStore().relocateBinding(from, to), + libraryMeta: async (from, to) => { + const binding = await getGhostLibraryBindingStore().getBinding(to); + assertCurrent(); + const root = binding ? path.join(binding.root, to) : ownerScopedUserDataPath('libraries', to); + await relocateLibraryMetaOwner(root, from, to, assertCurrent); + assertCurrent(); + await relocateLibraryStagingOwner( + ownerScopedUserDataPath('library-staging', to), from, to, ownerKey, assertCurrent, + ); + }, + workdirPreferences: relocateGhostWorkdirPrefs, + cindyPreferences: relocateGhostCindyPrefs, + errandPreferences: relocateGhostErrandPrefs, + pickedDirectories: relocateGhostPickedDirs, + media: ledger.relocateGhostMediaRefs, + cards: async (from, to) => { + await getGhostCardService().relocateGhost(from, to); + assertCurrent(); + await reassignGhostCards(from, to); + }, + unread: relocateGhostUnread, + }); + } finally { + try { + await slot.disposeGhost(fromPart); + await slot.disposeGhost(toPart); + } finally { + slot.setRelocating(fromPart, false); + slot.setRelocating(toPart, false); + } + } +} + /** 单轮对账:一次性 legacy 迁移 → 播种 → (有变化时)广播 + 首装停靠 + 常驻点火。 */ async function reconcileBuiltinGhosts( reason: string, @@ -1560,7 +1725,67 @@ export function getGhostManager(): GhostManager { // 随包批准入口的 builtin-only 边界:id 必须对应一颗随包种子。该入口不经用户 // 确认就铸出批准,不能只靠"唯一调用者是随包对账"这条纪律。 isTrustedBundledId: (id) => listBuiltinSeedIds(builtinSeedRootDirs()).includes(id), - isTokenBrokerAuthorized: (manifest) => isGhostTokenBrokerAuthorized(manifest.id, 'install'), + isTokenBrokerAuthorized: (manifest) => + isGhostTokenBrokerAuthorized(manifest.id, 'install'), + classifyPendingNamespace: (ghostId, marketSyncCompleted) => + classifyPendingNamespaceForGhost(ghostId, marketSyncCompleted === true), + captureLegacyFirstPartyEligibility: (ghostId, approvedPackageSha256) => { + const records = getPluginMarketLedger().installationsForGhost(ghostId).filter((record) => + record.installed && record.source === 'market' && record.scope === 'public' && + record.organizationId === null && record.namespace == null && record.sha256 === approvedPackageSha256); + return records.length === 1 && captureLegacyFirstPartyEligibility({ + legacyExistingInstall: true, + ghostId, + namespace: null, + approved: true, + approvedPackageSha256, + marketRecord: { ...records[0]!, approvedPackageSha256 }, + }); + }, + recoverUnstampedOrganizationNamespace: recoverUnstampedOrganizationNamespace, + isNamespaceMigrationBusy: (ghostId) => isNamespaceMigrationBusy(ghostId), + canResumePendingResidentOffline: (ghostId) => canResumePendingResidentOffline(ghostId), + onResumePendingResidentOffline: (ghost) => { + if (!isGhostAvailableForActiveSession(ghost.manifest.id)) return; + offlineResidentIdsForActiveScope().add(ghost.manifest.id); + spawnIfResident(ghost, true); + }, + preparePendingResidentForMigration: (ghostId) => + preparePendingResidentForMigration(ghostId), + onPendingResidentMigrationDeferred: (ghostId) => + schedulePendingResidentMigrationRetry(ghostId), + beforeNamespaceCommit: (ghostId, namespace) => { + const marketLedger = getPluginMarketLedger(); + if (!marketLedger.stampNamespaceIfAbsent(ghostId, namespace) && + marketLedger.hasInstalledRecordForGhostId(ghostId)) { + throw new Error('Plugin market namespace migration could not stamp the installed record'); + } + }, + onNamespaceCommitted: (ghostId, namespace) => { + revokeLegacyGhostProtocolPartition(ghostId); + offlineResidentIdsForActiveScope().delete(ghostId); + const retryTimer = pendingResidentMigrationRetryTimers.get(ghostId); + if (retryTimer) clearTimeout(retryTimer); + pendingResidentMigrationRetryTimers.delete(ghostId); + pendingResidentMigrationRetryAttempts.delete(ghostId); + const ownerKey = activeOwnerScopeKey(); + queueMicrotask(() => { + if (activeOwnerScopeKey() !== ownerKey) return; + const ghost = managerSingleton?.list().find((candidate) => + candidate.manifest.id === ghostId && candidate.namespace === namespace && + candidate.namespaceMigration !== 'pending'); + if (ghost) spawnIfResident(ghost); + }); + }, + onArchiveSourceState: async (fromPart, archivePart) => { + assertGhostRelocationDbReady(); + const releaseMutation = beginGhostMutation(captureGhostMutationOwner()); + try { + await relocateGhostUserData(fromPart, archivePart); + } finally { + releaseMutation(); + } + }, isTrustedBundledSource, recordBuiltinTombstone: (id) => recordBuiltinTombstone(brainRootDir(), id, log), clearBuiltinTombstone: (id) => clearBuiltinTombstone(brainRootDir(), id, log), @@ -1607,10 +1832,8 @@ function loadGhostTrustRegistry(): GhostTrustRegistry { /** 仅供 main 出网/OAuth 链使用;不要把补过 client 的 manifest 广播给 renderer。 */ function withRuntimeFiloGoogleClient(manifest: GhostManifest): GhostManifest { - return withFiloGoogleBuildClientConfig(manifest, { - clientId: process.env.XDT_FILO_GOOGLE_CLIENT_ID, - clientSecret: process.env.XDT_FILO_GOOGLE_CLIENT_SECRET, - }); + // §4.4: Filo 构建环境 OAuth client 注入已删除;清单自带 client,用户已存账号保留。 + return manifest; } let runtimeSingleton: GhostRuntime | null = null; @@ -1629,10 +1852,11 @@ export function getGhostRuntime(): GhostRuntime { // 关闭(沉睡)/ 重载都由用户在面板上决定,主机只记日志。 onFused: (id) => log.warn('ghost fused after repeated crashes', { id }), onStateChanged: (id, state) => { + const storagePart = isValidPluginInstallRelId(id) ? pluginInstallStoragePart(id) : id; log.info('ghost runtime state', { id, state }); - if (state !== 'running') disconnectRoutineSource(id); + if (state !== 'running') disconnectRoutineSource(storagePart); // 崩溃/熄灯时把该意识名下的在途工具调用收掉(结构化失败给 agent)。 - getGhostPipeDispatcher().onRuntimeState(id, state); + getGhostPipeDispatcher().onRuntimeState(storagePart, state); broadcastGhostRuntimeStates(); }, }); @@ -1650,7 +1874,7 @@ let dispatcherSingleton: GhostPipeDispatcher | null = null; export function getGhostPipeDispatcher(): GhostPipeDispatcher { if (!dispatcherSingleton) { dispatcherSingleton = new GhostPipeDispatcher({ - getGhost: findAvailableGhost, + getGhost: findGhostForInstanceId, ownerScope: ghostOwnerScope, runtimeStateOf: (id) => getGhostRuntime().stateOf(id), spawn: async (ghost) => { @@ -1674,7 +1898,7 @@ let agentSlotSingleton: GhostAgentSlot | null = null; export function getGhostAgentSlot(): GhostAgentSlot { if (!agentSlotSingleton) { agentSlotSingleton = new GhostAgentSlot({ - getGhost: findAvailableGhost, + getGhost: findGhostForInstanceId, log, }); } @@ -1698,7 +1922,7 @@ let errandSlotSingleton: GhostErrandSlot | null = null; export function getGhostErrandSlot(): GhostErrandSlot { if (!errandSlotSingleton) { errandSlotSingleton = new GhostErrandSlot({ - getGhost: findAvailableGhost, + getGhost: findGhostForInstanceId, // wait 模式的署名单在途期间替管子那头的 tool-call 续命(同 cindy 槽契约)。 holdPipeCall: (ghostId, callId, budgetMs) => getGhostPipeDispatcher().holdCall(ghostId, callId, budgetMs), @@ -1731,7 +1955,7 @@ export function noteGhostUserGesture(ghostId: string): void { /** 插件展示名(errand 会话默认标题等宿主侧使用;未装返回 null)。 */ export function getInstalledGhostName(id: string): string | null { - return findAvailableGhost(id)?.manifest.name ?? null; + return findGhostForInstanceId(id)?.manifest.name ?? null; } let nodeRuntimeBrokerSingleton: GhostNodeRuntimeBroker | null = null; @@ -1768,7 +1992,7 @@ function resetNodeRuntimeBrokerForAccountBoundary(): void { export function getGhostNodeRuntimeBroker(): GhostNodeRuntimeBroker { if (!nodeRuntimeBrokerSingleton) { nodeRuntimeBrokerSingleton = new GhostNodeRuntimeBroker({ - getGhost: findAvailableGhost, + getGhost: findGhostForInstanceId, getCallSignal: (ghostId, callId) => getGhostPipeDispatcher().getPendingCallSignal(ghostId, callId), getCallSessionId: (ghostId, callId) => @@ -1780,7 +2004,7 @@ export function getGhostNodeRuntimeBroker(): GhostNodeRuntimeBroker { { bridge, changeBus: getGhostSetupChangeBus(), - getManifest: (ghostId) => findAvailableGhost(ghostId)?.manifest ?? null, + getManifest: (ghostId) => findGhostForInstanceId(ghostId)?.manifest ?? null, secretSaved: ghostSecretSaved, storeSecret: storeGhostSecret, }, @@ -1816,7 +2040,7 @@ export function getGhostNodeRuntimeBroker(): GhostNodeRuntimeBroker { readSecret: (ghostId, secretKey) => readGhostSecret(ghostId, secretKey), secretSaved: ghostSecretSaved, resolveOauthSecret: async (ghostId, secretKey, accountId) => { - const ghost = findAvailableGhost(ghostId); + const ghost = findGhostForInstanceId(ghostId); const source = ghost ? withRuntimeFiloGoogleClient(ghost.manifest).network?.secrets?.find( (s) => s.key === secretKey, @@ -1881,7 +2105,7 @@ export function getGhostCardService(): GhostCardService { if (!cardServiceSingleton) { cardServiceSingleton = new GhostCardService({ hasCardSlot: (ghostId) => { - const g = findAvailableGhost(ghostId); + const g = findGhostForInstanceId(ghostId); return !!g && g.enabled && g.manifest.card !== undefined; }, sanitize: sanitizeGhostCardHtml, @@ -1912,14 +2136,18 @@ export function getGhostCardActionDispatcher(): GhostCardActionDispatcher { return c ? { ghostId: c.ghostId, sessionId: c.sessionId ?? null } : null; }, reopenForAction: (callId, info) => getGhostCardService().reopenForAction(callId, info), - getGhost: findAvailableGhost, - isRunning: (id) => getGhostRuntime().stateOf(id) === 'running', + getGhost: findGhostForInstanceId, + isRunning: (id) => { + const ghost = findGhostForInstanceId(id); + return getGhostRuntime().stateOf(ghost ? installedGhostStoragePart(ghost) : id) === 'running'; + }, wake: async (ghost) => { const r = await getGhostRuntime().spawn(ghost); if (!r.ok) throw new Error(r.reason); }, sendToGhost: (ghostId, payload) => { - if (!sendToGhostLogic(ghostId, payload)) { + const ghost = findGhostForInstanceId(ghostId); + if (!sendToGhostLogic(ghost ? installedGhostStoragePart(ghost) : ghostId, payload)) { throw new Error('ghost pipe send failed'); } }, @@ -1961,14 +2189,18 @@ export function getGhostSubscriptionGateway(): GhostSubscriptionGateway { if (!subscriptionGatewaySingleton) { subscriptionGatewaySingleton = new GhostSubscriptionGateway({ listGhosts: availableGhosts, - isRunning: (id) => getGhostRuntime().stateOf(id) === 'running', + isRunning: (id) => { + const ghost = findGhostForInstanceId(id); + return getGhostRuntime().stateOf(ghost ? installedGhostStoragePart(ghost) : id) === 'running'; + }, wake: async (ghost) => { const r = await getGhostRuntime().spawn(ghost); if (!r.ok) throw new Error(r.reason); }, sendToGhost: (ghostId, payload) => { + const ghost = findGhostForInstanceId(ghostId); // 适配:底层返回 false 表示投递失败(网关契约是抛错 → 走缓冲/熔断)。 - if (!sendToGhostLogic(ghostId, payload)) { + if (!sendToGhostLogic(ghost ? installedGhostStoragePart(ghost) : ghostId, payload)) { throw new Error('ghost pipe send failed'); } }, @@ -2657,7 +2889,7 @@ let iosSimulatorSlotSingleton: GhostIOSSimulatorSlot | null = null; export function getGhostIOSSimulatorSlot(): GhostIOSSimulatorSlot { if (!iosSimulatorSlotSingleton) { iosSimulatorSlotSingleton = new GhostIOSSimulatorSlot({ - getGhost: findAvailableGhost, + getGhost: findGhostForInstanceId, focusedContext: focusedIOSSimulatorContext, authorizeFocusedContext: authorizeFocusedIOSSimulatorContext, isContextCurrent: isIOSSimulatorContextCurrent, @@ -2690,7 +2922,7 @@ let cindySlotSingleton: GhostCindySlot | null = null; // redirect cleanup into the newly active account's persistent cache. const anonymousDownloadRoots = new Map(); const pluginDownloads = new PluginDownloadSlot({ - getGhost: findAvailableGhost, root: id => { + getGhost: findGhostForInstanceId, root: id => { const root = ownerScopedUserDataPath('plugin-downloads', id); if (!getActiveAppSession().dataOwnerId) anonymousDownloadRoots.set(id, { root, scope: activeOwnerScopeKey() }); return root; @@ -2713,13 +2945,168 @@ function getPluginMarketLedger(): PluginMarketLedger { return pluginMarketLedgerSingleton; } +function pluginMarketAuthorizationTargetFor(ghostId: string): { ghostId: string; namespace?: string | null } { + const ghost = findGhostForInstanceId(ghostId); + if (ghost) return { ghostId: ghost.manifest.id, ...deliveryNamespaceFields(ghost) }; + const identity = parsePluginInstanceId(ghostId); + return identity ?? { ghostId }; +} + +let offlineResidentScopeKey: string | null = null; +const offlineResidentIds = new Set(); +const pendingResidentMigrationRetryTimers = new Map(); +const pendingResidentMigrationRetryAttempts = new Map(); + +function offlineResidentIdsForActiveScope(): Set { + const scopeKey = activeOwnerScopeKey(); + if (offlineResidentScopeKey !== scopeKey) { + for (const timer of pendingResidentMigrationRetryTimers.values()) clearTimeout(timer); + pendingResidentMigrationRetryTimers.clear(); + pendingResidentMigrationRetryAttempts.clear(); + offlineResidentIds.clear(); + offlineResidentScopeKey = scopeKey; + } + return offlineResidentIds; +} + +function schedulePendingResidentMigrationRetry(ghostId: string): void { + if (!offlineResidentIdsForActiveScope().has(ghostId) || + pendingResidentMigrationRetryTimers.has(ghostId)) return; + const ownerScopeKey = activeOwnerScopeKey(); + const attempts = pendingResidentMigrationRetryAttempts.get(ghostId) ?? 0; + pendingResidentMigrationRetryAttempts.set(ghostId, attempts + 1); + const timer = setTimeout(() => { + if (pendingResidentMigrationRetryTimers.get(ghostId) !== timer) return; + pendingResidentMigrationRetryTimers.delete(ghostId); + if (activeOwnerScopeKey() !== ownerScopeKey || + !offlineResidentIdsForActiveScope().has(ghostId)) return; + void getGhostManager().reconcilePendingRootNamespaces(true).catch((error) => { + log.warn('offline resident namespace migration retry failed', { + ghostId, + error: error instanceof Error ? error.message : String(error), + }); + schedulePendingResidentMigrationRetry(ghostId); + }); + }, Math.min(1000 * 2 ** Math.min(attempts, 6), 60_000)); + timer.unref(); + pendingResidentMigrationRetryTimers.set(ghostId, timer); +} + +function isNamespaceMigrationBusy(ghostId: string): boolean { + if (isAppSessionBoundaryPending()) return true; + try { + if (fs.existsSync(ghostOauthMutationLockPath(ghostId))) return true; + } catch { + return true; + } + if (hasPendingGhostCalls(ghostId) || hasRunningGhostErrand(ghostId) || + hasRunningGhostCindyWork(ghostId)) return true; + const ghost = getGhostManager() + .list() + .find((candidate) => installedGhostPhysicalRelId(candidate) === ghostId); + if (!ghost) return false; + const runtimeId = installedGhostStoragePart(ghost); + const state = runtimeSingleton?.stateOf(runtimeId); + return state === 'starting' || state === 'running' || state === 'stopping' || + nodeRuntimeBrokerSingleton?.stateOf(runtimeId) === 'running'; +} + +function canResumePendingResidentOffline(ghostId: string): boolean { + if (isAppSessionBoundaryPending()) return false; + const record = readNamespaceMigrationMarketRecord(() => + getPluginMarketLedger().installationsForGhost(ghostId)); + return record === null && readNamespaceMigrationInstallOrigin(() => + getGhostManager().readApprovedInstallOriginStrict(ghostId)) === 'manual'; +} + +async function preparePendingResidentForMigration(ghostId: string): Promise { + if (isAppSessionBoundaryPending()) return false; + if (!offlineResidentIdsForActiveScope().has(ghostId)) return true; + const ownerScopeKey = activeOwnerScopeKey(); + if (hasPendingGhostCalls(ghostId) || hasRunningGhostErrand(ghostId) || + hasRunningGhostCindyWork(ghostId)) return false; + try { + if (fs.existsSync(ghostOauthMutationLockPath(ghostId))) return false; + } catch { + return false; + } + const ghost = getGhostManager().list().find((candidate) => + candidate.namespaceMigration === 'pending' && + installedGhostPhysicalRelId(candidate) === ghostId); + if (!ghost) return false; + const runtimeId = installedGhostStoragePart(ghost); + getGhostRuntime().stop(runtimeId); + await getGhostNodeRuntimeBroker().stopAndWait(runtimeId); + if (activeOwnerScopeKey() !== ownerScopeKey || isAppSessionBoundaryPending()) { + throw new Error('ghost owner changed while stopping an offline resident'); + } + return true; +} + +function classifyPendingNamespaceForGhost( + ghostId: string, + marketSyncCompleted = false, +) { + const builtin = + getGhostManager() + .list() + .some((ghost) => ghost.manifest.id === ghostId && ghost.builtin === true); + const marketRecord = readNamespaceMigrationMarketRecord(() => + getPluginMarketLedger().installationsForGhost(ghostId), + ); + const state = getAuthState(); + const user = state.isAuthenticated ? state.user : null; + let currentOrganization: { + organizationId: string; + orgSlug: string | null; + pluginPrefix: string | null; + } | null = null; + if (user?.membershipKind === 'org' && user.orgId) { + const prefix = createOrganizationPrefixStore( + ownerScopedUserDataPath('plugin-market', 'organization.v1.json'), + ).lookup(user.orgId); + currentOrganization = { + organizationId: user.orgId, + orgSlug: user.orgSlug ?? null, + pluginPrefix: prefix.kind === 'known' ? prefix.pluginPrefix : null, + }; + } + const installOrigin = readNamespaceMigrationInstallOrigin(() => + getGhostManager().readApprovedInstallOriginStrict(ghostId), + ); + return classifyNamespaceMigration({ + ghostId, + builtin, + installOrigin, + marketSyncCompleted, + marketRecord, + currentOrganization, + }); +} + +function recoverUnstampedOrganizationNamespace(ghostId: string): string | null { + try { + const state = getAuthState(); + const user = state.isAuthenticated ? state.user : null; + const snapshot = readInstalledGhostManifestSnapshot( + path.join(brainRootDir(), ghostId), GHOST_INSTALL_MANIFEST_MAX_BYTES); + return verifiedUnstampedOrganizationNamespace({ + records: getPluginMarketLedger().installationsForGhost(ghostId), + organizationId: user?.membershipKind === 'org' ? user.orgId ?? null : null, + orgSlug: user?.membershipKind === 'org' ? user.orgSlug ?? null : null, + evidence: getGhostManager().approvedInstallEvidence(ghostId), + identity: snapshot.ok ? installedMarketManifestIdentity(snapshot.snapshot) : null, + }); + } catch { + return null; + } +} + /** Read Manifest and byte identity together from one installed ghost.json snapshot. */ function readInstalledGhostManifestIdentity( ghostId: string, ): InstalledMarketManifestIdentity | null { - const ghost = getGhostManager() - .list() - .find((candidate) => candidate.manifest.id === ghostId); + const ghost = findGhostForInstanceId(ghostId); if (!ghost) return null; const result = readInstalledGhostManifestSnapshot(ghost.dir, GHOST_INSTALL_MANIFEST_MAX_BYTES); return result.ok ? installedMarketManifestIdentity(result.snapshot) : null; @@ -2731,10 +3118,12 @@ function getConnectionAudienceResolver(): ConnectionAudienceResolver { connectionAudienceResolverSingleton = loadConnectionAudienceResolver({ readInstalledManifestIdentity: readInstalledGhostManifestIdentity, readMarketInstallation: (ghostId) => - getPluginMarketLedger().lookupInstallationForOidc(ghostId), + getPluginMarketLedger().lookupInstallationForOidc(pluginMarketAuthorizationTargetFor(ghostId)), readApprovedPackageSha256: (ghostId) => getGhostManager().approvedInstallEvidence(ghostId)?.packageSha256 ?? null, readInstallOrigin: (ghostId) => getGhostManager().readEffectiveInstallOrigin(ghostId), + readInstallNamespace: (ghostId) => getGhostManager().readDeliveryNamespace(ghostId), + isPendingLegacyForge: (ghostId) => getGhostManager().isPendingLegacyForge(ghostId), lookupOrganizationPrefix: (orgId) => createOrganizationPrefixStore( ownerScopedUserDataPath('plugin-market', 'organization.v1.json'), @@ -2760,11 +3149,9 @@ function resolveConnectionAudienceForGhost(ghostId: string): ConnectionAudienceR function getGhostFirstPartyFactsLoader(): GhostFirstPartyFactsLoader { if (!ghostFirstPartyFactsLoaderSingleton) { ghostFirstPartyFactsLoaderSingleton = loadGhostFirstPartyFactsLoader({ - readInstalledBuiltin: (ghostId) => - getGhostManager() - .list() - .find((candidate) => candidate.manifest.id === ghostId)?.builtin === true, - readMarketInstallation: (ghostId) => getPluginMarketLedger().installationForGhost(ghostId), + readInstalledBuiltin: (ghostId) => findGhostForInstanceId(ghostId)?.builtin === true, + readMarketInstallation: (ghostId) => + getPluginMarketLedger().installationForAuthorization(pluginMarketAuthorizationTargetFor(ghostId)), readApprovedPackageSha256: (ghostId) => getGhostManager().approvedInstallEvidence(ghostId)?.packageSha256 ?? null, lookupOrganizationPrefix: (orgId) => @@ -2772,6 +3159,21 @@ function getGhostFirstPartyFactsLoader(): GhostFirstPartyFactsLoader { ownerScopedUserDataPath('plugin-market', 'organization.v1.json'), ).lookup(orgId), readInstallOrigin: (ghostId) => getGhostManager().readEffectiveInstallOrigin(ghostId), + readInstallNamespace: (ghostId) => getGhostManager().readDeliveryNamespace(ghostId), + isPendingLegacyForge: (ghostId) => getGhostManager().isPendingLegacyForge(ghostId), + isPendingLegacyNamespace: (ghostId) => getGhostManager().isPendingLegacyNamespace(ghostId), + readLegacyFirstPartyEligible: (ghostId) => getGhostManager().readLegacyFirstPartyEligible(ghostId), + readTrustedSource: (ghostId) => { + const ghost = findGhostForInstanceId(ghostId); + const evidence = getGhostManager().approvedInstallEvidence(ghostId); + if (!ghost?.builtin || ghost.trust?.level !== 'cindy-official' || !evidence?.packageSha256) return null; + return { + kind: 'builtin-official', + ghostId: ghost.manifest.id, + namespace: ghost.namespace ?? null, + packageSha256: evidence.packageSha256, + }; + }, }); } return ghostFirstPartyFactsLoaderSingleton; @@ -2801,24 +3203,41 @@ export function loadGhostFirstPartyFactsForGhost( { membershipKind: user?.membershipKind ?? 'personal', orgId: user?.orgId ?? null, + orgSlug: user?.orgSlug ?? null, }, overrides, ); } +setMivoSecretAliasVerifier((ghostId) => { + const loaded = loadGhostFirstPartyFactsForGhost(ghostId, 'runtime'); + if (loaded.kind !== 'ready') return false; + const pendingLegacy = loaded.facts.namespace === null && + getGhostManager().isPendingLegacyNamespace(ghostId); + return isTrustedMivoSecretAlias(loaded.facts, pendingLegacy); +}); + function isGhostTokenBrokerAuthorized( ghostId: string, purpose: GhostFirstPartyFactsPurpose, overrides?: GhostFirstPartyFactsOverrides, ): boolean { - // Official prefix keeps today's grant without consulting facts. - if (isBrokerEligibleGhostId(ghostId)) return true; return authorizeGhostTokenBroker( ghostId, loadGhostFirstPartyFactsForGhost(ghostId, purpose, overrides), ); } +function isGhostHostPrimitiveAuthorized( + ghostId: string, + purpose: GhostFirstPartyFactsPurpose = 'runtime', +): boolean { + return authorizeGhostHostPrimitive( + ghostId, + loadGhostFirstPartyFactsForGhost(ghostId, purpose), + ); +} + /** Main-memory-only Connection token issuer/cache. */ export function getConnectionTokenProvider(): ConnectionTokenProvider { if (!connectionTokenProviderSingleton) { @@ -2861,7 +3280,7 @@ export function broadcastGhostHostNotice( tone?: 'info' | 'success' | 'warning' | 'error'; }, ): void { - const ghost = findAvailableGhost(ghostId); + const ghost = findGhostForInstanceId(ghostId); if (!ghost) return; // 卸载竞态:意识已不在,提示无从署名,静默丢 const now = Date.now(); const last = hostNoticeLastAt.get(ghostId); @@ -2898,7 +3317,7 @@ export function broadcastGhostHostNotice( export function getGhostNotifySlot(): GhostNotifySlot { if (!notifySlotSingleton) { notifySlotSingleton = new GhostNotifySlot({ - getGhost: findAvailableGhost, + getGhost: findGhostForInstanceId, broadcast: (payload) => { broadcastGhostWindowPush(GHOST_NOTIFY_CHANNEL, payload); }, @@ -2969,9 +3388,26 @@ function visibleGhostUnread(): GhostUnreadEntry[] { // locale / 图标 / 信任文件。账本允许 200 条,逐条查就是 O(未读 × 已装) 次 // 磁盘扫描,而本函数服务的是**同步** ghosts:unread(首屏渲染路径), // 足以卡住启动(codex review)。 - const available = new Map(availableGhosts().map((ghost) => [ghost.manifest.id, ghost])); + const ghosts = availableGhosts(); + const availableByInstanceId = new Map( + ghosts.map((ghost) => [installedGhostStoragePart(ghost), ghost]), + ); + const uniqueByGhostId = new Map(); + const ambiguousGhostIds = new Set(); + for (const ghost of ghosts) { + const ghostId = ghost.manifest.id; + if (ambiguousGhostIds.has(ghostId)) continue; + if (uniqueByGhostId.has(ghostId)) { + uniqueByGhostId.delete(ghostId); + ambiguousGhostIds.add(ghostId); + continue; + } + uniqueByGhostId.set(ghostId, ghost); + } return entries.filter((entry) => - isGhostUnreadProjectable(available.get(entry.ghostId) ?? null), + isGhostUnreadProjectable( + availableByInstanceId.get(entry.ghostId) ?? uniqueByGhostId.get(entry.ghostId) ?? null, + ), ); } catch (error) { log.warn('ghost unread 读取失败', { @@ -2989,7 +3425,7 @@ function visibleGhostUnread(): GhostUnreadEntry[] { export function getGhostBadgeSlot(): GhostBadgeSlot { if (!badgeSlotSingleton) { badgeSlotSingleton = new GhostBadgeSlot({ - getGhost: findAvailableGhost, + getGhost: findGhostForInstanceId, mark: (ghostId, summary, at) => { try { // 触到上限被挤掉的条目要补一条熄灭广播,否则 renderer 表里留着账本 @@ -3189,7 +3625,7 @@ export function getGhostConfirmSlot(): GhostConfirmSlot { log, }); confirmSlotSingleton = new GhostConfirmSlot({ - getGhost: findAvailableGhost, + getGhost: findGhostForInstanceId, showConfirm: (params) => bridge.request({ ghostId: params.ghostId, @@ -3216,7 +3652,8 @@ let pickSlotSingleton: GhostPickSlot | null = null; export function getGhostPickSlot(): GhostPickSlot { if (!pickSlotSingleton) { pickSlotSingleton = new GhostPickSlot({ - getGhost: findAvailableGhost, + getGhost: findGhostForInstanceId, + getMutationTarget: ghostInstallMutationTargetFor, showDirectoryDialog: async ({ ghostName, purpose }) => { const win = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]; if (!win || win.isDestroyed()) throw new Error('没有可挂靠的宿主窗口'); @@ -3254,7 +3691,8 @@ let workspaceSlotSingleton: GhostWorkspaceSlot | null = null; export function getGhostWorkspaceSlot(): GhostWorkspaceSlot { if (!workspaceSlotSingleton) { workspaceSlotSingleton = new GhostWorkspaceSlot({ - getGhost: findAvailableGhost, + getGhost: findGhostForInstanceId, + getMutationTarget: ghostInstallMutationTargetFor, showDirectoryDialog: async ({ ghostName, purpose }) => { const win = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]; if (!win || win.isDestroyed()) throw new Error('没有可挂靠的宿主窗口'); @@ -3306,7 +3744,7 @@ export function getGhostWorkspaceSlot(): GhostWorkspaceSlot { } const decision = await bridge.request(sessionId, { ghostId, - ghostName: findAvailableGhost(ghostId)?.manifest.name ?? ghostId, + ghostName: findGhostForInstanceId(ghostId)?.manifest.name ?? ghostId, lane: 'workspace', items: [{ name: path.basename(dirAbs), absPath: dirAbs, size: 0, isDirectory: true }], }); @@ -3351,7 +3789,7 @@ function isLibraryCapableGhost(ghost: InstalledGhost | null | undefined): ghost } async function resolveLibraryCapableRoot(ghostId: string): Promise { - if (!isLibraryCapableGhost(findAvailableGhost(ghostId))) return null; + if (!isLibraryCapableGhost(findGhostForInstanceId(ghostId))) return null; const resolution = await getGhostLibraryBindingStore().resolveLibraryRoot(ghostId); if (resolution.kind === 'custom' && resolution.root === null) return null; return resolution.kind === 'custom' && resolution.root !== null @@ -3388,7 +3826,7 @@ async function syncMivoLibraryExtraDirFromSlot( root: string | null, ): Promise { if (!libraryExtraDirSync) return 'not-granted'; - if (root !== null && !isLibraryCapableGhost(findAvailableGhost(ghostId))) { + if (root !== null && !isLibraryCapableGhost(findGhostForInstanceId(ghostId))) { return 'not-granted'; } if (root === null) { @@ -3416,7 +3854,7 @@ export const GHOST_PREVIEW_OPEN_CHANNEL = 'ghosts:preview-open'; export function getGhostPreviewSlot(): GhostPreviewSlot { if (!previewSlotSingleton) { previewSlotSingleton = new GhostPreviewSlot({ - getGhost: findAvailableGhost, + getGhost: findGhostForInstanceId, focusedSessionId: () => ghostSessionFocusTracker.current(), broadcast: (payload) => { const windows = BrowserWindow.getAllWindows().filter((window) => !window.isDestroyed()); @@ -3457,7 +3895,7 @@ export const GHOST_SCHEDULE_DRAFT_CHANNEL = 'ghosts:schedule-draft'; export function getGhostScheduleSlot(): GhostScheduleSlot { if (!scheduleSlotSingleton) { scheduleSlotSingleton = new GhostScheduleSlot({ - getGhost: findAvailableGhost, + getGhost: findGhostForInstanceId, sendToWindow: (payload) => { // 候选只取**挂了完整主壳**的窗口:独立的插件面板窗 / 右侧栏窗与 MainLayout // 平级,没有这个订阅也去不了自动化页(判据见 isMainShellWindowUrl)。 @@ -3884,7 +4322,7 @@ async function getGhostConfigurableMediaModels( ghostId: string, type: GhostMediaModelType, ): Promise { - const ghost = findAvailableGhost(ghostId); + const ghost = findGhostForInstanceId(ghostId); if (!ghost || !ghost.enabled) { return { ok: false, errorCode: 'NOT_AVAILABLE', message: '插件当前不可用' }; } @@ -3989,7 +4427,7 @@ function getGhostConfiguredMediaModel( } const mediaCapability = capability as GhostMediaCapability; const [type, action] = mediaCapability.split('.') as ['image' | 'video', 'generate' | 'edit']; - const ghost = findAvailableGhost(ghostId); + const ghost = findGhostForInstanceId(ghostId); if (!ghost || !ghost.enabled) { return { ok: false, errorCode: 'NOT_AVAILABLE', message: '插件当前不可用' }; } @@ -4559,7 +4997,7 @@ function isGhostBoundaryPending(): boolean { export function getGhostCindySlot(): GhostCindySlot { if (!cindySlotSingleton) { cindySlotSingleton = new GhostCindySlot({ - getGhost: (id) => findAvailableGhost(id), + getGhost: findGhostForInstanceId, getOwnerScopeKey: () => activeOwnerScopeKey(), isOwnerBoundaryPending: () => isGhostBoundaryPending(), // model 已在 modelSlot 按白名单校验;归属来源(providerId)按白名单条目 @@ -4952,12 +5390,14 @@ export async function reconcileGhostOauthAccountsForActiveOwner(): Promise { + const vaultId = installedGhostStoragePart(ghost); + await withActiveOwnerGhostOauthMutationLock(vaultId, () => { if (activeOwnerScopeKey() !== ownerScope) { throw new Error('Plugin OAuth owner changed during reconciliation'); } const reconciliation = oauthManager.reconcileAccountsForInstalledManifestWithResult( withRuntimeFiloGoogleClient(ghost.manifest), + vaultId, ); if (reconciliation.retryPending) retryPending = true; }); @@ -4980,7 +5420,7 @@ function getGhostOauthAccountManager(): GhostOauthAccountManager { // 意识 OAuth 的 token 端点(Google / Atlassian 等)多在境外。 fetchImpl: (url, init) => outboundFetch(url, init as RequestInit), openExternal: (url) => shell.openExternal(url), - // 静态官方前缀照旧放行;其余资格由装入来源与当前组织事实共同判定。 + // tokenBroker 资格按可信安装事实判定,名称前缀不放行。 // 校验层保持纯函数不感知装入语境,门控在装入闸与连接闸。经独立 oauth-broker // 服务换/刷 token:serverApiFetch 自带登录 JWT 注入与 // TOKEN_EXPIRED 自动刷新。基地址来自运行期端点清单;当前 region 提供该 @@ -5033,8 +5473,11 @@ function getGhostOauthAccountManager(): GhostOauthAccountManager { }); broadcastGhostsChanged(getGhostManager().list(), false, { projectionOnly: true }); }, - isConnectTargetCurrent: (ghostId, secretKey, decl) => { - const ghost = findAvailableGhost(ghostId); + captureConnectTarget: ghostInstallMutationTargetFor, + isConnectTargetCurrent: (ghostId, secretKey, decl, expectedConnectTarget) => { + const ghost = findGhostForInstanceId(ghostId); + if (expectedConnectTarget !== undefined && + (!ghost || installedGhostMutationTargetToken(ghost, activeOwnerScopeKey()) !== expectedConnectTarget)) return false; const currentDecl = ghost ? withRuntimeFiloGoogleClient(ghost.manifest).network?.secrets?.find( (secret) => secret.key === secretKey && secret.source === 'oauth', @@ -5044,11 +5487,18 @@ function getGhostOauthAccountManager(): GhostOauthAccountManager { }, withMutationLock: withActiveOwnerGhostOauthMutationLock, isTokenBrokerAuthorized: (ghostId) => isGhostTokenBrokerAuthorized(ghostId, 'runtime'), + isHostPrimitiveAuthorized: (ghostId) => isGhostHostPrimitiveAuthorized(ghostId, 'runtime'), }); } return ghostOauthManagerSingleton; } +function ghostInstallMutationTargetFor(ghostId: string): string | null { + if (isAppSessionBoundaryPending()) return null; + const ghost = findGhostForInstanceId(ghostId); + return ghost ? installedGhostMutationTargetToken(ghost, activeOwnerScopeKey()) : null; +} + /** * 多连接(network.connections)管理器单例:连接清单与 token 的主机侧真身, * /connections 设置页端点与 networkSlot 出网注入共用同一实例。保险库真身 = @@ -5075,14 +5525,15 @@ let ghostSetupKvStore: GhostKvStore | null = null; /** 默认 OAuth 账号的授权面陈旧建议;只返回首个凭证槽,保持 envelope 有界。 */ function getGhostOauthReauthSuggest( runtimeManifest: GhostManifest, + storeId: string, ): GhostSetupReauthSuggest | undefined { const oauthManager = getGhostOauthAccountManager(); return findGhostOauthReauthSuggest(runtimeManifest, (secretKey, decl) => { const defaultAccount = oauthManager - .listAccounts(runtimeManifest.id, secretKey) + .listAccounts(storeId, secretKey) .find((account) => account.isDefault); if (defaultAccount?.status === 'expired') return []; - return oauthManager.defaultMissingScopes(runtimeManifest.id, secretKey, decl); + return oauthManager.defaultMissingScopes(storeId, secretKey, decl); }); } @@ -5093,10 +5544,11 @@ function getGhostOauthReauthSuggest( * errors block dispatch. */ export function getGhostSetupAssessment(ghostId: string): GhostSetupAssessment { - const ghost = findAvailableGhost(ghostId); + const ghost = findGhostForInstanceId(ghostId); if (!ghost || !ghostSetupKvStore) { throw new Error(`ghost setup unavailable: ${ghostId}`); } + const storeId = installedGhostStoragePart(ghost); const runtimeManifest = withRuntimeFiloGoogleClient(ghost.manifest); const oauthManager = getGhostOauthAccountManager(); const connectionManager = getGhostConnectionManager(); @@ -5104,24 +5556,24 @@ export function getGhostSetupAssessment(ghostId: string): GhostSetupAssessment { const assessment = evaluateGhostSetupAssessment( runtimeManifest, { - secretSaved: (key) => ghostSecretSaved(ghostId, key), + secretSaved: (key) => ghostSecretSaved(storeId, key), oauthStatus: (key) => { const decl = runtimeManifest.network?.secrets?.find((secret) => secret.key === key)?.oauth; - const accounts = oauthManager.listAccounts(ghostId, key); + const accounts = oauthManager.listAccounts(storeId, key); return { - clientConfigured: oauthManager.clientConfigured(ghostId, key, decl), + clientConfigured: oauthManager.clientConfigured(storeId, key, decl), connected: accounts.filter((account) => account.status === 'connected').length, expired: accounts.filter((account) => account.status === 'expired').length, }; }, - connectionCount: (key) => connectionManager.list(ghostId, key).length, + connectionCount: (key) => connectionManager.list(storeId, key).length, kvValue: (key) => { - if (kvSnapshot === null) kvSnapshot = ghostSetupKvStore?.readStrict(ghostId) ?? {}; + if (kvSnapshot === null) kvSnapshot = ghostSetupKvStore?.readStrict(storeId) ?? {}; return kvSnapshot[key]; }, }, { - revision: getGhostSetupChangeBus().currentRevision(ghostId), + revision: getGhostSetupChangeBus().currentRevision(storeId), strict: true, additionalGroups: assessGhostHostSetupRequirements(runtimeManifest, { clientConfigReady: (configId) => configId === 'model-provider' && isModelAccessReady(), @@ -5133,7 +5585,7 @@ export function getGhostSetupAssessment(ghostId: string): GhostSetupAssessment { if (assessment.state !== 'ready') return assessment; return appendReadyGhostOauthReauthSuggest( assessment, - getGhostOauthReauthSuggest(runtimeManifest), + getGhostOauthReauthSuggest(runtimeManifest, storeId), ); } @@ -5150,7 +5602,7 @@ export async function executeGhostSetupAction(args: { assertCurrent?: () => void; beforeCommit?: () => Promise; }): Promise { - const ghost = findAvailableGhost(args.ghostId); + const ghost = findGhostForInstanceId(args.ghostId); if (!ghost) { return { ok: false, @@ -5158,6 +5610,7 @@ export async function executeGhostSetupAction(args: { message: t('newChat.pluginSetup.pluginUnavailable'), }; } + const storeId = installedGhostStoragePart(ghost); if (args.action.kind === 'oauth_connect') { const secretKey = parseOauthConnectSecretKey(args.action.id); if (!secretKey) { @@ -5174,7 +5627,7 @@ export async function executeGhostSetupAction(args: { message: '授权声明已变更,请重新尝试', }; } - if (decl.tokenBroker !== undefined && !isGhostTokenBrokerAuthorized(args.ghostId, 'runtime')) { + if (decl.tokenBroker !== undefined && !isGhostTokenBrokerAuthorized(storeId, 'runtime')) { return { ok: false, errorCode: 'AUTH_FAILED', @@ -5185,7 +5638,7 @@ export async function executeGhostSetupAction(args: { try { remote?.assertCurrent(); const connected = await getGhostOauthAccountManager().connectAccount( - args.ghostId, + storeId, secretKey, decl, { @@ -5214,7 +5667,7 @@ export async function executeGhostSetupAction(args: { } } - const navigation = ghostSetupNavigationForAction(args.ghostId, args.action); + const navigation = ghostSetupNavigationForAction(storeId, args.action); if (!navigation) { return { ok: false, errorCode: 'ACTION_STALE', message: '不支持的插件设置动作' }; } @@ -5248,12 +5701,20 @@ export async function executeGhostSetupInlineAction( { getAssessment: getGhostSetupAssessment, getManifest: (ghostId) => { - const ghost = findAvailableGhost(ghostId); + const ghost = findGhostForInstanceId(ghostId); return ghost ? withRuntimeFiloGoogleClient(ghost.manifest) : null; }, - storeSecret: storeGhostSecret, + storeSecret: (ghostId, secretKey, value) => { + const ghost = findGhostForInstanceId(ghostId); + return storeGhostSecret( + ghost ? installedGhostStoragePart(ghost) : ghostId, + secretKey, + value, + ); + }, emitChange: (ghostId, secretKey) => { - getGhostSetupChangeBus().emit(ghostId, { + const ghost = findGhostForInstanceId(ghostId); + getGhostSetupChangeBus().emit(ghost ? installedGhostStoragePart(ghost) : ghostId, { source: 'secret', ref: secretKey, }); @@ -5271,26 +5732,24 @@ export async function executeGhostSetupInlineAction( } /** Only called under the authenticated remote connection-card context. */ -export function bindGhostSetupConnectionAction(args: { - ghostId: string; - actionId: string; - onCommitted(): void; -}): ((value: import('@cindy/device-link').PluginConnectionInput) => boolean) | null { - const manifest = findAvailableGhost(args.ghostId)?.manifest; - if (!manifest) return null; +export function bindGhostSetupConnectionAction(args: { ghostId: string; actionId: string; onCommitted(): void }): + ((value: import('@cindy/device-link').PluginConnectionInput) => boolean) | null { + const ghost = findGhostForInstanceId(args.ghostId); + const manifest = ghost?.manifest; + if (!manifest || !ghost) return null; const expectedManifest = JSON.stringify(manifest); - return (value) => - executeGhostSetupConnectionSubmission( - { - getAssessment: getGhostSetupAssessment, - getManifest: (ghostId) => findAvailableGhost(ghostId)?.manifest ?? null, - manager: getGhostConnectionManager(), - emitChange: (ghostId, key) => { - getGhostSetupChangeBus().emit(ghostId, { source: 'connection', ref: key }); - }, - }, - { ...args, value, expectedManifest }, - ); + return value => executeGhostSetupConnectionSubmission({ + getAssessment: getGhostSetupAssessment, + getManifest: ghostId => findGhostForInstanceId(ghostId)?.manifest ?? null, + manager: getGhostConnectionManager(), + emitChange: (ghostId, key) => { + const instance = findGhostForInstanceId(ghostId); + getGhostSetupChangeBus().emit( + instance ? installedGhostStoragePart(instance) : ghostId, + { source: 'connection', ref: key }, + ); + }, + }, { ...args, ghostId: installedGhostStoragePart(ghost), value, expectedManifest }); } /** @@ -5301,7 +5760,7 @@ export function getGhostNetworkSlot(): GhostNetworkSlot { if (!networkSlotSingleton) { networkSlotSingleton = new GhostNetworkSlot({ getGhost: (id) => { - const ghost = findAvailableGhost(id); + const ghost = findGhostForInstanceId(id); return ghost ? { ...ghost, manifest: withRuntimeFiloGoogleClient(ghost.manifest) } : null; }, inFlightCallInfo: (callId) => getGhostCardService().inFlightCallInfoOf(callId), @@ -5401,7 +5860,7 @@ export function getGhostNetworkSlot(): GhostNetworkSlot { // 时取第一个)。每单现查现读,设置页增删下一单即生效。 connections: { hostsFor: (ghostId) => { - const decls = findAvailableGhost(ghostId)?.manifest.network?.connections ?? []; + const decls = (findGhostForInstanceId(ghostId))?.manifest.network?.connections ?? []; const mgr = getGhostConnectionManager(); const hosts: string[] = []; for (const decl of decls) { @@ -5412,7 +5871,7 @@ export function getGhostNetworkSlot(): GhostNetworkSlot { return hosts; }, tokenFor: (ghostId, hostname) => { - const decls = findAvailableGhost(ghostId)?.manifest.network?.connections ?? []; + const decls = (findGhostForInstanceId(ghostId))?.manifest.network?.connections ?? []; const mgr = getGhostConnectionManager(); for (const decl of decls) { const token = mgr.resolveTokenByHost(ghostId, decl.key, hostname); @@ -5439,7 +5898,7 @@ let fsSlotSingleton: GhostFsSlot | null = null; export function getGhostFsSlot(): GhostFsSlot { if (!fsSlotSingleton) { fsSlotSingleton = new GhostFsSlot({ - getGhost: findAvailableGhost, + getGhost: findGhostForInstanceId, dataRootDir: () => ownerScopedUserDataPath('ghost-fs'), // callId → 归属/会话反查:与卡片供片同一本账(ghost_call 派单时 // cardService.registerCall 登记),不信意识自报。 @@ -5481,7 +5940,7 @@ export function getGhostLibrarySlot(): GhostLibrarySlot { log, }); librarySlotSingleton = new GhostLibrarySlot({ - getGhost: findAvailableGhost, + getGhost: findGhostForInstanceId, bindingStore, getDefaultRoot: (ghostId) => ownerScopedUserDataPath('libraries', ghostId), getStagingRoot: (ghostId) => ownerScopedUserDataPath('library-staging', ghostId), @@ -5568,6 +6027,9 @@ async function relocateGhostLibraryTo( candidate: string, opts?: { allowInsideManagedRoot?: boolean }, ): Promise<{ ok: boolean; message?: string }> { + const storagePart = libraryStorageKeyFor(id); + if (!storagePart) return { ok: false, message: '非法插件 id' }; + id = storagePart; const releaseMutation = beginGhostMutation(); const slot = getGhostLibrarySlot(); slot.setRelocating(id, true); @@ -5598,6 +6060,11 @@ async function relocateGhostLibraryTo( await refreshMivoLibraryExtraDirGrant(); return set.ok ? { ok: true } : { ok: false, message: set.message }; } + try { + await assertLibraryMetaOwner(fromRoot, id); + } catch { + return { ok: false, message: 'Library 归属无法确认,请先恢复原位置' }; + } const result = await migrateGhostLibrary({ ghostId: id, fromRoot, @@ -5650,15 +6117,30 @@ async function relocateGhostLibraryTo( * 不触发 open 的目录创建),漂移/未声明都给结构化结果,渲染层据此出横幅。 */ export async function getGhostLibraryOverview(ghostId: string): Promise { - const ghost = findAvailableGhost(ghostId); + const ghost = findGhostForInstanceId(ghostId); + const storagePart = libraryStorageKeyFor(ghostId); const supported = ghost?.manifest.library === true; + if (!storagePart) { + return { + supported, + state: 'unavailable', + reason: '非法插件 id', + location: 'default', + customCandidate: null, + usedBytes: 0, + fileCount: 0, + diskFreeBytes: null, + softLimitBytes: DEFAULT_LIBRARY_LIMITS.softLimitBytes, + softLimitExceeded: false, + orphaned: false, + }; + } const store = getGhostLibraryBindingStore(); - const binding = await store.getBinding(ghostId); - const resolution = await store.resolveLibraryRoot(ghostId); - const root = - resolution.kind === 'custom' && resolution.root !== null - ? resolution.root - : ownerScopedUserDataPath('libraries', ghostId); + const binding = await store.getBinding(storagePart); + const resolution = await store.resolveLibraryRoot(storagePart); + const root = resolution.kind === 'custom' && resolution.root !== null + ? resolution.root + : ownerScopedUserDataPath('libraries', storagePart); let usedBytes = 0; let fileCount = 0; let orphaned = false; @@ -5673,7 +6155,9 @@ export async function getGhostLibraryOverview(ghostId: string): Promise { - if (!isValidGhostId(ghostId)) return { ok: false, message: '非法插件 id' }; + return getGhostManager().runExclusiveMutation(() => + deleteGhostLibraryLocked(ghostId, expectedTarget), + ); +} + +async function deleteGhostLibraryLocked( + ghostId: string, + expectedTarget: string | null, +): Promise<{ ok: boolean; message?: string }> { + const storagePart = libraryStorageKeyFor(ghostId); + if (!storagePart) return { ok: false, message: '非法插件 id' }; + const assertCurrent = (): void => { + if (expectedTarget === null || ghostLibraryDeleteTargetFor(ghostId) !== expectedTarget) { + throw new Error('Library 对应的 Plugin 或账号已变化,请重新确认删除'); + } + }; + assertCurrent(); const slot = getGhostLibrarySlot(); - slot.setRelocating(ghostId, true); + slot.setRelocating(storagePart, true); try { - await slot.disposeGhost(ghostId); - const result = await trashGhostLibrary(ghostId, { + await slot.disposeGhost(storagePart); + assertCurrent(); + const result = await trashGhostLibrary(storagePart, { // 默认根与自定义根都经 binding store 的解析口径(漂移时返回 null → 上层 // 引导恢复位置,不误删)。 resolveLibraryRoot: async (id) => { const resolution = await getGhostLibraryBindingStore().resolveLibraryRoot(id); - return resolution.kind === 'custom' - ? resolution.root - : ownerScopedUserDataPath('libraries', id); + assertCurrent(); + const root = resolution.kind === 'custom' ? resolution.root : ownerScopedUserDataPath('libraries', id); + if (!root) return null; + try { + await assertLibraryMetaOwner(root, id); + } catch { + return null; + } + assertCurrent(); + return root; + }, + trashRoot: () => { + assertCurrent(); + return ownerScopedUserDataPath('libraries-trash'); }, - trashRoot: () => ownerScopedUserDataPath('libraries-trash'), removeBinding: async (id) => { + assertCurrent(); await getGhostLibraryBindingStore().removeBinding(id); }, log, @@ -5743,7 +6273,7 @@ export async function deleteGhostLibraryForActiveOwner( if (result.ok) { await refreshMivoLibraryExtraDirGrant().catch((error) => { log.warn('library extraDirs delete sync failed', { - ghostId, + ghostId: storagePart, error: error instanceof Error ? error.message : String(error), }); }); @@ -5751,7 +6281,7 @@ export async function deleteGhostLibraryForActiveOwner( } return { ok: false, message: result.message }; } finally { - slot.setRelocating(ghostId, false); + slot.setRelocating(storagePart, false); } } @@ -5807,14 +6337,13 @@ export function rejectReservedGhostIdForCustomMarket(id: string): void { } /** - * tokenBroker 第一方门控·装入闸。官方前缀命中照今天放行;否则问 first-party - * 判据。不区分 dev/packaged:broker 是服务端资产,dev 也不豁免。 + * tokenBroker 第一方门控·装入闸。按可信安装事实判定,名称前缀不放行。 + * 不区分 dev/packaged:broker 是服务端资产,dev 也不豁免。 */ function rejectUnauthorizedTokenBroker( manifest: GhostManifest, overrides?: GhostFirstPartyFactsOverrides, ): void { - if (isBrokerEligibleGhostId(manifest.id)) return; const brokered = (manifest.network?.secrets ?? []).some( (s) => s.oauth?.tokenBroker !== undefined, ); @@ -5850,6 +6379,8 @@ function throwInstallError(rejection: InstallRejection): never { throwIpcError('GHOST_COMMAND_CONFLICT', rejection.reason); case 'state-changed': throwIpcError('PRECONDITION_FAILED', rejection.reason); + case 'namespace-migration-pending': + throwIpcError('PRECONDITION_FAILED', rejection.reason); default: throwIpcError('INTERNAL', rejection.reason); } @@ -5892,6 +6423,7 @@ export async function installAndDock( * 同一份包)。必填,与 ghostId 同理:新增装入路径无法忘记交出确认结论。 */ consent: { decision: GhostInstallConsentDecision; manifest: GhostManifest }; + namespace?: string | null; trustOverride?: GhostHostTrustOverride; beforePackagePlacement?: () => void; }, @@ -5907,6 +6439,7 @@ async function installAndDockLocked( enable?: boolean; expectedPackageSha256: string; consent: { decision: GhostInstallConsentDecision; manifest: GhostManifest }; + namespace?: string | null; trustOverride?: GhostHostTrustOverride; installOrigin?: 'agent-forge'; beforePackagePlacement?: () => void; @@ -5916,7 +6449,12 @@ async function installAndDockLocked( // 复核,确认后同 id 被别处装上或包内容变化都不能沿用这次确认。 assertGhostInstallConsent( opts.consent.decision, - manager.list().find((ghost) => ghost.manifest.id === opts.ghostId), + findInstalledGhostForDeliveryTarget( + manager.list(), + Object.prototype.hasOwnProperty.call(opts, 'namespace') + ? { ghostId: opts.ghostId, namespace: opts.namespace ?? null } + : { ghostId: opts.ghostId }, + ), opts.consent.manifest, opts.expectedPackageSha256, ); @@ -5927,6 +6465,9 @@ async function installAndDockLocked( expectedPackageSha256: opts.expectedPackageSha256, ...(opts.trustOverride ? { trustOverride: opts.trustOverride } : {}), ...(opts.installOrigin ? { installOrigin: opts.installOrigin } : {}), + ...(Object.prototype.hasOwnProperty.call(opts, 'namespace') + ? { namespace: opts.namespace ?? null } + : {}), ...(opts.beforePackagePlacement ? { beforePackagePlacement: opts.beforePackagePlacement } : {}), }); if ('rejection' in result) throwInstallError(result.rejection); @@ -5944,7 +6485,7 @@ async function installAndDockLocked( // 顺序刻意:manager.install 内已广播 ghosts:changed(renderer 先注册面板), // 这里再 setLayout 触发 layout:changed(pane 出现时面板组件必然已就位,规则 7)。 const store = getLayoutStore(); - const docked = layoutWithGhostPanel(store.getLayout(), result.ghost.manifest); + const docked = layoutWithGhostPanel(store.getLayout(), result.ghost.manifest, installedGhostStoragePart(result.ghost)); if (docked) { const applied = store.setLayout(docked); if ('rejection' in applied) { @@ -5965,6 +6506,62 @@ type InspectedGhostPackage = Exclude< { rejection: InstallRejection } >; +function ghostSourceStateArchiveId(ghost: InstalledGhost): string { + return pluginStoragePart({ + namespace: 'cindy-archive-' + randomUUID(), + ghostId: ghost.manifest.id, + }); +} + +function readLocalGhostUpdateSource( + manager: GhostManager, + previousGhost: InstalledGhost, + inspected: InspectedGhostPackage, + installOrigin?: 'agent-forge', +): GhostLocalUpdateSourceDecision & { + marketRecord: PluginMarketInstallationRecord | null; + authorizationOverrides: GhostFirstPartyFactsOverrides; +} { + const instanceId = installedGhostStoragePart(previousGhost); + const receipt = previousGhost.approval.state === 'approved' + ? manager.readApprovedInstallReceipt(instanceId, previousGhost.approval.revision) + : null; + let marketRecord: PluginMarketInstallationRecord | null; + try { + marketRecord = getPluginMarketLedger().bind( + ownerScopedUserDataPath('plugin-market', 'ledger.v1.json'), + ).installationForPlugin({ + ghostId: previousGhost.manifest.id, + ...deliveryNamespaceFields(previousGhost), + }); + } catch { + throwIpcError('INTERNAL', 'Unable to verify the installed Plugin source'); + } + const nextOrigin = installOrigin ?? 'manual'; + const decision = classifyGhostLocalUpdateSource({ + existingSourceChanged: Boolean(marketRecord?.installed) || + (receipt?.installOrigin === 'agent-forge' ? 'agent-forge' : 'manual') !== nextOrigin, + previousApprovedReceipt: receipt, + inspectedPackage: { + ghostId: inspected.manifest.id, + namespace: previousGhost.namespace ?? null, + packageSha256: inspected.packageSha256, + trust: inspected.trust, + }, + }); + return { + ...decision, + marketRecord, + authorizationOverrides: { + namespace: previousGhost.namespace ?? null, + installOrigin: nextOrigin, + marketRecord: null, + trustedSource: null, + legacyFirstPartyEligible: decision.legacyFirstPartyEligible, + }, + }; +} + /** * 本地包原位更新的共享事务。调用方必须已经持有 owner lease 和对应 ghostId * 的安装锁;Renderer 导入与 Forge 显式安装共用,避免两条路径在运行时、OAuth、 @@ -5976,6 +6573,7 @@ async function updateLocalGhostPackageLocked( inspected: InspectedGhostPackage, expectedPackageSha256: string, expectedInstalledApproval: string, + expectedInstalledInstanceId: string, consent: GhostInstallConsentDecision, installOrigin?: 'agent-forge', isCurrent?: () => boolean, @@ -5990,26 +6588,23 @@ async function updateLocalGhostPackageLocked( const marketLedger = getPluginMarketLedger().bind( ownerScopedUserDataPath('plugin-market', 'ledger.v1.json'), ); - const previousGhost = manager.list().find((g) => g.manifest.id === inspected.manifest.id); + const previousGhost = findInstalledGhostForLocalUpdate( + manager.list(), inspected.manifest.id, expectedInstalledInstanceId, expectedInstalledApproval, + ); + if (!previousGhost) throwIpcError('PRECONDITION_FAILED', '目标插件实例已变化,请刷新后重试'); // 锁内按真实包与现读受体复核锁外求得的确认;熄灯之前拒绝,不打断正在用的旧版本。 assertGhostInstallConsent(consent, previousGhost, inspected.manifest, expectedPackageSha256); - runtime.stop(inspected.manifest.id); + const { marketRecord, sourceChanged, authorizationOverrides } = readLocalGhostUpdateSource( + manager, previousGhost, inspected, installOrigin, + ); + if (sourceChanged) assertGhostRelocationIdle(installedGhostStoragePart(previousGhost)); + rejectUnauthorizedTokenBroker(inspected.canonicalManifest, authorizationOverrides); + runtime.stop(previousGhost ? installedGhostStoragePart(previousGhost) : inspected.manifest.id); // 等待失败表示旧进程仍可能存活;此时不能恢复 resident,否则会产生 // 两份后台进程。仅在确认退出后的更新阶段失败时恢复旧版本。 - await getGhostNodeRuntimeBroker().stopAndWait(inspected.manifest.id); - let marketRecord: PluginMarketInstallationRecord | null; - try { - marketRecord = marketLedger.installationForGhost(inspected.manifest.id); - } catch (error) { - if (previousGhost) spawnIfResident(previousGhost); - log.warn('failed to verify Plugin provenance before local update', { - ghostId: inspected.manifest.id, - error: error instanceof Error ? error.message : String(error), - }); - throwIpcError('INTERNAL', 'Unable to verify the installed Plugin source'); - } + await getGhostNodeRuntimeBroker().stopAndWait(previousGhost ? installedGhostStoragePart(previousGhost) : inspected.manifest.id); // 用户已明确选择了这份本地真实包:同 id 可以原位替换,市场来源不是永久所有权。 - // 替换前先切断旧市场更新路由;落位失败再恢复,不清理按 ghostId 保存的用户状态。 + // 替换前先切断旧市场更新路由;用户状态由安装事务隔离归档,落位失败再恢复。 const detachMarketRecord = Boolean(marketRecord?.installed); const restoreMarketRecord = (): void => { if (!detachMarketRecord || !marketRecord) return; @@ -6025,11 +6620,11 @@ async function updateLocalGhostPackageLocked( }); } }; - if (detachMarketRecord) { + if (detachMarketRecord && marketRecord) { try { // 先持久化切断自动更新路由,再改真实包。普通本地/Forge 换源不是 // 用户显式卸载,不得产生 default-install opt-out。 - marketLedger.markRemoved(inspected.manifest.id, null); + marketLedger.markRemovedRecord(marketRecord, null); } catch (error) { restoreMarketRecord(); if (previousGhost) spawnIfResident(previousGhost); @@ -6040,22 +6635,31 @@ async function updateLocalGhostPackageLocked( throwIpcError('INTERNAL', 'Unable to detach the installed Plugin source'); } } - getGhostAgentSlot().clearGhost(inspected.manifest.id); - getGhostErrandSlot().clearGhost(inspected.manifest.id); + getGhostAgentSlot().clearGhost( + previousGhost ? installedGhostStoragePart(previousGhost) : inspected.manifest.id, + ); + getGhostErrandSlot().clearGhost( + previousGhost ? installedGhostStoragePart(previousGhost) : inspected.manifest.id, + ); let result: Awaited>; let packagePlaced = false; try { - result = await withActiveOwnerGhostOauthMutationLock(inspected.manifest.id, () => - manager.update(cindyFilePath, { + result = await withActiveOwnerGhostOauthMutationLock( + previousGhost ? installedGhostStoragePart(previousGhost) : inspected.manifest.id, + () => + manager.update(cindyFilePath, { expectedPackageSha256, expectedInstalledApproval, - ...(installOrigin ? { installOrigin } : {}), + installOrigin: installOrigin ?? 'manual', + ...(sourceChanged ? { sourceStateArchiveId: ghostSourceStateArchiveId(previousGhost) } : {}), + ...(previousGhost ? deliveryNamespaceFields(previousGhost) : {}), ...(previousGhost ? { beforePackageCommit: () => getGhostOauthAccountManager().prepareAccountsForChangedClients( withRuntimeFiloGoogleClient(previousGhost.manifest), withRuntimeFiloGoogleClient(inspected.canonicalManifest), + installedGhostStoragePart(previousGhost), ), } : {}), @@ -6070,7 +6674,11 @@ async function updateLocalGhostPackageLocked( if (previousGhost) spawnIfResident(previousGhost); throw err; } - const placed = manager.list().find((ghost) => ghost.manifest.id === inspected.manifest.id); + const placed = + previousGhost + ? findInstalledGhostByInstanceId(manager.list(), installedGhostStoragePart(previousGhost)) ?? + manager.list().find((ghost) => ghost.dir === previousGhost.dir) + : manager.list().find((ghost) => ghost.manifest.id === inspected.manifest.id); if (!placed) throw err; log.warn('local ghost post-placement notification failed', { ghostId: inspected.manifest.id, @@ -6086,9 +6694,9 @@ async function updateLocalGhostPackageLocked( } throwInstallError(result.rejection); } - runtime.resetFuse(inspected.manifest.id); + runtime.resetFuse(previousGhost ? installedGhostStoragePart(previousGhost) : inspected.manifest.id); const store = getLayoutStore(); - const docked = layoutWithGhostPanel(store.getLayout(), result.ghost.manifest); + const docked = layoutWithGhostPanel(store.getLayout(), result.ghost.manifest, installedGhostStoragePart(result.ghost)); if (docked) { const applied = store.setLayout(docked); if ('rejection' in applied) { @@ -6134,11 +6742,23 @@ export async function installOrUpdateLocalGhostPackageFromForge( const user = authState.isAuthenticated ? authState.user : null; const membershipKind = user?.membershipKind ?? 'personal'; const installOrigin = forgeInstallOriginForMembership(membershipKind); - rejectUnauthorizedTokenBroker(inspected.manifest, installOrigin ? { installOrigin } : undefined); + const forgeNamespace = membershipKind === 'org' && user?.orgSlug ? user.orgSlug : undefined; + const existingForForge = findInstalledGhostForDeliveryTarget( + manager.list(), + forgeNamespace !== undefined + ? { ghostId: inspected.manifest.id, namespace: forgeNamespace } + : { ghostId: inspected.manifest.id }, + ); + rejectUnauthorizedTokenBroker(inspected.canonicalManifest, existingForForge + ? readLocalGhostUpdateSource(manager, existingForForge, inspected, installOrigin).authorizationOverrides + : { + ...(installOrigin ? { installOrigin } : {}), + namespace: forgeNamespace ?? null, + }); // 首装与扩权更新先在任务里请用户确认;权限没变多的更新不打扰。 const consent = await obtainGhostInstallConsent( { mode: 'prompt', prompt: expected.consentPrompt, initiator: 'agent', origin: 'forge' }, - manager.list().find((ghost) => ghost.manifest.id === inspected.manifest.id), + existingForForge, inspected.manifest, inspected.packageSha256, ); @@ -6171,7 +6791,12 @@ export async function installOrUpdateLocalGhostPackageFromForge( // 确认已在 owner 租约外完成;落位再用 packing 时钉住的 owner 取租约。 const releaseMutation = beginGhostMutation(expected.mutationOwner); try { - const installed = manager.list().find((ghost) => ghost.manifest.id === inspected.manifest.id); + const installed = findInstalledGhostForDeliveryTarget( + manager.list(), + forgeNamespace !== undefined + ? { ghostId: inspected.manifest.id, namespace: forgeNamespace } + : { ghostId: inspected.manifest.id }, + ); if (!installed) { return { ghost: await installAndDockLocked(manager, cindyFilePath, { @@ -6180,9 +6805,10 @@ export async function installOrUpdateLocalGhostPackageFromForge( expectedPackageSha256: expected.packageSha256, consent: { decision: consent, manifest: inspected.manifest }, ...(installOrigin ? { installOrigin } : {}), + ...(forgeNamespace !== undefined ? { namespace: forgeNamespace } : {}), }).then((ghost) => { try { - markGhostRecommendationInstalled(ghost.manifest.id); + markGhostRecommendationInstalled(installedGhostStoragePart(ghost)); } catch { log.warn('ghost recommendation install history unavailable'); } @@ -6198,6 +6824,7 @@ export async function installOrUpdateLocalGhostPackageFromForge( inspected, expected.packageSha256, ghostInstallApprovalToken(installed.approval), + installedGhostStoragePart(installed), consent, installOrigin, expected.isCurrent, @@ -6229,13 +6856,14 @@ export async function installOrUpdateMarketGhostPackage( expected: { ghostId: string; version: string; + namespace?: string | null; + sourceChanged?: boolean; /** receipt 模型并发护栏:更新分支比对 receipt 派生 token(与 main 硬化叠加,决策 A)。 */ expectedInstalledApproval?: string; /** * Organization server-market packages pass this Host-built fact because the - * ledger row is written after install/update. Official-prefix ids never - * consult it (they short-circuit). Not "first install only": the same - * commitDownloadedPackage path covers org server-market install, update, + * ledger row is written after install/update. Not "first install only": the + * same commitDownloadedPackage path covers org server-market install, update, * defaultInstall, and source replacement. */ pendingMarketRecord?: GhostFirstPartyPendingMarketRecord; @@ -6271,6 +6899,8 @@ async function installOrUpdateMarketGhostPackageLocked( expected: { ghostId: string; version: string; + namespace?: string | null; + sourceChanged?: boolean; expectedInstalledApproval?: string; /** Same Host-built org server-market fact as the exported entry; not first-install only. */ pendingMarketRecord?: GhostFirstPartyPendingMarketRecord; @@ -6308,14 +6938,16 @@ async function installOrUpdateMarketGhostPackageLocked( throwIpcError('GHOST_FILE_INVALID', '下载包清单与市场 Release 不一致'); } const trustOverride: GhostHostTrustOverride | undefined = - expected.officialCindyGithub === true && expected.ghostId === 'cindy-github' + expected.officialCindyGithub === true && expected.ghostId === 'cindy-github' && + expected.namespace == null ? 'cindy-official' : undefined; requireGhostAvailableForActiveSession(expected.ghostId); // 自定义 Git/本地市场的活目录可能在发现后、打包前变化;它们传入发现时的 // Manifest 作为 TOCTOU 上限。官方市场由服务端 Release SHA 绑定真实包,目录 // Manifest 只用于展示,不参与这里的安装准入。 - const installed = manager.list().find((ghost) => ghost.manifest.id === expected.ghostId); + const installIdentity = createPluginLogicalIdentity(expected.namespace ?? null, expected.ghostId); + const installed = findInstalledGhostByIdentity(manager.list(), installIdentity); if (expected.manifestCap) { const undeclaredCapabilities = unreviewedGhostPermissionItems( expected.manifestCap, @@ -6342,17 +6974,19 @@ async function installOrUpdateMarketGhostPackageLocked( ); } } - rejectUnauthorizedTokenBroker( - inspected.canonicalManifest, - expected.pendingMarketRecord !== undefined + rejectUnauthorizedTokenBroker(inspected.canonicalManifest, { + namespace: expected.namespace ?? null, + legacyFirstPartyEligible: !expected.sourceChanged && installed != null && + manager.readLegacyFirstPartyEligible(installedGhostStoragePart(installed)), + ...(expected.pendingMarketRecord !== undefined ? { marketRecord: bindPendingMarketRecordToInspectedPackage( expected.pendingMarketRecord, inspected.packageSha256, ), } - : undefined, - ); + : {}), + }); // 用户确认在锁外求得;这里用即将落位的真实包与锁内现读的受体复核,确认后 // 包内容或已装版本变了就拒绝,后台更新遇到需要确认的扩权直接放弃本轮。 @@ -6381,24 +7015,28 @@ async function installOrUpdateMarketGhostPackageLocked( consent: { decision: expected.consent, manifest: inspected.manifest }, beforePackagePlacement: expected.beforeCommitInLock, ...(trustOverride ? { trustOverride } : {}), + ...(Object.prototype.hasOwnProperty.call(expected, 'namespace') + ? { namespace: expected.namespace ?? null } + : {}), }); await expected.afterCommitInLock?.(installedGhost, commitEvidence); return installedGhost; } expected.beforeCommitInLock?.(); + if (expected.sourceChanged) assertGhostRelocationIdle(installedGhostStoragePart(installed)); const runtime = getGhostRuntime(); - runtime.stop(expected.ghostId); + runtime.stop(installedGhostStoragePart(installed)); // 无法确认旧进程已退出时保持停止态,不能启动第二份 resident。只有旧进程 // 已确认退出、后续目录更新失败时,才恢复原版本。 - await getGhostNodeRuntimeBroker().stopAndWait(expected.ghostId); + await getGhostNodeRuntimeBroker().stopAndWait(installedGhostStoragePart(installed)); let result: Awaited>; let packagePlaced = false; try { // 市场更新同样会原位 rename 插件目录。Windows 上不能只发停止信号, // 必须确认旧 utilityProcess 已离开,否则入口文件仍可能被占用而报 EPERM。 - getGhostAgentSlot().clearGhost(expected.ghostId); - getGhostErrandSlot().clearGhost(expected.ghostId); + getGhostAgentSlot().clearGhost(installedGhostStoragePart(installed)); + getGhostErrandSlot().clearGhost(installedGhostStoragePart(installed)); // receipt 模型下更新必须绑定 receipt token(决策 A:与 main 的 sha 钉扎叠加), // 否则并发批准变更绕不过去。 if (!expected.expectedInstalledApproval) { @@ -6409,15 +7047,20 @@ async function installOrUpdateMarketGhostPackageLocked( } // Lock order: owner lease -> install lock -> OAuth security lock. Keep // the strict lock through package swap, receipt commit, and compensation. - result = await withActiveOwnerGhostOauthMutationLock(expected.ghostId, () => + result = await withActiveOwnerGhostOauthMutationLock(installedGhostStoragePart(installed), () => manager.update(cindyFilePath, { expectedPackageSha256: inspected.packageSha256, expectedInstalledApproval: expected.expectedInstalledApproval!, + ...(expected.sourceChanged ? { sourceStateArchiveId: ghostSourceStateArchiveId(installed) } : {}), ...(trustOverride ? { trustOverride } : {}), + ...(Object.prototype.hasOwnProperty.call(expected, 'namespace') + ? { namespace: expected.namespace ?? null } + : {}), beforePackageCommit: () => getGhostOauthAccountManager().prepareAccountsForChangedClients( withRuntimeFiloGoogleClient(installed.manifest), withRuntimeFiloGoogleClient(inspected.canonicalManifest), + installedGhostStoragePart(installed), ), onPackagePlaced: () => { packagePlaced = true; @@ -6430,7 +7073,7 @@ async function installOrUpdateMarketGhostPackageLocked( spawnIfResident(installed); throw error; } - const placed = manager.list().find((ghost) => ghost.manifest.id === expected.ghostId); + const placed = findInstalledGhostByIdentity(manager.list(), installIdentity); if (!placed) throw error; log.warn('market ghost post-placement notification failed', { ghostId: expected.ghostId, @@ -6446,9 +7089,9 @@ async function installOrUpdateMarketGhostPackageLocked( } throwInstallError(result.rejection); } - runtime.resetFuse(expected.ghostId); + runtime.resetFuse(installedGhostStoragePart(result.ghost)); const store = getLayoutStore(); - const docked = layoutWithGhostPanel(store.getLayout(), result.ghost.manifest); + const docked = layoutWithGhostPanel(store.getLayout(), result.ghost.manifest, installedGhostStoragePart(result.ghost)); if (docked) { const applied = store.setLayout(docked); if ('rejection' in applied) { @@ -6467,7 +7110,7 @@ async function installOrUpdateMarketGhostPackageLocked( } type GhostUninstallLedgerCompletion = () => Promise; -type GhostUninstallLedgerPreparer = (ghostId: string) => GhostUninstallLedgerCompletion | null; +type GhostUninstallLedgerPreparer = (target: string | InstalledGhost) => GhostUninstallLedgerCompletion | null; let prepareGhostUninstallLedgerCompletion: GhostUninstallLedgerPreparer | null = null; @@ -6487,11 +7130,20 @@ export async function uninstallGhostAndCleanup( id: string, options?: { skipMarketLedger?: boolean }, ): Promise { - // 按 ghostId 与装入/更新互斥:卸载与同 id 的市场/本地装入不得交错,否则 - // 市场装入的"目标是否已装"判定会被本卸载在其落位前抽走(反之亦然)。 - return withGhostInstallLock(id, () => - withActiveOwnerGhostOauthMutationLock(id, () => uninstallGhostAndCleanupLocked(id, options)), - ); + // Install lock is the catalog ghostId so public and org instances of the + // same name stay serial. OAuth/vault lock is the storage part, never a + // slashy `_ns/acme/helper.lock` path. + const identity = parsePluginInstanceId(id); + if (!identity) { + throwIpcError('INVALID_PARAMS', 'id must be a valid Ghost id'); + } + return withGhostInstallLock(identity.ghostId, () => { + const ghost = findGhostForInstanceId(id); + if (!ghost) throwIpcError('NOT_FOUND', '目标插件实例已不存在'); + return withActiveOwnerGhostOauthMutationLock(installedGhostStoragePart(ghost), () => + uninstallGhostAndCleanupLocked(id, options), + ); + }); } async function uninstallGhostAndCleanupLocked( @@ -6501,43 +7153,48 @@ async function uninstallGhostAndCleanupLocked( const releaseMutation = beginGhostMutation(); try { requireGhostAvailableForActiveSession(id); + const ghost = findGhostForInstanceId(id); + if (!ghost) throwIpcError('NOT_FOUND', '目标插件实例已不存在'); + // In-place migrated org plugins keep the original directory. Physical keys + // must follow that directory, not the namespaced logical identity. + const { relId, storagePart } = installedGhostPhysicalKeys(ghost); + getBotAuthorizationService()?.invalidatePlugin(storagePart); const completeLedger = options?.skipMarketLedger === true ? null - : (prepareGhostUninstallLedgerCompletion?.(id) ?? null); + : (prepareGhostUninstallLedgerCompletion?.(ghost) ?? null); const manager = getGhostManager(); const runtime = getGhostRuntime(); // Library 的 orphaned 标记要在 uninstall 之前取显示名(收走后 list 里就没了)。 - const libraryDisplayName = - manager.list().find((g) => g.manifest.id === id)?.manifest.name ?? id; - runtime.stop(id); - await getGhostNodeRuntimeBroker().stopAndWait(id); - getGhostAgentSlot().clearGhost(id); - getGhostErrandSlot().clearGhost(id); - getGhostSubscriptionGateway().dropGhost(id); - const downloadRoot = ownerScopedUserDataPath('plugin-downloads', id); + const libraryDisplayName = ghost.manifest.name; + runtime.stop(storagePart); + await getGhostNodeRuntimeBroker().stopAndWait(storagePart); + getGhostAgentSlot().clearGhost(storagePart); + getGhostErrandSlot().clearGhost(storagePart); + getGhostSubscriptionGateway().dropGhost(storagePart); + const downloadRoot = ownerScopedUserDataPath('plugin-downloads', storagePart); const downloadScope = activeOwnerScopeKey(); - const result = await manager.uninstall(id, { notify: false }); + const result = await manager.uninstall(relId, { notify: false }); if ('rejection' in result) throwUninstallError(result.rejection); - await pluginDownloads.removePlugin(id, downloadRoot, downloadScope).catch(err => log.warn('plugin download cache cleanup failed', { id, error: String(err) })); - removeGhostSecrets(id); + await pluginDownloads.removePlugin(storagePart, downloadRoot, downloadScope).catch(err => log.warn('plugin download cache cleanup failed', { id: storagePart, error: String(err) })); + removeGhostSecrets(storagePart); removeGhostKvBestEffort( createGhostKvStore({ getRootDir: () => ownerScopedUserDataPath('ghost-kv'), log, }), - id, + storagePart, log, ); - if (isValidGhostId(id)) { + if (storagePart) { try { - await fs.promises.rm(ownerScopedUserDataPath('ghost-fs', id), { + await fs.promises.rm(ownerScopedUserDataPath('ghost-fs', storagePart), { recursive: true, force: true, }); } catch (err) { log.warn('ghost-fs 私有目录回收失败', { - id, + id: storagePart, error: err instanceof Error ? err.message : String(err), }); } @@ -6548,11 +7205,11 @@ async function uninstallGhostAndCleanupLocked( // 存储位置不因重装消失(对齐 pick-grants 先例)。best-effort:失败只 // warn,不把卸载报成失败(与上面清账同纪律)。 try { - await getGhostLibrarySlot().disposeGhost(id); + await getGhostLibrarySlot().disposeGhost(storagePart); await refreshMivoLibraryExtraDirGrant(); const vault = new LibraryVault({ - rootDir: () => ownerScopedUserDataPath('libraries', id), - ghostId: id, + rootDir: () => ownerScopedUserDataPath('libraries', storagePart), + ghostId: storagePart, log, }); await vault.open(); @@ -6573,7 +7230,7 @@ async function uninstallGhostAndCleanupLocked( // 卸载是用户明确动作,失败只记日志:包已经收走了,不能因为清账失败把 // 卸载报成失败(与上面 ghost-fs / kv 清理同纪律)。 try { - const removed = await ledger.removeRefs({ refKind: 'ghost-deposit', refId: id }); + const removed = await ledger.removeRefs({ refKind: 'ghost-deposit', refId: storagePart }); if (removed > 0) log.info('ghost deposit media refs removed', { id, removed }); } catch (err) { log.warn('ghost deposit media refs 清理失败', { @@ -6583,7 +7240,7 @@ async function uninstallGhostAndCleanupLocked( } let recentIds: string[] | null = null; try { - recentIds = forgetGhostRecentUsage(id); + recentIds = forgetGhostRecentUsage(storagePart); } catch (error) { log.warn('ghost recent usage 清理失败', { id, @@ -6591,14 +7248,14 @@ async function uninstallGhostAndCleanupLocked( }); } try { - forgetGhostRecommendations(id); + forgetGhostRecommendations(storagePart); } catch { log.warn('ghost recommendation cleanup unavailable', { id }); } // 未读随意识一起走:包都没了还留一颗点,用户既点不开也清不掉。 // 限速记账一并抹掉,重装后的第一条不该被上一世的时刻挡住。 - extinguishGhostUnread(id); - badgeSlotSingleton?.forget(id); + extinguishGhostUnread(storagePart); + badgeSlotSingleton?.forget(storagePart); // 卸载刚落地,manager.list() 就是当下的全部事实(哪怕是空表)——标权威, // 好让「卸掉最后一个插件」也能把账本里的孤儿记录一并清掉。 broadcastGhostsChanged(manager.list(), true); @@ -6628,9 +7285,10 @@ export function isBuiltinGhostRemovedByUser(id: string): boolean { * "该在场了"时机的统一入口(应用启动扫描 / 装入即开 / 唤醒 / 更新换代后)。 * spawn 幂等,重复调用零成本;失败走熔断记账,不抛出(fire-and-forget)。 */ -function spawnIfResident(ghost: InstalledGhost): void { +function spawnIfResident(ghost: InstalledGhost, allowPendingLegacy = false): void { spawnResidentGhost(ghost, { isAvailable: isGhostAvailableForActiveSession, + allowPendingLegacy, startNode: (installed) => getGhostNodeRuntimeBroker().startResident(installed), spawnBrowser: (installed) => getGhostRuntime().spawn(installed), warn: (message, fields) => log.warn(message, fields), @@ -6661,7 +7319,7 @@ function readLegacyEncryptedSecret(file: string): LegacyMigrationRead { export function registerGhostIpc(): void { registerGhostCardRemoteProvider((id) => { - const ghost = availableGhosts().find((item) => item.manifest.id === id); + const ghost = findGhostForRemotePluginIdentity(availableGhosts(), id); return ghost ? { name: ghost.manifest.name, iconDataUrl: ghost.iconDataUrl } : undefined; }); if (ipcRegistered) return; @@ -6683,21 +7341,21 @@ export function registerGhostIpc(): void { setGhostSandboxDevToolsDisabled(app.isPackaged); setGhostAppContextProvider(currentGhostAppContext); setGhostMediaModelsProvider(getGhostConfigurableMediaModels); - setGhostAgentModelsProvider(async (ghostId) => { + setGhostAgentModelsProvider(async (instanceId) => { const owner = activeOwnerScopeKey(); - if (!findAvailableGhost(ghostId)?.enabled) { + if (!findGhostForInstanceId(instanceId)?.enabled) { return { ok: false, errorCode: 'NOT_AVAILABLE', message: 'Plugin unavailable' }; } const views = await getDesktopProviderService({ allowSideEffects: false }).listProviders({ allowSideEffects: false, snapshotOnly: true }); - if (owner !== activeOwnerScopeKey() || !findAvailableGhost(ghostId)?.enabled) { + if (owner !== activeOwnerScopeKey() || !findGhostForInstanceId(instanceId)?.enabled) { return { ok: false, errorCode: 'NOT_AVAILABLE', message: 'Plugin unavailable' }; } await waitForModelVisibilityMirror(); - if (owner !== activeOwnerScopeKey() || !findAvailableGhost(ghostId)?.enabled) return { ok: false, errorCode: 'NOT_AVAILABLE', message: 'Plugin unavailable' }; + if (owner !== activeOwnerScopeKey() || !findGhostForInstanceId(instanceId)?.enabled) return { ok: false, errorCode: 'NOT_AVAILABLE', message: 'Plugin unavailable' }; // Do not initialize runtimes as a side effect of a plugin's read-only GET. const { getMakerIfReady } = await import('../maker-host/index.js'); const maker = getMakerIfReady(); - if (owner !== activeOwnerScopeKey() || !findAvailableGhost(ghostId)?.enabled || !maker) { + if (owner !== activeOwnerScopeKey() || !findGhostForInstanceId(instanceId)?.enabled || !maker) { return { ok: false, errorCode: 'NOT_AVAILABLE', message: 'Model runtimes unavailable' }; } return projectGhostAgentModels(views, maker.listAvailableAgents(), getModelVisibilityOverride); @@ -6706,11 +7364,12 @@ export function registerGhostIpc(): void { // 自己的协议通道进来。只对"已装且唤醒"的意识放行;熔断态不清账(重载 / // 重新唤醒才 resetFuse),spawn 幂等所以重复唤醒零成本。 setGhostWakeHandler(async (ghostId) => { - const ghost = findAvailableGhost(ghostId); + const ghost = findGhostForInstanceId(ghostId); if (!ghost || !ghost.enabled) return { state: 'off' }; - if (runtime.stateOf(ghostId) === 'fused') return { state: 'fused' }; + const runtimeId = installedGhostStoragePart(ghost); + if (runtime.stateOf(runtimeId) === 'fused') return { state: 'fused' }; const spawned = await runtime.spawn(ghost); - return { state: spawned.ok ? spawned.state : runtime.stateOf(ghostId) }; + return { state: spawned.ok ? spawned.state : runtime.stateOf(runtimeId) }; }); // 意识自定义参数 KV(/kv 协议端点的存储接线,FORGE_GUIDE §4.8): // 真身单意识单文件落 userData/ghost-kv/;注入 adapter 的是带"在装态守卫" @@ -6721,8 +7380,11 @@ export function registerGhostIpc(): void { log, }); ghostSetupKvStore = ghostKv; - const ghostInstalled = (ghostId: string): boolean => findAvailableGhost(ghostId) !== null; + const ghostInstalled = (ghostId: string): boolean => findGhostForInstanceId(ghostId) !== null; setGhostKvStore({ + captureTarget: ghostInstallMutationTargetFor, + isTargetCurrent: (ghostId, expectedTarget) => expectedTarget != null && + ghostInstallMutationTargetFor(ghostId) === expectedTarget, read: (ghostId) => (ghostInstalled(ghostId) ? ghostKv.read(ghostId) : {}), write: (ghostId, value) => { if (!ghostInstalled(ghostId)) return; // 幽灵写静默丢弃,不留文件 @@ -6736,8 +7398,8 @@ export function registerGhostIpc(): void { // 旧快照);login-email 派生凭证没有收单动作,不在键集内。保险库真身 = // providerSecretStore(safeStorage 键名与官方别名同一套)。卸下后的残留 // 请求查无此意识,统一 404。 - setGhostSecretsHandler(async ({ ghostId, method, pathname, readBodyText }) => { - const ghost = findAvailableGhost(ghostId); + setGhostSecretsHandler(async ({ ghostId, method, pathname, readBodyText, isCurrent }) => { + const ghost = findGhostForInstanceId(ghostId); if (!ghost) return { status: 404 }; const networkSecretDecls = ghost.manifest.network?.secrets ?? []; const nodeSecretDecls = (ghost.manifest.node?.secretBindings ?? []).filter( @@ -6783,6 +7445,7 @@ export function registerGhostIpc(): void { method, pathname, readBodyText, + isCurrent, userSecretKeys, identitySecretKeys, managedSecretStates, @@ -6822,8 +7485,8 @@ export function registerGhostIpc(): void { // /oauth 通道(source:'oauth' 凭证的设置页动作面,FORGE_GUIDE §4.7): // client 凭证只写入库、连接/断开/默认账号由主机代办。同 /secrets 模式 // 现查在装清单(意识更新立即以新声明为准);卸下后残留请求统一 404。 - setGhostOauthHandler(async ({ ghostId, method, pathname, readBodyText }) => { - const ghost = findAvailableGhost(ghostId); + setGhostOauthHandler(async ({ ghostId, method, pathname, readBodyText, isCurrent }) => { + const ghost = findGhostForInstanceId(ghostId); if (!ghost) return { status: 404 }; const runtimeManifest = withRuntimeFiloGoogleClient(ghost.manifest); const oauthSecrets = new Map(); @@ -6834,6 +7497,7 @@ export function registerGhostIpc(): void { method, pathname, readBodyText, + isCurrent, oauthSecrets, networkHosts: runtimeManifest.network?.hosts, manager: getGhostOauthAccountManager(), @@ -6853,8 +7517,8 @@ export function registerGhostIpc(): void { // 关键闸:**新增地址必须过 main 侧受信确认弹窗**——意识设置页是意识自绘 // 的不可信界面,动态白名单扩张必须由主机模态拿到用户点头(规则 9:用代码 // 保证,不靠意识自觉)。 - setGhostConnectionsHandler(async ({ ghostId, method, pathname, readBodyText }) => { - const ghost = findAvailableGhost(ghostId); + setGhostConnectionsHandler(async ({ ghostId, method, pathname, readBodyText, isCurrent }) => { + const ghost = findGhostForInstanceId(ghostId); if (!ghost) return { status: 404 }; const connectionDecls = ghost.manifest.network?.connections ?? []; const decls = new Map(); @@ -6868,13 +7532,14 @@ export function registerGhostIpc(): void { method, pathname, readBodyText, + isCurrent, decls, manager: getGhostConnectionManager(), ghostId, // 受信确认:main 侧系统模态(对照 bootstrap 的 moveToApplications 弹窗 // 用法),默认落在「取消」上防误触;意识名从在装清单现查。 confirmAddHost: async (declLabel, host) => { - const ghostName = findAvailableGhost(ghostId)?.manifest.name ?? ghostId; + const ghostName = (findGhostForInstanceId(ghostId))?.manifest.name ?? ghostId; // main 迷你 i18n 只内置 {{appName}} 插值,其余变量按其约定在调用点 // 自行 replace(对照 bootstrap 菜单的用法)。 const { response } = await dialog.showMessageBox({ @@ -6923,6 +7588,11 @@ export function registerGhostIpc(): void { // 保持为 retry-pending,避免被 coordinator 误记为完成。 const activateGhostsAndMigrateLegacyAccounts = (): 'completed' | 'retry-pending' => { for (const ghost of manager.list()) spawnIfResident(ghost); + void manager.reconcilePendingRootNamespaces(false).catch((error) => { + log.warn('namespace migration reconcile failed', { + err: error instanceof Error ? error.message : String(error), + }); + }); let legacyMigrationNeedsRetry = false; const activeOwnerId = getActiveAppSession().dataOwnerId; const canMigrateLegacyAccounts = @@ -6932,6 +7602,7 @@ export function registerGhostIpc(): void { // 老 Google 集成 → Filo Google 意识的一次性搬账(lizi_google 退役配套): // filoCurrent 档案的账号同 client、refresh token 通用,直接迁入意识 // 保险库;意识侧已有账号或老存储不存在时为 no-op(模块内幂等)。 + // 这是存量数据迁移,不是按 filo- 名称授予运行时特权。 if ( canMigrateLegacyAccounts && manager.list().some((g) => g.manifest.id === FILO_GOOGLE_GHOST_ID) @@ -7166,7 +7837,7 @@ export function registerGhostIpc(): void { requireGhostAvailableForActiveSession(id); const type = (payload as { type?: unknown } | null)?.type; if (type === 'recommendations-update') { - const ghost = findAvailableGhost(id); + const ghost = findGhostForInstanceId(id); if (!ghost || !ghost.enabled) return { ok: false, errorCode: 'GHOST_ASLEEP' }; return replaceGhostRecommendations(id, (payload as { items?: unknown }).items); } @@ -7262,25 +7933,16 @@ export function registerGhostIpc(): void { // 资格审/净化/频率钳制/限速在 scheduleSlot,落地在 renderer。 if (type === 'routine-request') { const owner = activeOwnerScopeKey(); - const ghost = getGhostManager() - .list() - .find((item) => item.manifest.id === id); - return handleRoutineRequest( - ghost, - payload, - getRoutineEngine, - () => - activeOwnerScopeKey() === owner && - getGhostManager() - .list() - .some( - (item) => - item.manifest.id === id && - item.enabled && - ghostInstallApprovalToken(item.approval) === - ghostInstallApprovalToken(ghost?.approval), - ), - ); + const ghost = findGhostForInstanceId(id); + return handleRoutineRequest(ghost ?? undefined, payload, getRoutineEngine, () => { + if (activeOwnerScopeKey() !== owner || !ghost) return false; + const current = findGhostForInstanceId(id); + return Boolean( + current?.enabled && + ghostInstallApprovalToken(current.approval) === + ghostInstallApprovalToken(ghost.approval), + ); + }); } if (type === 'schedule-request') { return getGhostScheduleSlot().handleRequest(id, payload); @@ -7486,10 +8148,10 @@ export function registerGhostIpc(): void { }); ipcMain.handle('ghosts:mark-used', (event, id: unknown) => { assertTrustedAppRendererEvent(event); - if (typeof id !== 'string' || !isValidGhostId(id)) { + if (typeof id !== 'string' || !isGhostInstanceId(id)) { throwIpcError('INVALID_PARAMS', 'id must be a valid Ghost id'); } - if (!findAvailableGhost(id)) { + if (!findGhostForInstanceId(id)) { throwIpcError('NOT_FOUND', `意识 ${id} 未安装`); } try { @@ -7528,7 +8190,7 @@ export function registerGhostIpc(): void { // 插件 id 把别人的未读清掉(codex review)。 ipcMain.handle('ghosts:clear-unread', (event, id: unknown, seenAt: unknown) => { assertTrustedAppRendererEvent(event); - if (typeof id !== 'string' || !isValidGhostId(id)) { + if (typeof id !== 'string' || !isGhostInstanceId(id)) { throwIpcError('INVALID_PARAMS', 'id must be a valid Ghost id'); } if (seenAt !== undefined && (typeof seenAt !== 'number' || !Number.isFinite(seenAt))) { @@ -7553,11 +8215,12 @@ export function registerGhostIpc(): void { handleGhostSetupStatusRequest({ id, getRuntimeManifest: (ghostId) => { - const ghost = findAvailableGhost(ghostId); + const ghost = findGhostForInstanceId(ghostId); return ghost ? withRuntimeFiloGoogleClient(ghost.manifest) : null; }, probesFor: (runtimeManifest) => { - const ghostId = runtimeManifest.id; + const ghost = findGhostForInstanceId(typeof id === 'string' ? id : runtimeManifest.id); + const ghostId = ghost ? installedGhostStoragePart(ghost) : runtimeManifest.id; const oauthManager = getGhostOauthAccountManager(); const connectionManager = getGhostConnectionManager(); // kv 单意识单文件,同一次判定内最多读一次(多条 kv 需求不重复开盘); @@ -7599,16 +8262,36 @@ export function registerGhostIpc(): void { // 图片附件链路);视频附带指纹仓磁盘路径 + 体积(不复制字节,引渡侧落成与 // 「从系统拖 .mp4 进聊天」同款的 file 类别路径附件)。失败统一 NOT_FOUND // (调用方 toast / 静默即可,无需区分原因)。 - ipcMain.handle('ghosts:resolve-panel-media', async (_event, uri: unknown, purpose: unknown) => { + ipcMain.handle('ghosts:resolve-panel-media', async (_event, uri: unknown, purpose: unknown, instanceId: unknown, sourceToken: unknown) => { if (typeof uri !== 'string') throwIpcError('INVALID_PARAMS', 'uri must be a string'); + if (instanceId !== undefined && (typeof instanceId !== 'string' || !isGhostInstanceId(instanceId))) { + throwIpcError('INVALID_PARAMS', 'instanceId must be a valid Plugin instance'); + } + const handoverTarget = sourceToken === undefined ? null : resolveGhostMediaHandoverTarget(sourceToken, uri); + if (sourceToken !== undefined && (!handoverTarget || purpose === 'menu' || + (instanceId !== undefined && instanceId !== handoverTarget.instanceId))) { + throwIpcError('NOT_FOUND', '媒体拖拽来源已失效,请从当前插件面板重新拖拽'); + } + const requestedInstanceId = handoverTarget?.instanceId ?? instanceId; + const parsed = parseGhostPanelMediaUrl(uri); + const ghost = requestedInstanceId === undefined + ? parsed ? findAvailableGhostForAuthorization(parsed.ghostId) : null + : findGhostForInstanceId(requestedInstanceId as string); + if (!ghost) throwIpcError('NOT_FOUND', '目标插件实例已变化,请刷新后重试'); + const expectedTarget = ghostInstallMutationTargetFor(installedGhostStoragePart(ghost)); + if (expectedTarget === null) throwIpcError('NOT_FOUND', '目标插件实例暂不可用,请稍后重试'); const resolved = await resolveGhostPanelMedia(uri, purpose === 'menu' ? 'menu' : 'attach', { ghostCanRead: (hash, ghostId) => ledger.ghostCanRead(hash, ghostId), getBlobInfo: (hash) => ledger.getBlobInfo(hash), blobUrl: (hash, ext) => blobStore.blobUrl(hash, ext), blobAbsPath: (hash, ext) => blobStore.resolveHashRef(hash, ext).absPath, statSize: (absPath) => fs.promises.stat(absPath).then((s) => s.size), - }); + }, { ghostId: ghost.manifest.id, instanceId: installedGhostStoragePart(ghost) }); if (!resolved) throwIpcError('NOT_FOUND', '不是本意识名下的可用媒体'); + if (ghostInstallMutationTargetFor(installedGhostStoragePart(ghost)) !== expectedTarget || + (sourceToken !== undefined && !resolveGhostMediaHandoverTarget(sourceToken, uri))) { + throwIpcError('NOT_FOUND', '媒体来源已变化,请从当前插件面板重试'); + } return resolved; }); @@ -7659,12 +8342,10 @@ export function registerGhostIpc(): void { ); // 纯展示口径,不走 findAvailableGhost 的"当前会话可用"闸:插件被当前项目 // 停用时卡片的其余部分(overrides/options)照常渲染,声明偏好也不该凭空消失。 - const declaredRaw = - typeof ghostId === 'string' - ? getGhostManager() - .list() - .find((g) => g.manifest.id === ghostId)?.manifest.cindy?.oneshotModel - : undefined; + const declaredRaw = typeof ghostId === 'string' + ? (findInstalledGhostByInstanceId(getGhostManager().list(), ghostId) ?? + getGhostManager().list().find((g) => g.manifest.id === ghostId))?.manifest.cindy?.oneshotModel + : undefined; const declaredResolved = declaredRaw ? resolveOneshotCatalogModel( getActiveCatalog(), @@ -7726,13 +8407,15 @@ export function registerGhostIpc(): void { if (typeof disabled !== 'boolean') { throwIpcError('INVALID_PARAMS', 'disabled must be a boolean'); } - const wasDisabled = isGhostDisabledForWorkdir(ghostId, workdir); - const next = setGhostDisabledForWorkdir(workdir, ghostId, disabled); + const target = findGhostForInstanceId(ghostId); + const storedId = target ? installedGhostStoragePart(target) : ghostId; + const wasDisabled = isGhostDisabledForWorkdir(storedId, workdir); + const next = setGhostDisabledForWorkdir(workdir, storedId, disabled); // A setup card may already be waiting for this plugin in the affected // project. Wake all waiters for the plugin; each one revalidates its own // captured workdir and only the matching scope is rejected. if (wasDisabled !== disabled) { - getGhostSetupChangeBus().emit(ghostId, { source: 'workdir_policy' }); + getGhostSetupChangeBus().emit(storedId, { source: 'workdir_policy' }); } // 生效面变了(新会话花名册 / $ 菜单),借 ghosts:changed 通知所有窗口 // 重拉——载荷仍是完整已装清单,消费方按需再 sendSync 取目录级清单。 @@ -7777,12 +8460,15 @@ export function registerGhostIpc(): void { 'model must be null or an allowed model of the capability category', ); } + const ghost = findGhostForInstanceId(ghostId); + if (!ghost) throwIpcError('NOT_FOUND', '插件实例已变化,请刷新后重试'); + const storedId = installedGhostStoragePart(ghost); const overrides = writeGhostCindyOverride( - ghostId, + storedId, capability as CindyCapabilityKey, model as string | null, ); - getGhostSetupChangeBus().emit(ghostId, { + getGhostSetupChangeBus().emit(storedId, { source: 'host_config', ref: `cindy-pref:${String(capability)}`, }); @@ -7808,7 +8494,11 @@ export function registerGhostIpc(): void { if (config !== null && (typeof config !== 'object' || Array.isArray(config))) { throwIpcError('INVALID_PARAMS', 'config must be an object or null'); } - const saved = writeGhostErrandConfig(ghostId, config as Record | null); + const ghost = findGhostForInstanceId(ghostId); + if (!ghost) throwIpcError('NOT_FOUND', '插件实例已变化,请刷新后重试'); + const saved = writeGhostErrandConfig( + installedGhostStoragePart(ghost), config as Record | null, + ); return { config: saved }; }); @@ -7851,7 +8541,7 @@ export function registerGhostIpc(): void { initiator: 'user', origin: 'local-file', }, - manager.list().find((ghost) => ghost.manifest.id === probe.manifest.id), + findInstalledGhostForDeliveryTarget(manager.list(), { ghostId: probe.manifest.id }), probe.manifest, probe.packageSha256, ); @@ -7868,7 +8558,7 @@ export function registerGhostIpc(): void { consent: { decision: consent, manifest: probe.manifest }, }).then((ghost) => { try { - markGhostRecommendationInstalled(ghost.manifest.id); + markGhostRecommendationInstalled(installedGhostStoragePart(ghost)); } catch { log.warn('ghost recommendation install history unavailable'); } @@ -7894,10 +8584,12 @@ export function registerGhostIpc(): void { | { expectedPackageSha256?: unknown; expectedInstalledApproval?: unknown; + expectedInstalledInstanceId?: unknown; } | undefined; const expectedPackageSha256 = updateOptions?.expectedPackageSha256; const expectedInstalledApproval = updateOptions?.expectedInstalledApproval; + const expectedInstalledInstanceId = updateOptions?.expectedInstalledInstanceId; if ( typeof expectedPackageSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(expectedPackageSha256) @@ -7907,6 +8599,9 @@ export function registerGhostIpc(): void { if (!isGhostInstallApprovalToken(expectedInstalledApproval)) { throwIpcError('INVALID_PARAMS', 'expectedInstalledApproval must come from ghosts:list'); } + if (typeof expectedInstalledInstanceId !== 'string') { + throwIpcError('INVALID_PARAMS', 'expectedInstalledInstanceId must come from ghosts:list'); + } const inspected = await manager.inspect(lizFilePath); if ('rejection' in inspected) throwInstallError(inspected.rejection); if (inspected.packageSha256 !== expectedPackageSha256) { @@ -7914,8 +8609,13 @@ export function registerGhostIpc(): void { } rejectReservedGhostId(inspected.manifest.id); rejectBrokerWithoutDeclaredRedirectPort(inspected.manifest); - rejectUnauthorizedTokenBroker(inspected.manifest); // 新版本权限变多时先请用户确认;权限没变多的更新不打扰。 + const existingForUpdate = findInstalledGhostForLocalUpdate( + manager.list(), inspected.manifest.id, expectedInstalledInstanceId, expectedInstalledApproval, + ); + if (!existingForUpdate) throwIpcError('PRECONDITION_FAILED', '目标插件实例已变化,请刷新后重试'); + const updateSource = readLocalGhostUpdateSource(manager, existingForUpdate, inspected); + rejectUnauthorizedTokenBroker(inspected.canonicalManifest, updateSource.authorizationOverrides); const consent = await obtainGhostInstallConsent( { mode: 'prompt', @@ -7923,7 +8623,7 @@ export function registerGhostIpc(): void { initiator: 'user', origin: 'local-file', }, - manager.list().find((ghost) => ghost.manifest.id === inspected.manifest.id), + existingForUpdate, inspected.manifest, inspected.packageSha256, ); @@ -7941,6 +8641,7 @@ export function registerGhostIpc(): void { inspected, expectedPackageSha256, expectedInstalledApproval, + expectedInstalledInstanceId, consent, ), ), @@ -7972,17 +8673,41 @@ export function registerGhostIpc(): void { }); // 只验不装:读出 .cindy 的真实清单,供兼容性判断与安装摘要使用,零副作用。 - ipcMain.handle('ghosts:inspect', async (event, lizFilePath: unknown) => { + ipcMain.handle('ghosts:inspect', async (event, lizFilePath: unknown, opts: unknown) => { assertTrustedAppRendererEvent(event); + const inspectionOwner = captureGhostMutationOwner(); if (typeof lizFilePath !== 'string' || lizFilePath.trim().length === 0) { throwIpcError('INVALID_PARAMS', 'lizFilePath must be a non-empty string'); } const result = await manager.inspect(lizFilePath); if ('rejection' in result) throwInstallError(result.rejection); + if (!ghostOwnerScope.isStable(inspectionOwner)) { + throwIpcError('PRECONDITION_FAILED', '账号已切换,请重新检查插件'); + } + let target: InstalledGhost | undefined; + if (opts !== undefined) { + const options = opts as { + expectedInstalledInstanceId?: unknown; + expectedInstalledApproval?: unknown; + } | null; + if (!options || typeof options !== 'object' || + typeof options.expectedInstalledInstanceId !== 'string' || + !isGhostInstanceId(options.expectedInstalledInstanceId) || + !isGhostInstallApprovalToken(options.expectedInstalledApproval)) { + throwIpcError('INVALID_PARAMS', 'inspect target must come from ghosts:list'); + } + target = findInstalledGhostForLocalUpdate( + manager.list(), result.manifest.id, + options.expectedInstalledInstanceId, options.expectedInstalledApproval, + ); + if (!target) throwIpcError('PRECONDITION_FAILED', '目标插件实例已变化,请刷新后重试'); + } // 官方前缀在 inspect 就拒,install/update 双保险再拦。 rejectReservedGhostId(result.manifest.id); rejectBrokerWithoutDeclaredRedirectPort(result.manifest); - rejectUnauthorizedTokenBroker(result.manifest); + rejectUnauthorizedTokenBroker(result.canonicalManifest, target + ? readLocalGhostUpdateSource(manager, target, result).authorizationOverrides + : undefined); return { manifest: result.manifest, trust: result.trust, @@ -8013,42 +8738,56 @@ export function registerGhostIpc(): void { // 的坏包。 ipcMain.handle('ghosts:export', async (event, id: unknown) => { assertTrustedAppRendererEvent(event); + const exportOwner = captureGhostMutationOwner(); + const exportedGhost = typeof id === 'string' + ? findInstalledGhostByInstanceId(manager.list(), id) : undefined; + const exportedInstanceId = exportedGhost ? installedGhostStoragePart(exportedGhost) : null; + const exportedApproval = exportedGhost ? ghostInstallApprovalToken(exportedGhost.approval) : null; + let exportedPackageSha256: string | null = null; // 官方保留前缀在本地装入链路被拒,导出产物装不回——renderer 菜单 // 只是隐藏,handler 才是真正的强制边界(评审 P1)。 if (typeof id === 'string') rejectReservedGhostId(id); const win = BrowserWindow.fromWebContents(event.sender); const result = await exportGhostPackage(id, { - listInstalled: () => manager.list(), + listInstalled: () => exportedGhost ? [exportedGhost] : [], showSaveDialog: (opts) => win ? dialog.showSaveDialog(win, opts) : dialog.showSaveDialog(opts), getDownloadsDir: () => app.getPath('downloads'), fileTypeLabel: t('settings.ghosts.detail.exportFileType'), - writeFile: (filePath, data) => fs.promises.writeFile(filePath, data), + writeFile: async (filePath, data) => { + exportedPackageSha256 = createHash('sha256').update(data).digest('hex'); + await fs.promises.writeFile(filePath, data); + }, // 装入校验本尊 + 装入侧不变量:manager.inspect 带真实 trust // registry;指令查重与 tokenBroker 门控只存在于 install/update, // inspect 不覆盖,这里按同一口径补齐(评审 P1)。 inspectPackage: async (filePath) => { const probe = await manager.inspect(filePath); if ('rejection' in probe) return false; - // tokenBroker 门控(同 rejectUnauthorizedTokenBroker):官方前缀照旧; - // 其余问已装的市场来源与当前组织身份。 + if (!ghostOwnerScope.isStable(exportOwner) || !exportedGhost || + exportedInstanceId === null || exportedApproval === null || + probe.packageSha256 !== exportedPackageSha256 || + probe.manifest.id !== exportedGhost.manifest.id || + probe.manifest.version !== exportedGhost.manifest.version) return false; + const current = findInstalledGhostForLocalUpdate( + manager.list(), exportedGhost.manifest.id, exportedInstanceId, exportedApproval, + ); + if (!current || current.dir !== exportedGhost.dir) return false; + // tokenBroker 门控(同 rejectUnauthorizedTokenBroker):按可信安装事实判定, + // 名称前缀不放行。 const brokered = (probe.manifest.network?.secrets ?? []).some( (s) => s.oauth?.tokenBroker !== undefined, ); - if (brokered && !isGhostTokenBrokerAuthorized(probe.manifest.id, 'install')) { + if (brokered && (current.approval.state !== 'approved' || + !isGhostTokenBrokerAuthorized(exportedInstanceId, 'runtime'))) { return false; } // 指令查重(同 install/update):与当前已装撞名即拒,排除自身。 - const commandFold = probe.manifest.command?.toLowerCase(); - if (commandFold === undefined) return true; - return !manager - .list() - .some( - (g) => - g.manifest.id !== probe.manifest.id && - g.manifest.command !== undefined && - g.manifest.command.toLowerCase() === commandFold, - ); + if (probe.manifest.command === undefined) return true; + return !findConflictingGhostCommand(manager.list(), probe.manifest.command, { + incomingNamespace: current && hasDeliveryNamespace(current) ? current.namespace : null, + exemptPhysicalRelId: current ? installedGhostPhysicalRelId(current) : undefined, + }); }, }); switch (result.status) { @@ -8133,29 +8872,31 @@ export function registerGhostIpc(): void { if (typeof enabled !== 'boolean') { throwIpcError('INVALID_PARAMS', 'enabled must be a boolean'); } + const target = findGhostForInstanceId(id); + if (!target) throwIpcError('NOT_FOUND', '目标插件实例已不存在'); + const { relId, storagePart } = installedGhostPhysicalKeys(target); // 同 install/update 的 owner 租约:setEnabled 先 await pathExists(旧 owner 的 // 安装目录)再动态写 receipt —— 不持租约,这个异步窗口里切号落定会拿 A 的镜像 // 状态改 B 的 receipt(启停同时落在两个 owner 的两半)。 const releaseMutation = beginGhostMutation(captureGhostMutationOwner()); try { if (!enabled) { - runtime.stop(id); // 沉睡立即熄灯 - getGhostNodeRuntimeBroker().stop(id); // 随包 Node 也立即关闭 - getGhostSubscriptionGateway().dropGhost(id); // 订阅态清零(缓冲/熔断/seq) + runtime.stop(storagePart); // 沉睡立即熄灯 + getGhostNodeRuntimeBroker().stop(storagePart); // 随包 Node 也立即关闭 + getGhostSubscriptionGateway().dropGhost(storagePart); // 订阅态清零(缓冲/熔断/seq) // 与更新/撤销路径同款:agent 工具授权与 errand 节流/在途记录不跨停用存活, // 重新启用后从干净状态开始。 - getGhostAgentSlot().clearGhost(id); - getGhostErrandSlot().clearGhost(id); + getGhostAgentSlot().clearGhost(storagePart); + getGhostErrandSlot().clearGhost(storagePart); // 停用即熄灯 Library 会话:db worker 终止、handle 作废——被禁用的插件 // 不得继续后台读写(数据本体不动,重新启用后重开)。 - await getGhostLibrarySlot().disposeGhost(id); + await getGhostLibrarySlot().disposeGhost(storagePart); } - const result = await manager.setEnabled(id, enabled); + const result = await manager.setEnabled(relId, enabled); if ('rejection' in result) throwUninstallError(result.rejection); if (enabled) { - runtime.resetFuse(id); // 重新唤醒 = 清熔断记账,可再拉起 - const ghost = findAvailableGhost(id); - if (ghost) spawnIfResident(ghost); // 常驻意识:唤醒即启动 + runtime.resetFuse(storagePart); // 重新唤醒 = 清熔断记账,可再拉起 + spawnIfResident(target); resumeGhostUnreadProjection(id); // 沉睡期间保留的那颗点回来(#1421) await refreshMivoLibraryExtraDirGrant().catch((error) => { log.warn('library extraDirs enable sync failed', { @@ -8193,14 +8934,12 @@ export function registerGhostIpc(): void { * 共用同一裁决链(原生选择器 → 候选校验 → binding/迁移)。 */ ipcMain.handle('ghosts:library-overview', async (event, id: unknown) => { assertTrustedAppRendererEvent(event); - if (typeof id !== 'string' || !isValidGhostId(id)) - throwIpcError('INVALID_PARAMS', '非法插件 id'); + if (typeof id !== 'string' || !isGhostInstanceId(id)) throwIpcError('INVALID_PARAMS', '非法插件 id'); return getGhostLibraryOverview(id); }); ipcMain.handle('ghosts:library-pick-location', async (event, id: unknown) => { assertTrustedAppRendererEvent(event); - if (typeof id !== 'string' || !isValidGhostId(id)) - throwIpcError('INVALID_PARAMS', '非法插件 id'); + if (typeof id !== 'string' || !isGhostInstanceId(id)) throwIpcError('INVALID_PARAMS', '非法插件 id'); const win = BrowserWindow.fromWebContents(event.sender); const picked = win ? await dialog.showOpenDialog(win, { properties: ['openDirectory', 'createDirectory'] }) @@ -8227,7 +8966,7 @@ export function registerGhostIpc(): void { // 持 owner 租约:binding 写的是 owner-scoped 文件,切换在途不得跨 owner。 ipcMain.handle('ghosts:library-bind', async (event, id: unknown, candidate: unknown) => { assertTrustedAppRendererEvent(event); - if (typeof id !== 'string' || !isValidGhostId(id) || typeof candidate !== 'string') { + if (typeof id !== 'string' || !isGhostInstanceId(id) || typeof candidate !== 'string') { throwIpcError('INVALID_PARAMS', '参数非法'); } const releaseMutation = beginGhostMutation(); @@ -8250,7 +8989,7 @@ export function registerGhostIpc(): void { // 设置页「更改位置」(带数据迁移):precheck→copying→verifying→switching→grace。 ipcMain.handle('ghosts:library-relocate', async (event, id: unknown, candidate: unknown) => { assertTrustedAppRendererEvent(event); - if (typeof id !== 'string' || !isValidGhostId(id) || typeof candidate !== 'string') { + if (typeof id !== 'string' || !isGhostInstanceId(id) || typeof candidate !== 'string') { throwIpcError('INVALID_PARAMS', '参数非法'); } return relocateGhostLibraryTo(id, candidate); @@ -8258,8 +8997,7 @@ export function registerGhostIpc(): void { // 撤销自定义位置:迁回系统默认并清 binding(反向同一状态机)。 ipcMain.handle('ghosts:library-revert-default', async (event, id: unknown) => { assertTrustedAppRendererEvent(event); - if (typeof id !== 'string' || !isValidGhostId(id)) - throwIpcError('INVALID_PARAMS', '非法插件 id'); + if (typeof id !== 'string' || !isGhostInstanceId(id)) throwIpcError('INVALID_PARAMS', '非法插件 id'); const defaultParent = path.dirname(ownerScopedUserDataPath('libraries', id)); try { await fs.promises.mkdir(defaultParent, { recursive: true }); @@ -8278,8 +9016,7 @@ export function registerGhostIpc(): void { // 用户可手工找回;不自动猜测)。 ipcMain.handle('ghosts:library-unbind', async (event, id: unknown) => { assertTrustedAppRendererEvent(event); - if (typeof id !== 'string' || !isValidGhostId(id)) - throwIpcError('INVALID_PARAMS', '非法插件 id'); + if (typeof id !== 'string' || !isGhostInstanceId(id)) throwIpcError('INVALID_PARAMS', '非法插件 id'); const releaseMutation = beginGhostMutation(); const slot = getGhostLibrarySlot(); try { @@ -8296,14 +9033,15 @@ export function registerGhostIpc(): void { }); ipcMain.handle('ghosts:library-delete', async (event, id: unknown) => { assertTrustedAppRendererEvent(event); - if (typeof id !== 'string' || !isValidGhostId(id)) - throwIpcError('INVALID_PARAMS', '非法插件 id'); + if (typeof id !== 'string' || !isGhostInstanceId(id)) throwIpcError('INVALID_PARAMS', '非法插件 id'); + const expectedOwner = captureGhostMutationOwner(); + const expectedTarget = ghostLibraryDeleteTargetFor(id); // **唯一有效的删除确认在 Main**:preload 即使被其它 trusted renderer // 调用也绕不过用户点击(review:Renderer 确认可被内部调用方绕过)。文案走 // main i18n(与 Renderer 五语同一资源),壳由系统绘制;取消不取得 mutation // 租约、不触碰 binding/数据。 const parent = BrowserWindow.fromWebContents(event.sender); - const ghostName = findAvailableGhost(id)?.manifest.name ?? id; + const ghostName = findGhostForInstanceId(id)?.manifest.name ?? id; const options = { type: 'warning' as const, title: t('settings.ghosts.library.deleteConfirmTitle'), @@ -8321,9 +9059,9 @@ export function registerGhostIpc(): void { ? await dialog.showMessageBox(parent, options) : await dialog.showMessageBox(options); if (decision.response !== 0) return { ok: false as const, cancelled: true as const }; - const releaseMutation = beginGhostMutation(); + const releaseMutation = beginGhostMutation(expectedOwner); try { - const res = await deleteGhostLibraryForActiveOwner(id); + const res = await deleteGhostLibraryForActiveOwner(id, expectedTarget); return res.ok ? { ok: true as const } : { ok: false as const, cancelled: false as const, message: res.message }; @@ -8339,10 +9077,10 @@ export function registerGhostIpc(): void { throwIpcError('INVALID_PARAMS', 'id must be a non-empty string'); } requireGhostAvailableForActiveSession(id); - const ghost = findAvailableGhost(id); + const ghost = findGhostForInstanceId(id); if (!ghost) throwIpcError('NOT_FOUND', `未装入意识 ${id}`); if (!ghost.enabled) throwIpcError('INVALID_PARAMS', `意识 ${id} 处于沉睡态`); - runtime.resetFuse(id); + runtime.resetFuse(installedGhostStoragePart(ghost)); const result = await runtime.spawn(ghost); if (!result.ok) throwIpcError('INTERNAL', result.reason); return { state: result.state }; @@ -8362,19 +9100,25 @@ export function registerGhostIpc(): void { switch (action) { case 'spawn': { requireGhostAvailableForActiveSession(id); - const ghost = findAvailableGhost(id); + const ghost = findGhostForInstanceId(id); if (!ghost) throwIpcError('NOT_FOUND', `未装入意识 ${id}`); if (!ghost.enabled) throwIpcError('INVALID_PARAMS', `意识 ${id} 处于沉睡态,先唤醒`); const result = await runtime.spawn(ghost); if (!result.ok) throwIpcError('INTERNAL', result.reason); return { state: result.state }; } - case 'stop': - runtime.stop(id); - return { state: runtime.stateOf(id) }; - case 'crash': - if (!runtime.crashForTest(id)) throwIpcError('INVALID_PARAMS', `意识 ${id} 不在运行中`); - return { state: runtime.stateOf(id) }; + case 'stop': { + const target = findGhostForInstanceId(id); + const runtimeId = target ? installedGhostStoragePart(target) : id; + runtime.stop(runtimeId); + return { state: runtime.stateOf(runtimeId) }; + } + case 'crash': { + const target = findGhostForInstanceId(id); + const runtimeId = target ? installedGhostStoragePart(target) : id; + if (!runtime.crashForTest(runtimeId)) throwIpcError('INVALID_PARAMS', `意识 ${id} 不在运行中`); + return { state: runtime.stateOf(runtimeId) }; + } case 'call': { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { throwIpcError('INVALID_PARAMS', 'call payload must be an object'); @@ -8439,11 +9183,15 @@ export function handleGhostPreviewNavigation( hostContents: WebContents, guestContents: WebContents, isOwnerActive: () => boolean, + instanceId?: string, ): void { void runGhostPreviewNavigation( { ghostId, url, hostContents, guestContents }, { - request: (request) => getGhostPreviewGate().request(request), + request: (request) => getGhostPreviewGate().request({ + ...request, + ...(instanceId !== undefined ? { instanceId } : {}), + }), isOwnerActive, send: (outcome) => { sendGhostContentsPush(hostContents, GHOST_PREVIEW_MEDIA_CHANNEL, { @@ -8463,7 +9211,7 @@ function getGhostExternalLinkGate(): GhostExternalLinkGate { if (!externalLinkGateSingleton) { externalLinkGateSingleton = new GhostExternalLinkGate({ declaredExternalUrls: (ghostId) => { - const ghost = findAvailableGhost(ghostId); + const ghost = findGhostForInstanceId(ghostId); return ghost && ghost.enabled ? ghostExternalLinkUrls(ghost.manifest) : null; }, }); @@ -8483,9 +9231,10 @@ export function handleGhostExternalLinkNavigation( hostContents: WebContents, guestContents: WebContents, isOwnerActive: () => boolean, + instanceId?: string, ): void { void runGhostExternalLinkNavigation( - { ghostId, url, hostContents, guestContents }, + { ghostId: instanceId ?? ghostId, url, hostContents, guestContents }, { gate: getGhostExternalLinkGate(), resolveOwner: (contents) => BrowserWindow.fromWebContents(contents), @@ -8519,8 +9268,12 @@ export function resolveGhostWebviewAttach( const owner = getActiveAppSession(); const resolvedPartition = resolveGhostWebviewPartitionClaim(partitionClaim, owner); if (!resolvedPartition) return null; - const ghost = findAvailableGhost(resolvedPartition.ghostId); + if (!parsePluginStoragePart(resolvedPartition.ghostId)) return null; + const ghost = findGhostForInstanceId(resolvedPartition.ghostId); if (!ghost || !ghost.enabled) return null; + if (resolvedPartition.ghostId !== pluginStoragePart(installedGhostLogicalIdentity(ghost))) return null; + const partition = ownerScopedGhostPartitionForInstalledGhost(ghost, owner); + if (!partition) return null; const allowedPaths = ghostWebviewEntryPaths(ghost.manifest); if (allowedPaths.length === 0) return null; let url: URL; @@ -8529,12 +9282,12 @@ export function resolveGhostWebviewAttach( } catch { return null; } - if (url.protocol !== `${GHOST_SCHEME}:` || url.host !== resolvedPartition.ghostId) return null; + if (url.protocol !== `${GHOST_SCHEME}:` || url.host !== ghost.manifest.id) return null; if (!allowedPaths.includes(url.pathname)) return null; ensureGhostProtocolRegistered(ghost, owner); return { ghost, - partition: resolvedPartition.partition, + partition, owner: { mode: owner.mode, dataOwnerId: owner.dataOwnerId! }, }; } @@ -8696,7 +9449,7 @@ export function refreshGhostLocalization(): void { broadcastGhostsChanged(ghosts); const context = currentGhostAppContext(); for (const ghost of ghosts) { - sendToGhostLogic(ghost.manifest.id, { + sendToGhostLogic(installedGhostStoragePart(ghost), { type: 'host-context-changed', ...context, }); diff --git a/apps/desktop/src/main/cindy-brain/iosSimulatorPluginGate.ts b/apps/desktop/src/main/cindy-brain/iosSimulatorPluginGate.ts index f91b8098bb4..fab152c8165 100644 --- a/apps/desktop/src/main/cindy-brain/iosSimulatorPluginGate.ts +++ b/apps/desktop/src/main/cindy-brain/iosSimulatorPluginGate.ts @@ -1,6 +1,7 @@ import type { IOSSimulatorMcpAccessDecision } from '@cindy/mcps'; import type { InstalledGhost } from '../../shared/ghost.js'; +import { installedGhostStoragePart } from '../../shared/pluginIdentity.js'; export interface IOSSimulatorPluginGateDeps { isAvailableForActiveSession(ghostId: string): boolean; @@ -47,11 +48,11 @@ export function resolveIOSSimulatorPluginAccess( } const sessionCandidates = candidates.filter((ghost) => - deps.isAvailableForActiveSession(ghost.manifest.id), + deps.isAvailableForActiveSession(installedGhostStoragePart(ghost)), ); const enabledCandidates = sessionCandidates.filter((ghost) => ghost.enabled === true); const available = enabledCandidates.find( - (ghost) => !deps.isDisabledForWorkdir(ghost.manifest.id, workingDir), + (ghost) => !deps.isDisabledForWorkdir(installedGhostStoragePart(ghost), workingDir), ); if (available) return { allowed: true }; diff --git a/apps/desktop/src/main/cindy-brain/libraryBinding.ts b/apps/desktop/src/main/cindy-brain/libraryBinding.ts index f1528f531e8..7e1d2eef9ee 100644 --- a/apps/desktop/src/main/cindy-brain/libraryBinding.ts +++ b/apps/desktop/src/main/cindy-brain/libraryBinding.ts @@ -21,6 +21,9 @@ import { randomUUID } from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; + +import { isValidPluginStoragePart } from '../../shared/pluginIdentity.js'; /** 单个插件的自定义位置记录。 */ export interface LibraryBindingRecord { @@ -43,6 +46,96 @@ export interface LibraryBindingRecord { export interface LibraryBindingFileData { version: 1; bindings: Record; + pendingRelocation?: LibraryBindingRelocation; +} + +interface LibraryBindingRelocation { + version: 1; + ownerFile: string; + fromGhostId: string; + toGhostId: string; + record: LibraryBindingRecord; + rootIdentity: { dev: number; ino: number }; + libraryIdentity: { dev: number; ino: number } | null; +} + +async function directoryIdentity(root: string): Promise<{ dev: number; ino: number } | null> { + try { + const stat = await fs.promises.lstat(root); + if (!stat.isDirectory() || stat.isSymbolicLink() || stat.ino === 0) { + throw new Error('library relocation directory identity unavailable'); + } + return { dev: stat.dev, ino: stat.ino }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } +} + +async function syncDirectory(root: string): Promise { + if (process.platform === 'win32') return; + const directory = await fs.promises.open(root, 'r'); + try { + await directory.sync(); + } finally { + await directory.close(); + } +} + +function isDirectoryIdentity(value: unknown): value is { dev: number; ino: number } { + if (!value || typeof value !== 'object') return false; + const identity = value as { dev?: unknown; ino?: unknown }; + return typeof identity.dev === 'number' && Number.isFinite(identity.dev) && + typeof identity.ino === 'number' && Number.isFinite(identity.ino) && identity.ino > 0; +} + +async function readLibraryMeta(root: string): Promise | null> { + const file = path.join(root, '.cindy-library', 'meta.json'); + let raw: string; + try { + raw = await fs.promises.readFile(file, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT' && !fs.existsSync(root)) return null; + throw error; + } + const meta: unknown = JSON.parse(raw); + if (!meta || typeof meta !== 'object' || Array.isArray(meta) || + (meta as { version?: unknown }).version !== 1 || + typeof (meta as { createdAt?: unknown }).createdAt !== 'number' || + typeof (meta as { ghostId?: unknown }).ghostId !== 'string') { + throw new Error('library meta is invalid'); + } + return meta as Record; +} + +export async function assertLibraryMetaOwner(root: string, ownerId: string): Promise { + if (!isValidPluginStoragePart(ownerId)) throw new Error('library owner id is invalid'); + const meta = await readLibraryMeta(root); + if (meta && meta.ghostId !== ownerId) throw new Error('library meta belongs to a different plugin'); +} + +export async function relocateLibraryMetaOwner( + root: string, fromId: string, toId: string, assertCurrent: () => void = () => {}, +): Promise { + if (!isValidPluginStoragePart(fromId) || !isValidPluginStoragePart(toId)) { + throw new Error('library meta relocate ids are invalid'); + } + const meta = await readLibraryMeta(root); + assertCurrent(); + if (!meta) return false; + const owner = meta.ghostId; + if (owner === toId) return false; + if (owner !== fromId) throw new Error('library meta belongs to a different plugin'); + const file = path.join(root, '.cindy-library', 'meta.json'); + const temporary = file + '.' + randomUUID() + '.tmp'; + try { + await fs.promises.writeFile(temporary, JSON.stringify({ ...meta, ghostId: toId }), { flag: 'wx', mode: 0o600 }); + assertCurrent(); + await fs.promises.rename(temporary, file); + } finally { + await fs.promises.rm(temporary, { force: true }); + } + return true; } export type LibraryLocationResolution = @@ -200,29 +293,107 @@ export class LibraryBindingStore { } private async readData(): Promise { + const file = this.deps.getFile(); + let data: LibraryBindingFileData; + let hasRelocation = false; try { - const raw = JSON.parse(await fs.promises.readFile(this.deps.getFile(), 'utf8')) as LibraryBindingFileData; - if (typeof raw === 'object' && raw !== null && raw.version === 1 && typeof raw.bindings === 'object' && raw.bindings !== null) { - return raw; + const raw = JSON.parse(await fs.promises.readFile(file, 'utf8')) as LibraryBindingFileData; + hasRelocation = typeof raw === 'object' && raw !== null && 'pendingRelocation' in raw; + if (typeof raw === 'object' && raw !== null && raw.version === 1 && + typeof raw.bindings === 'object' && raw.bindings !== null && !Array.isArray(raw.bindings)) { + data = raw; + } else { + throw new Error('malformed'); } - throw new Error('malformed'); } catch (err) { + if (hasRelocation) throw new Error('library relocation journal is invalid'); if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { this.deps.log?.warn('library binding file unreadable; falling back to default roots', { error: err instanceof Error ? err.message : String(err), }); } - return { version: 1, bindings: {} }; + data = { version: 1, bindings: {} }; } + if (file !== this.deps.getFile()) throw new Error('library binding owner changed'); + return this.recoverRelocation(data, file); } /** 原子写(tmp+rename;损坏不放大)。 */ - private async writeData(data: LibraryBindingFileData): Promise { - const file = this.deps.getFile(); + private async writeData(data: LibraryBindingFileData, file = this.deps.getFile()): Promise { + if (file !== this.deps.getFile()) throw new Error('library binding owner changed'); await fs.promises.mkdir(path.dirname(file), { recursive: true }); const tmp = `${file}.${randomUUID()}.tmp`; - await fs.promises.writeFile(tmp, JSON.stringify(data, null, 2), 'utf8'); + const handle = await fs.promises.open(tmp, 'wx', 0o600); + try { + await handle.writeFile(JSON.stringify(data, null, 2), 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } + if (file !== this.deps.getFile()) throw new Error('library binding owner changed'); await fs.promises.rename(tmp, file); + await syncDirectory(path.dirname(file)); + } + + private async assertRelocationRoot(pending: LibraryBindingRelocation): Promise { + const realRoot = await fs.promises.realpath(pending.record.root); + const identity = await directoryIdentity(realRoot); + if (realRoot !== pending.record.realPathAtGrant || + !isDeepStrictEqual(identity, pending.rootIdentity) || + (pending.record.identity?.ino && !isDeepStrictEqual(identity, pending.record.identity))) { + throw new Error('library relocation root identity changed'); + } + } + + private async recoverRelocation(data: LibraryBindingFileData, file: string): Promise { + if (data.pendingRelocation === undefined) return data; + const pending = data.pendingRelocation; + if (!pending || pending.version !== 1 || pending.ownerFile !== path.resolve(file) || + !isValidPluginStoragePart(pending.fromGhostId) || !isValidPluginStoragePart(pending.toGhostId) || + pending.fromGhostId === pending.toGhostId || !pending.record || + typeof pending.record.root !== 'string' || !path.isAbsolute(pending.record.root) || + typeof pending.record.realPathAtGrant !== 'string' || + !Number.isInteger(pending.record.generation) || pending.record.generation < 1 || + !isDirectoryIdentity(pending.rootIdentity) || + (pending.libraryIdentity !== null && !isDirectoryIdentity(pending.libraryIdentity))) { + throw new Error('library relocation journal is invalid'); + } + const sourceBinding = data.bindings[pending.fromGhostId]; + const targetBinding = data.bindings[pending.toGhostId]; + const beforeCommit = sourceBinding !== undefined && targetBinding === undefined && + isDeepStrictEqual(sourceBinding, pending.record); + const afterCommit = sourceBinding === undefined && targetBinding !== undefined && + isDeepStrictEqual(targetBinding, pending.record); + if (!beforeCommit && !afterCommit) throw new Error('library relocation binding generation changed'); + await this.assertRelocationRoot(pending); + const fromRoot = path.join(pending.record.realPathAtGrant, pending.fromGhostId); + const toRoot = path.join(pending.record.realPathAtGrant, pending.toGhostId); + const sourceIdentity = await directoryIdentity(fromRoot); + const targetIdentity = await directoryIdentity(toRoot); + if (pending.libraryIdentity === null) { + if (sourceIdentity !== null || targetIdentity !== null || pending.record.libraryReady !== false) { + throw new Error('library relocation directory identity changed'); + } + } else if (beforeCommit && isDeepStrictEqual(sourceIdentity, pending.libraryIdentity) && targetIdentity === null) { + if (file !== this.deps.getFile()) throw new Error('library binding owner changed'); + await fs.promises.rename(fromRoot, toRoot); + } else if (sourceIdentity !== null || !isDeepStrictEqual(targetIdentity, pending.libraryIdentity)) { + throw new Error('library relocation directory identity changed'); + } + await syncDirectory(pending.record.realPathAtGrant); + await this.assertRelocationRoot(pending); + if (!isDeepStrictEqual(await directoryIdentity(toRoot), pending.libraryIdentity) || + await directoryIdentity(fromRoot) !== null) { + throw new Error('library relocation directory identity changed'); + } + if (beforeCommit) { + data.bindings[pending.toGhostId] = pending.record; + delete data.bindings[pending.fromGhostId]; + await this.writeData(data, file); + } + delete data.pendingRelocation; + await this.writeData(data, file); + return data; } /** @@ -235,7 +406,7 @@ export class LibraryBindingStore { getDiskFreeBytes?: (root: string) => Promise, opts?: { allowInsideManagedRoot?: boolean }, ): Promise<{ ok: true; record: LibraryBindingRecord; warnings: string[] } | LocationValidationFailure> { - if (!/^[a-z0-9][a-z0-9-]*$/.test(ghostId)) { + if (!isValidPluginStoragePart(ghostId)) { return { ok: false, errorCode: 'PATH_INVALID', message: 'ghostId 非法' }; } return this.runSerialized(async () => { @@ -289,7 +460,63 @@ export class LibraryBindingStore { } getBinding(ghostId: string): Promise { - return this.readData().then((d) => d.bindings[ghostId] ?? null); + return this.runSerialized(async () => (await this.readData()).bindings[ghostId] ?? null); + } + + async assertCanRelocateBinding(fromGhostId: string, toGhostId: string): Promise { + if (!isValidPluginStoragePart(fromGhostId) || !isValidPluginStoragePart(toGhostId)) { + throw new Error('library relocate ids are invalid'); + } + if (fromGhostId === toGhostId) return; + await this.runSerialized(async () => { + const data = await this.readData(); + this.assertRelocationDestination(data, fromGhostId, toGhostId); + }); + } + + private assertRelocationDestination(data: LibraryBindingFileData, fromGhostId: string, toGhostId: string): void { + const record = data.bindings[fromGhostId]; + if (!record) return; + if (data.bindings[toGhostId]) { + throw new Error(`library binding destination already exists: ${toGhostId}`); + } + const toRoot = path.join(record.root, toGhostId); + if (fs.lstatSync(toRoot, { throwIfNoEntry: false })) { + throw new Error(`library custom root destination already exists: ${toRoot}`); + } + } + + /** Move a custom binding key after a physical instance relocate. */ + async relocateBinding(fromGhostId: string, toGhostId: string): Promise { + if (fromGhostId === toGhostId) return; + if (!isValidPluginStoragePart(fromGhostId) || !isValidPluginStoragePart(toGhostId)) { + throw new Error('library relocate ids are invalid'); + } + await this.runSerialized(async () => { + const file = this.deps.getFile(); + const data = await this.readData(); + const record = data.bindings[fromGhostId]; + if (!record) return; + this.assertRelocationDestination(data, fromGhostId, toGhostId); + const rootIdentity = await directoryIdentity(record.realPathAtGrant); + if (!rootIdentity) throw new Error('library relocation root is missing'); + const libraryIdentity = await directoryIdentity(path.join(record.realPathAtGrant, fromGhostId)); + if (libraryIdentity === null && record.libraryReady !== false) { + throw new Error('library relocation source is missing'); + } + data.pendingRelocation = { + version: 1, + ownerFile: path.resolve(file), + fromGhostId, + toGhostId, + record, + rootIdentity, + libraryIdentity, + }; + await this.assertRelocationRoot(data.pendingRelocation); + await this.writeData(data, file); + await this.recoverRelocation(data, file); + }); } /** First successful custom open: persist ready without bumping generation. */ diff --git a/apps/desktop/src/main/cindy-brain/librarySlot.ts b/apps/desktop/src/main/cindy-brain/librarySlot.ts index 25c3e262a0f..898ce4a58da 100644 --- a/apps/desktop/src/main/cindy-brain/librarySlot.ts +++ b/apps/desktop/src/main/cindy-brain/librarySlot.ts @@ -26,6 +26,7 @@ import { GHOST_LIBRARY_CAPABILITIES_V1, GHOST_LIBRARY_OPS, GHOST_PICK_MIN_INTERVAL_MS, + ghostInstallApprovalToken, type GhostLibraryErrorReason, type GhostPipeLibraryResult, type InstalledGhost, @@ -167,6 +168,7 @@ export function mintLibraryEpochIdentity(input: { /** 单插件的库会话(vault + sql 绑定到同一根与 owner scope)。 */ interface GhostLibrarySession { ghostId: string; + requestTarget: GhostLibraryRequestTarget; vault: LibraryVault; sql: LibrarySqlService; /** 会话创建时捕获的 owner scope key;每请求比对,变了就整会话作废。 */ @@ -180,6 +182,14 @@ interface GhostLibrarySession { identity: string; } +interface GhostLibraryRequestTarget { + relocationGeneration: number; + ownerScopeKey: string | null; + ghostTarget: string | null; +} + +class GhostLibraryRequestCancelled extends Error {} + export interface GhostLibrarySlotDeps { getGhost(id: string): InstalledGhost | null; /** 自定义位置 binding 存储(owner-scoped;生产注入)。 */ @@ -241,6 +251,7 @@ export class GhostLibrarySlot { private readonly sessions = new Map(); /** 迁移进行中的插件:全部写操作只读化(切换与 grace 前不再有写入落旧根)。 */ private readonly relocating = new Set(); + private readonly relocationGenerations = new Map(); /** 插件 id → 上次 reveal 尝试时刻(按尝试记账;对齐 pick/confirm 骚扰钳制)。 */ private readonly lastRevealAttemptAt = new Map(); /** 插件 id → 上次 saveAs 尝试时刻(按尝试记账;对齐 pick/confirm 骚扰钳制)。 */ @@ -273,8 +284,42 @@ export class GhostLibrarySlot { /** 迁移期只读闸(设置页迁移在 copying 前置位、结束后清除)。 */ setRelocating(ghostId: string, on: boolean): void { - if (on) this.relocating.add(ghostId); - else this.relocating.delete(ghostId); + if (on) { + this.advanceRelocationGeneration(ghostId); + this.relocating.add(ghostId); + } else this.relocating.delete(ghostId); + } + + private advanceRelocationGeneration(ghostId: string): void { + this.relocationGenerations.set(ghostId, (this.relocationGenerations.get(ghostId) ?? 0) + 1); + } + + private ghostRequestTarget(ghostId: string): string | null { + const ghost = this.deps.getGhost(ghostId); + return ghost ? JSON.stringify([ + ghost.manifest.id, ghost.dir, ghost.namespace ?? null, ghost.enabled !== false, + ghostInstallApprovalToken(ghost.approval), + ]) : null; + } + + private captureRequestTarget(ghostId: string, ownerScopeKey = this.deps.captureOwnerScope()): GhostLibraryRequestTarget { + const relocationGeneration = this.relocationGenerations.get(ghostId) ?? 0; + this.relocationGenerations.set(ghostId, relocationGeneration); + return { relocationGeneration, ownerScopeKey, ghostTarget: this.ghostRequestTarget(ghostId) }; + } + + private isRequestCurrent(ghostId: string, target: GhostLibraryRequestTarget): boolean { + return !this.relocating.has(ghostId) + && target.relocationGeneration === this.relocationGenerations.get(ghostId) + && target.ownerScopeKey === this.deps.captureOwnerScope() + && this.checkEligibility(ghostId) + && target.ghostTarget === this.ghostRequestTarget(ghostId); + } + + private assertRequestCurrent(ghostId: string, target: GhostLibraryRequestTarget, session?: GhostLibrarySession): void { + if (!this.isRequestCurrent(ghostId, target) || (session && this.sessions.get(ghostId) !== session)) { + throw new GhostLibraryRequestCancelled('Library 请求目标已变化,操作已取消'); + } } private beginStagingRelease(ghostId: string): () => void { @@ -357,6 +402,9 @@ export class GhostLibrarySlot { try { return await this.dispatch(ghostId, payload); } catch (err) { + if (err instanceof GhostLibraryRequestCancelled) { + return fail('LIBRARY_UNAVAILABLE', err.message, 'CANCELLED'); + } this.deps.log?.warn('ghost library-request unexpected failure', { ghostId, error: err instanceof Error ? err.message : String(err), @@ -396,7 +444,7 @@ export class GhostLibrarySlot { } return this.dispatchStaging(ghostId, op, req); } - // 迁移期只读:写类操作在 copying 全程拒绝(读与状态查询照常)。 + // 迁移期只读:写类操作在 copying 全程拒绝。 const writeOps: ReadonlySet = new Set([ 'write', 'writeBegin', 'writeChunk', 'writeCommit', 'writeAbort', 'mkdir', 'delete', 'rename', @@ -408,46 +456,60 @@ export class GhostLibrarySlot { } } + const target = this.captureRequestTarget(ghostId); const runSessionOp = async (): Promise => { - const scopeKey = this.deps.captureOwnerScope(); - const session = await this.getOrCreateSession(ghostId, scopeKey); - return this.runOp(ghostId, session, op, req); + this.assertRequestCurrent(ghostId, target); + const session = await this.getOrCreateSession(ghostId, target.ownerScopeKey, target); + this.assertRequestCurrent(ghostId, target, session); + const result = await this.runOp(ghostId, session, op, req); + this.assertRequestCurrent(ghostId, target, session); + return result; }; if (writeOps.has(op)) return this.runGhostExclusive(ghostId, runSessionOp); return runSessionOp(); } - private async getOrCreateSession(ghostId: string, scopeKey: string | null): Promise { + private async getOrCreateSession( + ghostId: string, + scopeKey: string | null, + target = this.captureRequestTarget(ghostId, scopeKey), + ): Promise { + this.assertRequestCurrent(ghostId, target); let session = this.sessions.get(ghostId); const capturedScope = session; - if (session && session.ownerScopeKey !== scopeKey) { + if (session && !this.isRequestCurrent(ghostId, session.requestTarget)) { await this.teardownSession(ghostId, capturedScope); + this.assertRequestCurrent(ghostId, target); session = this.sessions.get(ghostId); if (session === capturedScope) session = undefined; } - const resolution = await this.confirmLiveCustomRoot( - await this.deps.bindingStore.resolveLibraryRoot(ghostId), - ); - session = this.sessions.get(ghostId) ?? session; + const resolved = await this.deps.bindingStore.resolveLibraryRoot(ghostId); + this.assertRequestCurrent(ghostId, target); + const resolution = await this.confirmLiveCustomRoot(resolved); + this.assertRequestCurrent(ghostId, target); + session = this.sessions.get(ghostId); if (session && session.ownerScopeKey !== scopeKey) { const staleScope = session; await this.teardownSession(ghostId, staleScope); + this.assertRequestCurrent(ghostId, target); session = this.sessions.get(ghostId); if (session === staleScope) session = undefined; } if (session && !this.sessionMatchesResolution(session, resolution)) { const staleRoot = session; await this.teardownSession(ghostId, staleRoot); + this.assertRequestCurrent(ghostId, target); session = this.sessions.get(ghostId); if (session === staleRoot) session = undefined; } if (!session) { - session = this.createSession(ghostId, resolution, scopeKey); + session = this.createSession(ghostId, resolution, scopeKey, target); this.sessions.set(ghostId, session); // 会话建立即自动 open vault(幂等):消除"write 前忘 open"的脚枪。 // extraDirs 只在显式 open 时挂,status / 首次任意请求不得抢槽。 if (session.drift === null) { const opened = await session.vault.open(); + this.assertRequestCurrent(ghostId, target, session); if ( opened.ok && opened.state === 'unavailable' @@ -457,6 +519,7 @@ export class GhostLibrarySlot { } else { if (opened.ok && opened.state === 'ready' && resolution.kind === 'custom' && resolution.root !== null) { await this.deps.bindingStore.markLibraryReady(ghostId).catch(() => {}); + this.assertRequestCurrent(ghostId, target, session); } if (session.vault.getMeta()?.orphaned) { // 重装自愈:能走到这里 = 插件已装入且启用,清掉卸载时留的 orphaned @@ -468,6 +531,7 @@ export class GhostLibrarySlot { await this.syncAgentReadonlyExtraDir(ghostId, null); } } + this.assertRequestCurrent(ghostId, target, session); return session; } @@ -481,6 +545,7 @@ export class GhostLibrarySlot { const ghost = this.deps.getGhost(ghostId); if (!ghost) return false; if (ghost.enabled === false) return false; + if (ghost.approval && ghost.approval.state !== 'approved') return false; return ghost.manifest.library === true; } @@ -494,7 +559,9 @@ export class GhostLibrarySlot { if (!this.checkEligibility(ghostId)) return null; const session = await this.getOrCreateSession(ghostId, this.deps.captureOwnerScope()); if (session.drift !== null) return null; - return await session.vault.resolveExistingFile(relPath); + const resolved = await session.vault.resolveExistingFile(relPath); + this.assertRequestCurrent(ghostId, session.requestTarget, session); + return resolved; } catch { return null; } @@ -560,6 +627,7 @@ export class GhostLibrarySlot { ghostId: string, resolution: LibraryLocationResolution, scopeKey: string | null, + requestTarget: GhostLibraryRequestTarget, ): GhostLibrarySession { const drift = 'drift' in resolution && resolution.root === null ? resolution.drift : null; const root = resolution.kind === 'custom' && resolution.root !== null @@ -594,6 +662,7 @@ export class GhostLibrarySlot { }); return { ghostId, + requestTarget, vault, sql, ownerScopeKey: scopeKey, @@ -910,13 +979,14 @@ export class GhostLibrarySlot { /** 停用/卸载/owner 切换收口:作废全部会话(commit 5 的生命周期接线点)。 */ async disposeGhost(ghostId: string): Promise { + this.advanceRelocationGeneration(ghostId); await this.waitForStagingReleases(ghostId); await this.teardownSession(ghostId); await this.disposeStagingStores(ghostId); } async disposeAll(): Promise { - const ids = new Set([...this.sessions.keys(), ...this.stagingReleaseInflight.keys()]); + const ids = new Set([...this.sessions.keys(), ...this.stagingReleaseInflight.keys(), ...this.relocationGenerations.keys()]); for (const key of this.stagingStores.keys()) { const ghostId = key.split('\0')[1]; if (ghostId) ids.add(ghostId); @@ -954,7 +1024,7 @@ export class GhostLibrarySlot { if (!this.checkEligibility(ghostId)) { return fail('NOT_DECLARED', '插件未装入、已停用或未声明 "library" 能力', 'PERMISSION_DENIED'); } - if (this.deps.captureOwnerScope() !== session.ownerScopeKey) { + if (!this.isRequestCurrent(ghostId, session.requestTarget)) { return fail('LIBRARY_UNAVAILABLE', cancelledMessage, 'CANCELLED'); } const live = this.sessions.get(ghostId); @@ -1005,6 +1075,7 @@ export class GhostLibrarySlot { op: string, req: Record, ): Promise { + this.assertRequestCurrent(ghostId, session.requestTarget, session); // 漂移占位会话:open/status 如实报 unavailable+reason,其余操作全拒 // (绝不当空库、绝不落默认根冒充)。 if (session.drift !== null && op !== 'open' && op !== 'status') { @@ -1025,6 +1096,7 @@ export class GhostLibrarySlot { switch (op) { case 'open': { const r = await vault.open(); + this.assertRequestCurrent(ghostId, session.requestTarget, session); if (!r.ok) return vaultFail(r); if (r.state === 'unavailable' && (r.reason === 'disk-missing' || r.reason === 'binding-moved')) { await this.latchCustomUnavailable(session, ghostId, r.reason); @@ -1038,6 +1110,7 @@ export class GhostLibrarySlot { const live = await this.confirmLiveCustomRoot( await this.deps.bindingStore.resolveLibraryRoot(ghostId), ); + this.assertRequestCurrent(ghostId, session.requestTarget, session); if (live.kind !== 'custom' || live.root === null) { const reason = live.kind === 'custom' && live.root === null && live.drift === 'binding-moved' ? 'binding-moved' @@ -1060,6 +1133,7 @@ export class GhostLibrarySlot { } case 'status': { const r = await vault.status(); + this.assertRequestCurrent(ghostId, session.requestTarget, session); if (!r.ok) return vaultFail(r); if (this.extraDirOpenerGhostId === ghostId) { await this.syncAgentReadonlyExtraDir(ghostId, vault.getRootDir()); @@ -1325,9 +1399,11 @@ export class GhostLibrarySlot { } case 'db.open': { const resolved = await this.resolveDbPath(session, req.dbPath); + this.assertRequestCurrent(ghostId, session.requestTarget, session); if (!('abs' in resolved)) return resolved; // 父目录由宿主建好(better-sqlite3 只建文件不建目录)。 await fs.promises.mkdir(path.dirname(resolved.abs), { recursive: true }).catch(() => {}); + this.assertRequestCurrent(ghostId, session.requestTarget, session); const r = await session.sql.open(resolved.abs); return this.dbResultToPipe(op, r); } @@ -1335,6 +1411,7 @@ export class GhostLibrarySlot { const diskGate = await this.dbDiskGate(session); if (diskGate) return diskGate; const resolved = await this.resolveDbPath(session, req.dbPath); + this.assertRequestCurrent(ghostId, session.requestTarget, session); if (!('abs' in resolved)) return resolved; const r = await session.sql.exec(resolved.abs, req.sql as string, req.params); return this.dbResultToPipe(op, r); @@ -1343,6 +1420,7 @@ export class GhostLibrarySlot { const diskGate = await this.dbDiskGate(session); if (diskGate) return diskGate; const resolved = await this.resolveDbPath(session, req.dbPath); + this.assertRequestCurrent(ghostId, session.requestTarget, session); if (!('abs' in resolved)) return resolved; const r = await session.sql.batch(resolved.abs, (req.statements as Array<{ sql: string; params?: unknown }>) ?? []); return this.dbResultToPipe(op, r); @@ -1351,6 +1429,7 @@ export class GhostLibrarySlot { const diskGate = await this.dbDiskGate(session); if (diskGate) return diskGate; const resolved = await this.resolveDbPath(session, req.dbPath); + this.assertRequestCurrent(ghostId, session.requestTarget, session); if (!('abs' in resolved)) return resolved; // 迁移前自动在线备份(宿主命名空间,插件路径语法写不进);备份失败 // 是硬前置——报错中止,不带伤迁移。 @@ -1359,7 +1438,9 @@ export class GhostLibrarySlot { `pre-migrate-${Date.now()}-${Math.floor(Math.random() * 1e6)}.db`, ); await fs.promises.mkdir(path.dirname(backupDest), { recursive: true }).catch(() => {}); + this.assertRequestCurrent(ghostId, session.requestTarget, session); const bak = await session.sql.backup(resolved.abs, backupDest); + this.assertRequestCurrent(ghostId, session.requestTarget, session); if (!bak.ok) return this.dbResultToPipe('db.backup', bak); const r = await session.sql.migrate( resolved.abs, @@ -1370,21 +1451,25 @@ export class GhostLibrarySlot { } case 'db.backup': { const resolved = await this.resolveDbPath(session, req.dbPath); + this.assertRequestCurrent(ghostId, session.requestTarget, session); if (!('abs' in resolved)) return resolved; const label = typeof req.label === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(req.label) ? req.label : 'manual'; const dest = path.join(session.vault.getRootDir(), '.cindy-library', 'backups', `${label}-${Date.now()}.db`); await fs.promises.mkdir(path.dirname(dest), { recursive: true }).catch(() => {}); + this.assertRequestCurrent(ghostId, session.requestTarget, session); const r = await session.sql.backup(resolved.abs, dest); return this.dbResultToPipe(op, r); } case 'db.check': { const resolved = await this.resolveDbPath(session, req.dbPath); + this.assertRequestCurrent(ghostId, session.requestTarget, session); if (!('abs' in resolved)) return resolved; const r = await session.sql.check(resolved.abs); return this.dbResultToPipe(op, r); } case 'db.userVersion': { const resolved = await this.resolveDbPath(session, req.dbPath); + this.assertRequestCurrent(ghostId, session.requestTarget, session); if (!('abs' in resolved)) return resolved; const r = await session.sql.userVersion(resolved.abs); return this.dbResultToPipe(op, r); diff --git a/apps/desktop/src/main/cindy-brain/libraryStaging.ts b/apps/desktop/src/main/cindy-brain/libraryStaging.ts index 774310c1736..d05f0915244 100644 --- a/apps/desktop/src/main/cindy-brain/libraryStaging.ts +++ b/apps/desktop/src/main/cindy-brain/libraryStaging.ts @@ -5,6 +5,8 @@ */ import { randomUUID } from 'node:crypto'; +import { lstat } from 'node:fs/promises'; +import { relocateLibraryMetaOwner } from './libraryBinding.js'; import { LibraryVault, @@ -355,6 +357,63 @@ function identityMatches(actual: TaskIdentity, expected: TaskIdentity): boolean && sameRecovery(actual.recovery, expected.recovery); } +export async function relocateLibraryStagingOwner( + root: string, fromId: string, toId: string, ownerScopeKey: string, assertCurrent: () => void, +): Promise { + assertCurrent(); + const stat = await lstat(root).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return null; + throw error; + }); + assertCurrent(); + if (!stat) return; + if (!stat.isDirectory()) throw new Error('staging relocation root is not a directory'); + await relocateLibraryMetaOwner(root, fromId, toId, assertCurrent); + assertCurrent(); + const vault = new LibraryVault({ rootDir: () => root, ghostId: toId }); + try { + const opened = await vault.open(); + assertCurrent(); + if (!opened.ok) throw new Error(opened.message); + let cursor: string | null = null; + do { + const page = await vault.list({ path: 'tasks', cursor, strict: true }); + assertCurrent(); + if (!page.ok) { + if (page.errorCode === 'NOT_FOUND' && cursor === null) break; + throw new Error(page.message); + } + for (const entry of page.entries) { + const stagingId = entry.path.slice('tasks/'.length); + if (entry.kind !== 'dir' || !UUID.test(stagingId)) continue; + for (const [file, parse] of [ + [manifestPath(stagingId), parseManifest], [intentPath(stagingId), parseIntent], + ] as const) { + const raw = await vault.read({ path: file, encoding: 'utf8' }); + assertCurrent(); + if (!raw.ok) { + if (raw.errorCode === 'NOT_FOUND') continue; + throw new Error(raw.message); + } + const parsed = parseJsonObject(raw.content, 'staging relocation'); + if (!parsed.ok) throw new Error(parsed.message); + const owner = parsed.value.ghostId; + if (owner !== fromId && owner !== toId) throw new Error('staging task belongs to a different plugin'); + const task = parse(raw.content, stagingId, owner, ownerScopeKey); + if ('errorCode' in task) throw new Error(task.message); + if (owner === toId) continue; + const written = await vault.write({ path: file, content: JSON.stringify({ ...parsed.value, ghostId: toId }) }); + assertCurrent(); + if (!written.ok) throw new Error(written.message); + } + } + cursor = page.hasMore ? page.nextCursor : null; + } while (cursor !== null); + } finally { + await vault.invalidate(); + } +} + export class LibraryStagingStore { private readonly limits: LibraryStagingLimits; private readonly ownerScopeKey: string; diff --git a/apps/desktop/src/main/cindy-brain/libraryVault.ts b/apps/desktop/src/main/cindy-brain/libraryVault.ts index b2572594177..a3d1a735b1e 100644 --- a/apps/desktop/src/main/cindy-brain/libraryVault.ts +++ b/apps/desktop/src/main/cindy-brain/libraryVault.ts @@ -498,7 +498,7 @@ export class LibraryVault { /* ── 打开与状态 ─────────────────────────────────────────────────── */ /** - * 打开(幂等):建目录骨架 → 清理超龄 staging 残渣 → 读/建 meta → 读/扫 + * 打开(幂等):建目录骨架 → 读/建 meta → 清理超龄 staging 残渣 → 读/扫 * 用量账本。meta 存在但读不出/不合法 → unavailable(corrupt),**不重建**。 */ async open(): Promise> { @@ -540,6 +540,9 @@ export class LibraryVault { ghostId: dirSeg, }); if (existing.ok) { + if (this.deps.ghostId && existing.meta.ghostId !== this.deps.ghostId) { + return this.customRootUnavailable('binding-moved'); + } this.meta = existing.meta; if (existing.usage) customUsage = existing.usage; } else if (existing.code === 'MISSING') { @@ -599,7 +602,6 @@ export class LibraryVault { return { ok: true as const, state: this.state, reason: this.unavailableReason, usedBytes: 0, fileCount: 0 }; } const customOpen = (this.deps.locationKind ?? 'default') === 'custom'; - if (!customOpen) await this.sweepStaleTmp(); // meta:已存在必须可解析(不可用 ≠ 空);不存在则首建。custom 首次 open 用 held-fd 已写的 meta,不再 path 写。 if (!this.meta) { @@ -609,6 +611,7 @@ export class LibraryVault { if ( typeof parsed !== 'object' || parsed === null || parsed.version !== 1 || typeof parsed.ghostId !== 'string' || typeof parsed.createdAt !== 'number' + || (this.deps.ghostId !== undefined && parsed.ghostId !== this.deps.ghostId) ) { throw new Error('malformed meta'); } @@ -634,6 +637,8 @@ export class LibraryVault { } } + if (!customOpen) await this.sweepStaleTmp(); + // 用量:合法账本只读复用;坏/缺才 scan。custom 首次 open 不 persist/unlink。 let ledger: UsageLedger | null = customUsage; if (!ledger) { diff --git a/apps/desktop/src/main/cindy-brain/networkSlot.ts b/apps/desktop/src/main/cindy-brain/networkSlot.ts index 36cda098316..26be29735bd 100644 --- a/apps/desktop/src/main/cindy-brain/networkSlot.ts +++ b/apps/desktop/src/main/cindy-brain/networkSlot.ts @@ -1063,7 +1063,9 @@ export class GhostNetworkSlot { const ghCliSecrets = declaredSecrets.filter((secret) => secret.source === 'gh-cli'); if ( ghCliSecrets.length > 0 && - (ghost.manifest.id !== 'cindy-github' || !isCindyOfficialTrustInfo(ghost.trust)) + (ghost.manifest.id !== 'cindy-github' || ghost.namespace != null || + ghost.namespaceMigration === 'pending' || + !isCindyOfficialTrustInfo(ghost.trust)) ) { return { ok: false, message: '本意识未通过官方 GitHub 宿主凭证信任校验,已阻断 gh-cli 凭证请求' }; } diff --git a/apps/desktop/src/main/cindy-brain/nodeRuntimeBroker.ts b/apps/desktop/src/main/cindy-brain/nodeRuntimeBroker.ts index f8befca7445..e8ffb918417 100644 --- a/apps/desktop/src/main/cindy-brain/nodeRuntimeBroker.ts +++ b/apps/desktop/src/main/cindy-brain/nodeRuntimeBroker.ts @@ -39,6 +39,7 @@ import type { GhostPipeNodeResult, InstalledGhost, } from '../../shared/ghost.js'; +import { installedGhostStoragePart } from '../../shared/pluginIdentity.js'; import { GHOST_NODE_CHILD_MODE_FLAG, GHOST_NODE_MAX_CHILDREN_PER_GHOST, @@ -785,9 +786,13 @@ export class GhostNodeRuntimeBroker { return `${ghostId}::${entryRel}`; } + private static instanceId(ghost: InstalledGhost): string { + return installedGhostStoragePart(ghost); + } + stateOf(ghostId: string): 'off' | 'running' { for (const entry of this.workers.values()) { - if (entry.ghost.manifest.id === ghostId) return 'running'; + if (GhostNodeRuntimeBroker.instanceId(entry.ghost) === ghostId) return 'running'; } return 'off'; } @@ -795,21 +800,21 @@ export class GhostNodeRuntimeBroker { /** resident 档在插件启用/启动时调用;按需档保持零进程。常驻只覆盖主入口。 * 同时也是 stop() 的对称点:上层完成更新/重启后调用此方法,清除停止标记。 */ async startResident(ghost: InstalledGhost): Promise { - this.stoppedGhosts.delete(ghost.manifest.id); + this.stoppedGhosts.delete(GhostNodeRuntimeBroker.instanceId(ghost)); if (!ghost.enabled || ghost.manifest.node?.lifecycle !== 'resident') return; const ownerScopeSnapshot = this.captureOwnerScope(); const entry = await this.ensureWorker(ghost, ghost.manifest.node.entry, ownerScopeSnapshot); - this.assertOwnerScopeUsable(ghost.manifest.id, ownerScopeSnapshot); + this.assertOwnerScopeUsable(GhostNodeRuntimeBroker.instanceId(ghost), ownerScopeSnapshot); if (ghost.manifest.node.protocol === 'mcp-stdio') await this.ensureMcpInitialized(entry); } /** Recovery-only restart for a runtime that was already running on demand. */ async startForRecovery(ghost: InstalledGhost): Promise { - this.stoppedGhosts.delete(ghost.manifest.id); + this.stoppedGhosts.delete(GhostNodeRuntimeBroker.instanceId(ghost)); if (!ghost.enabled || !ghost.manifest.node) return; const ownerScopeSnapshot = this.captureOwnerScope(); const entry = await this.ensureWorker(ghost, ghost.manifest.node.entry, ownerScopeSnapshot); - this.assertOwnerScopeUsable(ghost.manifest.id, ownerScopeSnapshot); + this.assertOwnerScopeUsable(GhostNodeRuntimeBroker.instanceId(ghost), ownerScopeSnapshot); if (ghost.manifest.node.protocol === 'mcp-stdio') await this.ensureMcpInitialized(entry); } @@ -1116,7 +1121,7 @@ export class GhostNodeRuntimeBroker { /** worker 引导层上行控制帧的总入口:形状不合静默丢,资格逐项查。 */ private handleWorkerControl(entry: WorkerEntry, raw: unknown): void { - if (!this.ownerScopeUsable(entry.ghost.manifest.id, entry.ownerScopeSnapshot)) return; + if (!this.ownerScopeUsable(GhostNodeRuntimeBroker.instanceId(entry.ghost), entry.ownerScopeSnapshot)) return; const message = parseGhostNodeChildToHostMessage(raw); if (!message) return; if (message.type === 'device-authorize' || message.type === 'plugin-authorize') { @@ -1142,7 +1147,7 @@ export class GhostNodeRuntimeBroker { entry: WorkerEntry, message: Extract, ): void { - const ghostId = entry.ghost.manifest.id; + const ghostId = GhostNodeRuntimeBroker.instanceId(entry.ghost); const pending = entry.pending.get(message.rpcId); const reply = (ok: boolean, result?: Awaited) => this.replyToWorker(entry, message.type === 'device-authorize' @@ -1226,7 +1231,7 @@ export class GhostNodeRuntimeBroker { private childCountOf(ghostId: string): number { let count = 0; for (const entry of this.workers.values()) { - if (entry.ghost.manifest.id === ghostId) count += entry.children.size; + if (GhostNodeRuntimeBroker.instanceId(entry.ghost) === ghostId) count += entry.children.size; } return count; } @@ -1238,7 +1243,7 @@ export class GhostNodeRuntimeBroker { private readonly childReservations = new Map(); private replyToWorker(entry: WorkerEntry, message: GhostNodeChildToWorkerMessage): void { - if (!this.ownerScopeUsable(entry.ghost.manifest.id, entry.ownerScopeSnapshot)) return; + if (!this.ownerScopeUsable(GhostNodeRuntimeBroker.instanceId(entry.ghost), entry.ownerScopeSnapshot)) return; entry.child.sendControl?.(message); } @@ -1252,7 +1257,7 @@ export class GhostNodeRuntimeBroker { entry: WorkerEntry, message: Extract, ): Promise { - const ghostId = entry.ghost.manifest.id; + const ghostId = GhostNodeRuntimeBroker.instanceId(entry.ghost); const fail = (reason: string): void => { this.deps.log?.warn('ghost node child spawn rejected', { ghostId, reason }); this.replyToWorker(entry, { @@ -1482,7 +1487,7 @@ export class GhostNodeRuntimeBroker { this.stopStartingChild(starting); } for (const [key, entry] of [...this.workers]) { - if (entry.ghost.manifest.id === ghostId) this.stopWorker(key, entry); + if (GhostNodeRuntimeBroker.instanceId(entry.ghost) === ghostId) this.stopWorker(key, entry); } } @@ -1687,18 +1692,18 @@ export class GhostNodeRuntimeBroker { entryRel: string, ownerScopeSnapshot: unknown, ): Promise { - const key = GhostNodeRuntimeBroker.keyOf(ghost.manifest.id, entryRel); + const key = GhostNodeRuntimeBroker.keyOf(GhostNodeRuntimeBroker.instanceId(ghost), entryRel); const inflight = this.startingWorkers.get(key); if (inflight) { const entry = await inflight; - this.assertOwnerScopeUsable(ghost.manifest.id, ownerScopeSnapshot); - this.assertOwnerScopeUsable(ghost.manifest.id, entry.ownerScopeSnapshot); + this.assertOwnerScopeUsable(GhostNodeRuntimeBroker.instanceId(ghost), ownerScopeSnapshot); + this.assertOwnerScopeUsable(GhostNodeRuntimeBroker.instanceId(ghost), entry.ownerScopeSnapshot); return entry; } const existing = this.workers.get(key); if (existing) { - this.assertOwnerScopeUsable(ghost.manifest.id, ownerScopeSnapshot); - this.assertOwnerScopeUsable(ghost.manifest.id, existing.ownerScopeSnapshot); + this.assertOwnerScopeUsable(GhostNodeRuntimeBroker.instanceId(ghost), ownerScopeSnapshot); + this.assertOwnerScopeUsable(GhostNodeRuntimeBroker.instanceId(ghost), existing.ownerScopeSnapshot); return existing; } this.startingWorkerScopes.set(key, ownerScopeSnapshot); @@ -1718,8 +1723,8 @@ export class GhostNodeRuntimeBroker { key: string, ownerScopeSnapshot: unknown, ): Promise { - this.assertOwnerScopeUsable(ghost.manifest.id, ownerScopeSnapshot); - if (this.destroyed || this.stoppedGhosts.has(ghost.manifest.id)) { + this.assertOwnerScopeUsable(GhostNodeRuntimeBroker.instanceId(ghost), ownerScopeSnapshot); + if (this.destroyed || this.stoppedGhosts.has(GhostNodeRuntimeBroker.instanceId(ghost))) { throw new WorkerStartError('Node 工作进程启动已取消', false, true); } this.sendStatus(ghost, 'starting', undefined, entryRel); @@ -1728,11 +1733,11 @@ export class GhostNodeRuntimeBroker { for (let attempt = 1; attempt <= WORKER_START_ATTEMPTS; attempt++) { if (attempt > 1) { await this.delay(WORKER_START_RETRY_DELAYS_MS[attempt - 2] ?? 750); - this.assertOwnerScopeUsable(ghost.manifest.id, ownerScopeSnapshot); + this.assertOwnerScopeUsable(GhostNodeRuntimeBroker.instanceId(ghost), ownerScopeSnapshot); // 退避期间插件可能被停用/卸载/更新/停止,主机也可能正在退出: // 现查现用;已停用/已收摊/已停止就不再拉进程,也不补发状态事件。 - if (this.destroyed || this.stoppedGhosts.has(ghost.manifest.id)) throw lastError; - const fresh = this.deps.getGhost(ghost.manifest.id); + if (this.destroyed || this.stoppedGhosts.has(GhostNodeRuntimeBroker.instanceId(ghost))) throw lastError; + const fresh = this.deps.getGhost(GhostNodeRuntimeBroker.instanceId(ghost)); if (!fresh?.enabled) throw lastError; // 跨更新边界时重验入口:新 manifest 可能已不再申报该 entry。 if (!fresh.manifest.node) throw lastError; @@ -1814,7 +1819,7 @@ export class GhostNodeRuntimeBroker { ownerScopeSnapshot: unknown, attemptDiagnostic: StartAttemptDiagnostic, ): Promise { - this.assertOwnerScopeUsable(ghost.manifest.id, ownerScopeSnapshot); + this.assertOwnerScopeUsable(GhostNodeRuntimeBroker.instanceId(ghost), ownerScopeSnapshot); const node = ghost.manifest.node; if (!node) throw new WorkerStartError('ghost.json 缺少 node 工作进程详单', false); const entryPath = path.resolve(ghost.dir, ...entryRel.split('/')); @@ -1835,7 +1840,7 @@ export class GhostNodeRuntimeBroker { { error: 'utility-process-fork-threw' }, ); } - this.trackLiveProcess(ghost.manifest.id, child); + this.trackLiveProcess(GhostNodeRuntimeBroker.instanceId(ghost), child); const readChildDiagnosticPid = (): number | undefined => readDiagnosticPid(child); attemptDiagnostic.pid = readChildDiagnosticPid(); const entry: WorkerEntry = { @@ -1907,7 +1912,7 @@ export class GhostNodeRuntimeBroker { // 诊断订阅失败不能改变 ready / retry 语义。 } this.workers.set(key, entry); - if (this.destroyed || this.stoppedGhosts.has(ghost.manifest.id)) { + if (this.destroyed || this.stoppedGhosts.has(GhostNodeRuntimeBroker.instanceId(ghost))) { this.workers.delete(key); try { child.kill('SIGKILL'); } catch { /* already gone */ } throw new WorkerStartError('Node 工作进程启动已取消', false, true); @@ -1946,7 +1951,7 @@ export class GhostNodeRuntimeBroker { // 进程已退出后不再续命——定时器已冻结,由 settleExit 统一结算。 if ( !entry.exitDrain - && this.ownerScopeUsable(entry.ghost.manifest.id, entry.ownerScopeSnapshot) + && this.ownerScopeUsable(GhostNodeRuntimeBroker.instanceId(entry.ghost), entry.ownerScopeSnapshot) ) { // stderr 是手册钦定的日志口——构建刷日志就是活着的证据,给续命请求重置沉默窗口。 this.renewPendingOnActivity(entry); @@ -2044,7 +2049,7 @@ export class GhostNodeRuntimeBroker { throw error; } entry.startupPhase = false; - this.assertOwnerScopeUsable(ghost.manifest.id, ownerScopeSnapshot); + this.assertOwnerScopeUsable(GhostNodeRuntimeBroker.instanceId(ghost), ownerScopeSnapshot); this.sendStatus(ghost, 'running', undefined, entryRel); this.scheduleIdleStop(entry); return entry; @@ -2069,7 +2074,7 @@ export class GhostNodeRuntimeBroker { }, 10_000, ).then(() => { - this.assertOwnerScopeUsable(entry.ghost.manifest.id, entry.ownerScopeSnapshot); + this.assertOwnerScopeUsable(GhostNodeRuntimeBroker.instanceId(entry.ghost), entry.ownerScopeSnapshot); this.writeLine(entry, { jsonrpc: '2.0', method: 'notifications/initialized', @@ -2095,7 +2100,7 @@ export class GhostNodeRuntimeBroker { callId?: string, secretInputCompleted = false, ): Promise { - this.assertOwnerScopeUsable(entry.ghost.manifest.id, ownerScopeSnapshot); + this.assertOwnerScopeUsable(GhostNodeRuntimeBroker.instanceId(entry.ghost), ownerScopeSnapshot); if (signal?.aborted) return Promise.reject(new NodeRpcError('cancelled', 'The tool call was cancelled')); this.clearIdleTimer(entry); const id = String(entry.nextId++); @@ -2205,9 +2210,9 @@ export class GhostNodeRuntimeBroker { } private handleStdout(entry: WorkerEntry, chunk: Buffer | string): void { - const key = GhostNodeRuntimeBroker.keyOf(entry.ghost.manifest.id, entry.entryRel); + const key = GhostNodeRuntimeBroker.keyOf(GhostNodeRuntimeBroker.instanceId(entry.ghost), entry.entryRel); if (this.workers.get(key) !== entry) return; - if (!this.ownerScopeUsable(entry.ghost.manifest.id, entry.ownerScopeSnapshot)) return; + if (!this.ownerScopeUsable(GhostNodeRuntimeBroker.instanceId(entry.ghost), entry.ownerScopeSnapshot)) return; entry.stdoutBuffer += entry.stdoutDecoder.write( Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, 'utf8'), ); @@ -2236,7 +2241,7 @@ export class GhostNodeRuntimeBroker { } private handleRpcMessage(entry: WorkerEntry, message: unknown): void { - if (!this.ownerScopeUsable(entry.ghost.manifest.id, entry.ownerScopeSnapshot)) return; + if (!this.ownerScopeUsable(GhostNodeRuntimeBroker.instanceId(entry.ghost), entry.ownerScopeSnapshot)) return; if (!message || typeof message !== 'object' || Array.isArray(message)) { this.failProtocol(entry, 'Node 返回的 JSON-RPC 消息必须是对象'); return; @@ -2266,7 +2271,7 @@ export class GhostNodeRuntimeBroker { entry.pending.delete(String(msg.id)); this.clearTimer(pending.timer); pending.cleanup(); - if (!this.ownerScopeUsable(entry.ghost.manifest.id, pending.ownerScopeSnapshot)) { + if (!this.ownerScopeUsable(GhostNodeRuntimeBroker.instanceId(entry.ghost), pending.ownerScopeSnapshot)) { pending.reject(new NodeRpcError('exit', 'Plugin owner boundary changed before response')); return; } @@ -2298,7 +2303,7 @@ export class GhostNodeRuntimeBroker { return; } if (typeof msg.method === 'string') { - this.deps.sendToGhost?.(entry.ghost.manifest.id, { + this.deps.sendToGhost?.(GhostNodeRuntimeBroker.instanceId(entry.ghost), { type: 'event', name: 'node-notification', method: msg.method, @@ -2339,7 +2344,7 @@ export class GhostNodeRuntimeBroker { // already belongs to the new owner. The runtime-level invalidation callback // is only safe when no fresh generation exists for the same ghost. for (const [key, entry] of [...this.workers]) { - if (entry.ghost.manifest.id !== ghostId) continue; + if (GhostNodeRuntimeBroker.instanceId(entry.ghost) !== ghostId) continue; if (isGhostOwnerScopeUsable(this.deps.ownerScope, entry.ownerScopeSnapshot)) { hasCurrentWorker = true; continue; @@ -2374,7 +2379,7 @@ export class GhostNodeRuntimeBroker { signal: string | null, error: Error | null, ): void { - const ghostId = entry.ghost.manifest.id; + const ghostId = GhostNodeRuntimeBroker.instanceId(entry.ghost); const key = GhostNodeRuntimeBroker.keyOf(ghostId, entry.entryRel); if (this.workers.get(key) !== entry) { // stopWorker 已先移除 map。只有真实 exit 才能取消强杀;error 仍可能 @@ -2451,7 +2456,7 @@ export class GhostNodeRuntimeBroker { entry.stderrSegments.push({ text: tail, at: this.now() }); entry.stderrTotalChars += tail.length; } - const ghostId = entry.ghost.manifest.id; + const ghostId = GhostNodeRuntimeBroker.instanceId(entry.ghost); const exitHint = this.exitStderrHint(entry, exitedAt); const detail = `${this.redactSecrets(entry, error?.message ?? `code=${code}, signal=${signal ?? 'none'}`)}${ exitHint ? `:${exitHint}` : '' @@ -2537,7 +2542,7 @@ export class GhostNodeRuntimeBroker { } private scheduleIdleStop(entry: WorkerEntry): void { - const key = GhostNodeRuntimeBroker.keyOf(entry.ghost.manifest.id, entry.entryRel); + const key = GhostNodeRuntimeBroker.keyOf(GhostNodeRuntimeBroker.instanceId(entry.ghost), entry.entryRel); if (this.workers.get(key) !== entry) return; if (entry.ghost.manifest.node?.lifecycle === 'resident' || entry.pending.size > 0) return; // 有代启子进程在世时不空闲回收——收 worker 会级联杀掉正在干活的代理。 @@ -2578,7 +2583,7 @@ export class GhostNodeRuntimeBroker { ): void { // entry 字段只在非主入口时携带("缺省 = 主入口"的协议语义;老包零变化)。 const isExtraEntry = entryRel !== undefined && entryRel !== ghost.manifest.node?.entry; - this.deps.sendToGhost?.(ghost.manifest.id, { + this.deps.sendToGhost?.(GhostNodeRuntimeBroker.instanceId(ghost), { type: 'event', name: 'node-status', state, diff --git a/apps/desktop/src/main/cindy-brain/pickGrantsStore.ts b/apps/desktop/src/main/cindy-brain/pickGrantsStore.ts index 860c651bb4d..2e0bddce0e6 100644 --- a/apps/desktop/src/main/cindy-brain/pickGrantsStore.ts +++ b/apps/desktop/src/main/cindy-brain/pickGrantsStore.ts @@ -24,9 +24,16 @@ import { normalizeWorkingDirForStorage } from '../../shared/workingDir.js'; import { desktopMakerLogger } from '../maker-host/logger-adapter.js'; import { createOverrideSettingsFile } from '../maker-host/override-settings-file.js'; import { ownerScopedUserDataPath } from '../appSessionState.js'; +import { assertGhostPrefsWritable, relocateGhostPreferenceMaps } from './ghostPreferenceRelocation.js'; const log = desktopMakerLogger.child('pick-grants-store'); +export async function relocateGhostPickedDirs(from: string, to: string): Promise { + await relocateGhostPreferenceMaps('ghost-pick-grants.json', ['grants'], from, to, () => { + store = createStore(); + }); +} + /** 每插件保留的亲选目录条数(超出淘汰最旧;够覆盖"换过几次项目目录")。 */ export const GRANTS_PER_GHOST = 8; /** 单条路径长度上限(与 errand 配置 workingDir 同口径)。 */ @@ -58,16 +65,22 @@ function normalize(raw: unknown): GhostPickGrants { return { grants }; } -const store = createOverrideSettingsFile({ - filePath: () => ownerScopedUserDataPath('ghost-pick-grants.json'), - defaults: DEFAULTS, - normalize, - log, - label: 'ghost-pick-grants', -}); +function createStore() { + return createOverrideSettingsFile({ + filePath: () => ownerScopedUserDataPath('ghost-pick-grants.json'), + defaults: DEFAULTS, + normalize, + log, + label: 'ghost-pick-grants', + preserveUnreadableFile: true, + }); +} + +let store = createStore(); /** 记一笔亲选目录(pick 槽成功交付时调用;重选同目录提位到最前)。 */ export function recordGhostPickedDir(ghostId: string, dirAbs: string): void { + assertGhostPrefsWritable('ghost-pick-grants.json'); const dir = normalizeWorkingDirForStorage(dirAbs); if (!dir || dir.length > MAX_DIR_LEN) return; store.invalidateIfChanged(); diff --git a/apps/desktop/src/main/cindy-brain/pickSlot.ts b/apps/desktop/src/main/cindy-brain/pickSlot.ts index d57b37cecd7..20dc7ca5bbe 100644 --- a/apps/desktop/src/main/cindy-brain/pickSlot.ts +++ b/apps/desktop/src/main/cindy-brain/pickSlot.ts @@ -35,6 +35,7 @@ import { sanitizeGhostNoticeText } from './notifySlot.js'; export interface PickSlotDeps { getGhost(id: string): InstalledGhost | null; + getMutationTarget(id: string): string | null; /** * 弹系统级选文件夹窗口;返回所选绝对路径,取消返回 null。 * 找不到可挂靠的 Cindy 窗口时应 reject(失败关闭,不弹无主对话框)。 @@ -114,6 +115,8 @@ export class GhostPickSlot { const purposeRaw = typeof request.title === 'string' ? sanitizeGhostNoticeText(request.title) : ''; const purpose = purposeRaw ? purposeRaw.slice(0, GHOST_PICK_TITLE_MAX_CHARS) : null; + const target = this.deps.getMutationTarget(ghostId); + if (target === null) return fail('PERMISSION_DENIED', '插件安装或账户状态已变化'); this.dialogInFlight = true; let picked: string | null; @@ -134,6 +137,11 @@ export class GhostPickSlot { if (picked === null) { return fail('CANCELLED', '用户取消了选择'); } + const current = this.deps.getGhost(ghostId); + if (!current?.enabled || current.manifest.pick !== true || + this.deps.getMutationTarget(ghostId) !== target) { + return fail('PERMISSION_DENIED', '插件安装或账户状态已变化,请重新选择'); + } // 用户已亲手选中:先记台账再分档发结果(票据签发失败也不该丢掉这次 // 授权事实——用户确实选了)。 this.deps.recordPickedDir?.(ghostId, picked); diff --git a/apps/desktop/src/main/cindy-brain/pipeDispatcher.ts b/apps/desktop/src/main/cindy-brain/pipeDispatcher.ts index 70cf845b6eb..9aaf477c680 100644 --- a/apps/desktop/src/main/cindy-brain/pipeDispatcher.ts +++ b/apps/desktop/src/main/cindy-brain/pipeDispatcher.ts @@ -27,12 +27,13 @@ import type { InstalledGhost, } from '../../shared/ghost.js'; import { GHOST_PIPE_CALL_MAX_TOTAL_MS, isGhostPluginErrorCode } from '../../shared/ghost.js'; +import { installedGhostStoragePart } from '../../shared/pluginIdentity.js'; import type { GhostRuntimeState } from './runtime/GhostRuntime.js'; import { isGhostOwnerScopeUsable, type GhostOwnerScope } from './ghostOwnerScope.js'; export interface PipeDispatcherDeps { /** 按 id 取已装意识(未装 → null)。 */ - getGhost(id: string): InstalledGhost | null; + getGhost(id: string, namespace?: string | null): InstalledGhost | null; /** 当前运行时状态。 */ runtimeStateOf(id: string): GhostRuntimeState; /** 拉起沙箱(幂等;fused/stopping 拒绝)。 */ @@ -172,6 +173,7 @@ export class GhostPipeDispatcher { private async dispatchCall(request: { ghostId: string; + namespace?: string | null; tool: string; args: Record; /** @@ -186,10 +188,10 @@ export class GhostPipeDispatcher { /** Trusted Host session attribution, never accepted from plugin input. */ sessionId?: string; }): Promise { - const { ghostId, tool, args } = request; + const { ghostId, namespace, tool, args } = request; // ── 资格审 ───────────────────────────────────────────────────────── - const ghost = this.deps.getGhost(ghostId); + const ghost = this.deps.getGhost(ghostId, namespace); if (!ghost) { return { ok: false, errorCode: 'GHOST_NOT_FOUND', message: `插件 ${ghostId} 未安装或已卸载` }; } @@ -200,7 +202,8 @@ export class GhostPipeDispatcher { if (!declared) { return { ok: false, errorCode: 'TOOL_NOT_FOUND', message: toolNotFoundMessage(ghostId, tool, ghost.manifest.tools) }; } - if (this.deps.runtimeStateOf(ghostId) === 'fused') { + const storagePart = installedGhostStoragePart(ghost); + if (this.deps.runtimeStateOf(storagePart) === 'fused') { return { ok: false, errorCode: 'GHOST_CRASHED', message: `插件 ${ghostId} 已熔断(反复崩溃),重载或重新启用后再试` }; } let ownerScopeSnapshot: unknown; @@ -211,13 +214,13 @@ export class GhostPipeDispatcher { } // ── 按需拉起 ──────────────────────────────────────────────────────── - if (this.deps.runtimeStateOf(ghostId) !== 'running') { + if (this.deps.runtimeStateOf(storagePart) !== 'running') { const spawned = await this.deps.spawn(ghost); if (!spawned.ok) { return { ok: false, errorCode: 'GHOST_CRASHED', message: `插件启动失败:${spawned.reason}` }; } } - if (!this.ownerScopeUsable(ghostId, ownerScopeSnapshot)) { + if (!this.ownerScopeUsable(storagePart, ownerScopeSnapshot)) { return this.ownerBoundaryResult(); } @@ -230,7 +233,7 @@ export class GhostPipeDispatcher { const baseTimeoutMs = this.baseTimeoutMs(request.timeoutMs); const entry: PendingCall = { sessionId: request.sessionId, - ghostId, + ghostId: storagePart, tool, ownerScopeSnapshot, claimedBindings: new Map(), @@ -247,11 +250,11 @@ export class GhostPipeDispatcher { this.pending.set(callId, entry); this.armTimer(callId, entry); - if (!this.ownerScopeUsable(ghostId, ownerScopeSnapshot)) { + if (!this.ownerScopeUsable(storagePart, ownerScopeSnapshot)) { this.settle(callId, this.ownerBoundaryResult()); return; } - if (!this.deps.sendToGhost(ghostId, payload)) { + if (!this.deps.sendToGhost(storagePart, payload)) { // 逻辑页不在线(拉起后瞬时死亡等):立即收卷。 this.settle(callId, { ok: false, errorCode: 'GHOST_CRASHED', message: '电子脑离线,派发失败' }); } diff --git a/apps/desktop/src/main/cindy-brain/previewGate.ts b/apps/desktop/src/main/cindy-brain/previewGate.ts index 2ce15da02e5..10359fceffd 100644 --- a/apps/desktop/src/main/cindy-brain/previewGate.ts +++ b/apps/desktop/src/main/cindy-brain/previewGate.ts @@ -21,7 +21,8 @@ * 真实组装在 cindy-brain/index.ts(账本/字节仓/webContents)。 */ -import { GHOST_SCHEME } from '../../shared/ghost.js'; +import { GHOST_SCHEME, type GhostPanelMediaTarget } from '../../shared/ghost.js'; +import { parsePluginStoragePart } from '../../shared/pluginIdentity.js'; /** 面板导航到该路径前缀 = 预览请求(不是真页面,协议 handler 也不会服务它)。 */ export const GHOST_PREVIEW_PATH_PREFIX = '/preview/'; @@ -135,9 +136,15 @@ export type GhostPanelMediaResolved = | { url: string; kind: 'image' } | { url: string; kind: 'video'; absPath: string; size: number; name: string; ext: string; mimeType: string }; +function ghostPanelMediaStoragePart(ghostId: string, target?: GhostPanelMediaTarget): string | null { + if (target && target.ghostId !== ghostId) return null; + const instanceId = target?.instanceId ?? ghostId; + return parsePluginStoragePart(instanceId)?.ghostId === ghostId ? instanceId : null; +} + /** * 面板媒体换发闸(拖拽引渡 / 右键菜单共用一条校验链): - * 形状 → 账本归属(绑定 URL 里声明的意识 id)→ mime(账本为准,不信后缀)。 + * 形状 → 账本归属(绑定 Main 核准的安装实例)→ mime(账本为准,不信后缀)。 * 图片 / 视频都放行:图片回 cindy-media 地址;视频额外解析指纹仓磁盘路径与 * 体积(路径解析或 stat 失败视同查无,统一 null)。 * 任一环不过返回 null,调用方统一 NOT_FOUND,不区分原因。 @@ -152,11 +159,13 @@ export async function resolveGhostPanelMedia( /** 文件体积(附件托盘/发送链路要 size;文件缺失时 reject)。 */ statSize(absPath: string): Promise; }, + target?: GhostPanelMediaTarget, ): Promise { // 两用途同一形状预筛(图片 + 视频);保留 purpose 是给未来通道分化留位。 const parsed = purpose === 'menu' ? parseGhostPanelMediaUrl(uri) : parseGhostMediaHandoverUrl(uri); if (!parsed) return null; - if (!(await deps.ghostCanRead(parsed.hash, parsed.ghostId))) return null; + const instanceId = ghostPanelMediaStoragePart(parsed.ghostId, target); + if (!instanceId || !(await deps.ghostCanRead(parsed.hash, instanceId))) return null; const info = await deps.getBlobInfo(parsed.hash); const kind = !info ? null @@ -334,23 +343,24 @@ export class GhostPreviewGate { constructor(private readonly deps: GhostPreviewGateDeps) {} - async request(params: { - ghostId: string; + async request(params: GhostPanelMediaTarget & { url: string; /** 面板 webview 当前是否持有焦点(guestContents.isFocused)。 */ isPanelFocused: () => boolean; }): Promise { const parsed = parseGhostPreviewUrl(params.url, params.ghostId); if (!parsed) return { ok: false, reason: 'bad-url' }; + const instanceId = ghostPanelMediaStoragePart(params.ghostId, params); + if (!instanceId) return { ok: false, reason: 'bad-url' }; // 焦点闸:用户不在面板上 = 不是用户点的,拒。lightbox 打开后焦点离开 // 面板,自动触发的连环预览在这里断链。 if (!params.isPanelFocused()) return { ok: false, reason: 'not-focused' }; const now = this.deps.now?.() ?? Date.now(); - const last = this.lastOpenedAt.get(params.ghostId); + const last = this.lastOpenedAt.get(instanceId); if (last !== undefined && now - last < GHOST_PREVIEW_MIN_INTERVAL_MS) { return { ok: false, reason: 'rate-limited' }; } - if (!(await this.deps.ghostCanRead(parsed.hash, params.ghostId))) { + if (!(await this.deps.ghostCanRead(parsed.hash, instanceId))) { return { ok: false, reason: 'not-owned' }; } // ext/mime 以账本为准(URL 后缀只是预筛):图片/视频各归各的 lightbox @@ -364,7 +374,7 @@ export class GhostPreviewGate { ? ('video' as const) : null; if (!info || !kind) return { ok: false, reason: 'not-media' }; - this.lastOpenedAt.set(params.ghostId, now); + this.lastOpenedAt.set(instanceId, now); return { ok: true, src: this.deps.blobUrl(parsed.hash, info.ext), kind }; } } diff --git a/apps/desktop/src/main/cindy-brain/residentGhost.ts b/apps/desktop/src/main/cindy-brain/residentGhost.ts index 717ce391e6b..60950134eaf 100644 --- a/apps/desktop/src/main/cindy-brain/residentGhost.ts +++ b/apps/desktop/src/main/cindy-brain/residentGhost.ts @@ -8,11 +8,13 @@ export function isResidentBrowserGhost(manifest: GhostManifest): boolean { /** Shared startup/enable/recovery path; runtime spawn retains its own authorization checks. */ export function spawnResidentGhost(ghost: InstalledGhost, deps: { isAvailable: (id: string) => boolean; + allowPendingLegacy?: boolean; startNode: (ghost: InstalledGhost) => Promise; spawnBrowser: (ghost: InstalledGhost) => Promise<{ ok: boolean; reason?: string }>; warn: (message: string, fields: Record) => void; }): void { - if (!ghost.enabled || !deps.isAvailable(ghost.manifest.id)) return; + if (!ghost.enabled || (ghost.namespaceMigration === 'pending' && !deps.allowPendingLegacy) || + !deps.isAvailable(ghost.manifest.id)) return; // Node residency remains an independent declaration; routine events do not expand it. if (ghost.manifest.node?.lifecycle === 'resident') { void deps.startNode(ghost).catch((error) => { diff --git a/apps/desktop/src/main/cindy-brain/runtime/GhostRuntime.ts b/apps/desktop/src/main/cindy-brain/runtime/GhostRuntime.ts index 7ea543c0434..ad6f0f771ba 100644 --- a/apps/desktop/src/main/cindy-brain/runtime/GhostRuntime.ts +++ b/apps/desktop/src/main/cindy-brain/runtime/GhostRuntime.ts @@ -1,4 +1,5 @@ import type { InstalledGhost } from '../../../shared/ghost.js'; +import { installedGhostStoragePart } from '../../../shared/pluginIdentity.js'; /** * GhostRuntime — 意识运行时状态机(docs/dev-rules/plugin-security-and-authoring.md)。 @@ -83,7 +84,7 @@ export class GhostRuntime { * 即用户重新唤醒);stopping 拒绝(等熄灯完成)。 */ async spawn(ghost: InstalledGhost): Promise<{ ok: true; state: GhostRuntimeState } | { ok: false; reason: string }> { - const id = ghost.manifest.id; + const id = installedGhostStoragePart(ghost); const entry = this.getEntry(id); if (entry.state === 'starting' || entry.state === 'running') return { ok: true, state: entry.state }; if (entry.state === 'fused') return { ok: false, reason: '已熔断(反复崩溃),重新唤醒后可再试' }; diff --git a/apps/desktop/src/main/cindy-brain/runtime/__tests__/GhostRuntime.test.ts b/apps/desktop/src/main/cindy-brain/runtime/__tests__/GhostRuntime.test.ts index 3ce7dd08b44..378f790137b 100644 --- a/apps/desktop/src/main/cindy-brain/runtime/__tests__/GhostRuntime.test.ts +++ b/apps/desktop/src/main/cindy-brain/runtime/__tests__/GhostRuntime.test.ts @@ -165,4 +165,22 @@ describe('GhostRuntime · 状态机', () => { expect(runtime.stateOf('a')).toBe('off'); expect(runtime.stateOf('b')).toBe('off'); }); + + it('同 ghostId 的 root 与企业实例可同时 running', async () => { + const { runtime, handles } = setup(); + const root = chipGhost('helper'); + const enterprise: InstalledGhost = { + ...chipGhost('helper'), + namespace: 'acme', + dir: '/fake/brain/_ns/acme/helper', + }; + expect((await runtime.spawn(root)).ok).toBe(true); + expect((await runtime.spawn(enterprise)).ok).toBe(true); + expect(handles.length).toBe(2); + expect(runtime.stateOf('helper')).toBe('running'); + expect(runtime.stateOf('_ns__acme__helper')).toBe('running'); + runtime.stop('helper'); + expect(runtime.stateOf('helper')).toBe('off'); + expect(runtime.stateOf('_ns__acme__helper')).toBe('running'); + }); }); diff --git a/apps/desktop/src/main/cindy-brain/runtime/__tests__/electronSandboxAdapter.ownerPartition.test.ts b/apps/desktop/src/main/cindy-brain/runtime/__tests__/electronSandboxAdapter.ownerPartition.test.ts index e27de8b472b..2bdbaba47b8 100644 --- a/apps/desktop/src/main/cindy-brain/runtime/__tests__/electronSandboxAdapter.ownerPartition.test.ts +++ b/apps/desktop/src/main/cindy-brain/runtime/__tests__/electronSandboxAdapter.ownerPartition.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createHash } from 'node:crypto'; const harness = vi.hoisted(() => { let nextWebContentsId = 1; @@ -103,12 +104,22 @@ import type { GhostMediaModelsResult, InstalledGhost, } from '../../../../shared/ghost'; +import { GhostConnectionManager } from '../../ghostConnections.js'; +import { GhostOauthAccountManager } from '../../ghostOauthAccounts.js'; +import { handleGhostConnectionsRequest } from '../ghostConnectionsEndpoint.js'; +import { handleGhostOauthRequest } from '../ghostOauthEndpoint.js'; +import { handleGhostSecretsRequest } from '../ghostSecretsEndpoint.js'; +import { ownerScopedGhostPartitionForInstalledGhost } from '../../ghostWebviewPartition.js'; import { electronSandboxAdapter, ensureGhostProtocolRegistered, + revokeLegacyGhostProtocolPartition, setGhostAppContextProvider, setGhostAgentModelsProvider, setGhostKvStore, + setGhostConnectionsHandler, + setGhostOauthHandler, + setGhostSecretsHandler, setGhostMediaModelsProvider, } from '../electronSandboxAdapter'; @@ -130,6 +141,15 @@ function ghost(id: string): InstalledGhost { }; } +function approvedPartition(base: string, generation = 1, dir?: string): string { + const storagePart = base.split(':')[3]; + const ghostId = storagePart.startsWith('_ns__') ? storagePart.split('__')[2] : storagePart; + const receipt = createHash('sha256') + .update(JSON.stringify(['approved:00000000-0000-4000-8000-000000000001', dir ?? '/plugins/' + ghostId, generation])) + .digest('hex'); + return base + ':receipt:' + receipt; +} + beforeEach(() => { harness.activeOwner = { mode: 'cloud', dataOwnerId: 'owner-a', generation: 1 }; harness.sessions.clear(); @@ -138,9 +158,240 @@ beforeEach(() => { harness.browserWindowOptions.length = 0; kvEndpoint.handleGhostKvRequest.mockReset(); kvEndpoint.readBoundedBodyText.mockReset(); + setGhostKvStore({ read: () => ({}), write: vi.fn(), captureTarget: () => 'approved', isTargetCurrent: () => true }); }); describe('electronSandboxAdapter owner partition', () => { + it.each(['/media/hash.png', '/library/result.png', '/gallery', '/wake', '/app-context', '/agent-models', '/media-models?type=image', '/kv', '/secrets/token', '/oauth/token/client', '/connections/token', '/', '/panel.html'])('rejects stale registration before routing %s', async (pathname) => { + let target = 'original'; + setGhostKvStore({ read: () => ({}), write: vi.fn(), captureTarget: () => target, isTargetCurrent: (_id, expected) => expected === target }); + const installed = ghost('route-' + pathname.replace(/[^a-z]/g, '').slice(0, 25)); + ensureGhostProtocolRegistered(installed); + const handler = harness.sessions.get(ownerScopedGhostPartitionForInstalledGhost(installed, harness.activeOwner)!)!.protocolHandler!; + target = 'replacement'; + const readUrl = vi.fn(() => 'cindy-ghost://' + installed.manifest.id + pathname); + const request = { get url() { return readUrl(); } } as Request; + expect((await handler(request)).status).toBe(403); + expect(readUrl).not.toHaveBeenCalled(); + expect(kvEndpoint.readBoundedBodyText).not.toHaveBeenCalled(); + }); + + it.each([undefined, { state: 'legacy-unapproved' as const }, { state: 'invalid' as const }])('denies unsigned or nonapproved registrations without a trusted target (%s)', async (approval) => { + setGhostKvStore({ read: () => ({}), write: vi.fn(), captureTarget: () => null, isTargetCurrent: () => true }); + const installed = { ...ghost('unapproved-' + (approval?.state ?? 'missing')), approval } as InstalledGhost; + ensureGhostProtocolRegistered(installed); + const handler = harness.sessions.get(ownerScopedGhostPartitionForInstalledGhost(installed, harness.activeOwner)!)!.protocolHandler!; + expect((await handler(new Request('cindy-ghost://' + installed.manifest.id + '/kv', { method: 'PUT' }))).status).toBe(403); + expect(kvEndpoint.readBoundedBodyText).not.toHaveBeenCalled(); + }); + + it('keeps old A denied and new A usable after an owner generation ABA', async () => { + const installed = ghost('owner-aba'); + const currentTarget = () => JSON.stringify([harness.activeOwner.mode, harness.activeOwner.dataOwnerId, harness.activeOwner.generation, installed.dir, installed.approval]); + setGhostKvStore({ read: () => ({}), write: vi.fn(), captureTarget: currentTarget, isTargetCurrent: (_id, expected) => currentTarget() === expected }); + ensureGhostProtocolRegistered(installed); + const firstPartition = ownerScopedGhostPartitionForInstalledGhost(installed, harness.activeOwner)!; + const oldHandler = harness.sessions.get(firstPartition)!.protocolHandler!; + harness.activeOwner = { mode: 'cloud', dataOwnerId: 'owner-b', generation: 2 }; + expect((await oldHandler(new Request('cindy-ghost://owner-aba/'))).status).toBe(403); + harness.activeOwner = { mode: 'cloud', dataOwnerId: 'owner-a', generation: 3 }; + ensureGhostProtocolRegistered(installed); + const currentPartition = ownerScopedGhostPartitionForInstalledGhost(installed, harness.activeOwner)!; + expect((await oldHandler(new Request('cindy-ghost://owner-aba/'))).status).toBe(403); + expect((await harness.sessions.get(currentPartition)!.protocolHandler!(new Request('cindy-ghost://owner-aba/'))).status).toBe(200); + expect(currentPartition).not.toBe(firstPartition); + }); + + it('rejects a delayed read response after its registered receipt changes', async () => { + let target = 'original'; + setGhostKvStore({ read: () => ({}), write: vi.fn(), captureTarget: () => target, isTargetCurrent: (_id, expected) => target === expected }); + let finishProvider!: (result: GhostMediaModelsResult) => void; + const provider = vi.fn(() => new Promise((resolve) => { finishProvider = resolve; })); + setGhostMediaModelsProvider(provider); + const installed = ghost('delayed-receipt-read'); + ensureGhostProtocolRegistered(installed); + const handler = harness.sessions.get(ownerScopedGhostPartitionForInstalledGhost(installed, harness.activeOwner)!)!.protocolHandler!; + const pending = handler(new Request('cindy-ghost://delayed-receipt-read/media-models?type=image')); + expect(provider).toHaveBeenCalledOnce(); + target = 'replacement'; + finishProvider({ ok: true, type: 'image', models: [], defaultModelId: null, defaultProviderId: null }); + expect((await pending).status).toBe(403); + }); + + it('denies a fresh old-session PUT after same-version receipt replacement and permits the new session', async () => { + const endpoint = await vi.importActual('../ghostKvEndpoint.js'); + kvEndpoint.handleGhostKvRequest.mockImplementation(endpoint.handleGhostKvRequest); + kvEndpoint.readBoundedBodyText.mockResolvedValue(JSON.stringify({ source: 'current' })); + let target = 'receipt-original'; + const write = vi.fn(); + const captureTarget = vi.fn(() => target); + setGhostKvStore({ read: () => ({}), write, captureTarget, isTargetCurrent: (_id, expected) => expected === target }); + const original = ghost('fresh-receipt-write'); + ensureGhostProtocolRegistered(original); + const oldPartition = ownerScopedGhostPartitionForInstalledGhost(original, harness.activeOwner)!; + const oldHandler = harness.sessions.get(oldPartition)!.protocolHandler!; + target = 'receipt-replacement'; + const replacement = { ...original, approval: { state: 'approved' as const, revision: '00000000-0000-4000-8000-000000000002' } }; + ensureGhostProtocolRegistered(replacement); + expect((await oldHandler(new Request('cindy-ghost://fresh-receipt-write/kv', { method: 'PUT' }))).status).toBe(403); + expect(write).not.toHaveBeenCalled(); + expect(kvEndpoint.readBoundedBodyText).not.toHaveBeenCalled(); + const newPartition = ownerScopedGhostPartitionForInstalledGhost(replacement, harness.activeOwner)!; + expect(newPartition).not.toBe(oldPartition); + expect((await harness.sessions.get(newPartition)!.protocolHandler!(new Request('cindy-ghost://fresh-receipt-write/kv', { method: 'PUT' }))).status).toBe(204); + expect(write).toHaveBeenCalledExactlyOnceWith('fresh-receipt-write', { source: 'current' }); + expect(captureTarget).toHaveBeenCalledTimes(2); + }); + + const credentialRoutes = [ + ['/secrets/token', 'PUT', '{"value":"fake-old-secret"}', 204], + ['/oauth/token/client', 'PUT', '{"clientId":"fake-old-client"}', 204], + ['/connections/token', 'POST', '{"host":"api.example.com","token":"fake-old-token"}', 200], + ] as const; + + it.each(credentialRoutes.flatMap((route) => + (['replacement', 'invalid', 'owner', 'unchanged'] as const).map((transition) => [...route, transition] as const), + ))('guards %s during a pending body (%s %s %s %s)', async (pathname, method, text, successStatus, transition) => { + let currentTarget: string | null = 'approved-original'; + const captureTarget = vi.fn(() => currentTarget); + const store = vi.fn(() => true); + const remove = vi.fn(); + const onChanged = vi.fn(); + const vault = { read: () => null, store, remove }; + const oauthManager = new GhostOauthAccountManager({ + vault, openExternal: vi.fn(), fetchImpl: vi.fn() as unknown as typeof fetch, + }); + const connectionManager = new GhostConnectionManager({ vault: { ...vault, readTail: () => null } }); + setGhostKvStore({ + read: vi.fn(() => ({})), write: vi.fn(), captureTarget, + isTargetCurrent: (_ghostId, expected) => currentTarget === expected, + }); + setGhostSecretsHandler((args) => handleGhostSecretsRequest({ + ...args, userSecretKeys: ['token'], + vault: { ...vault, saved: () => false, tail: () => null }, onStored: onChanged, + })); + setGhostOauthHandler((args) => handleGhostOauthRequest({ + ...args, manager: oauthManager, onChanged, + oauthSecrets: new Map([['token', { authorizeUrl: 'https://api.example.com/auth', tokenUrl: 'https://api.example.com/token', scopes: [] }]]), + })); + setGhostConnectionsHandler((args) => handleGhostConnectionsRequest({ + ...args, manager: connectionManager, onChanged, onAdded: onChanged, + decls: new Map([['token', { label: 'API', maxConnections: 2 }]]), confirmAddHost: async () => true, + })); + let finishBody!: (body: string) => void; + kvEndpoint.readBoundedBodyText.mockReturnValue(new Promise((resolve) => { finishBody = resolve; })); + const installed = ghost('cred-' + pathname.split('/')[1].slice(0, 4) + '-' + transition); + ensureGhostProtocolRegistered(installed); + const handler = harness.sessions.get(approvedPartition('cindy-ghost-owner:cloud:opaque-owner-a:' + installed.manifest.id))!.protocolHandler!; + const pending = handler(new Request('cindy-ghost://' + installed.manifest.id + pathname, { method })); + if (transition === 'replacement') currentTarget = 'approved-replacement'; + if (transition === 'invalid') currentTarget = null; + if (transition === 'owner') harness.activeOwner = { mode: 'cloud', dataOwnerId: 'owner-b', generation: 2 }; + finishBody(text); + expect((await pending).status).toBe(transition === 'unchanged' ? successStatus : 403); + if (transition === 'unchanged') { + expect(store).toHaveBeenCalled(); + expect(onChanged).toHaveBeenCalled(); + } else { + expect(store).not.toHaveBeenCalled(); + expect(onChanged).not.toHaveBeenCalled(); + } + expect(captureTarget).toHaveBeenCalledExactlyOnceWith(installed.manifest.id); + }); + + it.each(['replacement', 'invalid', 'owner'] as const)('rejects an old KV PUT after %s while its body is pending', async (transition) => { + const endpoint = await vi.importActual('../ghostKvEndpoint.js'); + kvEndpoint.handleGhostKvRequest.mockImplementation(endpoint.handleGhostKvRequest); + let finishBody!: (body: string) => void; + const body = new Promise((resolve) => { finishBody = resolve; }); + kvEndpoint.readBoundedBodyText.mockReturnValue(body); + const installed = { ...ghost('kv-late-' + transition), namespace: 'acme' }; + let target: string | null = 'approved-original'; + const captureTarget = vi.fn(() => target); + const write = vi.fn(); + setGhostKvStore({ + read: vi.fn(() => ({ source: 'replacement' })), write, captureTarget, + isTargetCurrent: (_ghostId, expected) => target === expected, + }); + ensureGhostProtocolRegistered(installed); + const partition = approvedPartition('cindy-ghost-owner:cloud:opaque-owner-a:_ns__acme__' + installed.manifest.id); + const handler = harness.sessions.get(partition)!.protocolHandler!; + const pending = handler(new Request('cindy-ghost://' + installed.manifest.id + '/kv', { method: 'PUT' })); + if (transition === 'replacement') target = 'approved-replacement'; + if (transition === 'invalid') target = null; + if (transition === 'owner') harness.activeOwner = { mode: 'cloud', dataOwnerId: 'owner-b', generation: 2 }; + finishBody('{"source":"old"}'); + expect((await pending).status).toBe(403); + expect(write).not.toHaveBeenCalled(); + expect(captureTarget).toHaveBeenCalledTimes(1); + expect(captureTarget).toHaveBeenNthCalledWith(1, installed.manifest.id); + }); + + it.each([undefined, null])('rejects an unavailable KV install target (%s) before body reading', async (target) => { + const endpoint = await vi.importActual('../ghostKvEndpoint.js'); + kvEndpoint.handleGhostKvRequest.mockImplementation(endpoint.handleGhostKvRequest); + const write = vi.fn(); + setGhostKvStore({ + read: vi.fn(() => ({})), write, captureTarget: () => target, isTargetCurrent: () => true, + }); + const installed = ghost('kv-unavailable-' + String(target)); + ensureGhostProtocolRegistered(installed); + const handler = harness.sessions.get(approvedPartition('cindy-ghost-owner:cloud:opaque-owner-a:' + installed.manifest.id))!.protocolHandler!; + expect((await handler(new Request('cindy-ghost://' + installed.manifest.id + '/kv', { method: 'PUT' }))).status).toBe(403); + expect(kvEndpoint.readBoundedBodyText).not.toHaveBeenCalled(); + expect(write).not.toHaveBeenCalled(); + }); + + it('allows a delayed KV PUT when the captured install remains current', async () => { + const endpoint = await vi.importActual('../ghostKvEndpoint.js'); + kvEndpoint.handleGhostKvRequest.mockImplementation(endpoint.handleGhostKvRequest); + let finishBody!: (body: string) => void; + kvEndpoint.readBoundedBodyText.mockReturnValue(new Promise((resolve) => { finishBody = resolve; })); + const captureTarget = vi.fn(() => 'approved-original'); + const write = vi.fn(); + setGhostKvStore({ + read: vi.fn(() => ({})), write, captureTarget, + isTargetCurrent: (_ghostId, expected) => expected === 'approved-original', + }); + ensureGhostProtocolRegistered(ghost('kv-unchanged')); + const handler = harness.sessions.get(approvedPartition('cindy-ghost-owner:cloud:opaque-owner-a:kv-unchanged'))!.protocolHandler!; + const pending = handler(new Request('cindy-ghost://kv-unchanged/kv', { method: 'PUT' })); + finishBody('{"source":"current"}'); + expect((await pending).status).toBe(204); + expect(write).toHaveBeenCalledExactlyOnceWith('kv-unchanged', { source: 'current' }); + expect(captureTarget).toHaveBeenCalledExactlyOnceWith('kv-unchanged'); + }); + + it('separates an in-place organization WebView session from a later root install', () => { + const organization = { ...ghost('shared'), namespace: 'acme' }; + ensureGhostProtocolRegistered(organization); + expect(harness.sessions.has(approvedPartition('cindy-ghost-owner:cloud:opaque-owner-a:_ns__acme__shared'))).toBe(true); + expect(harness.sessions.has(approvedPartition('cindy-ghost-owner:cloud:opaque-owner-a:shared'))).toBe(false); + ensureGhostProtocolRegistered({ ...ghost('shared'), namespace: null, dir: '/plugins/_root/shared' }); + expect(harness.sessions.size).toBe(2); + }); + + it('does not reuse a legacy root session after an organization stamp and root replacement', () => { + ensureGhostProtocolRegistered(ghost('legacy-shared')); + ensureGhostProtocolRegistered({ ...ghost('legacy-shared'), namespace: 'acme' }); + ensureGhostProtocolRegistered({ ...ghost('legacy-shared'), namespace: null }); + expect([...harness.sessions.keys()]).toEqual([ + approvedPartition('cindy-ghost-owner:cloud:opaque-owner-a:legacy-shared'), + approvedPartition('cindy-ghost-owner:cloud:opaque-owner-a:_ns__acme__legacy-shared'), + approvedPartition('cindy-ghost-owner:cloud:opaque-owner-a:legacy-shared:root'), + ]); + }); + + it('refuses new protocol requests from a legacy WebView after namespace commit', async () => { + ensureGhostProtocolRegistered(ghost('committed-legacy')); + const oldHandler = harness.sessions.get(approvedPartition('cindy-ghost-owner:cloud:opaque-owner-a:committed-legacy'))?.protocolHandler; + revokeLegacyGhostProtocolPartition('committed-legacy'); + expect((await oldHandler?.(new Request('cindy-ghost://committed-legacy/kv')))?.status).toBe(403); + expect(kvEndpoint.handleGhostKvRequest).not.toHaveBeenCalled(); + ensureGhostProtocolRegistered({ ...ghost('committed-legacy'), namespace: null }); + const rootHandler = harness.sessions.get(approvedPartition('cindy-ghost-owner:cloud:opaque-owner-a:committed-legacy:root'))?.protocolHandler; + expect((await rootHandler?.(new Request('cindy-ghost://committed-legacy/')))?.status).toBe(200); + }); it('同 ghostId 的不同 owner 使用不同的非持久 session,并显式拒绝权限和下载', () => { const installed = ghost('same-ghost'); ensureGhostProtocolRegistered(installed, { @@ -155,10 +406,10 @@ describe('electronSandboxAdapter owner partition', () => { }); const sessionA = harness.sessions.get( - 'cindy-ghost-owner:cloud:opaque-owner-a:same-ghost', + approvedPartition('cindy-ghost-owner:cloud:opaque-owner-a:same-ghost'), ); const sessionB = harness.sessions.get( - 'cindy-ghost-owner:cloud:opaque-owner-b:same-ghost', + approvedPartition('cindy-ghost-owner:cloud:opaque-owner-b:same-ghost', 2), ); for (const registered of [sessionA, sessionB]) { expect(registered).toBeDefined(); @@ -172,7 +423,7 @@ describe('electronSandboxAdapter owner partition', () => { } }); - it('同 owner 只增加 generation 时复用原 partition', () => { + it('同 owner 增加 generation 时使用新 partition', () => { const installed = ghost('generation-stable'); ensureGhostProtocolRegistered(installed, { mode: 'cloud', @@ -185,14 +436,18 @@ describe('electronSandboxAdapter owner partition', () => { generation: 2, }); - expect(harness.fromPartition).toHaveBeenCalledOnce(); + expect(harness.fromPartition).toHaveBeenCalledTimes(2); expect([...harness.sessions.keys()]).toEqual([ - 'cindy-ghost-owner:cloud:opaque-owner-a:generation-stable', + approvedPartition('cindy-ghost-owner:cloud:opaque-owner-a:generation-stable'), + approvedPartition('cindy-ghost-owner:cloud:opaque-owner-a:generation-stable', 2), ]); }); it('owner 切换后旧 Session 的新请求在路由和业务读取前返回 403', async () => { - const store = { read: vi.fn(() => ({})), write: vi.fn() }; + const store = { + read: vi.fn(() => ({})), write: vi.fn(), + captureTarget: () => 'approved', isTargetCurrent: () => true, + }; setGhostKvStore(store); ensureGhostProtocolRegistered(ghost('stale-request'), { mode: 'cloud', @@ -200,7 +455,7 @@ describe('electronSandboxAdapter owner partition', () => { generation: 1, }); const sessionA = harness.sessions.get( - 'cindy-ghost-owner:cloud:opaque-owner-a:stale-request', + approvedPartition('cindy-ghost-owner:cloud:opaque-owner-a:stale-request'), ); const routeRead = vi.fn(() => 'cindy-ghost://stale-request/kv'); const request = { @@ -223,7 +478,7 @@ describe('electronSandboxAdapter owner partition', () => { expect(store.write).not.toHaveBeenCalled(); }); - it('同 owner generation 变化后旧 Session 仍可走静态与能力路由', async () => { + it('同 owner generation 变化后旧 Session 拒绝静态与能力路由', async () => { const appContext: GhostAppContextResult = { ok: true, context: { region: 'global', locale: 'en' }, @@ -237,7 +492,7 @@ describe('electronSandboxAdapter owner partition', () => { }); harness.activeOwner.generation = 99; const registered = harness.sessions.get( - 'cindy-ghost-owner:cloud:opaque-owner-a:active-request', + approvedPartition('cindy-ghost-owner:cloud:opaque-owner-a:active-request'), ); const bootResponse = await registered?.protocolHandler?.( @@ -247,9 +502,9 @@ describe('electronSandboxAdapter owner partition', () => { new Request('cindy-ghost://active-request/app-context'), ); - expect(bootResponse?.status).toBe(200); - expect(contextResponse?.status).toBe(200); - expect(appContextProvider).toHaveBeenCalledOnce(); + expect(bootResponse?.status).toBe(403); + expect(contextResponse?.status).toBe(403); + expect(appContextProvider).not.toHaveBeenCalled(); }); it('boot 与任意插件 HTML 响应统一允许 HTTPS 图片,其他 CSP 能力不放宽', async () => { @@ -258,7 +513,7 @@ describe('electronSandboxAdapter owner partition', () => { installed.manifest.mainView = { html: 'main-view.html' }; ensureGhostProtocolRegistered(installed); const registered = harness.sessions.get( - 'cindy-ghost-owner:cloud:opaque-owner-a:https-image-csp', + approvedPartition('cindy-ghost-owner:cloud:opaque-owner-a:https-image-csp'), ); for (const pathname of ['/', '/panel.html', '/settings.html', '/main-view.html', '/other.html']) { @@ -278,7 +533,7 @@ describe('electronSandboxAdapter owner partition', () => { it('插件 session 只额外放行 HTTPS image,同源资源保持放行', () => { ensureGhostProtocolRegistered(ghost('https-image-network')); const registered = harness.sessions.get( - 'cindy-ghost-owner:cloud:opaque-owner-a:https-image-network', + approvedPartition('cindy-ghost-owner:cloud:opaque-owner-a:https-image-network'), ); const handler = registered?.beforeRequest.mock.calls[0]?.[0] as | (( @@ -322,14 +577,14 @@ describe('electronSandboxAdapter owner partition', () => { ensureGhostProtocolRegistered(installed); const registered = harness.sessions.get( - 'cindy-ghost-owner:cloud:opaque-owner-a:https-image-listener-once', + approvedPartition('cindy-ghost-owner:cloud:opaque-owner-a:https-image-listener-once'), ); expect(harness.fromPartition).toHaveBeenCalledOnce(); expect(registered?.beforeRequest).toHaveBeenCalledOnce(); expect(registered?.protocolHandle).toHaveBeenCalledOnce(); }); - it('请求已进入 handler 后切换 owner 不取消或重新检查在途 provider', async () => { + it('请求已进入 handler 后切换 owner 拒绝在途 provider 的响应', async () => { let finishProvider!: (result: GhostMediaModelsResult) => void; const provider = vi.fn( () => @@ -344,7 +599,7 @@ describe('electronSandboxAdapter owner partition', () => { generation: 1, }); const registered = harness.sessions.get( - 'cindy-ghost-owner:cloud:opaque-owner-a:inflight-request', + approvedPartition('cindy-ghost-owner:cloud:opaque-owner-a:inflight-request'), ); const pending = registered?.protocolHandler?.( new Request('cindy-ghost://inflight-request/media-models?type=image'), @@ -362,8 +617,7 @@ describe('electronSandboxAdapter owner partition', () => { finishProvider(outcome); const response = await pending; - expect(response?.status).toBe(200); - await expect(response?.json()).resolves.toEqual(outcome); + expect(response?.status).toBe(403); }); it('相同 partition 不能被不同 owner snapshot 重新认领', () => { @@ -378,7 +632,7 @@ describe('electronSandboxAdapter owner partition', () => { ensureGhostProtocolRegistered(installed, { mode: 'cloud', dataOwnerId: 'collision-b', - generation: 2, + generation: 1, }), ).toThrow('ghost protocol partition already belongs to a different data owner'); }); @@ -388,7 +642,7 @@ describe('electronSandboxAdapter owner partition', () => { const handle = electronSandboxAdapter.create(ghost('panel-owner')); expect(harness.browserWindowOptions[0]?.webPreferences).toMatchObject({ - partition: 'cindy-ghost-owner:local:opaque-local-owner:panel-owner', + partition: approvedPartition('cindy-ghost-owner:local:opaque-local-owner:panel-owner', 4), }); expect( (harness.browserWindowOptions[0]?.webPreferences as { partition: string }).partition, @@ -398,11 +652,19 @@ describe('electronSandboxAdapter owner partition', () => { }); describe('read-only agent model directory', () => { + it('identifies the organization instance when a root plugin has the same id', async () => { + const provider = vi.fn().mockResolvedValue({ ok: true, models: [] }); + setGhostAgentModelsProvider(provider); + ensureGhostProtocolRegistered({ ...ghost('shared-models'), namespace: 'acme', dir: '/plugins/_ns/acme/shared-models' }); + const handler = harness.sessions.get(approvedPartition('cindy-ghost-owner:cloud:opaque-owner-a:_ns__acme__shared-models', 1, '/plugins/_ns/acme/shared-models'))?.protocolHandler; + expect((await handler?.(new Request('cindy-ghost://shared-models/agent-models')))?.status).toBe(200); + expect(provider).toHaveBeenCalledWith('_ns__acme__shared-models'); + }); it('serves no-store metadata without accepting writes or query overrides', async () => { const provider = vi.fn().mockResolvedValue({ ok: true, models: [] }); setGhostAgentModelsProvider(provider); ensureGhostProtocolRegistered(ghost('agent-directory')); - const handler = harness.sessions.get('cindy-ghost-owner:cloud:opaque-owner-a:agent-directory')!.protocolHandler!; + const handler = harness.sessions.get(approvedPartition('cindy-ghost-owner:cloud:opaque-owner-a:agent-directory'))!.protocolHandler!; for (const [url, method, status] of [ ['agent-directory/agent-models', 'GET', 200], ['agent-directory/agent-models', 'POST', 405], @@ -425,7 +687,7 @@ describe('read-only agent model directory', () => { let finish!: (value: { ok: true; models: [] }) => void; setGhostAgentModelsProvider(() => new Promise(resolve => { finish = resolve; })); ensureGhostProtocolRegistered(ghost('agent-owner')); - const handler = harness.sessions.get('cindy-ghost-owner:cloud:opaque-owner-a:agent-owner')!.protocolHandler!; + const handler = harness.sessions.get(approvedPartition('cindy-ghost-owner:cloud:opaque-owner-a:agent-owner'))!.protocolHandler!; const pending = handler(new Request('cindy-ghost://agent-owner/agent-models')); harness.activeOwner = { mode: 'cloud', dataOwnerId: 'owner-b', generation: 2 }; finish({ ok: true, models: [] }); @@ -438,7 +700,7 @@ it('rejects failed in-flight model reads after owner changes', async () => { let reject!: (error: Error) => void; setGhostAgentModelsProvider(() => new Promise((_resolve, fail) => { reject = fail; })); ensureGhostProtocolRegistered(ghost('agent-rejected-owner')); - const handler = harness.sessions.get('cindy-ghost-owner:cloud:opaque-owner-a:agent-rejected-owner')!.protocolHandler!; + const handler = harness.sessions.get(approvedPartition('cindy-ghost-owner:cloud:opaque-owner-a:agent-rejected-owner'))!.protocolHandler!; const pending = handler(new Request('cindy-ghost://agent-rejected-owner/agent-models')); harness.activeOwner = { mode: 'cloud', dataOwnerId: 'owner-b', generation: 2 }; reject(new Error('old visibility mirror cleared')); diff --git a/apps/desktop/src/main/cindy-brain/runtime/__tests__/ghostConnectionsEndpoint.test.ts b/apps/desktop/src/main/cindy-brain/runtime/__tests__/ghostConnectionsEndpoint.test.ts index 36913933f01..9f79ce20473 100644 --- a/apps/desktop/src/main/cindy-brain/runtime/__tests__/ghostConnectionsEndpoint.test.ts +++ b/apps/desktop/src/main/cindy-brain/runtime/__tests__/ghostConnectionsEndpoint.test.ts @@ -34,6 +34,75 @@ function makeVault(): GhostConnectionsVault { const G = 'cindy-gitlab'; const DECLS = new Map([['gitlab', { label: 'GitLab 实例', maxConnections: 2 }]]); +describe('connection install target guard', () => { + it.each(['body', 'confirmation'] as const)('rejects a source replacement during %s without writing or notifying', async (transition) => { + const manager = new GhostConnectionManager({ vault: makeVault() }); + const upsert = vi.spyOn(manager, 'upsert'); + const onChanged = vi.fn(); + const onAdded = vi.fn(); + let current = true; + const confirmAddHost = vi.fn(async () => { current = false; return true; }); + const result = await handleGhostConnectionsRequest({ + method: 'POST', pathname: '/connections/gitlab', ghostId: G, decls: DECLS, + manager, onChanged, onAdded, isCurrent: () => current, + readBodyText: async () => { + if (transition === 'body') current = false; + return '{"host":"gitlab.example.com","token":"fake-old-token"}'; + }, + confirmAddHost, + }); + expect(result).toEqual({ status: 403 }); + expect(upsert).not.toHaveBeenCalled(); + expect(manager.list(G, 'gitlab')).toEqual([]); + expect(onChanged).not.toHaveBeenCalled(); + expect(onAdded).not.toHaveBeenCalled(); + if (transition === 'body') expect(confirmAddHost).not.toHaveBeenCalled(); + }); + + it('rejects a late default selection without changing the replacement connection', async () => { + const manager = new GhostConnectionManager({ vault: makeVault() }); + const setDefault = vi.spyOn(manager, 'setDefault'); + const onChanged = vi.fn(); + let current = true; + expect(await handleGhostConnectionsRequest({ + method: 'POST', pathname: '/connections/gitlab/default', ghostId: G, decls: DECLS, + manager, onChanged, isCurrent: () => current, confirmAddHost: async () => true, + readBodyText: async () => { current = false; return '{"connectionId":"old-id"}'; }, + })).toEqual({ status: 403 }); + expect(setDefault).not.toHaveBeenCalled(); + expect(onChanged).not.toHaveBeenCalled(); + }); + + it('rejects connection deletion from an invalid install', async () => { + const manager = new GhostConnectionManager({ vault: makeVault() }); + const remove = vi.spyOn(manager, 'remove'); + const onChanged = vi.fn(); + expect(await handleGhostConnectionsRequest({ + method: 'DELETE', pathname: '/connections/gitlab/old-id', ghostId: G, decls: DECLS, + manager, onChanged, isCurrent: () => false, confirmAddHost: async () => true, + readBodyText: vi.fn(), + })).toEqual({ status: 403 }); + expect(remove).not.toHaveBeenCalled(); + expect(onChanged).not.toHaveBeenCalled(); + }); + + it('still confirms and writes for the unchanged current install', async () => { + const manager = new GhostConnectionManager({ vault: makeVault() }); + const confirmAddHost = vi.fn(async () => true); + const onChanged = vi.fn(); + const result = await handleGhostConnectionsRequest({ + method: 'POST', pathname: '/connections/gitlab', ghostId: G, decls: DECLS, + manager, onChanged, isCurrent: () => true, confirmAddHost, + readBodyText: async () => '{"host":"gitlab.example.com","token":"fake-current-token"}', + }); + expect(result.status).toBe(200); + expect(JSON.parse(result.body!)).toMatchObject({ ok: true }); + expect(confirmAddHost).toHaveBeenCalledExactlyOnceWith('GitLab 实例', 'gitlab.example.com'); + expect(manager.list(G, 'gitlab')).toHaveLength(1); + expect(onChanged).toHaveBeenCalledExactlyOnceWith('gitlab'); + }); +}); + function call(args: { method: string; pathname: string; diff --git a/apps/desktop/src/main/cindy-brain/runtime/__tests__/ghostKvEndpoint.test.ts b/apps/desktop/src/main/cindy-brain/runtime/__tests__/ghostKvEndpoint.test.ts index 3129e4522fe..d3499b8d0b2 100644 --- a/apps/desktop/src/main/cindy-brain/runtime/__tests__/ghostKvEndpoint.test.ts +++ b/apps/desktop/src/main/cindy-brain/runtime/__tests__/ghostKvEndpoint.test.ts @@ -29,6 +29,47 @@ function call(args: { } describe('cindy-brain · ghostKvEndpoint(/kv 分派纯函数)', () => { + it.each(['PUT', 'POST'])('rejects a late %s body after its install target changes', async (method) => { + const store = memStore({ source: 'replacement' }); + let current = true; + let finishBody!: (body: string) => void; + const body = new Promise((resolve) => { finishBody = resolve; }); + const pending = handleGhostKvRequest({ + method, ghostId: 'demo', store, readBodyText: () => body, + isCurrent: () => current, + }); + current = false; + finishBody('{"source":"old"}'); + expect(await pending).toEqual({ status: 403 }); + expect(store.write).not.toHaveBeenCalled(); + expect(store.read('demo')).toEqual({ source: 'replacement' }); + }); + + it('rejects a non-current request before reading either its body or stored data', async () => { + const store = memStore(); + const readBodyText = vi.fn(async () => '{"source":"old"}'); + for (const method of ['GET', 'PUT', 'POST']) { + expect(await handleGhostKvRequest({ + method, ghostId: 'demo', store, readBodyText, isCurrent: () => false, + })).toEqual({ status: 403 }); + } + expect(readBodyText).not.toHaveBeenCalled(); + expect(store.read).not.toHaveBeenCalled(); + expect(store.write).not.toHaveBeenCalled(); + }); + + it('preserves a late body from the unchanged current install', async () => { + const store = memStore(); + let finishBody!: (body: string) => void; + const body = new Promise((resolve) => { finishBody = resolve; }); + const pending = handleGhostKvRequest({ + method: 'PUT', ghostId: 'demo', store, readBodyText: () => body, isCurrent: () => true, + }); + finishBody('{"source":"current"}'); + expect(await pending).toEqual({ status: 204 }); + expect(store.write).toHaveBeenCalledExactlyOnceWith('demo', { source: 'current' }); + }); + it('GET → 200 + store 内容 JSON', async () => { const store = memStore({ theme: 'dark' }); const out = await call({ method: 'GET', store }); diff --git a/apps/desktop/src/main/cindy-brain/runtime/__tests__/ghostOauthEndpoint.test.ts b/apps/desktop/src/main/cindy-brain/runtime/__tests__/ghostOauthEndpoint.test.ts index a061f958ad7..760cd85e187 100644 --- a/apps/desktop/src/main/cindy-brain/runtime/__tests__/ghostOauthEndpoint.test.ts +++ b/apps/desktop/src/main/cindy-brain/runtime/__tests__/ghostOauthEndpoint.test.ts @@ -8,7 +8,7 @@ import { describe, expect, it, vi } from 'vitest'; import { GhostKvError } from '../../ghostKvStore.js'; import { GHOST_SECRET_VALUE_MAX_CHARS } from '../ghostSecretsEndpoint.js'; import { handleGhostOauthRequest, type GhostOauthEndpointManager } from '../ghostOauthEndpoint.js'; -import type { GhostOauthDecl } from '../../ghostOauthAccounts.js'; +import { GhostOauthAccountManager, type GhostOauthDecl } from '../../ghostOauthAccounts.js'; const GHOST = 'g-oauth'; const DECL: GhostOauthDecl = { @@ -76,6 +76,128 @@ function call(params: { }); } +describe('OAuth credential install target guard', () => { + const mutations = [ + ['PUT', '/oauth/acct/client', '{"clientId":"fake-old-client"}', 'setClientConfig'], + ['DELETE', '/oauth/acct/client', '', 'clearClientConfig'], + ['DELETE', '/oauth/acct/accounts/acc-1', '', 'disconnectAccount'], + ['POST', '/oauth/acct/default', '{"accountId":"acc-1"}', 'setDefaultAccount'], + ['POST', '/oauth/acct/insufficient-scopes', '{"scopes":["read.a"]}', 'reportInsufficientScopes'], + ] as const; + + it.each(mutations)('rejects %s %s after waiting for the mutation lock', async (method, pathname, body, mutation) => { + const manager = fakeManager(); + const onChanged = vi.fn(); + let current = true; + const result = await handleGhostOauthRequest({ + method, pathname, ghostId: GHOST, oauthSecrets: SECRETS, manager, onChanged, + readBodyText: async () => body, isCurrent: () => current, + withMutationLock: async (_ghostId, task) => { current = false; return task(); }, + }); + expect(result).toEqual({ status: 403 }); + expect(manager[mutation]).not.toHaveBeenCalled(); + expect(onChanged).not.toHaveBeenCalled(); + }); + + it.each([...mutations.filter(([method]) => method !== 'DELETE'), + ['POST', '/oauth/acct/connect', '{}', 'connectAccount'] as const, + ])('rejects a late body for %s %s', async (method, pathname, body, mutation) => { + const manager = fakeManager(); + const onChanged = vi.fn(); + let current = true; + const result = await handleGhostOauthRequest({ + method, pathname, ghostId: GHOST, oauthSecrets: SECRETS, manager, onChanged, + isCurrent: () => current, + readBodyText: async () => { current = false; return body; }, + }); + expect(result).toEqual({ status: 403 }); + expect(manager[mutation]).not.toHaveBeenCalled(); + expect(onChanged).not.toHaveBeenCalled(); + }); + + it.each(mutations)('allows %s %s for the unchanged install', async (method, pathname, body, mutation) => { + const manager = fakeManager(); + const onChanged = vi.fn(); + const result = await handleGhostOauthRequest({ + method, pathname, ghostId: GHOST, oauthSecrets: SECRETS, manager, onChanged, + isCurrent: () => true, readBodyText: async () => body, + withMutationLock: async (_ghostId, task) => task(), + }); + expect(result.status).toBe(204); + expect(manager[mutation]).toHaveBeenCalledTimes(1); + expect(onChanged).toHaveBeenCalledExactlyOnceWith('acct'); + }); + + it('rechecks the protocol target inside the connect manager commit boundary', async () => { + let current = true; + const write = vi.fn(); + const manager = fakeManager({ + connectAccount: vi.fn(async (_ghostId, _secretKey, _decl, opts) => { + current = false; + opts?.assertCurrent?.(); + write(); + return { ok: false, error: 'INVALID_CONFIG' } as const; + }), + }); + expect(await handleGhostOauthRequest({ + method: 'POST', pathname: '/oauth/acct/connect', ghostId: GHOST, oauthSecrets: SECRETS, + manager, readBodyText: async () => '{}', isCurrent: () => current, + })).toEqual({ status: 403 }); + expect(write).not.toHaveBeenCalled(); + }); +}); + +describe('OAuth connect target identity', () => { + it('rejects a same-declaration replacement during body reading through the real manager', async () => { + const originalTarget = { revision: 'approved-org', storagePart: 'helper' }; + const replacementTarget = { revision: 'approved-root', storagePart: 'helper' }; + let currentTarget = originalTarget; + const store = vi.fn(() => true); + const openExternal = vi.fn(); + const manager = new GhostOauthAccountManager({ + vault: { read: () => null, store, remove: vi.fn() }, + fetchImpl: vi.fn() as unknown as typeof fetch, + openExternal, + captureConnectTarget: () => currentTarget, + isConnectTargetCurrent: (_ghostId, _secretKey, _decl, expected) => expected === currentTarget, + }); + const result = await handleGhostOauthRequest({ + method: 'POST', pathname: '/oauth/acct/connect', ghostId: 'helper', + oauthSecrets: new Map([['acct', { ...DECL, clientId: 'fake-client' }]]), + manager, + readBodyText: async () => { + currentTarget = replacementTarget; + return JSON.stringify({ expectedConnectTarget: replacementTarget }); + }, + }); + expect(result.status).toBe(200); + expect(JSON.parse(result.body ?? '{}')).toMatchObject({ ok: false, error: 'INVALID_CONFIG' }); + expect(openExternal).not.toHaveBeenCalled(); + expect(store).not.toHaveBeenCalled(); + }); + + it('captures the Host target before reading a body that replaces the instance', async () => { + const originalTarget = { revision: 'approved-org', storagePart: 'helper' }; + const replacementTarget = { revision: 'approved-root', storagePart: 'helper' }; + let currentTarget = originalTarget; + const captureConnectTarget = vi.fn(() => currentTarget); + const manager = fakeManager({ captureConnectTarget }); + const result = await handleGhostOauthRequest({ + method: 'POST', pathname: '/oauth/acct/connect', ghostId: 'helper', oauthSecrets: SECRETS, + manager, + readBodyText: async () => { + currentTarget = replacementTarget; + return JSON.stringify({ expectedConnectTarget: replacementTarget }); + }, + }); + expect(result.status).toBe(200); + expect(captureConnectTarget).toHaveBeenCalledExactlyOnceWith('helper'); + expect(manager.connectAccount).toHaveBeenCalledWith('helper', 'acct', DECL, { + expectedConnectTarget: originalTarget, + }); + }); +}); + describe('GET /oauth', () => { it('回全部 oauth 凭证槽状态,零令牌字节', async () => { const manager = fakeManager(); @@ -514,6 +636,7 @@ describe('tokenBroker 门控', () => { pathname: string, manager = fakeManager(), body: Record = {}, + authorized = false, ) { return handleGhostOauthRequest({ method, @@ -522,6 +645,7 @@ describe('tokenBroker 门控', () => { oauthSecrets: BROKERED_SECRETS, manager, ghostId, + isTokenBrokerAuthorized: () => authorized, }); } @@ -537,9 +661,18 @@ describe('tokenBroker 门控', () => { expect(manager.clearClientConfig).not.toHaveBeenCalled(); }); - it('connect:官方前缀 id 放行;第三方 id 结构化拒(不触发授权流程)', async () => { + it('connect: missing grant denies even official-looking ids; authorized grant proceeds', async () => { + const deniedOfficial = fakeManager(); + const denied = await callAs('xd-atlassian', 'POST', '/oauth/acct/connect', deniedOfficial); + expect(denied.status).toBe(200); + expect(JSON.parse(denied.body ?? '{}')).toMatchObject({ + ok: false, + error: 'BROKER_FORBIDDEN', + }); + expect(deniedOfficial.connectAccount).not.toHaveBeenCalled(); + const okManager = fakeManager(); - const allowed = await callAs('xd-atlassian', 'POST', '/oauth/acct/connect', okManager); + const allowed = await callAs('xd-atlassian', 'POST', '/oauth/acct/connect', okManager, {}, true); expect(allowed.status).toBe(200); expect(okManager.connectAccount).toHaveBeenCalledTimes(1); @@ -557,7 +690,7 @@ describe('tokenBroker 门控', () => { const manager = fakeManager(); const selected = await callAs('xd-atlassian', 'POST', '/oauth/acct/connect', manager, { clientId: 'global-cid', - }); + }, true); expect(selected.status).toBe(200); expect(manager.connectAccount).toHaveBeenCalledWith('xd-atlassian', 'acct', BROKERED, { clientId: 'global-cid', @@ -566,7 +699,7 @@ describe('tokenBroker 门控', () => { const rejectedManager = fakeManager(); const rejected = await callAs('xd-atlassian', 'POST', '/oauth/acct/connect', rejectedManager, { clientId: 'foreign-cid', - }); + }, true); expect(rejected.status).toBe(400); expect(rejectedManager.connectAccount).not.toHaveBeenCalled(); }); diff --git a/apps/desktop/src/main/cindy-brain/runtime/__tests__/ghostSecretsEndpoint.test.ts b/apps/desktop/src/main/cindy-brain/runtime/__tests__/ghostSecretsEndpoint.test.ts index 9f0840afef2..5a847c21f10 100644 --- a/apps/desktop/src/main/cindy-brain/runtime/__tests__/ghostSecretsEndpoint.test.ts +++ b/apps/desktop/src/main/cindy-brain/runtime/__tests__/ghostSecretsEndpoint.test.ts @@ -39,6 +39,46 @@ function memVault(saved: Record = {}): GhostSecretsVault & { }; } +describe('credential install target guard', () => { + it.each(['PUT', 'POST'])('rejects a late %s body without writing or notifying', async (method) => { + const vault = memVault({ api_key: 'fake-new-source' }); + const onStored = vi.fn(); + let current = true; + let finishBody!: (body: string) => void; + const body = new Promise((resolve) => { finishBody = resolve; }); + const pending = handleGhostSecretsRequest({ + method, pathname: '/secrets/api_key', ghostId: 'demo', userSecretKeys: ['api_key'], + vault, onStored, readBodyText: () => body, isCurrent: () => current, + }); + current = false; + finishBody('{"value":"fake-old-source"}'); + expect(await pending).toEqual({ status: 403 }); + expect(vault.data.api_key).toBe('fake-new-source'); + expect(vault.store).not.toHaveBeenCalled(); + expect(onStored).not.toHaveBeenCalled(); + }); + + it('rejects an invalid install before a secret deletion', async () => { + const vault = memVault({ api_key: 'fake-new-source' }); + expect(await handleGhostSecretsRequest({ + method: 'DELETE', pathname: '/secrets/api_key', ghostId: 'demo', userSecretKeys: ['api_key'], + vault, readBodyText: vi.fn(), isCurrent: () => false, + })).toEqual({ status: 403 }); + expect(vault.remove).not.toHaveBeenCalled(); + }); + + it('still writes and notifies for the unchanged current install', async () => { + const vault = memVault(); + const onStored = vi.fn(); + expect(await handleGhostSecretsRequest({ + method: 'PUT', pathname: '/secrets/api_key', ghostId: 'demo', userSecretKeys: ['api_key'], + vault, onStored, readBodyText: async () => '{"value":"fake-current"}', isCurrent: () => true, + })).toEqual({ status: 204 }); + expect(vault.store).toHaveBeenCalledExactlyOnceWith('demo', 'api_key', 'fake-current'); + expect(onStored).toHaveBeenCalledExactlyOnceWith('api_key'); + }); +}); + function call(args: { method: string; pathname: string; diff --git a/apps/desktop/src/main/cindy-brain/runtime/electronSandboxAdapter.ts b/apps/desktop/src/main/cindy-brain/runtime/electronSandboxAdapter.ts index 9ddebc453a3..0349060e240 100644 --- a/apps/desktop/src/main/cindy-brain/runtime/electronSandboxAdapter.ts +++ b/apps/desktop/src/main/cindy-brain/runtime/electronSandboxAdapter.ts @@ -15,7 +15,8 @@ import { type InstalledGhost, } from '../../../shared/ghost.js'; import { getActiveAppSession, type ActiveAppSession } from '../../appSessionState.js'; -import { ownerScopedGhostPartition } from '../ghostWebviewPartition.js'; +import { ownerScopedGhostPartition, ownerScopedGhostPartitionForInstalledGhost } from '../ghostWebviewPartition.js'; +import { installedGhostStoragePart } from '../../../shared/pluginIdentity.js'; import { GHOST_BOOT_PATH, ghostBootHtml, ghostFileMime, resolveGhostFilePath } from './ghostFiles.js'; import { handleGhostKvRequest, readBoundedBodyText } from './ghostKvEndpoint.js'; import { resolveHashRef as resolveBlobHashRef } from '../../cindy-media/blobStore.js'; @@ -164,15 +165,16 @@ export function setGhostAgentModelsProvider( * adapter 反向依赖)。注入的是带"在装态守卫"的包装层——卸下后分区里 * 残留的 fetch 不能复活写出新文件。 */ -let ghostKvStore: { +interface GhostKvProtocolStore { read(ghostId: string): Record; write(ghostId: string, value: Record): void; -} | null = null; + captureTarget(ghostId: string): unknown; + isTargetCurrent(ghostId: string, expectedTarget: unknown): boolean; +} -export function setGhostKvStore(store: { - read(ghostId: string): Record; - write(ghostId: string, value: Record): void; -}): void { +let ghostKvStore: GhostKvProtocolStore | null = null; + +export function setGhostKvStore(store: GhostKvProtocolStore): void { ghostKvStore = store; } @@ -185,6 +187,7 @@ type GhostSecretsProtocolHandler = (args: { method: string; pathname: string; readBodyText: () => Promise; + isCurrent: () => boolean; }) => Promise<{ status: number; body?: string }>; let ghostSecretsHandler: GhostSecretsProtocolHandler | null = null; @@ -203,6 +206,7 @@ type GhostOauthProtocolHandler = (args: { method: string; pathname: string; readBodyText: () => Promise; + isCurrent: () => boolean; }) => Promise<{ status: number; body?: string }>; let ghostOauthHandler: GhostOauthProtocolHandler | null = null; @@ -222,6 +226,7 @@ type GhostConnectionsProtocolHandler = (args: { method: string; pathname: string; readBodyText: () => Promise; + isCurrent: () => boolean; }) => Promise<{ status: number; body?: string }>; let ghostConnectionsHandler: GhostConnectionsProtocolHandler | null = null; @@ -232,12 +237,14 @@ export function setGhostConnectionsHandler(handler: GhostConnectionsProtocolHand /** 该分区是否已挂过协议 handler(session 分区随 app 生命周期,挂一次即可)。 */ const partitionRegistered = new Set(); -const partitionGhost = new Map(); -type GhostProtocolOwnerIdentity = Pick; +const revokedPartitions = new Set(); +const partitionGhost = new Map(); +const partitionTargetGuard = new Map boolean>(); +type GhostProtocolOwnerIdentity = Pick; const partitionOwner = new Map(); function ghostProtocolOwnerSnapshot(owner: ActiveAppSession): GhostProtocolOwnerIdentity { - return { mode: owner.mode, dataOwnerId: owner.dataOwnerId }; + return { mode: owner.mode, dataOwnerId: owner.dataOwnerId, generation: owner.generation }; } function isSameGhostProtocolOwner( @@ -248,7 +255,8 @@ function isSameGhostProtocolOwner( } function isGhostProtocolOwnerActive(owner: GhostProtocolOwnerIdentity): boolean { - return isSameGhostProtocolOwner(owner, getActiveAppSession()); + const current = getActiveAppSession(); + return isSameGhostProtocolOwner(owner, current) && owner.generation === current.generation; } /** @@ -260,11 +268,20 @@ export function ensureGhostProtocolRegistered( ghost: InstalledGhost, owner: ActiveAppSession = getActiveAppSession(), ): void { - const partition = ownerScopedGhostPartition(ghost.manifest.id, owner); + const partition = ownerScopedGhostPartitionForInstalledGhost(ghost, owner); if (!partition) throw new Error('ghost protocol requires an active data owner'); registerGhostProtocol(partition, ghost, ghostProtocolOwnerSnapshot(owner)); } +export function revokeLegacyGhostProtocolPartition(ghostId: string): void { + const partition = ownerScopedGhostPartition(ghostId, getActiveAppSession()); + if (!partition) return; + revokedPartitions.add(partition); + for (const registered of partitionRegistered) { + if (registered.startsWith(partition + ':receipt:')) revokedPartitions.add(registered); + } +} + /** * 意识页面(html 响应)统一佩戴的 CSP:脚本/样式/资源只许同源(= 自己的 * 安装目录),img 额外放行 data:/blob:/https:(远程图片),media 额外放行 @@ -274,6 +291,24 @@ export function ensureGhostProtocolRegistered( const GHOST_HTML_CSP = "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; media-src 'self' data: blob:"; +function createGhostProtocolTargetGuard( + ghostId: string, + partition: string, + owner: GhostProtocolOwnerIdentity, +): () => boolean { + const store = ghostKvStore; + if (!store) return () => false; + const expectedTarget = store.captureTarget(ghostId); + return () => expectedTarget !== null && expectedTarget !== undefined && + ghostKvStore === store && !revokedPartitions.has(partition) && + isGhostProtocolOwnerActive(owner) && + store.isTargetCurrent(ghostId, expectedTarget); +} + +function captureGhostProtocolTargetGuard(partition: string): () => boolean { + return partitionTargetGuard.get(partition) ?? (() => false); +} + function registerGhostProtocol( partition: string, ghost: InstalledGhost, @@ -283,11 +318,19 @@ function registerGhostProtocol( if (registeredOwner && !isSameGhostProtocolOwner(registeredOwner, owner)) { throw new Error('ghost protocol partition already belongs to a different data owner'); } - partitionGhost.set(partition, { + const binding = { dir: ghost.dir, entry: ghost.manifest.entry, - }); - if (partitionRegistered.has(partition)) return; + storagePart: installedGhostStoragePart(ghost), + }; + if (partitionRegistered.has(partition)) { + const registered = partitionGhost.get(partition); + if (!registered || registered.dir !== binding.dir || registered.entry !== binding.entry || registered.storagePart !== binding.storagePart) { + revokedPartitions.add(partition); + } + return; + } + const isRegistrationCurrent = createGhostProtocolTargetGuard(binding.storagePart, partition, owner); // 注意:登记发生在全部挂载成功之后(函数末尾)——session.fromPartition 在 // app ready 前会 throw,若先登记后挂载,失败分区会被永久标记"已注册"而 // 实际无 handler,面板与电子脑一起哑火(review P0 的中毒模式)。 @@ -313,17 +356,17 @@ function registerGhostProtocol( } callback({ cancel: !allowed }); }); - ses.protocol.handle(SCHEME, async (request) => { + const handleRequest = async (request: Request): Promise => { try { // owner B 提交后,owner A 的旧 guest 仍可能短暂存活并新发请求。 - // 在 URL 路由、body 读取和任何 provider 调用前拒绝旧 Session;已经 - // 进入 handler 的请求不在这里取消或排空。 - if (!isGhostProtocolOwnerActive(owner)) { + // 在 URL 路由、body 读取和任何 provider 调用前拒绝旧 Session。 + if (!isRegistrationCurrent()) { return new Response(null, { status: 403, headers: { 'Cache-Control': 'no-store' }, }); } + const storagePart = binding.storagePart; const url = new URL(request.url); // 分区专属通道只认自己的 id,其它 host 一律 403(结构隔离的最后一道断言)。 if (url.host !== ghostId) return new Response(null, { status: 403 }); @@ -332,19 +375,19 @@ function registerGhostProtocol( // 账本验归属(出生自本意识或挂本意识画廊),通过才从字节仓读—— // 查无此账与不属于你统一 404,不给沙箱探测面。 if (url.pathname.startsWith('/media/')) { - return serveGhostMedia(ghostId, url.pathname.slice('/media/'.length), request.headers.get('range')); + return serveGhostMedia(storagePart, url.pathname.slice('/media/'.length), request.headers.get('range')); } // /library/<相对路径>:面板只读投影本意识的持久作品库文件(图片/视频/ // 导出物)。解析器由 cindy-brain/index 注入(binding 根 + vault 路径纪律, // 与电子脑 read 同源校验);内容可变,Cache-Control 走 no-cache(与 // 内容寻址的 /media 长缓存不同)。失败统一折叠 404。 if (url.pathname.startsWith('/library/')) { - return serveGhostLibraryFile(ghostId, decodeURIComponent(url.pathname.slice('/library/'.length)), request.headers.get('range')); + return serveGhostLibraryFile(storagePart, decodeURIComponent(url.pathname.slice('/library/'.length)), request.headers.get('range')); } // /gallery:本意识画廊清单(重启回放)。分区专属通道天然只答自己的账; // 内容只有指纹地址与备注字符串,零文件字节。 if (url.pathname === '/gallery') { - return serveGhostGallery(ghostId); + return serveGhostGallery(storagePart, ghostId); } // /wake:面板叫醒自己的电子脑(§4"确实有活才开门"的面板侧入口)。 // spawn 幂等(已在跑立即返回);沉睡/熔断由注入的 handler 拒绝。 @@ -352,7 +395,7 @@ function registerGhostProtocol( // 只回状态字符串,不回任何细节。 if (url.pathname === '/wake') { if (!ghostWakeHandler) return new Response(null, { status: 503 }); - const wake = await ghostWakeHandler(ghostId); + const wake = await ghostWakeHandler(storagePart); return new Response(JSON.stringify(wake), { status: 200, headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-cache' }, @@ -377,7 +420,7 @@ function registerGhostProtocol( if (url.search) return new Response(null, { status: 400, headers }); if (!ghostAgentModelsProvider) return new Response(null, { status: 503, headers }); try { - const result = await ghostAgentModelsProvider(ghostId); + const result = await ghostAgentModelsProvider(storagePart); if (!isGhostProtocolOwnerActive(owner)) return new Response(null, { status: 403, headers }); return new Response(JSON.stringify(result), { status: result.ok ? 200 : result.errorCode === 'PERMISSION_DENIED' ? 403 : 503, @@ -406,7 +449,7 @@ function registerGhostProtocol( return new Response(null, { status: 400 }); } if (!ghostMediaModelsProvider) return new Response(null, { status: 503 }); - const result = await ghostMediaModelsProvider(ghostId, type); + const result = await ghostMediaModelsProvider(storagePart, type); return new Response(JSON.stringify(result), { status: result.ok ? 200 : result.errorCode === 'PERMISSION_DENIED' ? 403 : 503, headers: { @@ -420,13 +463,16 @@ function registerGhostProtocol( // (ghostKvEndpoint,已单测),这里只做 Response 包装。 if (url.pathname === '/kv') { if (!ghostKvStore) return new Response(null, { status: 503 }); + const store = ghostKvStore; + const isCurrent = captureGhostProtocolTargetGuard(partition); const out = await handleGhostKvRequest({ method: request.method, // 有界读取(readBoundedBodyText):content-length 预检 + 流式限额, // 不受信 body 永不全量进主进程内存——沙箱允许死,主机不能被 OOM。 readBodyText: () => readBoundedBodyText(request), - store: ghostKvStore, - ghostId, + store, + ghostId: storagePart, + isCurrent, log, }); return new Response(out.body ?? null, { @@ -443,11 +489,14 @@ function registerGhostProtocol( // 明文单向进保险库,无任何读回动作;分区专属通道天然只碰自己的账。 if (url.pathname === '/secrets' || url.pathname.startsWith('/secrets/')) { if (!ghostSecretsHandler) return new Response(null, { status: 503 }); + const isCurrent = captureGhostProtocolTargetGuard(partition); + if (!isCurrent()) return new Response(null, { status: 403 }); const out = await ghostSecretsHandler({ - ghostId, + ghostId: storagePart, method: request.method, pathname: url.pathname, readBodyText: () => readBoundedBodyText(request), + isCurrent, }); return new Response(out.body ?? null, { status: out.status, @@ -464,11 +513,14 @@ function registerGhostProtocol( // 动作;分区专属通道天然只碰自己的账。 if (url.pathname === '/oauth' || url.pathname.startsWith('/oauth/')) { if (!ghostOauthHandler) return new Response(null, { status: 503 }); + const isCurrent = captureGhostProtocolTargetGuard(partition); + if (!isCurrent()) return new Response(null, { status: 403 }); const out = await ghostOauthHandler({ - ghostId, + ghostId: storagePart, method: request.method, pathname: url.pathname, readBodyText: () => readBoundedBodyText(request), + isCurrent, }); return new Response(out.body ?? null, { status: out.status, @@ -485,11 +537,14 @@ function registerGhostProtocol( // 与尾 4 位指纹;分区专属通道天然只碰自己的账。 if (url.pathname === '/connections' || url.pathname.startsWith('/connections/')) { if (!ghostConnectionsHandler) return new Response(null, { status: 503 }); + const isCurrent = captureGhostProtocolTargetGuard(partition); + if (!isCurrent()) return new Response(null, { status: 403 }); const out = await ghostConnectionsHandler({ - ghostId, + ghostId: storagePart, method: request.method, pathname: url.pathname, readBodyText: () => readBoundedBodyText(request), + isCurrent, }); return new Response(out.body ?? null, { status: out.status, @@ -502,7 +557,7 @@ function registerGhostProtocol( }); } if (url.pathname === GHOST_BOOT_PATH || url.pathname === '/') { - const entry = partitionGhost.get(partition)?.entry; + const entry = binding.entry; if (!entry) return new Response(null, { status: 404 }); return new Response(ghostBootHtml(entry), { status: 200, @@ -513,7 +568,7 @@ function registerGhostProtocol( }, }); } - const installDir = partitionGhost.get(partition)?.dir; + const installDir = binding.dir; if (!installDir) return new Response(null, { status: 404 }); const filePath = resolveGhostFilePath(installDir, url.pathname); if (!filePath) return new Response(null, { status: 403 }); @@ -533,8 +588,16 @@ function registerGhostProtocol( log.error('cindy-ghost protocol error', { error: err instanceof Error ? err.message : String(err) }); return new Response(null, { status: 500 }); } + }; + ses.protocol.handle(SCHEME, async (request) => { + const response = await handleRequest(request); + if (isRegistrationCurrent()) return response; + void response.body?.cancel().catch(() => undefined); + return new Response(null, { status: 403, headers: { 'Cache-Control': 'no-store' } }); }); partitionOwner.set(partition, owner); + partitionGhost.set(partition, binding); + partitionTargetGuard.set(partition, isRegistrationCurrent); partitionRegistered.add(partition); // 全部挂载成功,才算注册完成 } @@ -673,16 +736,16 @@ function mediaTypeForLibraryPath(relPath: string): string { } /** 画廊清单响应:[{src, caption}](新的在前;账本不可用时回空数组不报错)。 */ -async function serveGhostGallery(ghostId: string): Promise { +async function serveGhostGallery(storagePart: string, protocolHost: string): Promise { let items: Awaited> = []; try { - items = await listGhostGalleryFromLedger(ghostId); + items = await listGhostGalleryFromLedger(storagePart); } catch (err) { // 账本未就绪(登录早期等):回空墙,面板照常渲染空态,不给沙箱报错面。 - log.warn('ghost gallery list unavailable', { ghostId, error: err instanceof Error ? err.message : String(err) }); + log.warn('ghost gallery list unavailable', { ghostId: storagePart, error: err instanceof Error ? err.message : String(err) }); } const payload = items.map((it) => ({ - src: `${SCHEME}://${ghostId}/media/${it.hash}${it.ext}`, + src: `${SCHEME}://${protocolHost}/media/${it.hash}${it.ext}`, caption: it.label ?? '', })); return new Response(JSON.stringify(payload), { @@ -698,7 +761,7 @@ class ElectronSandboxHandle implements SandboxHandle { constructor(private readonly ghost: InstalledGhost) { const activeOwner = getActiveAppSession(); - const partition = ownerScopedGhostPartition(ghost.manifest.id, activeOwner); + const partition = ownerScopedGhostPartitionForInstalledGhost(ghost, activeOwner); if (!partition) throw new Error('ghost sandbox requires an active data owner'); registerGhostProtocol(partition, ghost, ghostProtocolOwnerSnapshot(activeOwner)); this.win = new BrowserWindow({ @@ -720,7 +783,7 @@ class ElectronSandboxHandle implements SandboxHandle { }, }); ghostWebContentsIds.add(this.win.webContents.id); - logicWebContentsToGhost.set(this.win.webContents.id, ghost.manifest.id); + logicWebContentsToGhost.set(this.win.webContents.id, installedGhostStoragePart(ghost)); this.win.webContents.on('render-process-gone', (_event, details) => { if (this.destroyed) return; // 延迟一拍再收尸:不在 Chromium 事件分发中途销毁窗口(重入风险)。 diff --git a/apps/desktop/src/main/cindy-brain/runtime/ghostConnectionsEndpoint.ts b/apps/desktop/src/main/cindy-brain/runtime/ghostConnectionsEndpoint.ts index ecc60b115b9..b64245518b2 100644 --- a/apps/desktop/src/main/cindy-brain/runtime/ghostConnectionsEndpoint.ts +++ b/apps/desktop/src/main/cindy-brain/runtime/ghostConnectionsEndpoint.ts @@ -30,6 +30,7 @@ */ import { GhostKvError } from '../ghostKvStore.js'; +import { assertGhostProtocolTargetCurrent, GhostProtocolTargetChangedError } from './ghostProtocolTargetGuard.js'; import { GHOST_SECRET_VALUE_MAX_CHARS } from './ghostSecretsEndpoint.js'; import { normalizeGhostConnectionHost } from '../ghostConnections.js'; import type { GhostConnectionUpsertResult, GhostConnectionView } from '../ghostConnections.js'; @@ -62,6 +63,7 @@ export async function handleGhostConnectionsRequest(args: { decls: ReadonlyMap; manager: GhostConnectionsEndpointManager; ghostId: string; + isCurrent?: () => boolean; /** * 新增地址的主机受信确认(main 侧模态弹窗;index.ts 注入)。返回 false = * 用户拒绝。弹窗抛错按拒绝收(fail-closed:确认不了就不扩白名单)。 @@ -74,6 +76,7 @@ export async function handleGhostConnectionsRequest(args: { log?: { warn(message: string, meta?: Record): void }; }): Promise { const { method, pathname, readBodyText, decls, manager, ghostId, log } = args; + if (args.isCurrent?.() === false) return { status: 403 }; const notifyChanged = (declKey: string): void => { try { args.onChanged?.(declKey); @@ -121,9 +124,11 @@ export async function handleGhostConnectionsRequest(args: { try { text = await readBodyText(); } catch (err) { + if (args.isCurrent?.() === false || err instanceof GhostProtocolTargetChangedError) return { status: 403 }; if (err instanceof GhostKvError && err.code === 'TOO_LARGE') return { status: 413 }; return { status: 400 }; } + if (args.isCurrent?.() === false) return { status: 403 }; let parsed: unknown; try { parsed = JSON.parse(text); @@ -162,8 +167,10 @@ export async function handleGhostConnectionsRequest(args: { log?.warn('ghost connections 受信确认弹窗异常(按拒绝收)', { ghostId, declKey, err: String(err) }); allowed = false; } + assertGhostProtocolTargetCurrent(args.isCurrent); if (!allowed) return json(200, { ok: false, error: 'CONFIRM_DENIED' }); } + assertGhostProtocolTargetCurrent(args.isCurrent); const result = manager.upsert(ghostId, declKey, { host, token, @@ -179,6 +186,7 @@ export async function handleGhostConnectionsRequest(args: { } return json(200, { ok: true, connection: result.connection }); } catch (err) { + if (err instanceof GhostProtocolTargetChangedError) return { status: 403 }; log?.warn('ghost connections 入库意外失败', { ghostId, declKey, err: String(err) }); return { status: 500 }; } @@ -192,9 +200,11 @@ export async function handleGhostConnectionsRequest(args: { try { text = await readBodyText(); } catch (err) { + if (args.isCurrent?.() === false || err instanceof GhostProtocolTargetChangedError) return { status: 403 }; if (err instanceof GhostKvError && err.code === 'TOO_LARGE') return { status: 413 }; return { status: 400 }; } + if (args.isCurrent?.() === false) return { status: 403 }; let parsed: unknown; try { parsed = JSON.parse(text); @@ -207,10 +217,12 @@ export async function handleGhostConnectionsRequest(args: { : undefined; if (typeof connectionId !== 'string' || connectionId.length === 0) return { status: 400 }; try { + assertGhostProtocolTargetCurrent(args.isCurrent); if (!manager.setDefault(ghostId, declKey, connectionId)) return { status: 404 }; notifyChanged(declKey); return { status: 204 }; } catch (err) { + if (err instanceof GhostProtocolTargetChangedError) return { status: 403 }; log?.warn('ghost connections 设默认连接意外失败', { ghostId, declKey, err: String(err) }); return { status: 500 }; } @@ -220,10 +232,12 @@ export async function handleGhostConnectionsRequest(args: { const connectionId = segments[1]; if (!connectionId) return { status: 404 }; try { + assertGhostProtocolTargetCurrent(args.isCurrent); manager.remove(ghostId, declKey, connectionId); notifyChanged(declKey); return { status: 204 }; } catch (err) { + if (err instanceof GhostProtocolTargetChangedError) return { status: 403 }; log?.warn('ghost connections 删除连接意外失败', { ghostId, declKey, err: String(err) }); return { status: 500 }; } diff --git a/apps/desktop/src/main/cindy-brain/runtime/ghostKvEndpoint.ts b/apps/desktop/src/main/cindy-brain/runtime/ghostKvEndpoint.ts index 96253756c1d..360ff5a45c5 100644 --- a/apps/desktop/src/main/cindy-brain/runtime/ghostKvEndpoint.ts +++ b/apps/desktop/src/main/cindy-brain/runtime/ghostKvEndpoint.ts @@ -82,9 +82,12 @@ export async function handleGhostKvRequest(args: { readBodyText: () => Promise; store: GhostKvEndpointStore; ghostId: string; + isCurrent?: () => boolean; log?: { warn(message: string, meta?: Record): void }; }): Promise { const { method, readBodyText, store, ghostId, log } = args; + const isCurrent = args.isCurrent ?? (() => true); + if (!isCurrent()) return { status: 403 }; if (method === 'GET') { try { @@ -100,12 +103,14 @@ export async function handleGhostKvRequest(args: { try { text = await readBodyText(); } catch (err) { + if (!isCurrent()) return { status: 403 }; // 有界读取器的超限断流 → 413;其它读流失败(中断等)→ 400。 if (err instanceof GhostKvError && err.code === 'TOO_LARGE') { return { status: 413 }; } return { status: 400 }; } + if (!isCurrent()) return { status: 403 }; // 体积双保险(readBodyText 已流式限额;这里兜非有界注入的调用方) // 且先量再 parse:不给超限 payload 任何 JSON.parse 面。 if (Buffer.byteLength(text, 'utf8') > GHOST_KV_MAX_BYTES) { @@ -121,6 +126,7 @@ export async function handleGhostKvRequest(args: { return { status: 400 }; } try { + if (!isCurrent()) return { status: 403 }; store.write(ghostId, value as Record); return { status: 204 }; } catch (err) { diff --git a/apps/desktop/src/main/cindy-brain/runtime/ghostOauthEndpoint.ts b/apps/desktop/src/main/cindy-brain/runtime/ghostOauthEndpoint.ts index 5ab29af614d..a7eceeb70a9 100644 --- a/apps/desktop/src/main/cindy-brain/runtime/ghostOauthEndpoint.ts +++ b/apps/desktop/src/main/cindy-brain/runtime/ghostOauthEndpoint.ts @@ -28,8 +28,8 @@ * 产生的全部令牌只存在主机保险库与内存缓存,本端点没有任何读回动作。 */ -import { isBrokerEligibleGhostId } from '../../../shared/ghost.js'; import { GhostKvError } from '../ghostKvStore.js'; +import { assertGhostProtocolTargetCurrent, GhostProtocolTargetChangedError } from './ghostProtocolTargetGuard.js'; import { GHOST_SECRET_VALUE_MAX_CHARS } from './ghostSecretsEndpoint.js'; import type { GhostOauthAccountView, @@ -57,6 +57,7 @@ export interface GhostOauthEndpointManager { ): boolean; clearClientConfig(ghostId: string, secretKey: string): void; listAccounts(ghostId: string, secretKey: string, decl?: GhostOauthDecl): GhostOauthAccountView[]; + captureConnectTarget?(ghostId: string): unknown; connectAccount( ghostId: string, secretKey: string, @@ -65,6 +66,8 @@ export interface GhostOauthEndpointManager { scopes?: readonly string[]; clientId?: string; deliveryHosts?: readonly string[]; + expectedConnectTarget?: unknown; + assertCurrent?: () => void; }, ): Promise; disconnectAccount(ghostId: string, secretKey: string, accountId: string): void; @@ -90,6 +93,7 @@ export async function handleGhostOauthRequest(args: { networkHosts?: readonly string[]; manager: GhostOauthEndpointManager; ghostId: string; + isCurrent?: () => boolean; /** Official prefix first; otherwise first-party resolver. Defaults to official-prefix only. */ isTokenBrokerAuthorized?: (ghostId: string) => boolean; /** Serialize credential/account persistence with package OAuth migration. */ @@ -100,8 +104,17 @@ export async function handleGhostOauthRequest(args: { }): Promise { const { method, pathname, readBodyText, oauthSecrets, networkHosts, manager, ghostId, log } = args; - const runMutation = (task: () => Promise | T): Promise => - args.withMutationLock?.(ghostId, task) ?? Promise.resolve(task()); + if (args.isCurrent?.() === false) return { status: 403 }; + const assertCurrent = (): void => assertGhostProtocolTargetCurrent(args.isCurrent); + const runMutation = async (task: () => Promise | T): Promise => { + const guardedTask = () => { + assertCurrent(); + return task(); + }; + const result = await (args.withMutationLock?.(ghostId, guardedTask) ?? guardedTask()); + assertCurrent(); + return result; + }; const notifyChanged = (secretKey: string): void => { try { args.onChanged?.(secretKey); @@ -147,10 +160,12 @@ export async function handleGhostOauthRequest(args: { try { text = await readBodyText(); } catch (err) { + if (args.isCurrent?.() === false || err instanceof GhostProtocolTargetChangedError) return { ok: false, status: 403 }; if (err instanceof GhostKvError && err.code === 'TOO_LARGE') return { ok: false, status: 413 }; return { ok: false, status: 400 }; } + if (args.isCurrent?.() === false) return { ok: false, status: 403 }; try { const parsed = JSON.parse(text) as unknown; if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { @@ -168,6 +183,7 @@ export async function handleGhostOauthRequest(args: { if (decl.tokenBroker !== undefined) return { status: 405 }; if (method === 'PUT' || method === 'POST') { const parsed = await readJsonBody(); + if (args.isCurrent?.() === false) return { status: 403 }; if (!parsed.ok) return { status: parsed.status }; const body = parsed.body; const clientId = typeof body.clientId === 'string' ? body.clientId.trim() : ''; @@ -188,6 +204,7 @@ export async function handleGhostOauthRequest(args: { notifyChanged(secretKey); return { status: 204 }; } catch (err) { + if (err instanceof GhostProtocolTargetChangedError || args.isCurrent?.() === false) return { status: 403 }; log?.warn('ghost oauth client 凭证入库意外失败', { ghostId, secretKey, err: String(err) }); return { status: 500 }; } @@ -198,6 +215,7 @@ export async function handleGhostOauthRequest(args: { notifyChanged(secretKey); return { status: 204 }; } catch (err) { + if (err instanceof GhostProtocolTargetChangedError || args.isCurrent?.() === false) return { status: 403 }; log?.warn('ghost oauth client 凭证清除意外失败', { ghostId, secretKey, err: String(err) }); return { status: 500 }; } @@ -207,9 +225,9 @@ export async function handleGhostOauthRequest(args: { if (action === 'connect' && segments.length === 2) { if (method !== 'POST') return { status: 405 }; - // tokenBroker 第一方门控·连接闸。官方前缀命中照今天放行;否则问接线处判据。 + // tokenBroker 第一方门控·连接闸。接线处按可信安装事实判定,缺省拒绝。 const brokerAuthorized = - args.isTokenBrokerAuthorized?.(ghostId) ?? isBrokerEligibleGhostId(ghostId); + args.isTokenBrokerAuthorized?.(ghostId) === true; if (decl.tokenBroker !== undefined && !brokerAuthorized) { return { status: 200, @@ -224,10 +242,14 @@ export async function handleGhostOauthRequest(args: { // 清单的非空子集;clientId 仅 broker 模式可从默认/备用声明值中选择。 // (设置页"只读连接"这类降面授权)。无 body / 空 body = 申请全量声明面; // 越界或形态不对 400(意识不能借连接动作扩权,manager 侧还有防御性重验)。 + const connectTarget = manager.captureConnectTarget + ? { expectedConnectTarget: manager.captureConnectTarget(ghostId) } + : {}; let scopesOverride: string[] | undefined; let clientIdOverride: string | undefined; try { const text = await readBodyText(); + assertCurrent(); if (text.trim().length > 0) { const parsed = JSON.parse(text) as unknown; if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) @@ -262,11 +284,14 @@ export async function handleGhostOauthRequest(args: { } } } catch (err) { + if (err instanceof GhostProtocolTargetChangedError || args.isCurrent?.() === false) return { status: 403 }; if (err instanceof GhostKvError && err.code === 'TOO_LARGE') return { status: 413 }; return { status: 400 }; } try { const opts = { + ...connectTarget, + ...(args.isCurrent ? { assertCurrent } : {}), ...(scopesOverride !== undefined ? { scopes: scopesOverride } : {}), ...(clientIdOverride !== undefined ? { clientId: clientIdOverride } : {}), ...(networkHosts?.length ? { deliveryHosts: networkHosts } : {}), @@ -277,10 +302,12 @@ export async function handleGhostOauthRequest(args: { decl, Object.keys(opts).length > 0 ? opts : undefined, ); + assertCurrent(); // 结构化透传(ok:false 也是 200——授权被拒/超时是业务态不是协议错; // detail 可能含服务端错误摘录,已由引擎保证不含凭证字节)。 return { status: 200, body: JSON.stringify(result) }; } catch (err) { + if (err instanceof GhostProtocolTargetChangedError || args.isCurrent?.() === false) return { status: 403 }; log?.warn('ghost oauth 授权流程意外失败', { ghostId, secretKey, err: String(err) }); return { status: 500 }; } @@ -289,6 +316,7 @@ export async function handleGhostOauthRequest(args: { if (action === 'insufficient-scopes' && segments.length === 2) { if (method !== 'POST') return { status: 405 }; const parsed = await readJsonBody(); + if (args.isCurrent?.() === false) return { status: 403 }; if (!parsed.ok) return { status: parsed.status }; const rawScopes = parsed.body.scopes; if (!Array.isArray(rawScopes) || rawScopes.length === 0 || rawScopes.length > 320) { @@ -312,6 +340,7 @@ export async function handleGhostOauthRequest(args: { if (stored === 'stored') notifyChanged(secretKey); return { status: 204 }; } catch (err) { + if (err instanceof GhostProtocolTargetChangedError || args.isCurrent?.() === false) return { status: 403 }; log?.warn('ghost oauth 缺失 scope 证据入库失败', { ghostId, secretKey, err: String(err) }); return { status: 500 }; } @@ -326,6 +355,7 @@ export async function handleGhostOauthRequest(args: { notifyChanged(secretKey); return { status: 204 }; } catch (err) { + if (err instanceof GhostProtocolTargetChangedError || args.isCurrent?.() === false) return { status: 403 }; log?.warn('ghost oauth 断开账号意外失败', { ghostId, secretKey, err: String(err) }); return { status: 500 }; } @@ -334,6 +364,7 @@ export async function handleGhostOauthRequest(args: { if (action === 'default' && segments.length === 2) { if (method !== 'POST') return { status: 405 }; const parsed = await readJsonBody(); + if (args.isCurrent?.() === false) return { status: 403 }; if (!parsed.ok) return { status: parsed.status }; const accountId = parsed.body.accountId; if (typeof accountId !== 'string' || accountId.length === 0) return { status: 400 }; @@ -344,6 +375,7 @@ export async function handleGhostOauthRequest(args: { notifyChanged(secretKey); return { status: 204 }; } catch (err) { + if (err instanceof GhostProtocolTargetChangedError || args.isCurrent?.() === false) return { status: 403 }; log?.warn('ghost oauth 设默认账号意外失败', { ghostId, secretKey, err: String(err) }); return { status: 500 }; } diff --git a/apps/desktop/src/main/cindy-brain/runtime/ghostProtocolTargetGuard.ts b/apps/desktop/src/main/cindy-brain/runtime/ghostProtocolTargetGuard.ts new file mode 100644 index 00000000000..e4c38bf2615 --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/runtime/ghostProtocolTargetGuard.ts @@ -0,0 +1,9 @@ +export class GhostProtocolTargetChangedError extends Error { + constructor() { + super('Plugin request target changed'); + } +} + +export function assertGhostProtocolTargetCurrent(isCurrent?: () => boolean): void { + if (isCurrent?.() === false) throw new GhostProtocolTargetChangedError(); +} diff --git a/apps/desktop/src/main/cindy-brain/runtime/ghostSecretsEndpoint.ts b/apps/desktop/src/main/cindy-brain/runtime/ghostSecretsEndpoint.ts index bbde4b7d533..233806f024a 100644 --- a/apps/desktop/src/main/cindy-brain/runtime/ghostSecretsEndpoint.ts +++ b/apps/desktop/src/main/cindy-brain/runtime/ghostSecretsEndpoint.ts @@ -36,6 +36,7 @@ import { GHOST_SECRET_VALUE_MAX_CHARS } from '../../../shared/ghost.js'; import { GhostKvError } from '../ghostKvStore.js'; +import { assertGhostProtocolTargetCurrent, GhostProtocolTargetChangedError } from './ghostProtocolTargetGuard.js'; /** 单条凭证值的字符上限(粘贴的 key/token 量级;超限 413)。 */ export { GHOST_SECRET_VALUE_MAX_CHARS }; @@ -82,6 +83,7 @@ export async function handleGhostSecretsRequest(args: { }>; vault: GhostSecretsVault; ghostId: string; + isCurrent?: () => boolean; /** * 入库成功(PUT/POST → 204)后的通知钩子(2026-07-14):调用方拿它广播 * "凭证已保存"的主机代言 tips。只报成功——失败面(400/413/500)设置页 @@ -91,6 +93,7 @@ export async function handleGhostSecretsRequest(args: { log?: { warn(message: string, meta?: Record): void }; }): Promise { const { method, pathname, readBodyText, userSecretKeys, vault, ghostId, log } = args; + if (args.isCurrent?.() === false) return { status: 403 }; const identityKeys = args.identitySecretKeys ?? []; const managedStates = args.managedSecretStates ?? []; const managedKeys = managedStates.map(({ key }) => key); @@ -145,9 +148,11 @@ export async function handleGhostSecretsRequest(args: { try { text = await readBodyText(); } catch (err) { + if (args.isCurrent?.() === false || err instanceof GhostProtocolTargetChangedError) return { status: 403 }; if (err instanceof GhostKvError && err.code === 'TOO_LARGE') return { status: 413 }; return { status: 400 }; } + if (args.isCurrent?.() === false) return { status: 403 }; let parsed: unknown; try { parsed = JSON.parse(text); @@ -161,8 +166,10 @@ export async function handleGhostSecretsRequest(args: { if (typeof value !== 'string' || value.trim().length === 0) return { status: 400 }; if (value.length > GHOST_SECRET_VALUE_MAX_CHARS) return { status: 413 }; try { + assertGhostProtocolTargetCurrent(args.isCurrent); if (!vault.store(ghostId, secretKey, value.trim())) return { status: 500 }; } catch (err) { + if (err instanceof GhostProtocolTargetChangedError) return { status: 403 }; log?.warn('ghost secret 入库意外失败', { ghostId, secretKey, err: String(err) }); return { status: 500 }; } @@ -181,9 +188,11 @@ export async function handleGhostSecretsRequest(args: { if (method === 'DELETE') { try { + assertGhostProtocolTargetCurrent(args.isCurrent); vault.remove(ghostId, secretKey); return { status: 204 }; } catch (err) { + if (err instanceof GhostProtocolTargetChangedError) return { status: 403 }; log?.warn('ghost secret 清除意外失败', { ghostId, secretKey, err: String(err) }); return { status: 500 }; } diff --git a/apps/desktop/src/main/cindy-brain/skillSlot.ts b/apps/desktop/src/main/cindy-brain/skillSlot.ts index 00f65932125..8820edb6ee4 100644 --- a/apps/desktop/src/main/cindy-brain/skillSlot.ts +++ b/apps/desktop/src/main/cindy-brain/skillSlot.ts @@ -44,18 +44,22 @@ import matter from 'gray-matter'; import { GHOST_SKILL_NAME_RE, - isValidGhostId, type GhostSkillItem, type InstalledGhost, } from '../../shared/ghost.js'; +import { + installedGhostLogicalIdentity, + parsePluginInstallRelId, + parsePluginStoragePart, + pluginStoragePart, +} from '../../shared/pluginIdentity.js'; import { parseAndValidateFrontmatter } from '../skillhub/frontmatterValidation.js'; import { prepareSharedGlobalSkillLinks, sharedGlobalSkillsPaths, } from '../maker-host/shared-global-skills.js'; -/** 共享技能根里 ghost 技能的链接名。name 侧禁 `--`(GHOST_SKILL_NAME_RE), - * 按"最后一个 `--`"拆分唯一,不同插件不可能撞名。 */ +/** 共享技能根里 ghost 技能的链接名。root/legacy 保留旧名,企业插件使用逻辑 storage part。 */ export function ghostSkillLinkName(ghostId: string, skillName: string): string { return `${ghostId}--${skillName}`; } @@ -282,7 +286,8 @@ function targetLooksGhostManaged( if (splitAt <= 0) return false; const ghostId = linkName.slice(0, splitAt); const skillName = linkName.slice(splitAt + 2); - if (!isValidGhostId(ghostId) || !GHOST_SKILL_NAME_RE.test(skillName)) return false; + const identity = parsePluginStoragePart(ghostId); + if (!identity || !GHOST_SKILL_NAME_RE.test(skillName)) return false; // 目标结构必须命中**我们铺过的两种布局之一**,且布局里的 id 段必须等于链接名里 // 的 ghostId —— 单看"路径里有个段叫 cindy-brain"会把用户指向自己项目目录 @@ -292,15 +297,20 @@ function targetLooksGhostManaged( // 新模型: .../<状态根名>/skill-snapshots///... const segments = target.split(/[\\/]/).map((segment) => segment.toLowerCase()); const stateDirName = approvalStateDirName.toLowerCase(); - const idLower = ghostId.toLowerCase(); + const idLower = identity.ghostId.toLowerCase(); const normalizedTarget = normalizeForCompare(target); if (!managedRoots.some((root) => isSameOrInside(normalizedTarget, root))) return false; return segments.some( - (segment, index) => - (segment === 'cindy-brain' && segments[index + 1] === idLower) || - (segment === stateDirName && - segments[index + 1] === 'skill-snapshots' && - segments[index + 2] === idLower), + (segment, index) => { + if (segment === 'cindy-brain' && segments[index + 1] === idLower) return true; + if (segment !== stateDirName || segments[index + 1] !== 'skill-snapshots') return false; + if (segments[index + 2] === idLower) return true; + const relId = segments.slice(index + 2, index + 5).join('/'); + const targetIdentity = parsePluginInstallRelId(relId); + return targetIdentity !== null && targetIdentity.namespace !== null && + targetIdentity.ghostId === identity.ghostId && + (identity.namespace === null || targetIdentity.namespace === identity.namespace); + }, ); } @@ -372,7 +382,9 @@ export async function reconcileGhostSkillLinks( a.name.localeCompare(b.name), ); for (const item of sortedItems) { - const linkName = ghostSkillLinkName(ghost.manifest.id, item.name); + const linkName = ghostSkillLinkName( + pluginStoragePart(installedGhostLogicalIdentity(ghost)), item.name, + ); if (desired.has(linkName)) { warnings.push(`技能链接名冲突 ${linkName},保留先到者`); continue; diff --git a/apps/desktop/src/main/cindy-brain/subscriptionGateway.ts b/apps/desktop/src/main/cindy-brain/subscriptionGateway.ts index 66eac14d2c3..d64dc5cb3d7 100644 --- a/apps/desktop/src/main/cindy-brain/subscriptionGateway.ts +++ b/apps/desktop/src/main/cindy-brain/subscriptionGateway.ts @@ -46,6 +46,7 @@ import { import { isGhostOwnerScopeUsable, type GhostOwnerScope } from './ghostOwnerScope.js'; import { getDbClient } from '../localDb/client/current.js'; import * as localDbSchema from '../localDb/schema.js'; +import { installedGhostStoragePart } from '../../shared/pluginIdentity.js'; /** block 理由展示上限(超长截断,防意识用理由塞小作文)。 */ const BLOCK_REASON_MAX_CHARS = 200; @@ -224,7 +225,7 @@ export class GhostSubscriptionGateway { name: GhostDidEventName, data: GhostDidEventData, ): void { - const ghostId = ghost.manifest.id; + const ghostId = installedGhostStoragePart(ghost); const e = this.entry(ghostId); const ownerScope = this.captureOwnerScope(); if (!this.ownerScopeUsable(ownerScope)) { @@ -292,23 +293,24 @@ export class GhostSubscriptionGateway { private kickWake(ghost: InstalledGhost, e: SubEntry, ownerScope: unknown): void { if (e.waking) return; if (!this.ownerScopeUsable(ownerScope)) { - this.invalidateOwner(ghost.manifest.id, e); + this.invalidateOwner(installedGhostStoragePart(ghost), e); return; } e.waking = true; void this.deps .wake(ghost) .then(() => { + if (this.entries.get(installedGhostStoragePart(ghost)) !== e) return; if (!this.ownerScopeUsable(ownerScope)) { - this.invalidateOwner(ghost.manifest.id, e); + this.invalidateOwner(installedGhostStoragePart(ghost), e); return; } - this.flush(ghost.manifest.id, e, ownerScope); + this.flush(installedGhostStoragePart(ghost), e, ownerScope); }) .catch((err) => { // 唤醒失败缓冲保留(封顶丢最旧),下一条事件再试。 this.deps.log?.warn('ghost subscribe wake failed', { - ghostId: ghost.manifest.id, + ghostId: installedGhostStoragePart(ghost), error: err instanceof Error ? err.message : String(err), }); }) @@ -376,7 +378,7 @@ export class GhostSubscriptionGateway { for (const ghost of this.deps.listGhosts()) { if (!ghost.enabled) continue; if (!ghost.manifest.subscribe?.hooks?.includes('will-user-message')) continue; - const ghostId = ghost.manifest.id; + const ghostId = installedGhostStoragePart(ghost); const e = this.entry(ghostId); if (e.hookFused) continue; context ??= this.resolveMessageHookContext(input.sessionId, GHOST_HOOK_TIMEOUT_MS / 2); @@ -405,7 +407,7 @@ export class GhostSubscriptionGateway { if (rewritten && lastRewriteGhost) { return { action: 'rewrite', - ghostId: lastRewriteGhost.manifest.id, + ghostId: installedGhostStoragePart(lastRewriteGhost), ghostName: lastRewriteGhost.manifest.name, text: currentText, }; @@ -440,7 +442,7 @@ export class GhostSubscriptionGateway { for (const ghost of this.deps.listGhosts()) { if (!ghost.enabled) continue; if (!ghost.manifest.subscribe?.hooks?.includes('will-assistant-message')) continue; - const ghostId = ghost.manifest.id; + const ghostId = installedGhostStoragePart(ghost); const e = this.entry(ghostId); if (e.hookFused) continue; context ??= this.resolveMessageHookContext( @@ -475,7 +477,7 @@ export class GhostSubscriptionGateway { if (renderGhost) { return { action: 'render', - ghostId: renderGhost.manifest.id, + ghostId: installedGhostStoragePart(renderGhost), ghostName: renderGhost.manifest.name, html: renderHtml, height: renderHeight, @@ -485,7 +487,7 @@ export class GhostSubscriptionGateway { if (lastRewriteGhost) { return { action: 'rewrite', - ghostId: lastRewriteGhost.manifest.id, + ghostId: installedGhostStoragePart(lastRewriteGhost), ghostName: lastRewriteGhost.manifest.name, text: currentText, }; @@ -504,7 +506,7 @@ export class GhostSubscriptionGateway { ownerStamp?: unknown, ownerScope?: unknown, ): Promise { - const ghostId = ghost.manifest.id; + const ghostId = installedGhostStoragePart(ghost); const hookId = this.deps.newHookId?.() ?? randomUUID(); // 超时按钩子分:入口(user-message)必须快(挡发送);出口(assistant-message) // 是后台后置钩,容许长处理(见 GHOST_ASSISTANT_HOOK_TIMEOUT_MS)。 @@ -602,14 +604,14 @@ export class GhostSubscriptionGateway { ): void { e.hookFails += 1; this.deps.log?.warn('ghost hook failed (fail-open)', { - ghostId: ghost.manifest.id, + ghostId: installedGhostStoragePart(ghost), why, fails: e.hookFails, }); if (e.hookFails >= GHOST_HOOK_FUSE_THRESHOLD && !e.hookFused) { e.hookFused = true; this.deps.log?.warn('ghost hook fused: degraded to observe-only', { - ghostId: ghost.manifest.id, + ghostId: installedGhostStoragePart(ghost), }); this.deps.onHookFused?.(ghost, ownerStamp); } @@ -636,6 +638,13 @@ export class GhostSubscriptionGateway { /** 意识停用/抽离时清态(缓冲、熔断、seq 全部归零;待决钩子按超时自然收口)。 */ dropGhost(ghostId: string): void { + const entry = this.entries.get(ghostId); + if (entry) entry.buffer.length = 0; + for (const [hookId, pending] of this.pendingHooks) { + if (pending.ghostId !== ghostId) continue; + this.pendingHooks.delete(hookId); + pending.resolve(null); + } this.entries.delete(ghostId); } } diff --git a/apps/desktop/src/main/cindy-brain/workspaceSlot.ts b/apps/desktop/src/main/cindy-brain/workspaceSlot.ts index 4c4b50882e8..e01a1349850 100644 --- a/apps/desktop/src/main/cindy-brain/workspaceSlot.ts +++ b/apps/desktop/src/main/cindy-brain/workspaceSlot.ts @@ -60,6 +60,7 @@ export interface WorkspaceSessionService { export interface WorkspaceSlotDeps { getGhost(id: string): InstalledGhost | null; + getMutationTarget(id: string): string | null; /** * 弹系统级选文件夹窗口;返回所选绝对路径,取消返回 null。 * 找不到可挂靠的 Cindy 窗口时应 reject(失败关闭,不弹无主对话框)。 @@ -142,6 +143,10 @@ export class GhostWorkspaceSlot { if (!service) { return fail('HOST_NOT_READY', '会话服务尚未准备好,请稍后再试'); } + const mutationTarget = this.deps.getMutationTarget(ghostId); + if (mutationTarget === null) { + return fail('PERMISSION_DENIED', '插件安装授权已失效,请重新发起工作区请求'); + } // 骚扰钳制:限速按尝试记账(spam 顺延窗口),再看全局在场标记。 const now = this.deps.now?.() ?? Date.now(); @@ -160,6 +165,13 @@ export class GhostWorkspaceSlot { // ── 目录授权 ──────────────────────────────────────────────────────── let dirAbs: string; let callIsCurrent: (() => boolean) | undefined; + const isCurrent = () => { + const current = this.deps.getGhost(ghostId); + return current?.enabled === true && current.manifest.workspace === true && + this.deps.getMutationTarget(ghostId) === mutationTarget && + (!callIsCurrent || callIsCurrent()); + }; + const cancelled = () => fail('CANCELLED', '插件或发起任务已变化,请重新发起工作区请求'); if (request.mode === 'pick') { this.consentInFlight = true; let picked: string | null; @@ -177,6 +189,7 @@ export class GhostWorkspaceSlot { } finally { this.consentInFlight = false; } + if (!isCurrent()) return cancelled(); if (picked === null) { return fail('CANCELLED', '用户取消了选择'); } @@ -213,9 +226,11 @@ export class GhostWorkspaceSlot { && current?.sessionInstanceId === ctx.sessionInstanceId; }; const stat = await this.deps.statDir(request.dir); + if (!isCurrent()) return cancelled(); if (stat === 'not-found') return fail('DIR_NOT_FOUND', '目录不存在(只支持本机已存在的目录)'); if (stat === 'not-directory') return fail('NOT_DIRECTORY', '该路径不是目录'); const dirInfo = await this.deps.getSessionDirInfo(ctx.sessionId); + if (!isCurrent()) return cancelled(); // fail closed:快照读不到(查无会话/读失败)或远程(SSH)会话一律硬拒 // ——证明不了"本机工作区语境"就连确认卡也不发,防快照失败把远程会话 // 漏进确认卡路径(与管子契约"远程一律拒"一致)。 @@ -242,6 +257,7 @@ export class GhostWorkspaceSlot { toolAutoReviewAction('plugin_workspace', { ghostId, dir: request.dir, title, focus: request.focus }, 'Ensure a local draft task exists in this directory. This does not start an agent.')) : undefined; + if (!isCurrent()) return cancelled(); confirmed = review?.verdict === 'allow' ? { ok: true } : review?.verdict === 'block' ? { ok: false, message: review.reason ?? 'Automatic review denied this workspace request.' } : await this.deps.confirmDir({ @@ -260,6 +276,7 @@ export class GhostWorkspaceSlot { } finally { this.consentInFlight = false; } + if (!isCurrent()) return cancelled(); if (!confirmed.ok) { return fail('CANCELLED', confirmed.message); } @@ -271,18 +288,16 @@ export class GhostWorkspaceSlot { const name = path.basename(dirAbs) || dirAbs; const ensure = async (): Promise => { try { - if (callIsCurrent && !callIsCurrent()) return fail('CANCELLED', 'The originating tool call has ended.'); + if (!isCurrent()) return cancelled(); const existing = await service.findActiveSessionByWorkdir(dirAbs); - if (callIsCurrent && !callIsCurrent()) return fail('CANCELLED', 'The originating tool call has ended.'); + if (!isCurrent()) return cancelled(); if (existing) { if (request.focus === true) service.focusSession(existing); this.deps.log?.info('ghost workspace ensured (reused)', { ghostId, sessionId: existing }); return { ok: true, sessionId: existing, created: false, name }; } - const sessionId = await service.createDraftSession({ dirAbs, title, ghostId, - ...(callIsCurrent ? { shouldContinue: callIsCurrent } : {}), - }); - if (!sessionId || (callIsCurrent && !callIsCurrent())) return fail('CANCELLED', 'The originating tool call has ended.'); + const sessionId = await service.createDraftSession({ dirAbs, title, ghostId, shouldContinue: isCurrent }); + if (!sessionId || !isCurrent()) return cancelled(); if (request.focus === true) service.focusSession(sessionId); this.deps.log?.info('ghost workspace ensured (created)', { ghostId, sessionId }); return { ok: true, sessionId, created: true, name }; diff --git a/apps/desktop/src/main/cindy-media/__tests__/ledger.test.ts b/apps/desktop/src/main/cindy-media/__tests__/ledger.test.ts index 391dca5da3b..11f0d73840e 100644 --- a/apps/desktop/src/main/cindy-media/__tests__/ledger.test.ts +++ b/apps/desktop/src/main/cindy-media/__tests__/ledger.test.ts @@ -75,6 +75,25 @@ describe('recordBlob(幂等入账)', () => { }); describe('addRef / removeRefs(引用增删)', () => { + it('moves only the old physical ghost media ownership and remains safe to replay', async () => { + await seedBlob(HASH_A); + await seedBlob(HASH_B); + await ledger.addRef({ hash: HASH_A, refKind: 'ghost-gallery', refId: 'helper', originKind: 'ghost', originId: 'helper' }, db); + await ledger.addRef({ hash: HASH_B, refKind: 'ghost-grant', refId: 'helper', originKind: 'user' }, db); + await seedSession('session-1', 'active'); + await ledger.addRef({ hash: HASH_B, refKind: 'message', refId: 'helper', originSessionId: 'session-1', originKind: 'tool', originId: 'helper' }, db); + await ledger.relocateGhostMediaRefs('helper', '_ns__acme__helper', db); + await ledger.relocateGhostMediaRefs('helper', '_ns__acme__helper', db); + expect(await ledger.ghostCanRead(HASH_A, 'helper', db)).toBe(false); + expect(await ledger.ghostCanRead(HASH_A, '_ns__acme__helper', db)).toBe(true); + expect(await ledger.ghostCanRead(HASH_B, 'helper', db)).toBe(false); + expect(await ledger.ghostCanRead(HASH_B, '_ns__acme__helper', db)).toBe(true); + expect(await ledger.listGhostGallery('helper', db)).toHaveLength(0); + expect(await ledger.listGhostGallery('_ns__acme__helper', db)).toHaveLength(1); + const messageRef = (await db.select().from(schema.mediaRefs).all()).find((row) => row.refKind === 'message'); + expect(messageRef?.refId).toBe('helper'); + }); + it('未入账的指纹加引用被 FK 拒绝(先记 blob 后记 ref 的顺序由类型层保证)', async () => { await expect( ledger.addRef( diff --git a/apps/desktop/src/main/cindy-media/ledger.ts b/apps/desktop/src/main/cindy-media/ledger.ts index 69c050d7d16..516903692fb 100644 --- a/apps/desktop/src/main/cindy-media/ledger.ts +++ b/apps/desktop/src/main/cindy-media/ledger.ts @@ -17,7 +17,7 @@ */ import { randomUUID } from 'node:crypto'; -import { and, asc, desc, eq, exists, lt, ne, or, sql } from 'drizzle-orm'; +import { and, asc, desc, eq, exists, inArray, lt, ne, or, sql } from 'drizzle-orm'; import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; import { getDbClient } from '../localDb/client/current'; @@ -31,6 +31,24 @@ function defaultDb(): LedgerDb { return getDbClient().drizzle; } +const ghostOwnedRefKinds = [ + 'ghost-gallery', 'ghost-grant', 'ghost-tool-grant', 'ghost-deposit', +] as const; + +export async function relocateGhostMediaRefs( + fromId: string, + toId: string, + db: LedgerDb = defaultDb(), +): Promise { + if (fromId === toId) return; + const ownedOrigin = and(eq(mediaRefs.originKind, 'ghost'), eq(mediaRefs.originId, fromId)); + const ownedRef = and(inArray(mediaRefs.refKind, ghostOwnedRefKinds), eq(mediaRefs.refId, fromId)); + await db.update(mediaRefs).set({ + originId: sql`case when ${ownedOrigin} then ${toId} else ${mediaRefs.originId} end`, + refId: sql`case when ${ownedRef} then ${toId} else ${mediaRefs.refId} end`, + }).where(or(ownedOrigin, ownedRef)).run(); +} + /** Shared task reads use existing provenance; knowing a blob hash grants nothing. */ export async function sessionCanRead(hash: string, sessionId: string, db: LedgerDb = defaultDb()): Promise { const rows = await db.select({ one: sql`1` }).from(mediaRefs).where(and( diff --git a/apps/desktop/src/main/ghost-panel-window/__tests__/controller.test.ts b/apps/desktop/src/main/ghost-panel-window/__tests__/controller.test.ts index 88108b59468..aa7c2876031 100644 --- a/apps/desktop/src/main/ghost-panel-window/__tests__/controller.test.ts +++ b/apps/desktop/src/main/ghost-panel-window/__tests__/controller.test.ts @@ -398,6 +398,18 @@ describe('multi-instance isolation', () => { h.controller.open('b'); expect(winB.show).toHaveBeenCalled(); }); + it('same ghostId in different namespaces get independent windows', () => { + const h = makeHarness(new Set(['helper', '_ns__acme__helper'])); + h.controller.setDetached('helper', true); + h.controller.setDetached('_ns__acme__helper', true); + expect(h.created).toHaveLength(2); + expect(h.created[0].ghostId).toBe('helper'); + expect(h.created[1].ghostId).toBe('_ns__acme__helper'); + h.controller.setDetached('helper', false); + expect(h.created[0].win.isDestroyed()).toBe(true); + expect(h.created[1].win.isDestroyed()).toBe(false); + }); + it('setDetached(false) on one ghost does not affect another', () => { const h = makeHarness(new Set(['a', 'b'])); @@ -528,9 +540,35 @@ describe('reconcile', () => { expect(h.created[0].win.isDestroyed()).toBe(true); expect(h.entries().a).toEqual({ detached: false, lastOpen: false }); }); + it('reconcile keeps a namespaced detached window and does not treat it as the root id', () => { + const h = makeHarness(new Set(['_ns__acme__helper'])); + h.controller.setDetached('_ns__acme__helper', true); + const org = ghost('helper'); + org.namespace = 'acme'; + org.dir = '/fake/_ns/acme/helper'; + h.controller.reconcile([org]); + expect(h.created[0].win.isDestroyed()).toBe(false); + expect(h.entries()['_ns__acme__helper']).toEqual({ detached: true, lastOpen: true }); + }); + }); describe('two-phase ready + sender guard', () => { + it('keeps a legacy organization window when a same-name root is installed separately', () => { + const h = makeHarness(new Set(['helper', '_root__helper'])); + h.controller.setDetached('helper', true); + const oldWindow = h.created[0].win; + const org = ghost('helper'); + org.namespace = 'acme'; + org.dir = '/fake/helper'; + const root = ghost('helper'); + root.namespace = null; + root.dir = '/fake/_root/helper'; + h.controller.reconcile([org, root]); + expect(oldWindow.isDestroyed()).toBe(false); + expect(h.entries().helper).toEqual({ detached: true, lastOpen: true }); + expect(h.entries()['_root__helper']).toBeUndefined(); + }); it('markRendererReady from correct sender succeeds', () => { const h = makeHarness(new Set(['a'])); h.controller.prewarm('a'); diff --git a/apps/desktop/src/main/ghost-panel-window/__tests__/settings-store.test.ts b/apps/desktop/src/main/ghost-panel-window/__tests__/settings-store.test.ts index f65d767001a..b147a832cfc 100644 --- a/apps/desktop/src/main/ghost-panel-window/__tests__/settings-store.test.ts +++ b/apps/desktop/src/main/ghost-panel-window/__tests__/settings-store.test.ts @@ -67,6 +67,23 @@ describe('normalizeGhostPanelWindowsSettings', () => { }), ).toEqual({ windows: { good: { detached: false, lastOpen: true } } }); }); + + it('企业实例 storage part 是合法键,斜杠目录 id 丢弃', () => { + expect( + normalizeGhostPanelWindowsSettings({ + windows: { + '_ns__acme__helper': { detached: true, lastOpen: true }, + '_ns/acme/helper': { detached: true, lastOpen: true }, + helper: { detached: false, lastOpen: false }, + }, + }), + ).toEqual({ + windows: { + '_ns__acme__helper': { detached: true, lastOpen: true }, + helper: { detached: false, lastOpen: false }, + }, + }); + }); }); describe('runtime state', () => { diff --git a/apps/desktop/src/main/ghost-panel-window/controller.ts b/apps/desktop/src/main/ghost-panel-window/controller.ts index f3f1c6cac6e..b0c8f5d77a7 100644 --- a/apps/desktop/src/main/ghost-panel-window/controller.ts +++ b/apps/desktop/src/main/ghost-panel-window/controller.ts @@ -26,12 +26,11 @@ import { GHOST_PANEL_WINDOW_CLOSE_REQUESTED_CHANNEL, GHOST_PANEL_WINDOW_LOCALE_CHANGED_CHANNEL, GHOST_PANEL_WINDOW_MINIMIZE_REQUESTED_CHANNEL, - GHOST_PANEL_WINDOW_PRESENTATION_READY_CHANNEL, - GHOST_PANEL_WINDOW_RENDERER_READY_CHANNEL, GHOST_PANEL_WINDOW_VISIBILITY_CHANGED_CHANNEL, } from '../../shared/ghostPanelWindow.js'; import type { SupportedLocale } from '../../shared/locale.js'; import type { InstalledGhost } from '../../shared/ghost.js'; +import { installedGhostStoragePart } from '../../shared/pluginIdentity.js'; import type { GhostPanelWindowsSettings } from './settings-store.js'; interface ControllerLogger { @@ -191,6 +190,7 @@ export class GhostPanelWindowsController { return this.getState(); } + // ── 双阶段就绪 ────────────────────────────────────────────────────── markRendererReady(sender: WebContents): void { @@ -249,7 +249,7 @@ export class GhostPanelWindowsController { // ── reconcile ────────────────────────────────────────────────────── reconcile(ghosts: InstalledGhost[]): void { - const byId = new Map(ghosts.map((g) => [g.manifest.id, g])); + const byId = new Map(ghosts.map((g) => [installedGhostStoragePart(g), g])); const knownIds = new Set([ ...Object.keys(this.deps.settings.read().windows), ...this.slots.keys(), @@ -296,7 +296,7 @@ export class GhostPanelWindowsController { /** 主窗口销毁时回收所有隐藏窗口;controller 仍可随下一扇主窗重新预热。 */ destroyAllWindows(): void { - for (const [id, slot] of this.slots) { + for (const [, slot] of this.slots) { this.clearTimeouts(slot); if (!slot.win.isDestroyed()) { slot.destroyingWindow = true; diff --git a/apps/desktop/src/main/ghost-panel-window/ipc.ts b/apps/desktop/src/main/ghost-panel-window/ipc.ts index a0056e66e5f..5cd0c87d9f1 100644 --- a/apps/desktop/src/main/ghost-panel-window/ipc.ts +++ b/apps/desktop/src/main/ghost-panel-window/ipc.ts @@ -15,7 +15,7 @@ import { MAKER_INVOKE } from '../maker-ipc/channels.js'; import { createLogger } from '../logger.js'; import { assertTrustedAppRendererEvent, isTrustedAppRendererEvent } from '../security/trustedAppRenderer.js'; import { throwIpcError } from '../utils/ipcValidate.js'; -import { isValidGhostId } from '../../shared/ghost.js'; +import { isValidPluginStoragePart } from '../../shared/pluginIdentity.js'; import { GHOST_PANEL_WINDOW_PRESENTATION_READY_CHANNEL, GHOST_PANEL_WINDOW_RENDERER_READY_CHANNEL, @@ -36,7 +36,7 @@ export function registerGhostPanelWindowIpc(controller: GhostPanelWindowsControl ipcMain.handle(MAKER_INVOKE.GHOST_PANEL_WINDOW_OPEN, (event, ghostId: unknown) => { assertTrustedAppRendererEvent(event); - if (!isValidGhostId(ghostId)) { + if (!isValidPluginStoragePart(ghostId)) { throwIpcError('INVALID_PARAMS', 'ghostId must be a valid ghost id'); } controller.open(ghostId); @@ -46,7 +46,7 @@ export function registerGhostPanelWindowIpc(controller: GhostPanelWindowsControl MAKER_INVOKE.GHOST_PANEL_WINDOW_SET_DETACHED, (event, ghostId: unknown, detached: unknown) => { assertTrustedAppRendererEvent(event); - if (!isValidGhostId(ghostId)) { + if (!isValidPluginStoragePart(ghostId)) { throwIpcError('INVALID_PARAMS', 'ghostId must be a valid ghost id'); } if (typeof detached !== 'boolean') { diff --git a/apps/desktop/src/main/ghost-panel-window/settings-store.ts b/apps/desktop/src/main/ghost-panel-window/settings-store.ts index 92258a8fe04..2a3e2a320e1 100644 --- a/apps/desktop/src/main/ghost-panel-window/settings-store.ts +++ b/apps/desktop/src/main/ghost-panel-window/settings-store.ts @@ -10,7 +10,7 @@ import { app } from 'electron'; import fs from 'node:fs'; import path from 'node:path'; -import { isValidGhostId } from '../../shared/ghost.js'; +import { isValidPluginStoragePart } from '../../shared/pluginIdentity.js'; import { desktopMakerLogger } from '../maker-host/logger-adapter.js'; const log = desktopMakerLogger.child('ghost-panel-window-settings-store'); @@ -37,7 +37,7 @@ export function normalizeGhostPanelWindowsSettings(raw: unknown): GhostPanelWind if (!rawWindows || typeof rawWindows !== 'object') return { windows: {} }; const windows: Record = {}; for (const [id, entry] of Object.entries(rawWindows as Record)) { - if (!isValidGhostId(id)) continue; + if (!isValidPluginStoragePart(id)) continue; if (!entry || typeof entry !== 'object') continue; const e = entry as Record; if (typeof e.detached !== 'boolean' || typeof e.lastOpen !== 'boolean') continue; diff --git a/apps/desktop/src/main/ghost-panel-window/window.ts b/apps/desktop/src/main/ghost-panel-window/window.ts index d6f34c90e59..2a94cf832b0 100644 --- a/apps/desktop/src/main/ghost-panel-window/window.ts +++ b/apps/desktop/src/main/ghost-panel-window/window.ts @@ -3,7 +3,7 @@ * * 蓝本是 right-sidebar-window/window.ts,差异: * - 按 ghostId 多实例:窗口位置记忆每插件一份 - * (ghost-panel-window-state-.json,id 字符集 [a-z0-9-] 文件名安全); + * (ghost-panel-window-state-.json,id 为 storage part,文件名安全); * - webPreferences 按 electron-security-and-process-boundaries §3 显式带全量 * 安全项(该规则晚于主窗/RSB 窗,新窗口必须逐项写明,不吃默认值); * - `webviewTag: true`:面板体就是 (cindy-ghost:// 分区)。附加闸/ diff --git a/apps/desktop/src/main/maker-ipc/__tests__/botAuthorizationHost.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/botAuthorizationHost.test.ts index 5e342b0160a..4a9e7148864 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/botAuthorizationHost.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/botAuthorizationHost.test.ts @@ -1,6 +1,6 @@ import { advanceSessionRewindGeneration, withSendToSessionLock } from '../sendToSessionLock'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { GhostSetupAssessment } from '../../../shared/ghost'; +import type { GhostSetupAssessment, InstalledGhost } from '../../../shared/ghost'; import type { BotAuthorizationCard } from '../../../shared/botAuthorization'; import type { initBotAuthorizationService } from '../botAuthorizationService'; @@ -12,6 +12,7 @@ const state = vi.hoisted(() => ({ login: vi.fn(async () => ({ ok: true })), continued: vi.fn(), visible: true, + ghost: null as InstalledGhost | null, realService: false, persistedCard: null as BotAuthorizationCard | null, deps: null as unknown as Parameters[0], @@ -55,7 +56,7 @@ vi.mock('../../cindy-brain/index.js', () => ({ })); vi.mock('../../cindy-brain/ghostVisibility.js', () => ({ classifyGhostVisibility: () => state.visible - ? { ok: true, ghost: { manifest: { id: 'art', name: 'Art' } } } + ? { ok: true, ghost: state.ghost } : { ok: false, errorCode: 'GHOST_DISABLED_IN_WORKDIR' }, })); vi.mock('../../cindy-brain/ghostWorkdirPrefs.js', () => ({ isGhostDisabledForWorkdir: () => false })); @@ -74,6 +75,10 @@ describe('authorization Host live plugin policy', () => { state.assessment.mockReturnValue({ state: 'ready', revision: 1, groups: [] }); state.subscribe.mockClear(); state.visible = true; + state.ghost = { dir: '/fake/art', namespace: null, + manifest: { id: 'art', name: 'Art' }, + approval: { state: 'approved', revision: '00000000-0000-4000-8000-000000000001' }, + } as InstalledGhost; state.realService = false; state.persistedCard = null; state.save.mockClear(); @@ -87,6 +92,57 @@ describe('authorization Host live plugin policy', () => { }); }); + it.each(['move', 'replace'] as const)('rejects a stale adapter after its physical target changes by %s', async (change) => { + state.ghost = { ...state.ghost!, namespace: 'acme' }; + const adapter = await state.deps.adapter('session', { kind: 'plugin', id: 'art', namespace: 'acme' }); + state.ghost = change === 'move' + ? { ...state.ghost, dir: '/fake/_ns/acme/art' } + : { ...state.ghost, approval: { state: 'approved', revision: '00000000-0000-4000-8000-000000000002' } }; + await expect(adapter.assess()).rejects.toThrow(); + await expect(adapter.execute({ id: 'save', kind: 'inline_form' } as never, + undefined, 'synthetic-secret')).rejects.toThrow(); + expect(state.execute).not.toHaveBeenCalled(); + const current = await state.deps.adapter('session', { kind: 'plugin', id: 'art', namespace: 'acme' }); + await expect(current.assess()).resolves.toMatchObject({ state: 'ready' }); + }); + + it('invalidates a relocated card and restores it with a fresh revision before executing on the new instance', async () => { + state.realService = true; + state.ghost = { ...state.ghost!, namespace: 'acme' }; + state.save.mockImplementation(async (_sessionId, message) => { + state.persistedCard = structuredClone(message.agentMeta.botAuthorization); + }); + state.assessment.mockReturnValue({ state: 'required', revision: 1, groups: [{ + id: 'account', mode: 'any_of', items: [{ ref: 'oauth:account', kind: 'oauth', label: 'Account', state: 'missing', + actions: [{ id: 'connect', kind: 'oauth_connect' }] }], + }] }); + const service = initializeBotAuthorizationHost(async () => {}); + const sender = { id: 1, isDestroyed: () => false, send: vi.fn() }; + try { + await service.request('session', { kind: 'plugin', id: 'art', namespace: 'acme' }); + const original = structuredClone(state.persistedCard!); + service.invalidatePlugin('art'); + state.ghost = { ...state.ghost!, dir: '/fake/_ns/acme/art' }; + const action = { kind: 'plugin_setup' as const, action: 'run_action' as const, + actionId: original.snapshot.steps[0]!.action!.id, expectedRevision: original.snapshot.revision }; + expect(await service.resolve(original.snapshot.requestId, action, sender)).toBe(true); + expect(state.execute).not.toHaveBeenCalled(); + const restored = state.persistedCard!; + expect(restored.snapshot.ghost.id).toBe('_ns__acme__art'); + expect(restored.snapshot.revision).toBeGreaterThan(original.snapshot.revision); + expect(await service.resolve(restored.snapshot.requestId, { ...action, expectedRevision: restored.snapshot.revision }, sender)).toBe(true); + await vi.waitFor(() => expect(state.execute).toHaveBeenCalledTimes(1)); + expect(state.execute).toHaveBeenCalledWith(expect.objectContaining({ ghostId: '_ns__acme__art' })); + expect(await service.resolve(restored.snapshot.requestId, { + kind: 'plugin_setup', action: 'cancel', expectedRevision: state.persistedCard!.snapshot.revision, + }, sender)).toBe(true); + expect(state.persistedCard!.snapshot.terminal).toBe(true); + } finally { + await service.dispose(); + state.save.mockReset(); + } + }); + it('runs the real Host and card lifecycle: dynamic plugin login saves a card and completion resumes once', async () => { state.realService = true; state.save.mockImplementation(async (_sessionId, message) => { diff --git a/apps/desktop/src/main/maker-ipc/__tests__/botAuthorizationService.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/botAuthorizationService.test.ts index 5606999ed62..fff6dad55cb 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/botAuthorizationService.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/botAuthorizationService.test.ts @@ -58,6 +58,7 @@ function harness() { card.sessionId === sessionId && card.target.kind === target.kind && card.target.id === target.id && + (card.target.kind !== 'plugin' || card.target.namespace === (target.kind === 'plugin' ? target.namespace : undefined)) && !!card.target.reauthorize === !!target.reauthorize && !card.snapshot.terminal, ) ?? null, @@ -105,6 +106,30 @@ async function flush() { beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); describe('Bot authorization transcript lifecycle (Grok parity)', () => { + it('does not merge concurrent OAuth flows for omitted, root and organization namespace', async () => { + const h = harness(); + let release!: () => void; + const pending = new Promise((resolve) => { release = resolve; }); + h.adapter.execute = vi.fn(async () => { await pending; return { ok: true as const, waitingExternal: true }; }); + try { + for (const fields of [{}, { namespace: null }, { namespace: 'acme' }]) { + await h.service.request('s', { kind: 'plugin', id: 'p', ...fields }); + } + expect(h.stored.size).toBe(3); + for (const card of h.stored.values()) { + await h.service.resolve(card.snapshot.requestId, { + kind: 'plugin_setup', action: 'run_action', actionId: 'connect', expectedRevision: card.snapshot.revision, + }, h.sender); + } + await flush(); + expect(h.adapter.execute).toHaveBeenCalledTimes(3); + release(); + await flush(); + } finally { + release(); + await h.service.dispose(); + } + }); it('dedicates the bridge to current plugin OAuth cards, without a fake Renderer sender', async () => { const h = harness(); const context: RemoteOauthContext = { scope: 'tx', assertCurrent: vi.fn(), authorize: vi.fn(), finish: vi.fn() }; @@ -142,6 +167,17 @@ describe('Bot authorization transcript lifecycle (Grok parity)', () => { expect(h.stored.size).toBe(1); await h.service.dispose(); }); + it('keeps root and organization authorization cards separate for the same id', async () => { + const h = harness(); + const root = await h.service.request('s', { kind: 'plugin', id: 'p', namespace: null }); + const organization = await h.service.request('s', { kind: 'plugin', id: 'p', namespace: 'acme' }); + expect(root).toMatchObject({ ok: false, errorCode: 'SETUP_REQUIRED' }); + expect(organization).toMatchObject({ ok: false, errorCode: 'SETUP_REQUIRED' }); + expect(h.stored.size).toBe(2); + expect([...h.stored.values()].map((card) => card.target.kind === 'plugin' ? card.target.namespace : undefined)) + .toEqual([null, 'acme']); + await h.service.dispose(); + }); it('an old unclicked card remains usable after the one-hour fallback expires', async () => { const h = harness(); await h.service.request('s', { kind: 'plugin', id: 'p' }); @@ -154,6 +190,17 @@ describe('Bot authorization transcript lifecycle (Grok parity)', () => { expect(h.card().snapshot.terminal).toBe(true); await h.service.dispose(); }); + it('does not merge concurrent legacy, root and organization authorization requests', async () => { + const h = harness(); + try { + const requests = [undefined, null, 'acme'].map((namespace) => + h.service.request('s', { kind: 'plugin', id: 'p', ...(namespace === undefined ? {} : { namespace }) }), + ); + await Promise.all(requests); + expect(new Set(requests).size).toBe(3); + expect(h.stored.size).toBe(3); + } finally { await h.service.dispose(); } + }); it('watch timeout retains the card and late completion still resumes through the fallback listener', async () => { const h = harness(); await h.service.request('s', { kind: 'plugin', id: 'p' }); @@ -434,6 +481,28 @@ describe('authorization completion races', () => { expect(h.deps.resume).toHaveBeenCalledTimes(2); await h.service.dispose(); }); + it('does not share an OAuth flight between same-id plugins in different namespaces', async () => { + const h = harness(); + const finish: Array<() => void> = []; + h.adapter.execute = vi.fn(async () => { + await new Promise((resolve) => finish.push(resolve)); + return { ok: true as const }; + }); + await h.service.request('s', { kind: 'plugin', id: 'p', namespace: null }); + await h.service.request('s', { kind: 'plugin', id: 'p', namespace: 'acme' }); + for (const card of h.stored.values()) { + await h.service.resolve(card.snapshot.requestId, { + kind: 'plugin_setup', action: 'run_action', actionId: 'connect', + expectedRevision: card.snapshot.revision, + }, h.sender); + } + await flush(); + expect(h.adapter.execute).toHaveBeenCalledTimes(2); + h.setReady(); + for (const settle of finish) settle(); + await flush(); + await h.service.dispose(); + }); }); describe('authorization durable completion boundary', () => { diff --git a/apps/desktop/src/main/maker-ipc/botAuthorizationHost.ts b/apps/desktop/src/main/maker-ipc/botAuthorizationHost.ts index 68264513170..4c5b2b32419 100644 --- a/apps/desktop/src/main/maker-ipc/botAuthorizationHost.ts +++ b/apps/desktop/src/main/maker-ipc/botAuthorizationHost.ts @@ -25,6 +25,7 @@ import { } from '../cindy-brain/index.js'; import { getGhostSetupChangeBus } from '../cindy-brain/ghostSetupChangeBus.js'; import { classifyGhostVisibility } from '../cindy-brain/ghostVisibility.js'; +import { installedGhostStoragePart, installedGhostMutationTargetToken } from '../../shared/pluginIdentity.js'; import { isGhostDisabledForWorkdir } from '../cindy-brain/ghostWorkdirPrefs.js'; import { getGrokAccessToken, @@ -155,27 +156,33 @@ export function initializeBotAuthorizationHost( .from(sessions) .where(eq(sessions.id, sessionId)) .limit(1); - const validate = async () => { + const validate = async (expectedTarget?: string | null) => { await assertSession(sessionId); const result = classifyGhostVisibility(target.id, session?.workingDir ?? null, { listGhosts: () => getGhostManager().list(), isAvailableForActiveSession: isGhostAvailableForActiveSession, isDisabledForWorkdir: isGhostDisabledForWorkdir, - }); + }, target.namespace); if (!result.ok) throw new Error('Plugin is unavailable'); + if (expectedTarget !== undefined && (expectedTarget === null || + installedGhostMutationTargetToken(result.ghost, '') !== expectedTarget)) { + throw new Error('Authorization plugin instance changed'); + } return result.ghost; }; const ghost = await validate(); + const expectedTarget = installedGhostMutationTargetToken(ghost, ''); + const instanceId = installedGhostStoragePart(ghost); let reconnected = false; return { identity: { - id: target.id, + id: instanceId, name: ghost.manifest.name, ...(ghost.iconDataUrl ? { iconDataUrl: ghost.iconDataUrl } : {}), }, async assess() { - await validate(); - const assessment = getGhostSetupAssessment(target.id); + await validate(expectedTarget); + const assessment = getGhostSetupAssessment(instanceId); if (!target.reauthorize) return assessment; const suggested = toReauthInteractionAssessment(assessment); // A plugin-wide OAuth event (or successful action) cannot satisfy a @@ -196,12 +203,12 @@ export function initializeBotAuthorizationHost( return groups.length ? { ...assessment, state: 'required' as const, groups } : assessment; }, subscribe: (wake) => - bus.subscribe(target.id, (event) => { + bus.subscribe(instanceId, (event) => { if (event.source === 'oauth') reconnected = true; wake(); }), async execute(action, sender, value, onAuthorizationUrl, assertCurrent, beforeCommit) { - await validate(); + await validate(expectedTarget); assertCurrent?.(); const release = acquireGhostMutationLeaseForMcp(captureGhostMutationOwnerForMcp()); try { @@ -209,14 +216,14 @@ export function initializeBotAuthorizationHost( if (value === undefined) return { ok: false, errorCode: 'INLINE_UNAVAILABLE' }; return await executeGhostSetupInlineAction({ sessionId, - ghostId: target.id, + ghostId: instanceId, action, value, }); } const result = await executeGhostSetupAction({ sessionId, - ghostId: target.id, + ghostId: instanceId, action, responseTarget: sender, onAuthorizationUrl, @@ -327,6 +334,7 @@ export function initializeBotAuthorizationHost( !card.snapshot.terminal && card.target.kind === target.kind && card.target.id === target.id && + (card.target.kind !== 'plugin' || card.target.namespace === (target.kind === 'plugin' ? target.namespace : undefined)) && !!card.target.reauthorize === !!target.reauthorize ) return card; diff --git a/apps/desktop/src/main/maker-ipc/botAuthorizationService.ts b/apps/desktop/src/main/maker-ipc/botAuthorizationService.ts index aa056ef0ce9..64fc3085d62 100644 --- a/apps/desktop/src/main/maker-ipc/botAuthorizationService.ts +++ b/apps/desktop/src/main/maker-ipc/botAuthorizationService.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import { deliveryNamespaceFields } from '../../shared/pluginIdentity.js'; import { getRemoteOauthContext, notifyOauthCardClosed } from '../plugin-oauth/context.js'; import type { PluginOauthAction } from '@cindy/device-link'; import type { OauthCardBinding } from '../plugin-oauth/transactions.js'; @@ -128,7 +129,8 @@ export class BotAuthorizationService { private requests = new Map>(); request(sessionId: string, target: BotAuthorizationTarget, plan?: GhostSetupPlan) { - const key = `${this.epoch}:${sessionId}:${target.kind}:${target.id}:${!!target.reauthorize}`; + const key = JSON.stringify([this.epoch, sessionId, target.kind, target.id, + target.kind === 'plugin' ? deliveryNamespaceFields(target) : null, !!target.reauthorize]); const existing = this.requests.get(key); if (existing) return existing; const pending = this.requestCard(sessionId, target, plan).finally(() => @@ -156,6 +158,7 @@ export class BotAuthorizationService { e.card.sessionId === sessionId && e.card.target.kind === target.kind && e.card.target.id === target.id && + (e.card.target.kind !== 'plugin' || e.card.target.namespace === (target.kind === 'plugin' ? target.namespace : undefined)) && !!e.card.target.reauthorize === !!target.reauthorize, ); if (existing) { @@ -282,6 +285,7 @@ export class BotAuthorizationService { const entry = this.attach(card, adapter); entry.assessmentFingerprint = JSON.stringify(assessment); if (!(await this.isVisible(entry))) return null; + if (card.snapshot.ghost.id !== adapter.identity.id) await this.refresh(entry, assessment); // Broadcast a newer revision before accepting a stale reopen click. The // retained card can then start a new flow without a generic action error. if (hadReopenAction) await this.save(entry); @@ -456,7 +460,9 @@ export class BotAuthorizationService { assertCurrent(); }, }; - const key = `${entry.card.target.kind}:${entry.card.target.id}:${action.id}:${getRemoteOauthContext()?.scope ?? 'local'}`; + const key = JSON.stringify([entry.card.target.kind, entry.card.target.id, + entry.card.target.kind === 'plugin' ? deliveryNamespaceFields(entry.card.target) : null, + action.id, getRemoteOauthContext()?.scope ?? 'local']); let flight = this.oauthFlights.get(key); if (!flight) { const listeners = new Set<(url: string) => void>([onUrl]); @@ -635,6 +641,12 @@ export class BotAuthorizationService { if (entry.expiry) clearTimeout(entry.expiry); this.entries.delete(entry.card.snapshot.requestId); } + invalidatePlugin(instanceId: string): void { + for (const entry of this.entries.values()) { + if (entry.card.target.kind === 'plugin' && entry.adapter.identity.id === instanceId) this.close(entry); + } + } + async dispose() { this.epoch += 1; this.deps.onDisposing?.(); diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index fbe610dce51..8bd3f9df518 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -172,12 +172,17 @@ import { executeGhostSetupAction, executeGhostSetupInlineAction, bindGhostSetupConnectionAction, + findGhostForInstanceId, getGhostManager, getGhostPipeDispatcher, getGhostSetupAssessment, getIOSSimulatorPluginAccessDecision, isGhostAvailableForActiveSession, } from '../cindy-brain/index.js'; +import { + hasDeliveryNamespace, + installedGhostStoragePart, +} from '../../shared/pluginIdentity.js'; import { assertTrustedAppRendererEvent, isTrustedAppRendererEvent, @@ -2483,15 +2488,25 @@ initGhostSetupCoordinator({ remoteConnection: true, changeBus: getGhostSetupChangeBus(), bridge: ghostSetupInteractionBridge, + resolveStoreId: (ghostId) => { + const ghost = findGhostForInstanceId(ghostId); + return ghost ? installedGhostStoragePart(ghost) : ghostId; + }, assess: (ghostId) => getGhostSetupAssessment(ghostId), validateTarget: (ghostId, tool, workingDir) => { // Coordinator 的 UI 只消费 TARGET_UNAVAILABLE 状态;这里的 message 会随 // ensureReady 结果回到模型,因此与 ghost_info / ghost_call 共用同一口径。 - const visibility = classifyGhostVisibility(ghostId, workingDir ?? null, { - listGhosts: () => getGhostManager().list(), - isAvailableForActiveSession: isGhostAvailableForActiveSession, - isDisabledForWorkdir: isGhostDisabledForWorkdir, - }); + const instance = findGhostForInstanceId(ghostId); + const visibility = classifyGhostVisibility( + instance?.manifest.id ?? ghostId, + workingDir ?? null, + { + listGhosts: () => getGhostManager().list(), + isAvailableForActiveSession: isGhostAvailableForActiveSession, + isDisabledForWorkdir: isGhostDisabledForWorkdir, + }, + instance && hasDeliveryNamespace(instance) ? instance.namespace : undefined, + ); if (!visibility.ok) return visibility; const ghost = visibility.ghost; if (tool && !(ghost.manifest.tools ?? []).some((candidate) => candidate.name === tool)) { @@ -2504,16 +2519,13 @@ initGhostSetupCoordinator({ return { ok: true }; }, getGhostIdentity: (ghostId) => { - const ghost = getGhostManager() - .list() - .find((candidate) => candidate.manifest.id === ghostId); - return ghost - ? { - id: ghostId, - name: ghost.manifest.name, - ...(ghost.iconDataUrl ? { iconDataUrl: ghost.iconDataUrl } : {}), - } - : null; + const ghost = findGhostForInstanceId(ghostId); + if (!ghost) return null; + return { + id: installedGhostStoragePart(ghost), + name: ghost.manifest.name, + ...(ghost.iconDataUrl ? { iconDataUrl: ghost.iconDataUrl } : {}), + }; }, executeAction: ({ sessionId, ghostId, action, responseTarget }) => executeGhostSetupAction({ diff --git a/apps/desktop/src/main/mcp-integrations/__tests__/ghostWorkdirGate.test.ts b/apps/desktop/src/main/mcp-integrations/__tests__/ghostWorkdirGate.test.ts index d155b297a74..45ebcb1653f 100644 --- a/apps/desktop/src/main/mcp-integrations/__tests__/ghostWorkdirGate.test.ts +++ b/apps/desktop/src/main/mcp-integrations/__tests__/ghostWorkdirGate.test.ts @@ -1206,6 +1206,28 @@ describe('connect_account shares Host live plugin policy', () => { }); describe('connect_account ordinary task entry', () => { + it('selects an explicit root when an organization instance shares its id', async () => { + setupAssessmentMock.mockReturnValue(configured); + listMock.mockReturnValue([ + { ...(chipGhost('art') as object), namespace: null }, + { ...(chipGhost('art') as object), namespace: 'acme', dir: '/fake/_ns/acme/art' }, + ]); + expect(await makeDeps().connectAccount!({ kind: 'plugin', id: 'art' })) + .toMatchObject({ ok: false, errorCode: 'GHOST_AMBIGUOUS' }); + expect(await makeDeps().connectAccount!({ kind: 'plugin', id: 'art', namespace: null })) + .toMatchObject({ ok: true, status: 'ready' }); + expect(ensureReadyMock).toHaveBeenCalledWith(expect.objectContaining({ ghostId: 'art' })); + }); + it('rechecks a physically namespaced organization after its setup completes', async () => { + setupAssessmentMock.mockReturnValue(configured); + listMock.mockReturnValue([ + { ...(chipGhost('art') as object), namespace: null }, + { ...(chipGhost('art') as object), namespace: 'acme', dir: '/fake/_ns/acme/art' }, + ]); + expect(await makeDeps().connectAccount!({ kind: 'plugin', id: 'art', namespace: 'acme' })) + .toMatchObject({ ok: true, status: 'ready' }); + expect(ensureReadyMock).toHaveBeenCalledWith(expect.objectContaining({ ghostId: '_ns__acme__art' })); + }); it('keeps Host-derived GitHub login on its existing path without a cloud-only adapter', async () => { listMock.mockReturnValue([chipGhost('cindy-github')]); const signal = new AbortController().signal; @@ -1516,12 +1538,13 @@ describe('Manual-only Ghost discovery and read gates', () => { const roster = deps.getRosterItems?.() ?? []; expect(roster.map(({ id }) => id)).toEqual(['ios-simulator', 'art']); expect(roster[0]).toEqual({ - id: 'ios-simulator', name: 'iOS Simulator', recall: ghost.manifest.whenToUse, + id: 'ios-simulator', namespace: null, name: 'iOS Simulator', recall: ghost.manifest.whenToUse, }); const ghosts = await deps.listAwakeGhosts(); expect(ghosts).toHaveLength(2); expect(ghosts[0]).toEqual({ ...roster[0], + namespace: null, tools: [], manual: [{ name: 'ios-simulator', description: 'Simulator workflow' }], setup: { state: 'ready', revision: 0, groups: [] }, @@ -1899,6 +1922,58 @@ describe('ghost_call 兜底拒绝', () => { }); describe('session-context 宿主铸造', () => { + it.each([null, 'acme'])('pins namespace %s across revalidation when a twin exists', async (namespace) => { + listMock.mockReturnValue([ + chipGhost('art', ['tool', 'session-context']), + { + ...(chipGhost('art', ['tool', 'session-context']) as object), + namespace: 'acme', + dir: path.join(tmpUserData, '_ns', 'acme', 'art'), + }, + ]); + sessionSnapshotMock.mockResolvedValueOnce({ + workingDir: WORKDIR, + permissionMode: 'auto', + planModeEnabled: true, + remoteHostId: null, + }); + + const result = await makeDeps().callGhostTool({ + ghostId: 'art', + namespace, + tool: 'run', + args: { + session_context: { + session_id: 'forged', + workdir: '/tmp/forged', + workdir_is_local: true, + workdir_is_read_only: false, + }, + }, + }); + + expect(result).toMatchObject({ ok: true, result: 'done' }); + expect(ensureReadyMock).toHaveBeenCalledWith( + expect.objectContaining({ ghostId: namespace === null ? 'art' : '_ns__acme__art' }), + ); + expect(dispatchMock).toHaveBeenCalledTimes(1); + expect(dispatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + ghostId: namespace === null ? 'art' : '_ns__acme__art', + args: { + session_context: { + session_id: 's1', + workdir: WORKDIR, + workdir_is_local: true, + workdir_is_read_only: true, + }, + }, + }), + ); + const dispatched = dispatchMock.mock.calls.at(0)?.at(0) as Record | undefined; + expect(dispatched).not.toHaveProperty('namespace'); + }); + it('剥除上游伪造值,并按会话权限注入可信只读状态', async () => { listMock.mockReturnValue([chipGhost('art', ['tool', 'session-context'])]); sessionSnapshotMock.mockResolvedValueOnce({ diff --git a/apps/desktop/src/main/mcp-integrations/ghost.ts b/apps/desktop/src/main/mcp-integrations/ghost.ts index f082c074d96..21a358f59e2 100644 --- a/apps/desktop/src/main/mcp-integrations/ghost.ts +++ b/apps/desktop/src/main/mcp-integrations/ghost.ts @@ -83,6 +83,14 @@ import { type GhostSetupAssessment, type InstalledGhost, } from '../../shared/ghost.js'; +import { + findInstalledGhostByInstanceId, + installedGhostLogicalIdentity, + deliveryNamespaceFields, + installedGhostStoragePart, + pluginStoragePart, + resolveInstalledGhost, +} from '../../shared/pluginIdentity.js'; import { withCardToken } from '../cindy-brain/cardService.js'; import { drainGhostCallMedia } from '../cindy-brain/ghostMediaLedger.js'; import { @@ -98,7 +106,7 @@ import { } from '../cindy-brain/index.js'; import { writeForgeScaffoldWithStableParent } from '../cindy-brain/forgeScaffoldCapability.js'; import { getGhostSetupCoordinator } from '../cindy-brain/ghostSetupCoordinator.js'; -import { classifyGhostVisibility } from '../cindy-brain/ghostVisibility.js'; +import { classifyGhostVisibility, classifyInstalledGhostVisibility } from '../cindy-brain/ghostVisibility.js'; import { readInstalledGhostManual } from '../cindy-brain/ghostManual.js'; import { isGhostDisabledForWorkdir } from '../cindy-brain/ghostWorkdirPrefs.js'; import { FORGE_GUIDE, packGhostDir, scaffoldGhostDir } from '../cindy-brain/forge.js'; @@ -628,9 +636,7 @@ async function getForgeSessionFsGate( function ghostDisplayName(ghostId: string): string { if (ghostId === CINDY_FORGE_GRANT_ID) return 'Forge'; if (ghostId === CINDY_SESSION_FS_GRANT_ID) return 'Cindy'; - const g = getGhostManager() - .list() - .find((x) => x.manifest.id === ghostId); + const g = findInstalledGhostByInstanceId(getGhostManager().list(), ghostId); return g?.manifest.name ?? ghostId; } @@ -1588,7 +1594,7 @@ function visibleChipGhosts( isGhostAvailableForActiveSession(ghost.manifest.id) && ghost.manifest.kind === 'chip' && (ghostHasTools(ghost) || ghostHasManual(ghost)) && - !isGhostDisabledForWorkdir(ghost.manifest.id, workdir), + !isGhostDisabledForWorkdir(installedGhostStoragePart(ghost), workdir), ); } @@ -1609,6 +1615,7 @@ export function getGhostRosterPrompt({ workingDir }: { workingDir?: string }): s const recall = ghostRecall(ghost); return { id: ghost.manifest.id, + ...(ghost.namespaceMigration === 'pending' ? {} : { namespace: ghost.namespace ?? null }), name: ghost.manifest.name, ...(ghost.manifest.command ? { command: ghost.manifest.command } : {}), ...(recall ? { recall } : {}), @@ -1621,17 +1628,20 @@ function toCindyGhostInfo(ghost: InstalledGhost): CindyGhostInfo { const recall = ghostRecall(ghost); let setup: CindyGhostInfo['setup']; try { - setup = getGhostSetupAssessment(ghost.manifest.id); + setup = getGhostSetupAssessment(installedGhostStoragePart(ghost)); } catch (error) { // Discovery is best-effort per plugin. Keep this plugin discoverable // without claiming it is ready; ghost_call retains the strict setup gate. log.warn('ghost setup assessment omitted from discovery', { - ghostId: ghost.manifest.id, + ghostId: installedGhostStoragePart(ghost), errorType: error instanceof Error ? error.name : typeof error, }); } return { id: ghost.manifest.id, + namespace: Object.prototype.hasOwnProperty.call(ghost, 'namespace') + ? ghost.namespace ?? null + : null, name: ghost.manifest.name, ...(ghost.manifest.command ? { command: ghost.manifest.command } : {}), ...(recall ? { recall } : {}), @@ -1678,10 +1688,11 @@ export function getCindyGhostsMcpDeps( }; const marketTools = hostDeps.pluginMarket && createPluginMarketAgentTools({ market: hostDeps.pluginMarket, - installedState: (ghostId) => { - const visibility = classifyGhostVisibility(ghostId, resolveSessionContext()?.workingDir ?? null, ghostVisibilityDeps); + installedState: (ghostId, namespace) => { + const visibility = classifyGhostVisibility(ghostId, resolveSessionContext()?.workingDir ?? null, ghostVisibilityDeps, namespace); + const resolved = resolveInstalledGhost(getGhostManager().list(), ghostId, namespace); return { - exists: getGhostManager().list().some(ghost => ghost.manifest.id === ghostId), + exists: resolved.status === 'unique', errorCode: visibility.ok ? null : visibility.errorCode, }; }, @@ -1735,9 +1746,11 @@ export function getCindyGhostsMcpDeps( return service.request(sessionId, target); } const workingDir = context?.workingDir ?? null; - const visible = classifyGhostVisibility(target.id, workingDir, ghostVisibilityDeps); + const visible = classifyGhostVisibility(target.id, workingDir, ghostVisibilityDeps, + target.kind === 'plugin' ? target.namespace : undefined); if (!visible.ok) return visible; - const assessment = getGhostSetupAssessment(target.id); + const instanceId = installedGhostStoragePart(visible.ghost); + const assessment = getGhostSetupAssessment(instanceId); if (assessment.state === 'ready' && assessment.groups.length === 0) { // gh-cli and other Host-derived sources deliberately have no synchronous // setup requirement. An empty assessment is not proof of platform login. @@ -1752,14 +1765,18 @@ export function getCindyGhostsMcpDeps( const coordinator = getGhostSetupCoordinator(); if (!coordinator) return { ok: false, errorCode: 'HOST_NOT_READY' }; const result = await coordinator.ensureReady({ - sessionId, ghostId: target.id, workingDir, signal, + sessionId, ghostId: instanceId, workingDir, signal, ...(target.reauthorize ? { reauthorize: true } : {}), }); if (!result.ok) return result; if (signal?.aborted) return { ok: false, errorCode: 'SETUP_CANCELLED' }; - const current = classifyGhostVisibility(target.id, workingDir, ghostVisibilityDeps); + const current = classifyGhostVisibility(target.id, workingDir, ghostVisibilityDeps, + target.namespace); if (!current.ok) return current; - const final = getGhostSetupAssessment(target.id); + if (installedGhostStoragePart(current.ghost) !== instanceId) { + return { ok: false, errorCode: 'GHOST_NOT_FOUND' }; + } + const final = getGhostSetupAssessment(instanceId); if (final.state !== 'ready') return { ok: false, errorCode: 'SETUP_REQUIRED' }; return { ok: true, status: 'ready', ghostId: target.id, message: 'Host setup is ready. No plugin business operation was executed. Platform permissions are verified only by the requested operation.' }; @@ -1843,6 +1860,7 @@ export function getCindyGhostsMcpDeps( const recall = ghostRecall(g); return { id: g.manifest.id, + ...(g.namespaceMigration === 'pending' ? {} : { namespace: g.namespace ?? null }), name: g.manifest.name, ...(g.manifest.command ? { command: g.manifest.command } : {}), ...(recall ? { recall } : {}), @@ -1857,15 +1875,15 @@ export function getCindyGhostsMcpDeps( return visibleChipGhosts(workdir) .map(toCindyGhostInfo); }, - async getAwakeGhost(ghostId) { + async getAwakeGhost(ghostId, namespace) { const workdir = resolveSessionContext()?.workingDir ?? null; - const visibility = classifyGhostVisibility(ghostId, workdir, ghostVisibilityDeps); + const visibility = classifyGhostVisibility(ghostId, workdir, ghostVisibilityDeps, namespace); if (!visibility.ok) return visibility; - const visible = visibleChipGhosts(workdir).find( - (ghost) => ghost.manifest.id === ghostId, + const visible = visibleChipGhosts(workdir).some( + (ghost) => ghost === visibility.ghost || ghost.dir === visibility.ghost.dir, ); if (visible) { - return { ok: true, ghost: toCindyGhostInfo(visible) }; + return { ok: true, ghost: toCindyGhostInfo(visibility.ghost) }; } return { ok: false, @@ -1873,9 +1891,9 @@ export function getCindyGhostsMcpDeps( message: GHOST_NO_AGENT_SURFACE_MESSAGE, }; }, - async readGhostManual({ ghostId, path: manualPath }) { + async readGhostManual({ ghostId, namespace, path: manualPath }) { const workdir = resolveSessionContext()?.workingDir ?? null; - const visibility = classifyGhostVisibility(ghostId, workdir, ghostVisibilityDeps); + const visibility = classifyGhostVisibility(ghostId, workdir, ghostVisibilityDeps, namespace); if (!visibility.ok) { return { ok: false, @@ -1898,6 +1916,7 @@ export function getCindyGhostsMcpDeps( }, async callGhostTool({ ghostId, + namespace, tool, args, attachments, @@ -1916,9 +1935,11 @@ export function getCindyGhostsMcpDeps( ghostId, sessionWorkdir, ghostVisibilityDeps, + namespace, ); if (!initialVisibility.ok) return initialVisibility; const target = initialVisibility.ghost; + const instanceId = installedGhostStoragePart(target); // 媒体过户:显式 attachments 逐张落媒体总仓 + 记可读引用 // (人工确认 = ghost-grant;Host 工具代办 = ghost-tool-grant),指纹注入 // args.attachments 交给意识。任何一张失败整批拒(ATTACHMENT_INVALID), @@ -1961,12 +1982,12 @@ export function getCindyGhostsMcpDeps( const authorizationService = getBotAuthorizationService(); if (authorizationService && authorizationSessionId && await isBotAuthorizationSession(authorizationSessionId)) { const service = authorizationService; - const card = await service.request(authorizationSessionId, { kind: 'plugin', id: ghostId, ...(setupPlan && getGhostSetupAssessment(ghostId).reauthSuggest ? { reauthorize: true } : {}) }, setupPlan); + const card = await service.request(authorizationSessionId, { kind: 'plugin', id: target.manifest.id, ...deliveryNamespaceFields(target), ...(setupPlan && getGhostSetupAssessment(instanceId).reauthSuggest ? { reauthorize: true } : {}) }, setupPlan); if (!card.ok) return card; } const setup = await setupCoordinator.ensureReady({ sessionId: ghostSetupInteractionSessionId(sessionContext), - ghostId, + ghostId: instanceId, ...(!grantOnly ? { tool } : {}), workingDir: sessionWorkdir, ...(setupPlan ? { plan: setupPlan } : {}), @@ -1975,8 +1996,8 @@ export function getCindyGhostsMcpDeps( // OAuth/settings may take minutes. Re-resolve mutable target facts after // the waiter completes and before beginning the existing side effects. - const refreshedVisibility = classifyGhostVisibility( - ghostId, + const refreshedVisibility = classifyInstalledGhostVisibility( + target, sessionWorkdir, ghostVisibilityDeps, ); @@ -1994,7 +2015,7 @@ export function getCindyGhostsMcpDeps( } let finalAssessment; try { - finalAssessment = getGhostSetupAssessment(ghostId); + finalAssessment = getGhostSetupAssessment(instanceId); } catch { return { ok: false, @@ -2015,14 +2036,14 @@ export function getCindyGhostsMcpDeps( if (grantOnly) { // Full pre-grant gate: confirm target, workdir, and setup readiness // BEFORE grantAttachmentUrls creates durable ledger entries. - const grantVisibility = classifyGhostVisibility( - ghostId, + const grantVisibility = classifyInstalledGhostVisibility( + target, sessionWorkdir, ghostVisibilityDeps, ); if (!grantVisibility.ok) return grantVisibility; try { - const grantOnlyAssessment = getGhostSetupAssessment(ghostId); + const grantOnlyAssessment = getGhostSetupAssessment(instanceId); if (grantOnlyAssessment.state !== 'ready') { return { ok: false, @@ -2039,7 +2060,7 @@ export function getCindyGhostsMcpDeps( }; } const grant = await grantAttachmentUrls({ - ghostId, + ghostId: instanceId, urls: attachments!, workdirAbs: sessionWorkdir, sessionId: sessionIdForConfirm, @@ -2052,15 +2073,15 @@ export function getCindyGhostsMcpDeps( } // Post-grant revalidation: the grant process includes an async user // confirmation step; re-check everything before returning success. - const postGrantVisibility = classifyGhostVisibility( - ghostId, + const postGrantVisibility = classifyInstalledGhostVisibility( + target, sessionWorkdir, ghostVisibilityDeps, ); if (!postGrantVisibility.ok) return postGrantVisibility; let postGrantAssessment: GhostSetupAssessment; try { - postGrantAssessment = getGhostSetupAssessment(ghostId); + postGrantAssessment = getGhostSetupAssessment(instanceId); if (postGrantAssessment.state !== 'ready') { return { ok: false, @@ -2093,7 +2114,7 @@ export function getCindyGhostsMcpDeps( const attachmentUrls = [...new Set(attachments ?? [])]; if (attachmentUrls.length > 0) { const grant = await grantAttachmentUrls({ - ghostId, + ghostId: instanceId, urls: attachmentUrls, workdirAbs: sessionWorkdir, sessionId: sessionIdForConfirm, @@ -2115,7 +2136,7 @@ export function getCindyGhostsMcpDeps( if (dir !== undefined) { if (handoffExpired()) return handoffDenied; const dirConfirm = await confirmDepositOutsideWorkdir({ - ghostId, + ghostId: instanceId, sessionId: sessionIdForConfirm, sessionInstanceId: sessionInstanceIdForGrant, lane: 'dir', @@ -2129,7 +2150,7 @@ export function getCindyGhostsMcpDeps( if (dirConfirm.isCurrent) handoffChecks.push(dirConfirm.isCurrent); if (handoffExpired()) return handoffDenied; const deposited = getDirDepositVault().deposit({ - ghostId, + ghostId: installedGhostStoragePart(target), dirAbs: dirConfirm.userGranted ? dirConfirm.approvedRealPath : dir, workdirAbs: sessionWorkdir, userGranted: dirConfirm.userGranted, @@ -2146,7 +2167,7 @@ export function getCindyGhostsMcpDeps( if (saveDir !== undefined) { if (handoffExpired()) return handoffDenied; const saveConfirm = await confirmDepositOutsideWorkdir({ - ghostId, + ghostId: instanceId, sessionId: sessionIdForConfirm, sessionInstanceId: sessionInstanceIdForGrant, lane: 'save_dir', @@ -2160,7 +2181,7 @@ export function getCindyGhostsMcpDeps( if (saveConfirm.isCurrent) handoffChecks.push(saveConfirm.isCurrent); if (handoffExpired()) return handoffDenied; const saveDeposited = getSaveDepositVault().deposit({ - ghostId, + ghostId: installedGhostStoragePart(target), dirAbs: saveConfirm.userGranted ? saveConfirm.approvedRealPath : saveDir, workdirAbs: sessionWorkdir, userGranted: saveConfirm.userGranted, @@ -2182,8 +2203,8 @@ export function getCindyGhostsMcpDeps( // Pre-dispatch revalidation: attachment grants and dir tickets may have // taken time; confirm the target is still available before committing the // callId and dispatching to the sandbox. - const preDispatchVisibility = classifyGhostVisibility( - ghostId, + const preDispatchVisibility = classifyInstalledGhostVisibility( + target, sessionWorkdir, ghostVisibilityDeps, ); @@ -2197,7 +2218,7 @@ export function getCindyGhostsMcpDeps( }; } try { - const preDispatchAssessment = getGhostSetupAssessment(ghostId); + const preDispatchAssessment = getGhostSetupAssessment(instanceId); if (preDispatchAssessment.state !== 'ready') { return { ok: false, @@ -2218,16 +2239,17 @@ export function getCindyGhostsMcpDeps( // a same-ID plugin replacement removing the declaration during the await. if (preDispatch.manifest.sessionContext === true) { const ctx = await buildGhostSessionContext(sessionIdForConfirm, sessionWorkdir); - const postCtxManifest = getGhostManager() - .list() - .find((g) => g.manifest.id === ghostId)?.manifest; + const postCtxManifest = findInstalledGhostByInstanceId( + getGhostManager().list(), + instanceId, + )?.manifest; if (postCtxManifest?.sessionContext === true) { mergedArgs = { ...mergedArgs, session_context: ctx }; } } // Full revalidation after session-context await (DB query may take time) - const postCtxVisibility = classifyGhostVisibility( - ghostId, + const postCtxVisibility = classifyInstalledGhostVisibility( + target, sessionWorkdir, ghostVisibilityDeps, ); @@ -2242,7 +2264,7 @@ export function getCindyGhostsMcpDeps( } let postCtxAssessment: GhostSetupAssessment; try { - postCtxAssessment = getGhostSetupAssessment(ghostId); + postCtxAssessment = getGhostSetupAssessment(instanceId); if (postCtxAssessment.state !== 'ready') { return { ok: false, @@ -2268,7 +2290,8 @@ export function getCindyGhostsMcpDeps( const cardService = getGhostCardService(); const callSessionContext = resolveSessionContext(); cardService.registerCall(callId, { - ghostId, + ghostId: instanceId, + logicalGhostId: pluginStoragePart(installedGhostLogicalIdentity(target)), toolUseId: agentToolUseId ?? null, // ALS 优先(codex 每单恢复)、闭包兜底(claude 建线期按 session 绑定) // ——此前 claude 路径这里恒为 null,卡片只能靠 toolUseId 启发式锚定。 @@ -2281,7 +2304,7 @@ export function getCindyGhostsMcpDeps( // GhostToolCallResult 与 CindyGhostCallResult 同构(错误码枚举一致), // 原样透传;类型层若有漂移 tsc 会拦。 const result = await getGhostPipeDispatcher().callGhostTool({ - ghostId, + ghostId: instanceId, tool, args: mergedArgs, callId, @@ -2291,7 +2314,7 @@ export function getCindyGhostsMcpDeps( // 收口取账(ghostMediaLedger):本次调用期间主机实际入库的媒体地址。 // 失败也 drain(清账防泄漏),但只在成功结果上附带——cindy-tools 层 // 在意识未声明媒体字段时以 xdt_media_produced 注入,兜底 IM/hook 送达。 - const producedMedia = drainGhostCallMedia(ghostId, callId); + const producedMedia = drainGhostCallMedia(instanceId, callId); const finalized = withCardToken(result, cardService.finalizeCall(callId), callId); if (!finalized.ok) return finalized; // 附最后一道 gate(postCtx)的快照:它是派发前最新的 ready 判定。 diff --git a/apps/desktop/src/main/plugin-market/__tests__/api.test.ts b/apps/desktop/src/main/plugin-market/__tests__/api.test.ts index ddbf540f9ed..d7b861d8038 100644 --- a/apps/desktop/src/main/plugin-market/__tests__/api.test.ts +++ b/apps/desktop/src/main/plugin-market/__tests__/api.test.ts @@ -205,6 +205,31 @@ describe('PluginMarketApi', () => { }); }); + it('rejects a later page whose organization namespace conflicts with the first-page identity', async () => { + const fetcher = pagedFetcher( + { + plugins: [], nextCursor: PLUGIN_A, + currentOrganization: { organizationId: 'org-acme', orgSlug: 'acme', pluginPrefix: 'acme' }, + }, + { + plugins: [{ ...summary(PLUGIN_B, 'beta'), scope: 'organization', + organizationId: 'org-other', namespace: 'other' }], + nextCursor: null, + }, + ); + await expect(new PluginMarketApi(fetcher).listAll()).rejects.toThrow('namespace'); + }); + + it('rejects conflicting organization facts across pages even with empty listings', async () => { + const fetcher = pagedFetcher( + { plugins: [], nextCursor: PLUGIN_A, + currentOrganization: { organizationId: 'org-acme', orgSlug: 'acme', pluginPrefix: 'acme' } }, + { plugins: [], nextCursor: null, + currentOrganization: { organizationId: 'org-other', orgSlug: 'other', pluginPrefix: 'other' } }, + ); + await expect(new PluginMarketApi(fetcher).listAll()).rejects.toThrow('currentOrganization'); + }); + it('keeps a null currentOrganization as a personal-identity fact', async () => { const fetcher = pagedFetcher({ plugins: [summary(PLUGIN_A, 'alpha')], diff --git a/apps/desktop/src/main/plugin-market/__tests__/ledger-ns-durability.test.ts b/apps/desktop/src/main/plugin-market/__tests__/ledger-ns-durability.test.ts new file mode 100644 index 00000000000..8848156e126 --- /dev/null +++ b/apps/desktop/src/main/plugin-market/__tests__/ledger-ns-durability.test.ts @@ -0,0 +1,115 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const crash = vi.hoisted(() => ({ + failMain: false, + writes: [] as string[], +})); + +vi.mock('../../utils/atomicWriteFile.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + atomicWriteFileSync(filePath: string, contents: string) { + crash.writes.push(path.basename(filePath)); + if (crash.failMain && path.basename(filePath) === 'ledger.v1.json') { + throw new Error('simulated crash after ns sidecar'); + } + actual.atomicWriteFileSync(filePath, contents); + }, + }; +}); + +import { + NS_LEDGER_FILE, + PluginMarketLedger, + type PluginMarketInstallationRecord, +} from '../ledger'; + +const roots: string[] = []; + +afterEach(() => { + crash.failMain = false; + crash.writes = []; + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +function harness() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cindy-plugin-ledger-ns-')); + roots.push(root); + const filePath = path.join(root, 'plugin-market', 'ledger.v1.json'); + return { filePath, ledger: new PluginMarketLedger(filePath) }; +} + +function record( + overrides: Partial = {}, +): PluginMarketInstallationRecord { + return { + pluginId: `c${'a'.repeat(24)}`, + ghostId: 'cindy-test', + releaseId: 'release-1', + version: '1.0.0', + sha256: 'b'.repeat(64), + scope: 'public', + organizationId: null, + source: 'market', + installed: true, + updatedAt: '2026-07-23T00:00:00.000Z', + ...overrides, + }; +} + +describe('PluginMarketLedger namespace write order', () => { + it('writes the ns sidecar before rewriting the main ledger', () => { + const { ledger } = harness(); + ledger.upsertInstallation( + record({ ghostId: 'helper', namespace: null, pluginId: `c${'a'.repeat(24)}` }), + ); + crash.writes = []; + ledger.upsertInstallation( + record({ + ghostId: 'helper', + namespace: 'acme', + pluginId: `c${'b'.repeat(24)}`, + scope: 'organization', + organizationId: 'org_1', + }), + ); + expect(crash.writes).toEqual([ + 'ns-ledger.v1.json', + 'custom-ledger.v1.json', + 'ledger.v1.json', + ]); + }); + + it('keeps org provenance if the main ledger rewrite fails after the sidecar write', () => { + const { filePath, ledger } = harness(); + ledger.upsertInstallation( + record({ + ghostId: 'helper', + namespace: 'acme', + pluginId: `c${'b'.repeat(24)}`, + scope: 'organization', + organizationId: 'org_1', + }), + ); + crash.failMain = true; + expect(() => + ledger.upsertInstallation( + record({ ghostId: 'helper', namespace: null, pluginId: `c${'a'.repeat(24)}` }), + ), + ).toThrow(/simulated crash after ns sidecar/); + crash.failMain = false; + expect(ledger.installationForPlugin({ ghostId: 'helper', namespace: 'acme' })).toMatchObject({ + namespace: 'acme', + pluginId: `c${'b'.repeat(24)}`, + }); + const sidecar = JSON.parse( + fs.readFileSync(path.join(path.dirname(filePath), NS_LEDGER_FILE), 'utf8'), + ) as { installations: Record }; + expect(sidecar.installations['_ns__acme__helper']).toMatchObject({ namespace: 'acme' }); + }); +}); diff --git a/apps/desktop/src/main/plugin-market/__tests__/ledger.test.ts b/apps/desktop/src/main/plugin-market/__tests__/ledger.test.ts index bb489d1f113..117d7ce81a5 100644 --- a/apps/desktop/src/main/plugin-market/__tests__/ledger.test.ts +++ b/apps/desktop/src/main/plugin-market/__tests__/ledger.test.ts @@ -5,9 +5,15 @@ import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { + NS_LEDGER_FILE, PluginMarketLedger, + ghostManifestDigest, type PluginMarketInstallationRecord, } from '../ledger'; +import { loadGhostFirstPartyFactsLoader } from '../../cindy-brain/ghostFirstPartyFacts'; +import { authorizeGhostTokenBroker } from '../../cindy-brain/ghostFirstPartyPrivilege'; +import { loadConnectionAudienceResolver } from '../../cindy-brain/connectionAudienceResolver'; +import type { GhostManifest } from '../../../shared/ghost'; const roots: string[] = []; @@ -41,6 +47,94 @@ function record( } describe('PluginMarketLedger', () => { + it.each([false, true])('does not authorize a known root through an org sibling with a root tombstone=%s', (tombstone) => { + const { ledger } = harness(); + ledger.upsertInstallation(record({ ghostId: 'helper', namespace: 'acme', scope: 'organization', organizationId: 'org-1' })); + if (tombstone) ledger.upsertInstallation(record({ ghostId: 'helper', namespace: null, installed: false })); + const preciseRoot = ledger.lookupInstallationForAuthorization({ ghostId: 'helper', namespace: null }); + expect(preciseRoot).toEqual(tombstone + ? { kind: 'found', record: expect.objectContaining({ namespace: null, installed: false }) } + : { kind: 'absent' }); + expect(ledger.lookupInstallationForOidc('helper', null)).toEqual(preciseRoot); + expect(ledger.lookupInstallationForOidc({ ghostId: 'helper', namespace: null })).toEqual(preciseRoot); + expect(ledger.lookupInstallationForAuthorization({ ghostId: 'helper', namespace: 'acme' })).toMatchObject({ kind: 'found', record: { namespace: 'acme' } }); + expect(ledger.lookupInstallationForAuthorization({ ghostId: 'helper', namespace: 'other' })).toEqual({ kind: 'absent' }); + expect(ledger.lookupInstallationForAuthorization({ ghostId: 'helper' })).toMatchObject({ kind: 'found', record: { namespace: 'acme' } }); + }); + + it('resolves an old unnamespaced organization row only for a pending target, not known root', () => { + const { ledger } = harness(); + ledger.upsertInstallation(record({ ghostId: 'helper', scope: 'organization', organizationId: 'org-1' })); + expect(ledger.lookupInstallationForAuthorization({ ghostId: 'helper', namespace: null })).toEqual({ kind: 'absent' }); + expect(ledger.lookupInstallationForAuthorization({ ghostId: 'helper' })).toMatchObject({ kind: 'found' }); + }); + + it('keeps precise authorization fail-closed for malformed records and files', () => { + const { filePath, ledger } = harness(); + ledger.upsertInstallation(record({ ghostId: 'helper', namespace: null })); + const raw = JSON.parse(fs.readFileSync(filePath, 'utf8')); + raw.installations.helper.sha256 = 42; + fs.writeFileSync(filePath, JSON.stringify(raw)); + const target = { ghostId: 'helper', namespace: null }; + expect(ledger.lookupInstallationForAuthorization(target)).toEqual({ kind: 'invalid' }); + expect(ledger.lookupInstallationForOidc(target)).toEqual({ kind: 'invalid' }); + expect(() => ledger.installationForAuthorization(target)).toThrow(/unreadable/); + fs.writeFileSync(filePath, '{'); + expect(ledger.lookupInstallationForOidc(target)).toEqual({ kind: 'invalid' }); + }); + + it('does not grant enterprise Broker or OIDC to a known root copied from identical org package bytes', () => { + const { ledger } = harness(); + const manifest: GhostManifest = { + schemaVersion: 2, id: 'helper', name: 'Helper', version: '1.0.0', kind: 'chip', entry: 'main.js', + network: { hosts: ['service.test'], secrets: [{ + key: 'identity', label: 'Identity', source: 'oidc-token', + inject: { header: 'Authorization', hosts: ['service.test'], format: 'Bearer {value}' }, + }] }, + }; + const packageSha256 = 'a'.repeat(64); + const manifestDigest = ghostManifestDigest(manifest); + ledger.upsertInstallation(record({ ghostId: 'helper', namespace: 'acme', scope: 'organization', + organizationId: 'org-1', sha256: packageSha256, manifestDigest, rawManifestSha256: manifestDigest })); + const identity = { membershipId: 'member-1', membershipKind: 'org' as const, orgId: 'org-1', orgSlug: 'acme' }; + for (const namespace of [null, 'acme']) { + const target = { ghostId: 'helper', namespace }; + const factsLoader = loadGhostFirstPartyFactsLoader({ + readInstalledBuiltin: () => false, readInstallOrigin: () => 'manual', + readInstallNamespace: () => namespace, readApprovedPackageSha256: () => packageSha256, + readMarketInstallation: () => ledger.installationForAuthorization(target), + lookupOrganizationPrefix: () => ({ kind: 'known', pluginPrefix: 'acme' }), + }); + expect(authorizeGhostTokenBroker('helper', factsLoader.load('helper', 'runtime', identity))).toBe(namespace !== null); + const resolver = loadConnectionAudienceResolver({ + readInstalledManifestIdentity: () => ({ manifest, rawManifestSha256: manifestDigest, + legacyManifestDigest: manifestDigest, legacyManifestDigests: [manifestDigest] }), + readApprovedPackageSha256: () => packageSha256, readInstallNamespace: () => namespace, + readMarketInstallation: () => ledger.lookupInstallationForOidc(target), + }); + expect(resolver.resolve('helper', identity) !== null).toBe(namespace !== null); + } + }); + it('never falls back from an explicit root uninstall to a same-id organization sibling', () => { + const { ledger } = harness(); + ledger.upsertInstallation(record({ namespace: 'acme', scope: 'organization', organizationId: 'org-1' })); + expect(ledger.installationForLocalUninstall({ ghostId: 'cindy-test', namespace: null })).toBeNull(); + expect(ledger.installationForLocalUninstall({ ghostId: 'cindy-test', namespace: 'acme' })).toMatchObject({ namespace: 'acme' }); + ledger.upsertInstallation(record({ namespace: null, installed: false })); + expect(ledger.installationForLocalUninstall({ ghostId: 'cindy-test', namespace: null })).toBeNull(); + }); + + it('retires only the unchanged provenance captured for local uninstall', () => { + const { ledger } = harness(); + const original = record({ namespace: null }); + ledger.upsertInstallation(original); + const replacement = record({ namespace: null, source: 'local-market', sourceKey: 'new-source' }); + ledger.upsertInstallation(replacement); + ledger.markRemovedRecordIfUnchanged(original, 'user-1'); + expect(ledger.installationForGhost('cindy-test')).toMatchObject({ installed: true, sourceKey: 'new-source' }); + ledger.markRemovedRecordIfUnchanged(replacement, 'user-1'); + expect(ledger.installationForGhost('cindy-test')).toMatchObject({ installed: false }); + }); it('writes provenance atomically and reads it back', () => { const { filePath, ledger } = harness(); ledger.upsertInstallation(record({ @@ -66,6 +160,50 @@ describe('PluginMarketLedger', () => { ).toEqual([]); }); + it('persists known namespace and drops invalid namespace records as unreadable', () => { + const { filePath, ledger } = harness(); + ledger.upsertInstallation(record({ namespace: null })); + expect(ledger.installationForGhost('cindy-test')).toMatchObject({ namespace: null }); + ledger.upsertInstallation(record({ ghostId: 'org-helper', namespace: 'acme' })); + expect(ledger.installationForGhost('org-helper')).toMatchObject({ namespace: 'acme' }); + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as { + schemaVersion: number; + installations: Record>; + defaultInstallOptOuts: Record; + }; + parsed.installations['cindy-test'] = { + ...parsed.installations['cindy-test'], + namespace: 'Bad Namespace', + }; + fs.writeFileSync(filePath, JSON.stringify(parsed)); + expect(ledger.installationForGhost('cindy-test')).toBeNull(); + expect(ledger.installationForGhost('org-helper')).toMatchObject({ namespace: 'acme' }); + }); + + it('stamps namespace onto a pre-namespace row and refuses to overwrite a known value', () => { + const { ledger } = harness(); + ledger.upsertInstallation(record({ ghostId: 'helper' })); + expect(ledger.stampNamespaceIfAbsent('helper', 'acme')).toBe(true); + expect(ledger.installationForGhost('helper')).toMatchObject({ namespace: 'acme' }); + expect(ledger.stampNamespaceIfAbsent('helper', 'acme')).toBe(true); + expect(ledger.stampNamespaceIfAbsent('helper', null)).toBe(false); + expect(ledger.installationForGhost('helper')).toMatchObject({ namespace: 'acme' }); + expect(ledger.stampNamespaceIfAbsent('missing', 'acme')).toBe(false); + }); + + it('stamps the installed record even when a removed record shares its ghost id', () => { + const { ledger } = harness(); + ledger.upsertInstallation(record({ ghostId: 'helper' })); + ledger.upsertInstallation(record({ ghostId: 'helper', pluginId: 'removed', + namespace: 'other', scope: 'organization', organizationId: 'org-other', installed: false })); + expect(ledger.hasInstalledRecordForGhostId('helper')).toBe(true); + expect(ledger.stampNamespaceIfAbsent('helper', 'acme')).toBe(true); + expect(ledger.installationsForGhost('helper')).toEqual(expect.arrayContaining([ + expect.objectContaining({ installed: true, namespace: 'acme' }), + expect.objectContaining({ installed: false, namespace: 'other' }), + ])); + }); + it('backfills raw manifest identity without changing legacy routing fields', () => { const { ledger } = harness(); const legacy = record({ manifestDigest: 'c'.repeat(64) }); @@ -189,16 +327,12 @@ describe('PluginMarketLedger', () => { }); }); - it('fails closed to an empty ledger for malformed or future data', () => { + it('refuses to rewrite a future ledger version', () => { const { filePath, ledger } = harness(); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, '{"schemaVersion":99,"installations":{"x":{}}}'); - expect(ledger.read()).toEqual({ - schemaVersion: 1, - installations: {}, - defaultInstallOptOuts: {}, - }); + expect(() => ledger.read()).toThrow(/market ledger is unreadable/i); expect(ledger.lookupInstallationForOidc('cindy-test')).toEqual({ kind: 'invalid' }); }); @@ -230,7 +364,7 @@ describe('PluginMarketLedger', () => { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, '{not-json'); - expect(ledger.installationForGhost('cindy-test')).toBeNull(); + expect(() => ledger.installationForGhost('cindy-test')).toThrow(/market ledger is unreadable/i); expect(ledger.lookupInstallationForOidc('cindy-test')).toEqual({ kind: 'invalid' }); }); @@ -239,7 +373,7 @@ describe('PluginMarketLedger', () => { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify({ schemaVersion: 1, installations: null })); - expect(ledger.installationForGhost('cindy-test')).toBeNull(); + expect(() => ledger.installationForGhost('cindy-test')).toThrow(/market ledger is unreadable/i); expect(ledger.lookupInstallationForOidc('cindy-test')).toEqual({ kind: 'invalid' }); }); @@ -476,4 +610,143 @@ describe('PluginMarketLedger', () => { const custom = JSON.parse(fs.readFileSync(customPath, 'utf8')); expect(Object.keys(custom.installations)).toEqual(['cindy-custom']); }); + + it('keeps same-name root and organization rows and writes the org row to the ns sidecar', () => { + const { filePath, ledger } = harness(); + ledger.upsertInstallation(record({ ghostId: 'helper', namespace: null, pluginId: `c${'a'.repeat(24)}` })); + ledger.upsertInstallation( + record({ + ghostId: 'helper', + namespace: 'acme', + pluginId: `c${'b'.repeat(24)}`, + scope: 'organization', + organizationId: 'org_1', + }), + ); + expect(ledger.installationForPlugin({ ghostId: 'helper', namespace: null })).toMatchObject({ + namespace: null, + pluginId: `c${'a'.repeat(24)}`, + }); + expect(ledger.installationForPlugin({ ghostId: 'helper', namespace: 'acme' })).toMatchObject({ + namespace: 'acme', + pluginId: `c${'b'.repeat(24)}`, + }); + expect(ledger.installationForGhost('helper')).toBeNull(); + expect(ledger.installationsForGhost('helper')).toHaveLength(2); + expect(ledger.installationForLookup('helper')).toMatchObject({ namespace: null }); + expect(ledger.installationForLookup('_root/helper')).toMatchObject({ namespace: null }); + expect(ledger.installationForLookup('_root__helper')).toMatchObject({ namespace: null }); + expect(ledger.installationForLookup('_ns__acme__helper')).toMatchObject({ namespace: 'acme' }); + const main = JSON.parse(fs.readFileSync(filePath, 'utf8')) as { + installations: Record; + }; + expect(main.installations.helper).toMatchObject({ namespace: null }); + expect(main.installations.helper).not.toHaveProperty('namespace', 'acme'); + const sidecar = JSON.parse( + fs.readFileSync(path.join(path.dirname(filePath), NS_LEDGER_FILE), 'utf8'), + ) as { installations: Record }; + expect(sidecar.installations['_ns__acme__helper']).toMatchObject({ + ghostId: 'helper', + namespace: 'acme', + }); + }); + + it('skips an uninstalled public row when a unique live organization row remains', () => { + const { ledger } = harness(); + ledger.upsertInstallation( + record({ + ghostId: 'helper', + namespace: null, + pluginId: `c${'a'.repeat(24)}`, + installed: false, + }), + ); + ledger.upsertInstallation( + record({ + ghostId: 'helper', + namespace: 'acme', + pluginId: `c${'b'.repeat(24)}`, + scope: 'organization', + organizationId: 'org_1', + }), + ); + expect(ledger.installationForLookup('helper')).toMatchObject({ + namespace: 'acme', + installed: true, + }); + expect(ledger.lookupInstallationForOidc('helper')).toMatchObject({ + kind: 'found', + record: { namespace: 'acme', installed: true }, + }); + }); + + it('still returns the public tombstone when nothing remains installed', () => { + const { ledger } = harness(); + ledger.upsertInstallation( + record({ ghostId: 'helper', namespace: null, installed: false }), + ); + expect(ledger.installationForLookup('helper')).toMatchObject({ + namespace: null, + installed: false, + }); + }); + + it('keeps a unique organization row in the main ledger keyed by ghostId', () => { + const { filePath, ledger } = harness(); + ledger.upsertInstallation(record({ ghostId: 'xd-feishu', namespace: 'xd' })); + expect(ledger.installationForGhost('xd-feishu')).toMatchObject({ namespace: 'xd' }); + const main = JSON.parse(fs.readFileSync(filePath, 'utf8')) as { + installations: Record; + }; + expect(main.installations['xd-feishu']).toMatchObject({ namespace: 'xd' }); + expect(fs.existsSync(path.join(path.dirname(filePath), NS_LEDGER_FILE))).toBe(true); + const sidecar = JSON.parse( + fs.readFileSync(path.join(path.dirname(filePath), NS_LEDGER_FILE), 'utf8'), + ) as { installations: Record }; + expect(sidecar.installations).toEqual({}); + }); + + it("does not wipe namespaced org rows when ns-ledger JSON is unreadable", () => { + const { filePath, ledger } = harness(); + ledger.upsertInstallation( + record({ ghostId: "helper", namespace: null, pluginId: `c${"a".repeat(24)}` }), + ); + ledger.upsertInstallation( + record({ + ghostId: "helper", + namespace: "acme", + pluginId: `c${"b".repeat(24)}`, + scope: "organization", + organizationId: "org_1", + }), + ); + const nsPath = path.join(path.dirname(filePath), NS_LEDGER_FILE); + const before = fs.readFileSync(nsPath, "utf8"); + fs.writeFileSync(nsPath, "{not-json"); + expect(() => ledger.read()).toThrow(/market ledger is unreadable/i); + expect(() => + ledger.upsertInstallation(record({ ghostId: "other", pluginId: `c${"c".repeat(24)}` })), + ).toThrow(/market ledger is unreadable/i); + expect(fs.readFileSync(nsPath, "utf8")).toBe("{not-json"); + expect(ledger.lookupInstallationForOidc("helper")).toEqual({ kind: "invalid" }); + fs.writeFileSync(nsPath, before); + expect(ledger.installationForPlugin({ ghostId: "helper", namespace: "acme" })).toMatchObject({ + namespace: "acme", + pluginId: `c${"b".repeat(24)}`, + }); + }); + + it.each(['ledger.v1.json', 'custom-ledger.v1.json'])( + 'does not overwrite a damaged %s during a later installation', + (name) => { + const { filePath, ledger } = harness(); + ledger.upsertInstallation(record({ ghostId: 'kept' })); + const damagedPath = path.join(path.dirname(filePath), name); + fs.writeFileSync(damagedPath, '{corrupt'); + expect(() => ledger.upsertInstallation(record({ ghostId: 'new' }))).toThrow(/market ledger is unreadable/i); + expect(fs.readFileSync(damagedPath, 'utf8')).toBe('{corrupt'); + expect(ledger.lookupInstallationForOidc('kept')).toEqual({ kind: 'invalid' }); + }, + ); + }); diff --git a/apps/desktop/src/main/plugin-market/__tests__/service-custom-sources.test.ts b/apps/desktop/src/main/plugin-market/__tests__/service-custom-sources.test.ts index 74c45b93a42..45c7e424167 100644 --- a/apps/desktop/src/main/plugin-market/__tests__/service-custom-sources.test.ts +++ b/apps/desktop/src/main/plugin-market/__tests__/service-custom-sources.test.ts @@ -646,6 +646,7 @@ describe('PluginMarketService 自定义市场聚合', () => { enabled: true, }); expect(runtime.install).toHaveBeenCalledTimes(1); + expect(runtime.install.mock.calls[0]?.[1]).not.toHaveProperty('sourceChanged'); }); }); @@ -1719,6 +1720,7 @@ describe('PluginMarketService 自定义市场 detail/install', () => { ).resolves.toMatchObject({ ghost: { manifest: { id: 'alpha' } } }); expect(runtime.install.mock.calls[0]?.[1]).toMatchObject({ manifestCap: ghostManifest('alpha'), + sourceChanged: true, }); expect(h.ledger.installationForGhost('alpha')).toMatchObject({ pluginId: customMarketPluginId('team-lib', 'alpha'), @@ -1980,6 +1982,7 @@ describe('PluginMarketService 自定义市场 detail/install', () => { ).resolves.toMatchObject({ ghost: { manifest: { version: '2.0.0' } } }); expect(runtime.install.mock.calls[0]?.[1]).toMatchObject({ manifestCap: ghostManifest('alpha', '2.0.0'), + sourceChanged: true, }); expect(h.ledger.installationForGhost('alpha')).toMatchObject({ sourceKey: marketSourceKey({ type: 'local', path: dirB }), @@ -2274,6 +2277,7 @@ describe('PluginMarketService 自定义市场 detail/install', () => { }, TEST_INSTALL_CONTEXT), ).resolves.toMatchObject({ ghost: { manifest: { id: 'server-plugin' } } }); expect(runtime.install.mock.calls[0]?.[1]).not.toHaveProperty('manifestCap'); + expect(runtime.install.mock.calls[0]?.[1]).toHaveProperty('sourceChanged', true); expect(h.ledger.installationForGhost('server-plugin')).toMatchObject({ pluginId: item.id, source: 'market', @@ -2312,6 +2316,7 @@ describe('PluginMarketService 自定义市场 detail/install', () => { ).resolves.toMatchObject({ ghost: { manifest: { id: 'alpha' } } }); expect(runtime.install.mock.calls[0]?.[1]).toMatchObject({ manifestCap: ghostManifest('alpha'), + sourceChanged: true, }); expect(h.ledger.installationForGhost('alpha')).toMatchObject({ pluginId: customMarketPluginId('team-lib', 'alpha'), @@ -2373,6 +2378,7 @@ describe('PluginMarketService 自定义市场 detail/install', () => { updatedAt: '2026-07-30T02:00:00.000Z', }); + runtime.ghosts = [installedGhost(root, 'alpha')]; await expect(h.service.uninstall(pluginId)).resolves.toEqual({ ok: true }); expect(runtime.uninstall).toHaveBeenCalledWith('alpha', { skipMarketLedger: true }); expect(h.ledger.installationForGhost('alpha')?.installed).toBe(false); diff --git a/apps/desktop/src/main/plugin-market/__tests__/service.test.ts b/apps/desktop/src/main/plugin-market/__tests__/service.test.ts index 8dde7e5599d..c4273f08840 100644 --- a/apps/desktop/src/main/plugin-market/__tests__/service.test.ts +++ b/apps/desktop/src/main/plugin-market/__tests__/service.test.ts @@ -19,6 +19,7 @@ const runtime = vi.hoisted(() => ({ manifest: Record; dir: string; enabled: boolean; + namespace?: string | null; approval?: GhostInstallApproval; trust?: GhostTrustInfo; }>, @@ -31,9 +32,11 @@ const runtime = vi.hoisted(() => ({ pendingCalls: false, runningErrand: false, cindyWork: false, + busyQueryIds: [] as string[], generatedInstallDirs: [] as string[], installOrigins: new Map(), installOriginError: false, + resumeOfflineResidents: vi.fn(), currentOrganization: null as { organizationId: string; pluginPrefix: string | null; @@ -81,6 +84,7 @@ vi.mock('../../logger.js', () => ({ })); vi.mock('../../cindy-brain/index.js', () => ({ getGhostManager: () => ({ + resumePendingResidentsOffline: runtime.resumeOfflineResidents, list: () => runtime.ghosts.map((ghost) => { // Historical service tests used a production-looking placeholder path. @@ -162,9 +166,18 @@ vi.mock('../../cindy-brain/index.js', () => ({ }); return installed; }, - hasPendingGhostCalls: vi.fn(() => runtime.pendingCalls), - hasRunningGhostErrand: vi.fn(() => runtime.runningErrand), - hasRunningGhostCindyWork: vi.fn(() => runtime.cindyWork), + hasPendingGhostCalls: vi.fn((id: string) => { + runtime.busyQueryIds.push(id); + return runtime.pendingCalls; + }), + hasRunningGhostErrand: vi.fn((id: string) => { + runtime.busyQueryIds.push(id); + return runtime.runningErrand; + }), + hasRunningGhostCindyWork: vi.fn((id: string) => { + runtime.busyQueryIds.push(id); + return runtime.cindyWork; + }), isBuiltinGhostRemovedByUser: (id: string) => runtime.builtinRemoved.has(id), uninstallGhostAndCleanup: runtime.uninstall, })); @@ -173,6 +186,7 @@ vi.mock('../download.js', () => ({ })); import type { + PluginDownloadResponse, PluginRemovalNotice, VisiblePluginDetail, VisiblePluginSummary, @@ -223,11 +237,13 @@ afterEach(() => { runtime.pendingCalls = false; runtime.runningErrand = false; runtime.cindyWork = false; + runtime.busyQueryIds = []; for (const dir of runtime.generatedInstallDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } runtime.installOrigins.clear(); runtime.installOriginError = false; + runtime.resumeOfflineResidents.mockClear(); runtime.currentOrganization = null; runtime.boundaryPending = false; runtime.approvedInstallEvidence.mockReset(); @@ -398,7 +414,7 @@ function harness(items: VisiblePluginSummary[], removals: PluginRemovalNotice[] }, } satisfies VisiblePluginDetail; }), - download: vi.fn(async () => ({ + download: vi.fn(async (): Promise => ({ url: 'https://downloads.test.invalid/plugin.cindy', expiresAt: '2099-01-01T00:00:00.000Z', sha256: 'a'.repeat(64), @@ -452,6 +468,19 @@ function mockUninstallDropsGhost(failFor?: string): void { } describe('PluginMarketService migration and defaultInstall', () => { + it('resumes eligible pending residents only when market discovery is unavailable', async () => { + const h = harness([]); + h.api.listAll.mockRejectedValueOnce(new Error('market offline')); + await h.service.snapshot(); + expect(runtime.resumeOfflineResidents).toHaveBeenCalledOnce(); + await h.service.snapshot(); + expect(runtime.resumeOfflineResidents).toHaveBeenCalledOnce(); + runtime.pluginApiBaseUrl = null; + await h.service.snapshot({ discoveryOnly: true }); + expect(runtime.resumeOfflineResidents).toHaveBeenCalledOnce(); + await h.service.snapshot(); + expect(runtime.resumeOfflineResidents).toHaveBeenCalledTimes(2); + }); it('backfills the exact raw identity for an unchanged v0.1.61 v2 card record', async () => { const rawManifest = { schemaVersion: 2 as const, @@ -1135,6 +1164,72 @@ describe('PluginMarketService migration and defaultInstall', () => { expect(runtime.install).not.toHaveBeenCalled(); }); + it('recovers a disconnected namespaced install beside a same-id public catalog entry', async () => { + const publicItem = summary({ ghostId: 'helper', namespace: null }); + const orgItem = summary({ + id: `c${'d'.repeat(24)}`, + ghostId: 'helper', namespace: 'acme', scope: 'organization', organizationId: 'org-1', + currentRelease: { ...summary().currentRelease, id: RELEASE_ID }, + }); + const canonicalManifest = normalizedManifest(manifest('helper')); + const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cindy-market-namespace-recovery-')); + roots.push(installRoot); + const orgDir = path.join(installRoot, '_ns', 'acme', 'helper'); + fs.mkdirSync(orgDir, { recursive: true }); + fs.writeFileSync(path.join(orgDir, 'ghost.json'), JSON.stringify(canonicalManifest)); + runtime.ghosts = [{ manifest: canonicalManifest as unknown as Record, + dir: orgDir, namespace: 'acme', enabled: true }]; + runtime.approvedInstallEvidence.mockReturnValue({ + packageSha256: orgItem.currentRelease.sha256, + approvedManifest: canonicalManifest, + legacyMigrated: false, + }); + const h = harness([publicItem, orgItem]); + const record = recordForTest(orgItem, { + namespace: 'acme', manifestDigest: ghostManifestDigest(canonicalManifest), + }); + h.ledger.upsertInstallation(record); + h.ledger.markRemovedRecord(record, 'user-1'); + + const snapshot = await h.service.snapshot(); + + expect(h.ledger.installationForIdentity({ namespace: 'acme', ghostId: 'helper' })) + .toMatchObject({ installed: true, pluginId: orgItem.id }); + expect(snapshot.items.find((item) => item.pluginId === orgItem.id)?.installState).toBe('installed'); + expect(h.api.download).not.toHaveBeenCalled(); + }); + + it('does not reconnect a namespaced install to a same-id catalog entry from another namespace', async () => { + const canonicalManifest = normalizedManifest(manifest('helper')); + const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cindy-market-namespace-drift-')); + roots.push(installRoot); + const orgDir = path.join(installRoot, '_ns', 'acme', 'helper'); + fs.mkdirSync(orgDir, { recursive: true }); + fs.writeFileSync(path.join(orgDir, 'ghost.json'), JSON.stringify(canonicalManifest)); + runtime.ghosts = [{ manifest: canonicalManifest as unknown as Record, + dir: orgDir, namespace: 'acme', enabled: true }]; + runtime.approvedInstallEvidence.mockReturnValue({ + packageSha256: summary().currentRelease.sha256, + approvedManifest: canonicalManifest, + legacyMigrated: false, + }); + const catalogItem = summary({ + ghostId: 'helper', namespace: 'other', scope: 'organization', organizationId: 'org-1', + currentRelease: { ...summary().currentRelease, id: RELEASE_ID }, + }); + const h = harness([catalogItem, summary({ id: `c${'d'.repeat(24)}`, ghostId: 'helper', namespace: null })]); + const record = recordForTest(catalogItem, { + namespace: 'acme', manifestDigest: ghostManifestDigest(canonicalManifest), + }); + h.ledger.upsertInstallation(record); + h.ledger.markRemovedRecord(record, 'user-1'); + + await h.service.snapshot(); + + expect(h.ledger.installationForIdentity({ namespace: 'acme', ghostId: 'helper' })) + .toMatchObject({ installed: false }); + }); + it('keeps a disconnected route detached when a modern receipt names another manifest', async () => { const canonicalManifest = normalizedManifest(manifest()); const item = summary({ @@ -1418,6 +1513,25 @@ describe('PluginMarketService migration and defaultInstall', () => { manifestDigest: ghostManifestDigest(manifest()), }); }); + it('installs a namespaced default even when a public catalog entry shares the ghostId', async () => { + const publicItem = summary({ ghostId: 'helper', namespace: null }); + const orgItem = summary({ + id: 'c'.repeat(25), ghostId: 'helper', namespace: 'acme', scope: 'organization', + organizationId: 'org-1', defaultInstall: true, + }); + const h = harness([publicItem, orgItem]); + runtime.install.mockImplementationOnce(async () => { + const ghost = { manifest: manifest('helper'), namespace: 'acme', + dir: '/userData/cindy-brain/_ns/acme/helper', enabled: true }; + runtime.ghosts = [ghost]; + return ghost; + }); + await h.service.snapshot(); + expect(runtime.install).toHaveBeenCalledWith(expect.any(String), + expect.objectContaining({ ghostId: 'helper', namespace: 'acme' })); + expect(h.ledger.installationForIdentity({ namespace: 'acme', ghostId: 'helper' })) + .toMatchObject({ installed: true, pluginId: orgItem.id }); + }); it('installs a default package whose detail manifest contains normalized setup requirements', async () => { const item = summary({ defaultInstall: true }); @@ -1443,6 +1557,7 @@ describe('PluginMarketService migration and defaultInstall', () => { items: [{ installState: 'installed', enabled: true }], }); expect(runtime.install.mock.calls[0]?.[1]).not.toHaveProperty('manifestCap'); + expect(runtime.install.mock.calls[0]?.[1]).not.toHaveProperty('sourceChanged'); }); it('returns a Renderer snapshot before a default install download finishes', async () => { @@ -1569,6 +1684,10 @@ describe('PluginMarketService migration and defaultInstall', () => { version: '1.0.0', consent: { mode: 'confirmed', key: expect.any(String) }, afterCommitInLock: expect.any(Function), + pendingMarketRecord: { + scope: 'public', organizationId: null, source: 'market', installed: true, + sha256: item.currentRelease.sha256, + }, }); }); @@ -1696,42 +1815,40 @@ describe('PluginMarketService migration and defaultInstall', () => { version: '1.0.0', consent: { mode: 'confirmed', key: expect.any(String) }, afterCommitInLock: expect.any(Function), + pendingMarketRecord: { + scope: 'public', organizationId: null, source: 'market', installed: true, + sha256: item.currentRelease.sha256, + }, }); // 安装入口用目录 summary 做 detail 身份绑定(防止把 A 的确认导向 B 的内容), // 因此手动安装也会先取一次目录,但不做任何 listAll 之外的多余请求。 expect(h.api.listAll).toHaveBeenCalledTimes(1); // 锁定装完即开的最终结果:装入入口返回的 ghost 必须是启用态。 expect(ghost?.enabled).toBe(true); - expect(runtime.install.mock.calls[0]?.[1]).not.toHaveProperty('pendingMarketRecord'); + expect(runtime.install.mock.calls[0]?.[1]?.pendingMarketRecord).toMatchObject({ + scope: 'public', organizationId: null, source: 'market', sha256: item.currentRelease.sha256, + }); }); - it('passes a Host-built pendingMarketRecord only for organization server-market packages', async () => { - const orgItem = summary({ - ghostId: 'acme-tool', - scope: 'organization', - organizationId: 'org-1', - source: 'local-market', - installed: false, - } as Partial & { source: string; installed: boolean }); + it.each([ + { scope: 'organization', organizationId: 'org-1', ghostId: 'acme-tool', source: 'local-market', installed: false }, + { scope: 'public', organizationId: null }, + { scope: 'personal', organizationId: null }, + ] as const)('passes a Host-built pendingMarketRecord for $scope server-market installs', async (overrides) => { + const item = summary(overrides); runtime.install.mockResolvedValue({ - manifest: manifest('acme-tool'), - dir: '/userData/cindy-brain/acme-tool', + manifest: manifest(item.ghostId), + dir: '/userData/cindy-brain/' + item.ghostId, enabled: true, }); - const orgHarness = harness([orgItem]); - await orgHarness.service.install(orgItem.id, { - ...reviewedInstallOptions(orgItem), - expectedManifest: manifest('acme-tool'), - }, TEST_INSTALL_CONTEXT); + const target = harness([item]); + await target.service.install(item.id, reviewedInstallOptions(item), TEST_INSTALL_CONTEXT); expect(runtime.install).toHaveBeenCalledWith( expect.stringMatching(/\.cindy$/), expect.objectContaining({ pendingMarketRecord: { - scope: 'organization', - organizationId: 'org-1', - source: 'market', - installed: true, - sha256: orgItem.currentRelease.sha256, + scope: item.scope, organizationId: item.organizationId, + source: 'market', installed: true, sha256: item.currentRelease.sha256, }, }), ); @@ -1742,33 +1859,8 @@ describe('PluginMarketService migration and defaultInstall', () => { }; expect(pending.source).toBe('market'); expect(pending.installed).toBe(true); - // The pending ticket carries only the server Release hash. The approved - // side is Host-bound later to inspect(package bytes), so the service cannot - // mint a self-reported match. - expect(pending.sha256).toBe(orgItem.currentRelease.sha256); + expect(pending.sha256).toBe(item.currentRelease.sha256); expect(pending).not.toHaveProperty('approvedPackageSha256'); - - runtime.install.mockReset(); - const publicItem = summary({ scope: 'public', organizationId: null }); - runtime.install.mockResolvedValue({ - manifest: manifest(), - dir: '/userData/cindy-brain/cindy-test', - enabled: true, - }); - const publicHarness = harness([publicItem]); - await publicHarness.service.install(publicItem.id, reviewedInstallOptions(publicItem), TEST_INSTALL_CONTEXT); - expect(runtime.install.mock.calls[0]?.[1]).not.toHaveProperty('pendingMarketRecord'); - - runtime.install.mockReset(); - const personalItem = summary({ scope: 'personal', organizationId: null }); - runtime.install.mockResolvedValue({ - manifest: manifest(), - dir: '/userData/cindy-brain/cindy-test', - enabled: true, - }); - const personalHarness = harness([personalItem]); - await personalHarness.service.install(personalItem.id, reviewedInstallOptions(personalItem), TEST_INSTALL_CONTEXT); - expect(runtime.install.mock.calls[0]?.[1]).not.toHaveProperty('pendingMarketRecord'); }); it('manual market install accepts the normalized setup manifest returned by detail', async () => { @@ -1845,9 +1937,37 @@ describe('PluginMarketService migration and defaultInstall', () => { version: '1.0.0', consent: { mode: 'confirmed', key: expect.any(String) }, afterCommitInLock: expect.any(Function), + pendingMarketRecord: { + scope: 'public', organizationId: null, source: 'market', installed: true, + sha256: ordinary.currentRelease.sha256, + }, }); }); + it('organization cindy-github cannot receive root official trust', async () => { + const github = summary({ + ghostId: 'cindy-github', scope: 'organization', organizationId: 'org-1', namespace: 'acme', + }); + runtime.install.mockResolvedValue({ + manifest: manifest('cindy-github'), namespace: 'acme', + dir: '/userData/cindy-brain/_ns/acme/cindy-github', enabled: true, + }); + const h = harness([github]); + await h.service.install(github.id, reviewedInstallOptions(github), TEST_INSTALL_CONTEXT); + expect(runtime.install.mock.calls[0]?.[1]).not.toHaveProperty('officialCindyGithub'); + }); + + it('explicit root public cindy-github keeps its official trust', async () => { + const github = summary({ ghostId: 'cindy-github', namespace: null }); + runtime.install.mockResolvedValue({ + manifest: manifest('cindy-github'), namespace: null, + dir: '/userData/cindy-brain/cindy-github', enabled: true, + }); + const h = harness([github]); + await h.service.install(github.id, reviewedInstallOptions(github), TEST_INSTALL_CONTEXT); + expect(runtime.install.mock.calls[0]?.[1]).toMatchObject({ officialCindyGithub: true }); + }); + it('writes the v0.1.61 digest for a newly installed v2 card package', async () => { const rawManifest = { schemaVersion: 2 as const, @@ -1935,6 +2055,17 @@ describe('PluginMarketService migration and defaultInstall', () => { updatedAt: '2026-08-07T00:00:00.000Z', manifestDigest: digest, }); + h.ledger.upsertInstallation({ + ...recordForTest(item), pluginId: 'c'.repeat(25), namespace: 'acme', + scope: 'organization', organizationId: 'org-1', + }); + runtime.ghosts.unshift({ + manifest: rawManifest, + namespace: 'acme', + dir: '/userData/cindy-brain/_ns/acme/cindy-github', + enabled: true, + trust: { level: 'unverified', publisherSigned: false, publisherVerified: false, reviewed: false }, + }); runtime.install.mockResolvedValue({ manifest: rawManifest, @@ -2919,6 +3050,136 @@ describe('PluginMarketService migration and defaultInstall', () => { }, ); + it('uses the namespaced storage part as the busy key', async () => { + const item = summary({ + ghostId: 'helper', + namespace: 'acme', + scope: 'organization', + organizationId: 'org-1', + defaultInstall: true, + currentRelease: { ...summary().currentRelease, id: 'release-2', version: '2.0.0' }, + }); + const oldManifest = manifest(item.ghostId, '1.0.0'); + const installDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cindy-market-ns-busy-')); + roots.push(installDir); + const ghostDir = path.join(installDir, '_ns', 'acme', 'helper'); + fs.mkdirSync(ghostDir, { recursive: true }); + fs.writeFileSync(path.join(ghostDir, 'ghost.json'), JSON.stringify(oldManifest)); + runtime.ghosts = [{ manifest: oldManifest, dir: ghostDir, enabled: true, namespace: 'acme' }]; + const h = harness([item]); + h.ledger.upsertInstallation({ + ...recordForTest(item, { namespace: 'acme', scope: 'organization', organizationId: 'org-1' }), + releaseId: 'release-1', + version: '1.0.0', + manifestDigest: ghostManifestDigest(oldManifest), + }); + runtime.pendingCalls = true; + runtime.install.mockImplementation(async () => { + throw new Error('should not install while namespaced instance is busy'); + }); + + await h.service.snapshot(); + expect(runtime.install).not.toHaveBeenCalled(); + expect(runtime.busyQueryIds).toContain('_ns__acme__helper'); + expect(runtime.busyQueryIds).not.toContain('helper'); + }); + + it('recognizes an in-place organization install when an older server omits namespace', async () => { + const item = summary({ ghostId: 'helper', scope: 'organization', organizationId: 'org-1' }); + const installedManifest = manifest('helper'); + const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cindy-legacy-organization-')); + roots.push(installRoot); + const installDir = path.join(installRoot, 'helper'); + fs.mkdirSync(installDir); + fs.writeFileSync(path.join(installDir, 'ghost.json'), JSON.stringify(installedManifest)); + runtime.ghosts = [{ manifest: installedManifest, dir: installDir, namespace: 'acme', enabled: true }]; + const h = harness([item]); + h.ledger.upsertInstallation(recordForTest(item, { + namespace: 'acme', + manifestDigest: ghostManifestDigest(installedManifest), + })); + + expect((await h.service.snapshot()).items[0]?.installState).toBe('installed'); + expect(runtime.install).not.toHaveBeenCalled(); + }); + + it('updates the in-place organization instance instead of installing a root copy for an older server', async () => { + const item = summary({ + ghostId: 'helper', scope: 'organization', organizationId: 'org-1', + currentRelease: { ...summary().currentRelease, id: 'release-2', version: '2.0.0' }, + }); + const oldManifest = manifest('helper'); + const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cindy-legacy-update-')); + roots.push(installRoot); + const installDir = path.join(installRoot, 'helper'); + fs.mkdirSync(installDir); + fs.writeFileSync(path.join(installDir, 'ghost.json'), JSON.stringify(oldManifest)); + runtime.ghosts = [{ manifest: oldManifest, dir: installDir, namespace: 'acme', enabled: true }]; + const h = harness([item]); + h.ledger.upsertInstallation(recordForTest(item, { + namespace: 'acme', releaseId: 'release-1', version: '1.0.0', + manifestDigest: ghostManifestDigest(oldManifest), + })); + const updatedManifest = manifest('helper', '2.0.0'); + runtime.install.mockImplementation(async (_filePath, options) => { + expect(options.namespace).toBe('acme'); + fs.writeFileSync(path.join(installDir, 'ghost.json'), JSON.stringify(updatedManifest)); + const updated = { manifest: updatedManifest, dir: installDir, namespace: 'acme', enabled: true }; + runtime.ghosts = [updated]; + return updated; + }); + + await h.service.install(item.id, { + ...reviewedInstallOptions(item), expectedInstalledApproval: APPROVED_INSTALL_TOKEN, + }, TEST_INSTALL_CONTEXT); + expect(runtime.install).toHaveBeenCalledOnce(); + expect(h.ledger.installationForPlugin({ ghostId: 'helper', namespace: 'acme' })).toMatchObject({ + releaseId: 'release-2', installed: true, + }); + expect(h.ledger.installationForPlugin({ ghostId: 'helper', namespace: null })).toBeNull(); + }); + + it('does not bind an ambiguous old organization response to either same-name instance', async () => { + const item = summary({ ghostId: 'helper', scope: 'organization', organizationId: 'org-1' }); + const root = manifest('helper'); + const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cindy-legacy-ambiguous-')); + roots.push(installRoot); + const rootDir = path.join(installRoot, 'helper'); + const orgDir = path.join(installRoot, '_ns', 'acme', 'helper'); + fs.mkdirSync(rootDir); + fs.mkdirSync(orgDir, { recursive: true }); + fs.writeFileSync(path.join(rootDir, 'ghost.json'), JSON.stringify(root)); + fs.writeFileSync(path.join(orgDir, 'ghost.json'), JSON.stringify(root)); + runtime.ghosts = [ + { manifest: root, dir: rootDir, namespace: null, enabled: true }, + { manifest: root, dir: orgDir, namespace: 'acme', enabled: true }, + ]; + const h = harness([item]); + h.ledger.upsertInstallation(recordForTest(item, { namespace: 'acme', manifestDigest: ghostManifestDigest(root) })); + expect((await h.service.snapshot()).items[0]?.installState).toBe('conflict'); + expect(runtime.install).not.toHaveBeenCalled(); + await expect(h.service.install(item.id, { + ...reviewedInstallOptions(item, true), expectedInstalledApproval: APPROVED_INSTALL_TOKEN, + }, TEST_INSTALL_CONTEXT)).rejects.toThrow('[PRECONDITION_FAILED]'); + expect(runtime.install).not.toHaveBeenCalled(); + }); + + it('does not claim a namespace from an unverified legacy market record', async () => { + const item = summary({ ghostId: 'helper', scope: 'organization', organizationId: 'org-1' }); + const installedManifest = manifest('helper'); + const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cindy-legacy-unverified-')); + roots.push(installRoot); + const installDir = path.join(installRoot, 'helper'); + fs.mkdirSync(installDir); + fs.writeFileSync(path.join(installDir, 'ghost.json'), JSON.stringify(installedManifest)); + runtime.ghosts = [{ manifest: installedManifest, dir: installDir, namespace: 'acme', enabled: true }]; + const h = harness([item]); + h.ledger.upsertInstallation(recordForTest(item, { namespace: 'acme' })); + + expect((await h.service.snapshot()).items[0]?.installState).toBe('conflict'); + expect(runtime.install).not.toHaveBeenCalled(); + }); + it('does not re-check the server-selected organization upgrade against the client version', async () => { const item = summary({ scope: 'organization', @@ -3258,10 +3519,47 @@ describe('PluginMarketService migration and defaultInstall', () => { expect(h.ledger.isDefaultInstallSuppressed('user-1', item.id)).toBe(false); }); + it.each([false, true])('does not track an unowned root uninstall through an org sibling (root tombstone: %s)', async (tombstone) => { + const item = summary({ ghostId: 'helper', namespace: 'acme', scope: 'organization', organizationId: 'org-1' }); + const h = harness([item]); + h.ledger.upsertInstallation(recordForTest(item, { namespace: 'acme' })); + if (tombstone) { + h.ledger.upsertInstallation(recordForTest(summary({ ghostId: 'helper' }), { namespace: null, installed: false })); + } + installRuntimeGhost({ ...manifest('helper'), namespace: undefined }); + runtime.ghosts[0]!.namespace = null; + expect(h.service.prepareLocalUninstallTracking('helper')).toBeNull(); + expect(h.ledger.installationForPlugin({ ghostId: 'helper', namespace: 'acme' })?.installed).toBe(true); + expect(h.ledger.isDefaultInstallSuppressed('user-1', item.id)).toBe(false); + }); + + it('does not track a local replacement with a stale provenance digest', () => { + const item = summary(); + const h = harness([item]); + h.ledger.upsertInstallation(recordForTest(item, { manifestDigest: 'f'.repeat(64) })); + installRuntimeGhost(manifest(item.ghostId)); + expect(h.service.prepareLocalUninstallTracking(item.ghostId)).toBeNull(); + expect(h.ledger.installationForGhost(item.ghostId)?.installed).toBe(true); + }); + + it('does not retire a replacement route after local uninstall tracking was prepared', async () => { + const item = summary(); + const h = harness([item]); + installRuntimeGhost(manifest(item.ghostId)); + h.ledger.upsertInstallation(recordForTest(item, { manifestDigest: ghostManifestDigest(manifest(item.ghostId)) })); + const complete = h.service.prepareLocalUninstallTracking(item.ghostId); + expect(complete).not.toBeNull(); + h.ledger.upsertInstallation(recordForTest(item, { pluginId: 'replacement-resource', source: 'local-market', sourceKey: 'replacement' })); + await complete?.(); + expect(h.ledger.installationForGhost(item.ghostId)).toMatchObject({ pluginId: 'replacement-resource', installed: true }); + expect(h.ledger.isDefaultInstallSuppressed('user-1', 'replacement-resource')).toBe(false); + }); + it('records an opt-out only after a tracked local uninstall succeeds', async () => { const item = summary({ defaultInstall: true }); const h = harness([item]); h.ledger.upsertInstallation(recordForTest(item)); + installRuntimeGhost(manifest(item.ghostId)); const complete = h.service.prepareLocalUninstallTracking(item.ghostId); @@ -3295,7 +3593,7 @@ describe('PluginMarketService migration and defaultInstall', () => { const item = summary({ defaultInstall: true }); const h = harness([item]); h.ledger.upsertInstallation(recordForTest(item)); - runtime.ghosts = [ghostEntry(item.ghostId)]; + installRuntimeGhost(manifest(item.ghostId)); const completeLocalUninstall = h.service.prepareLocalUninstallTracking(item.ghostId); expect(completeLocalUninstall).not.toBeNull(); @@ -3333,6 +3631,7 @@ describe('PluginMarketService migration and defaultInstall', () => { const item = summary({ defaultInstall: true }); const h = harness([item]); h.ledger.upsertInstallation(recordForTest(item)); + installRuntimeGhost(manifest(item.ghostId)); const complete = h.service.prepareLocalUninstallTracking(item.ghostId); @@ -3346,6 +3645,7 @@ describe('PluginMarketService migration and defaultInstall', () => { const item = summary({ defaultInstall: true }); const h = harness([item]); h.ledger.upsertInstallation(recordForTest(item)); + installRuntimeGhost(manifest(item.ghostId)); const complete = h.service.prepareLocalUninstallTracking(item.ghostId); runtime.session = { @@ -3396,6 +3696,50 @@ describe('PluginMarketService migration and defaultInstall', () => { }, ); + it('purges only the matching namespaced organization installation from a legacy notice', async () => { + const notice = removal({ ghostId: 'helper' }); + const h = harness([], [notice]); + const orgDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cindy-market-org-')); + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cindy-market-root-')); + roots.push(orgDir, rootDir); + fs.writeFileSync(path.join(orgDir, 'ghost.json'), JSON.stringify(manifest('helper'))); + fs.writeFileSync(path.join(rootDir, 'ghost.json'), JSON.stringify(manifest('helper'))); + runtime.ghosts = [ + { ...ghostEntry('helper'), dir: rootDir, namespace: null }, + { ...ghostEntry('helper'), dir: orgDir, namespace: 'acme' }, + ]; + h.ledger.upsertInstallation(removalRecord({ ghostId: 'helper', namespace: 'acme', manifestDigest: ghostManifestDigest(manifest('helper')) })); + h.ledger.upsertInstallation(removalRecord({ ghostId: 'helper', namespace: null, pluginId: 'other-plugin', scope: 'public', organizationId: null })); + await h.service.snapshot(); + expect(runtime.uninstall).toHaveBeenCalledWith('_ns/acme/helper', { skipMarketLedger: true }); + expect(h.ledger.installationForPlugin({ ghostId: 'helper', namespace: 'acme' })?.installed).toBe(false); + expect(h.ledger.installationForPlugin({ ghostId: 'helper', namespace: null })?.installed).toBe(true); + }); + + it('does not purge a namespaced organization for another organization', async () => { + const notice = removal({ ghostId: 'helper' }); + const h = harness([], [notice]); + runtime.ghosts = [{ ...ghostEntry('helper'), namespace: 'acme' }]; + h.ledger.upsertInstallation(removalRecord({ ghostId: 'helper', namespace: 'acme', organizationId: 'other-org' })); + await h.service.snapshot(); + expect(runtime.uninstall).not.toHaveBeenCalled(); + expect(h.ledger.installationForPlugin({ ghostId: 'helper', namespace: 'acme' })?.installed).toBe(true); + }); + + it('does not guess a namespace for an ambiguous old notice or override an explicit namespace', async () => { + const notice = removal({ ghostId: 'helper' }); + const h = harness([], [notice]); + runtime.ghosts = [{ ...ghostEntry('helper'), namespace: 'acme' }]; + h.ledger.upsertInstallation(removalRecord({ ghostId: 'helper', namespace: 'acme' })); + h.ledger.upsertInstallation(removalRecord({ ghostId: 'helper', namespace: 'other' })); + h.ledger.upsertInstallation(removalRecord({ ghostId: 'helper', namespace: null })); + await h.service.snapshot(); + expect(runtime.uninstall).not.toHaveBeenCalled(); + h.api.listAll.mockResolvedValueOnce({ plugins: [], removals: [{ ...notice, namespace: 'unknown' }], currentOrganization: null }); + await h.service.snapshot(); + expect(runtime.uninstall).not.toHaveBeenCalled(); + }); + it('purges when the ledger provenance digest matches the installed package', async () => { const notice = removal(); const h = harness([], [notice]); @@ -3634,7 +3978,7 @@ describe('PluginMarketService migration and defaultInstall', () => { expect(h.ledger.installationForGhost('third-party')).toBeNull(); }); - it('allows explicit replacement when a removed market record has an existing directory', async () => { + it.each([false, true])('archives an explicit local-to-server replacement with a removed ledger row=%s', async (removedRecord) => { const item = summary(); const installedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cindy-local-installed-')); roots.push(installedDir); @@ -3652,10 +3996,9 @@ describe('PluginMarketService migration and defaultInstall', () => { enabled: true, }); const h = harness([item]); - h.ledger.upsertInstallation({ - ...recordForTest(item), - installed: false, - }); + if (removedRecord) { + h.ledger.upsertInstallation({ ...recordForTest(item), installed: false }); + } await expect( h.service.install(item.id, { @@ -3666,6 +4009,7 @@ describe('PluginMarketService migration and defaultInstall', () => { ghost: { manifest: { id: item.ghostId } }, }); expect(runtime.install.mock.calls[0]?.[1]).not.toHaveProperty('manifestCap'); + expect(runtime.install.mock.calls[0]?.[1]).toHaveProperty('sourceChanged', true); expect(h.ledger.installationForGhost(item.ghostId)).toMatchObject({ pluginId: item.id, source: 'market', @@ -3979,6 +4323,39 @@ describe('PluginMarketService migration and defaultInstall', () => { expect(h.ledger.installationForGhost(item.ghostId)?.installed).toBe(false); expect(h.ledger.isDefaultInstallSuppressed('user-1', item.id)).toBe(true); }); + + it('does not uninstall a public sibling when the org instance is already gone', async () => { + const publicItem = summary({ ghostId: 'helper' }); + const orgItem = summary({ + id: `c${'d'.repeat(24)}`, + ghostId: 'helper', + namespace: 'acme', + scope: 'organization', + organizationId: 'org-1', + }); + const h = harness([publicItem, orgItem]); + h.ledger.upsertInstallation(recordForTest(publicItem, { namespace: null })); + h.ledger.upsertInstallation( + recordForTest(orgItem, { + namespace: 'acme', + scope: 'organization', + organizationId: 'org-1', + }), + ); + runtime.ghosts = [ghostEntry('helper')]; + + await expect(h.service.uninstall(orgItem.id)).resolves.toEqual({ ok: true }); + + expect(runtime.uninstall).not.toHaveBeenCalled(); + expect(runtime.ghosts).toHaveLength(1); + expect(h.ledger.installationForPlugin({ ghostId: 'helper', namespace: 'acme' })?.installed).toBe( + false, + ); + expect(h.ledger.installationForPlugin({ ghostId: 'helper', namespace: null })?.installed).toBe( + true, + ); + }); + }); function recordForTest( @@ -4040,6 +4417,82 @@ function installRuntimeGhost( } describe('organization default Plugin takeover', () => { + it.each([ + ['S1 verified namespace without token slug', 'acme', 'acme', undefined, 'old-prefix', 'helper', true], + ['S2-off pending legacy prefix', undefined, undefined, undefined, 'acme', 'acme-tool', true], + ['pending legacy cannot guess a natural name', undefined, undefined, 'acme', 'acme', 'helper', false], + ['pending legacy cannot guess a namespace', undefined, 'acme', 'acme', 'acme', 'acme-tool', false], + ['known organization cannot select root', 'acme', null, 'acme', 'acme', 'acme-tool', false], + ['known organization cannot select pending legacy', 'acme', undefined, 'acme', 'acme', 'acme-tool', false], + ['known namespace rejects conflicting verified slug', 'acme', 'acme', 'other', 'acme', 'acme-tool', false], + ['organization default cannot have root identity', null, null, 'acme', 'acme', 'acme-tool', false], + ] as const)('uses verified identity instead of guessing: %s', (_name, namespace, installedNamespace, orgSlug, pluginPrefix, ghostId, eligible) => { + const item = organizationDefaultSummary({ ghostId, ...(namespace !== undefined ? { namespace } : {}) }); + const installed = { + manifest: manifest(ghostId), dir: '/unused', enabled: true, + ...(installedNamespace !== undefined ? { namespace: installedNamespace } : {}), + approval: { state: 'approved', revision: '00000000-0000-4000-8000-000000000001' }, + trust: { level: 'unverified', publisherSigned: false, publisherVerified: false, reviewed: false }, + } satisfies InstalledGhost; + expect(organizationDefaultTakeoverEligibility({ + summary: item, currentOrganization: { organizationId: 'org-1', pluginPrefix, ...(orgSlug ? { orgSlug } : {}) }, + uniqueGhostId: true, installed, record: null, installOrigin: 'manual', + runtimeAvailable: true, optedOut: false, builtinRemoved: false, busy: false, + }).eligible).toBe(eligible); + }); + + it.each([null, 'old-prefix'])('allows verified natural namespace default takeover with prefix %s', (pluginPrefix) => { + const item = organizationDefaultSummary({ namespace: 'acme', ghostId: 'helper' }); + const installed = { + manifest: manifest(item.ghostId), namespace: 'acme', dir: '/unused', enabled: true, + approval: { state: 'approved', revision: '00000000-0000-4000-8000-000000000001' }, + trust: { level: 'unverified', publisherSigned: false, publisherVerified: false, reviewed: false }, + } satisfies InstalledGhost; + expect(organizationDefaultTakeoverEligibility({ + summary: item, currentOrganization: { organizationId: 'org-1', orgSlug: 'acme', pluginPrefix }, + uniqueGhostId: false, installed, record: null, installOrigin: 'manual', + runtimeAvailable: true, optedOut: false, builtinRemoved: true, busy: false, + })).toEqual({ eligible: true }); + }); + + it('installs a namespaced organization default despite a root tombstone and public same-id listing', async () => { + setCurrentOrganization(); + const item = organizationDefaultSummary({ namespace: 'acme' }); + const publicItem = summary({ id: 'c' + 'f'.repeat(24), ghostId: item.ghostId }); + runtime.builtinRemoved.add(item.ghostId); + runtime.install.mockResolvedValue({ + manifest: manifest(item.ghostId), namespace: 'acme', + dir: '/userData/cindy-brain/_ns/acme/acme-tool', enabled: true, + }); + const h = harness([item, publicItem]); + await h.service.snapshot(); + expect(runtime.install).toHaveBeenCalledWith( + expect.any(String), expect.objectContaining({ namespace: 'acme', ghostId: item.ghostId }), + ); + }); + + it('keeps an explicit root default uninstall suppressed by the root tombstone', async () => { + const item = summary({ ghostId: 'cindy-art', namespace: null, defaultInstall: true }); + runtime.builtinRemoved.add(item.ghostId); + const h = harness([item]); + await h.service.snapshot(); + expect(runtime.install).not.toHaveBeenCalled(); + }); + + it('allows a namespaced approved manual takeover even if a public entry shares the id', () => { + const item = organizationDefaultSummary({ namespace: 'acme' }); + const installed = { + manifest: manifest(item.ghostId), namespace: 'acme', dir: '/unused', enabled: true, + approval: { state: 'approved', revision: '00000000-0000-4000-8000-000000000001' }, + trust: { level: 'unverified', publisherSigned: false, publisherVerified: false, reviewed: false }, + } satisfies InstalledGhost; + expect(organizationDefaultTakeoverEligibility({ + summary: item, currentOrganization: { organizationId: 'org-1', pluginPrefix: 'acme' }, + uniqueGhostId: false, installed, record: null, installOrigin: 'manual', + runtimeAvailable: true, optedOut: false, builtinRemoved: true, busy: false, + })).toEqual({ eligible: true }); + }); + it('re-downloads and replaces a same-release bad target record without writing opt-out', async () => { setCurrentOrganization(); const item = organizationDefaultSummary(); @@ -4052,6 +4505,7 @@ describe('organization default Plugin takeover', () => { manifestDigest: ghostManifestDigest(manifest(item.ghostId)), }); runtime.install.mockImplementationOnce(async (_file, options) => { + expect(options.sourceChanged).toBe(true); options.beforeCommitInLock?.(); expect(h.ledger.installationForGhost(item.ghostId)).toMatchObject({ installed: true }); expect(h.ledger.isDefaultInstallSuppressed('user-1', item.id)).toBe(false); @@ -4300,6 +4754,7 @@ describe('organization default Plugin takeover', () => { h.ledger.upsertInstallation(recordForTest(item)); h.ledger.markRemoved(item.ghostId, null); runtime.install.mockImplementationOnce(async (_file, options) => { + expect(options.sourceChanged).toBe(true); options.beforeCommitInLock?.(); const installed = { manifest: manifest(item.ghostId), dir, enabled: true }; fs.writeFileSync(path.join(dir, 'ghost.json'), JSON.stringify(installed.manifest)); @@ -4523,6 +4978,43 @@ describe('organization default Plugin takeover', () => { ).toBe(false); }); + it('does not take over a known root instance for an organization namespace', () => { + const item = organizationDefaultSummary({ namespace: 'acme' }); + const installed = { + manifest: manifest(item.ghostId), + dir: '/not-read-for-cross-namespace', + enabled: true, + approval: { state: 'approved', revision: '00000000-0000-4000-8000-000000000001' }, + trust: { + level: 'unverified', + publisherSigned: false, + publisherVerified: false, + reviewed: false, + }, + } satisfies InstalledGhost; + expect( + organizationDefaultTakeoverEligibility({ + summary: item, + currentOrganization: { organizationId: 'org-1', pluginPrefix: 'acme' }, + uniqueGhostId: true, + installed, + record: { + ...recordForTest(item), + pluginId: `c${'d'.repeat(24)}`, + source: 'market', + scope: 'public', + organizationId: null, + namespace: null, + }, + installOrigin: 'manual', + runtimeAvailable: true, + optedOut: false, + builtinRemoved: false, + busy: false, + }), + ).toEqual({ eligible: false, reason: 'cross-namespace' }); + }); + it('skips busy work without backoff and retries after it becomes idle', async () => { setCurrentOrganization(); const item = organizationDefaultSummary(); @@ -4877,6 +5369,30 @@ describe('market detail 响应身份绑定', () => { h.api.detail.mockImplementation(async () => detail({ ...item, id: 'plg_other' })); await expect(h.service.detail(item.id)).rejects.toThrow('[PRECONDITION_FAILED]'); }); + + it('rejects list/detail namespace drift and mismatched download identity', async () => { + const item = summary({ namespace: null }); + const h = harness([item]); + h.api.detail.mockImplementationOnce(async () => detail({ ...item, namespace: 'acme' })); + await expect(h.service.detail(item.id)).rejects.toThrow('[PRECONDITION_FAILED]'); + + const enterprise = summary({ namespace: 'acme' }); + const h2 = harness([enterprise]); + h2.api.download.mockResolvedValue({ + pluginId: enterprise.id, + releaseId: enterprise.currentRelease.id, + ghostId: enterprise.ghostId, + namespace: null, + url: 'https://downloads.test.invalid/plugin.cindy', + expiresAt: '2099-01-01T00:00:00.000Z', + sha256: 'a'.repeat(64), + sizeBytes: 42, + }); + await expect( + h2.service.install(enterprise.id, reviewedInstallOptions(enterprise), TEST_INSTALL_CONTEXT), + ).rejects.toThrow('[PRECONDITION_FAILED]'); + expect(runtime.install).not.toHaveBeenCalled(); + }); }); describe('Agent catalog discovery and install boundary', () => { diff --git a/apps/desktop/src/main/plugin-market/__tests__/unstampedNamespaceRecovery.test.ts b/apps/desktop/src/main/plugin-market/__tests__/unstampedNamespaceRecovery.test.ts new file mode 100644 index 00000000000..09b6826cd93 --- /dev/null +++ b/apps/desktop/src/main/plugin-market/__tests__/unstampedNamespaceRecovery.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; + +import type { GhostManifest } from '../../../shared/ghost.js'; +import { ghostManifestDigest, type PluginMarketInstallationRecord } from '../ledger.js'; +import { + verifiedUnstampedOrganizationNamespace, + type InstalledMarketManifestIdentity, +} from '../installedManifestIdentity.js'; + +const manifest = { + schemaVersion: 2, id: 'hello', name: 'Hello', version: '1.0.0', kind: 'chip', entry: 'main.js', +} as GhostManifest; +const digest = ghostManifestDigest(manifest); +const packageSha256 = 'b'.repeat(64); +const identity: InstalledMarketManifestIdentity = { + manifest, rawManifestSha256: digest, legacyManifestDigest: digest, + legacyManifestDigests: [digest], +}; +const record: PluginMarketInstallationRecord = { + pluginId: 'plugin', ghostId: 'hello', releaseId: 'release', version: '1.0.0', + sha256: packageSha256, scope: 'organization', organizationId: 'org-acme', + namespace: 'acme', source: 'market', installed: true, updatedAt: '2026-01-01T00:00:00Z', + rawManifestSha256: digest, +}; +const evidence = { packageSha256, approvedManifest: manifest, legacyMigrated: false }; +const input = { records: [record], organizationId: 'org-acme', orgSlug: 'acme', evidence, identity }; + +describe('unstamped organization namespace recovery', () => { + it('restores only the approved package for the current organization', () => { + expect(verifiedUnstampedOrganizationNamespace(input)).toBe('acme'); + expect(verifiedUnstampedOrganizationNamespace({ ...input, organizationId: 'other' })).toBeNull(); + expect(verifiedUnstampedOrganizationNamespace({ ...input, records: [record, record] })).toBeNull(); + expect(verifiedUnstampedOrganizationNamespace({ ...input, records: [{ ...record, namespace: null }] })).toBeNull(); + }); + + it('rejects changed package, installed manifest, or approval', () => { + expect(verifiedUnstampedOrganizationNamespace({ + ...input, evidence: { ...evidence, packageSha256: 'c'.repeat(64) }, + })).toBeNull(); + expect(verifiedUnstampedOrganizationNamespace({ + ...input, identity: { ...identity, rawManifestSha256: 'c'.repeat(64) }, + })).toBeNull(); + expect(verifiedUnstampedOrganizationNamespace({ + ...input, evidence: { ...evidence, approvedManifest: { ...manifest, name: 'Other' } }, + })).toBeNull(); + expect(verifiedUnstampedOrganizationNamespace({ + ...input, records: [{ ...record, rawManifestSha256: undefined }], + evidence: { ...evidence, packageSha256: null }, + })).toBeNull(); + }); + + it('recovers a legacy-approved package only with completed migration and matched digest', () => { + const legacyInput = { + ...input, + records: [{ ...record, rawManifestSha256: undefined, manifestDigest: digest }], + evidence: { ...evidence, packageSha256: null, legacyMigrated: true }, + }; + expect(verifiedUnstampedOrganizationNamespace(legacyInput)).toBe('acme'); + expect(verifiedUnstampedOrganizationNamespace({ + ...legacyInput, evidence: { ...legacyInput.evidence, legacyMigrated: false }, + })).toBeNull(); + expect(verifiedUnstampedOrganizationNamespace({ + ...legacyInput, records: [{ ...legacyInput.records[0], manifestDigest: 'c'.repeat(64) }], + })).toBeNull(); + }); +}); diff --git a/apps/desktop/src/main/plugin-market/agentTools.ts b/apps/desktop/src/main/plugin-market/agentTools.ts index 9758baf1d54..4b1da762362 100644 --- a/apps/desktop/src/main/plugin-market/agentTools.ts +++ b/apps/desktop/src/main/plugin-market/agentTools.ts @@ -12,7 +12,7 @@ interface InstalledState { export interface PluginMarketAgentDeps { market: Pick; - installedState(ghostId: string): InstalledState; + installedState(ghostId: string, namespace?: string | null): InstalledState; /** Owner generation and caller identity are captured before discovery's first await. */ captureRead(): () => void; /** @@ -29,7 +29,7 @@ export interface PluginMarketAgentDeps { function catalogItem(item: PluginMarketItem, installed: InstalledState) { return { - plugin_id: item.pluginId, ghost_id: item.ghostId, release_id: item.releaseId, + plugin_id: item.pluginId, ghost_id: item.ghostId, namespace: item.namespace ?? null, release_id: item.releaseId, name: item.name, description: item.description, author: item.author, version: item.version, scope: item.scope, source: item.sourceType, marketplace: item.sourceMarketName, @@ -52,7 +52,7 @@ export function createPluginMarketAgentTools(deps: PluginMarketAgentDeps) { .toLocaleLowerCase().includes(needle)); return { ok: true, - items: matches.slice(0, 20).map(item => catalogItem(item, deps.installedState(item.ghostId))), + items: matches.slice(0, 20).map(item => catalogItem(item, deps.installedState(item.ghostId, item.namespace))), has_more: matches.length > 20, complete: !snapshot.unavailableReason && snapshot.unavailableCustomSourceNames.length === 0, // Source errors can contain local paths or server responses; expose status only. @@ -73,7 +73,7 @@ export function createPluginMarketAgentTools(deps: PluginMarketAgentDeps) { if (detail.releaseId !== request.releaseId) { throwIpcError('PRECONDITION_FAILED', 'Plugin release changed after selection'); } - const installed = deps.installedState(detail.ghostId); + const installed = deps.installedState(detail.ghostId, detail.namespace); if (installed.exists) return { ok: true, status: 'already-installed', plugin: catalogItem(detail, installed), @@ -82,7 +82,7 @@ export function createPluginMarketAgentTools(deps: PluginMarketAgentDeps) { // Recheck at the service's final commit boundary, including concurrent local imports. const assertCurrent = () => { authority.assertCurrent(); - if (deps.installedState(detail.ghostId).exists) { + if (deps.installedState(detail.ghostId, detail.namespace).exists) { throwIpcError('PRECONDITION_FAILED', 'Plugin appeared during installation; inspect it before continuing'); } }; diff --git a/apps/desktop/src/main/plugin-market/api.ts b/apps/desktop/src/main/plugin-market/api.ts index ef2c98f9917..16475e14485 100644 --- a/apps/desktop/src/main/plugin-market/api.ts +++ b/apps/desktop/src/main/plugin-market/api.ts @@ -1,4 +1,5 @@ import { + assertPluginListOrganizationNamespace, CINDY_CLIENT_VERSION_HEADER, parseGetPluginResponse, parseListPluginsResponse, @@ -79,6 +80,11 @@ export class PluginMarketApi { // `currentOrganization` 并没有写进契约(PLAN §6 没有这一条),若它只在首页带, // 逐页覆盖会让第二页的 null 把身份事实抹掉——多页目录的组织就永远缓存不到前缀。 // 两种服务端行为下这个写法都对,且结果确定。 + if (currentOrganization && response.currentOrganization && + (currentOrganization.organizationId !== response.currentOrganization.organizationId || + currentOrganization.orgSlug !== response.currentOrganization.orgSlug)) { + throw new Error('Plugin 市场分页 currentOrganization 不一致'); + } currentOrganization ??= response.currentOrganization; if (!response.nextCursor) { // 在架优先(契约:通告与**任一页** plugins 有交集即作废)的作用域是 @@ -91,6 +97,7 @@ export class PluginMarketApi { }); return false; }); + assertPluginListOrganizationNamespace(currentOrganization, plugins, removals); return { plugins, removals, currentOrganization }; } if (response.nextCursor === cursor) throw new Error('Plugin 市场分页游标未前进'); diff --git a/apps/desktop/src/main/plugin-market/install.ts b/apps/desktop/src/main/plugin-market/install.ts index 4973ef80997..607c296fded 100644 --- a/apps/desktop/src/main/plugin-market/install.ts +++ b/apps/desktop/src/main/plugin-market/install.ts @@ -14,6 +14,7 @@ import crypto from 'node:crypto'; import { app } from 'electron'; +import { authorDeclaredNamespaceReason } from '@cindy/plugin-protocol'; import { ghostNetworkAuthorizationWithinCap, ghostNodeSecretAuthorizationWithinCap, @@ -74,6 +75,7 @@ export interface PackedCustomMarketPlugin { } type CustomMarketCommitHooks = { + sourceChanged?: boolean | (() => boolean); expectedInstalledApproval?: string; beforeCommit?: () => void | Promise; beforePackagePlacement?: () => void; @@ -138,6 +140,10 @@ export async function packCustomMarketPlugin(input: { // reason 会插值 ghost.json 里的未知字段值(不受长度约束的不可信内容), // packed.message 会带 fs 错误自附的宿主绝对路径——进 IPC 前一律脱敏+截断, // 完整原文只留 main 日志。 + const reservedNamespace = authorDeclaredNamespaceReason(raw); + if (reservedNamespace) { + throwIpcError('GHOST_FILE_INVALID', sanitizeInstallDetail(reservedNamespace)); + } const validated = validateGhostManifest(raw); if (!validated.ok) { throwIpcError('GHOST_FILE_INVALID', sanitizeInstallDetail(validated.reason)); @@ -242,6 +248,8 @@ export async function commitCustomMarketPlugin( ghostId: input.expectedGhostId, version: input.expectedVersion, consent: input.consent, + ...((typeof input.sourceChanged === 'function' ? input.sourceChanged() : input.sourceChanged) + ? { sourceChanged: true } : {}), // 发现时读到的规范化 Manifest 是这次安装允许的能力上限。打包窗口 // 中目录若发生能力扩张,Host 会按真实包不一致直接拒绝。 manifestCap: packed.validatedManifest, @@ -267,6 +275,7 @@ export async function commitCustomMarketPlugin( } export async function installCustomMarketPlugin(input: { + sourceChanged?: boolean | (() => boolean); pluginDir: string; expected?: GhostManifest; /** 更新时把发起操作时读取的 Host receipt token 贯穿到最终安装出口。 */ @@ -331,6 +340,7 @@ export async function installCustomMarketPlugin(input: { expectedGhostId: input.expectedGhostId, expectedVersion: input.expectedVersion, consent, + sourceChanged: input.sourceChanged, expectedInstalledApproval: input.expectedInstalledApproval, beforeCommit: input.beforeCommit, beforePackagePlacement: input.beforePackagePlacement, diff --git a/apps/desktop/src/main/plugin-market/installedManifestIdentity.ts b/apps/desktop/src/main/plugin-market/installedManifestIdentity.ts index a3581d60e21..d7f61e01ce1 100644 --- a/apps/desktop/src/main/plugin-market/installedManifestIdentity.ts +++ b/apps/desktop/src/main/plugin-market/installedManifestIdentity.ts @@ -1,4 +1,5 @@ import type { GhostManifest } from '../../shared/ghost.js'; +import { isValidPluginNamespace } from '@cindy/plugin-protocol'; import type { InstalledGhostManifestSnapshot } from '../installedGhostManifest.js'; import { ghostManifestDigest, @@ -73,3 +74,27 @@ export function installedIdentityMatchesManifest( ): boolean { return identity.legacyManifestDigests.includes(ghostManifestDigest(manifest)); } + +export function verifiedUnstampedOrganizationNamespace(input: { + records: readonly PluginMarketInstallationRecord[]; + organizationId: string | null; + orgSlug: string | null; + evidence: { packageSha256: string | null; approvedManifest: GhostManifest; legacyMigrated: boolean } | null; + identity: InstalledMarketManifestIdentity | null; +}): string | null { + const installedRecords = input.records.filter((record) => record.installed); + if (installedRecords.length !== 1 || !input.evidence || !input.identity || + !input.orgSlug || !isValidPluginNamespace(input.orgSlug)) return null; + const record = installedRecords[0]; + if (record.scope !== 'organization' || + (record.source !== 'market' && record.source !== 'legacy-adopted') || + !record.organizationId || record.organizationId !== input.organizationId || + (record.namespace !== undefined && record.namespace !== input.orgSlug) || + (input.evidence.packageSha256 !== null && input.evidence.packageSha256 !== record.sha256) || + (input.evidence.packageSha256 === null && !input.evidence.legacyMigrated) || + !installedIdentityMatchesManifest(input.identity, input.evidence.approvedManifest)) return null; + if (!verifyInstalledMarketManifest(record, input.identity) && + !(input.evidence.packageSha256 === record.sha256 && + record.rawManifestSha256 === undefined && record.manifestDigest === undefined)) return null; + return input.orgSlug; +} diff --git a/apps/desktop/src/main/plugin-market/ledger.ts b/apps/desktop/src/main/plugin-market/ledger.ts index e4b1f1fb7e9..11b5b7d8895 100644 --- a/apps/desktop/src/main/plugin-market/ledger.ts +++ b/apps/desktop/src/main/plugin-market/ledger.ts @@ -1,8 +1,17 @@ import crypto from 'node:crypto'; import path from 'node:path'; -import type { PluginScope } from '@cindy/plugin-protocol'; +import { isValidPluginNamespace, type PluginScope } from '@cindy/plugin-protocol'; import { ghostManifestToLegacyV2DigestFormat } from '../../shared/ghost.js'; +import { + hasDeliveryNamespace, + parsePluginInstallRelId, + parsePluginStoragePart, + pluginLedgerRecordKey, + PLUGIN_NS_INSTALL_ROOT, + PLUGIN_ROOT_INSTALL_ROOT, + type PluginLogicalIdentity, +} from '../../shared/pluginIdentity.js'; import { atomicWriteFileSync, readAtomicFileSync, @@ -12,6 +21,8 @@ const LEDGER_SCHEMA_VERSION = 1; /** 自定义市场溯源的独立账本文件名(与 ledger.v1.json 同目录)。 */ const CUSTOM_LEDGER_FILE = 'custom-ledger.v1.json'; +/** 同 ghostId 跨 namespace 共存时,企业记录进这个旧客户端不会碰的文件。 */ +export const NS_LEDGER_FILE = 'ns-ledger.v1.json'; /** 服务端市场安装的溯源来源(旧版本也认识的封闭集合)。 */ const SERVER_SOURCES = new Set(['market', 'legacy-adopted']); @@ -24,6 +35,8 @@ export interface PluginMarketInstallationRecord { sha256: string; scope: PluginScope; organizationId: string | null; + /** Missing is a pre-namespace record. null is root; a string is an organization. */ + namespace?: string | null; source: 'market' | 'legacy-adopted' | 'git-market' | 'local-market'; installed: boolean; updatedAt: string; @@ -49,6 +62,13 @@ export interface PluginMarketInstallationRecord { rawManifestSha256?: string; } +export type PluginMarketAuthorizationTarget = { ghostId: string; namespace?: string | null }; + +export type PluginMarketAuthorizationLookup = + | { kind: 'absent' } + | { kind: 'found'; record: PluginMarketInstallationRecord } + | { kind: 'invalid' }; + /** 递归按键排序的规范化 JSON(摘要必须与对象键序无关,两侧独立算也一致)。 */ function canonicalJson(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; @@ -92,6 +112,40 @@ function emptyLedger(): PluginMarketLedgerData { }; } +function isOrgNamespaceRecord(record: PluginMarketInstallationRecord): boolean { + return hasDeliveryNamespace(record) && record.namespace !== null; +} + +function indexInstallation( + installations: Record, + record: PluginMarketInstallationRecord, +): void { + installations[pluginLedgerRecordKey(record)] = record; +} + +function recordsForGhostId( + installations: Record, + ghostId: string, +): PluginMarketInstallationRecord[] { + return Object.values(installations).filter((record) => record.ghostId === ghostId); +} + +function uniqueRecordForGhostId( + installations: Record, + ghostId: string, +): PluginMarketInstallationRecord | null { + const matches = recordsForGhostId(installations, ghostId); + return matches.length === 1 ? matches[0]! : null; +} + +function uniqueInstalledRecordForGhostId( + installations: Record, + ghostId: string, +): PluginMarketInstallationRecord | null { + const matches = recordsForGhostId(installations, ghostId).filter((record) => record.installed); + return matches.length === 1 ? matches[0]! : null; +} + function isCustomRecord(record: PluginMarketInstallationRecord): boolean { return record.source === 'git-market' || record.source === 'local-market'; } @@ -123,6 +177,9 @@ function validRecord(value: unknown): value is PluginMarketInstallationRecord { record.scope === 'organization' || record.scope === 'personal') && (record.organizationId === null || typeof record.organizationId === 'string') && + (record.namespace === undefined || + record.namespace === null || + isValidPluginNamespace(record.namespace)) && (record.source === 'market' || record.source === 'legacy-adopted' || record.source === 'git-market' || @@ -147,7 +204,7 @@ type InstallationsFileRead = { function readInstallationsFile(filePath: string): InstallationsFileRead { // 读失败与解析失败分开处理:文件不存在(ENOENT)才是空;文件在但读不到(文件锁/ // 权限/瞬时 I/O)或备份救不回来时由 readAtomicFileSync **上抛**——降级成空会让 - // 紧接着的写入把真实记录覆盖掉。只有"内容确实不是合法 JSON"才按空重建。 + // 紧接着的写入把真实记录覆盖掉;损坏的文件也必须留给上层处理。 const text = readAtomicFileSync(filePath); if (text === null) return { kind: 'absent', installations: {}, raw: null }; let parsed: unknown; @@ -173,8 +230,11 @@ function readInstallationsFile(filePath: string): InstallationsFileRead { return { kind: 'invalid', installations: {}, raw: value }; } const installations: Record = {}; - for (const [ghostId, record] of Object.entries(rawInstallations)) { - if (validRecord(record) && record.ghostId === ghostId) installations[ghostId] = record; + for (const [key, record] of Object.entries(rawInstallations)) { + if (!validRecord(record)) continue; + const logicalKey = pluginLedgerRecordKey(record); + if (key !== record.ghostId && key !== logicalKey) continue; + installations[logicalKey] = record; } return { kind: 'ok', installations, raw: value }; } @@ -234,23 +294,33 @@ export class PluginMarketLedger { return path.join(path.dirname(this.filePath()), CUSTOM_LEDGER_FILE); } - private readFiles(): { main: InstallationsFileRead; custom: InstallationsFileRead } { + private nsFilePath(): string { + return path.join(path.dirname(this.filePath()), NS_LEDGER_FILE); + } + + private readFiles(): { + main: InstallationsFileRead; + custom: InstallationsFileRead; + namespaced: InstallationsFileRead; + } { return { main: readInstallationsFile(this.filePath()), custom: readInstallationsFile(this.customFilePath()), + namespaced: readInstallationsFile(this.nsFilePath()), }; } private mergeInstallations( main: InstallationsFileRead, custom: InstallationsFileRead, + namespaced: InstallationsFileRead, ): PluginMarketLedgerData { const installations: Record = {}; - for (const [ghostId, record] of Object.entries(main.installations)) { + for (const [key, record] of Object.entries(main.installations)) { // 主账本里的自定义记录是早期开发版写入的存量,一并纳入(下次写入时归位)。 - installations[ghostId] = record; + installations[key] = record; } - for (const [ghostId, record] of Object.entries(custom.installations)) { + for (const [key, record] of Object.entries(custom.installations)) { if (!isCustomRecord(record)) continue; // 自定义账本只承载自定义溯源 // 两个文件出现同一 ghostId 只发生在降级窗口:旧版本只写主账本(比如降级后 // 卸载了自定义安装、又从服务端装了同 ghostId),custom 账本里留着它不认识、 @@ -258,8 +328,13 @@ export class PluginMarketLedger { // 来源,并允许该来源提供更新 —— 必须按"实际安装状态 + 时间"消解,平手保 // 主账本(冲突本身即意味着旧版操作过主账本)。胜出后由任意一次写入按 source // 归位,败方记录随整份重写被清掉。 - const existing = installations[ghostId]; - installations[ghostId] = existing ? preferRecord(existing, record) : record; + const existing = installations[key]; + installations[key] = existing ? preferRecord(existing, record) : record; + } + for (const [key, record] of Object.entries(namespaced.installations)) { + if (!isOrgNamespaceRecord(record)) continue; + const existing = installations[key]; + installations[key] = existing ? preferRecord(existing, record) : record; } const defaultInstallOptOuts: Record = {}; @@ -276,12 +351,103 @@ export class PluginMarketLedger { } read(): PluginMarketLedgerData { - const { main, custom } = this.readFiles(); - return this.mergeInstallations(main, custom); + const { main, custom, namespaced } = this.readFiles(); + if (main.kind === 'invalid' || custom.kind === 'invalid' || namespaced.kind === 'invalid') { + throw new Error('Plugin market ledger is unreadable'); + } + return this.mergeInstallations(main, custom, namespaced); } installationForGhost(ghostId: string): PluginMarketInstallationRecord | null { - return this.read().installations[ghostId] ?? null; + return uniqueRecordForGhostId(this.read().installations, ghostId); + } + + installationsForGhost(ghostId: string): PluginMarketInstallationRecord[] { + return recordsForGhostId(this.read().installations, ghostId); + } + + installationForIdentity(identity: PluginLogicalIdentity): PluginMarketInstallationRecord | null { + return this.read().installations[pluginLedgerRecordKey(identity)] ?? null; + } + + installationForPlugin(plugin: { + ghostId: string; + namespace?: string | null; + }): PluginMarketInstallationRecord | null { + return this.read().installations[pluginLedgerRecordKey(plugin)] ?? null; + } + + installationForLocalUninstall(plugin: { + ghostId: string; + namespace?: string | null; + }): PluginMarketInstallationRecord | null { + const record = this.installationForPlugin(plugin); + if (!record?.installed || record.ghostId !== plugin.ghostId) return null; + if (hasDeliveryNamespace(plugin) && hasDeliveryNamespace(record) && + plugin.namespace !== record.namespace) return null; + if (hasDeliveryNamespace(plugin) && plugin.namespace === null && record.scope === 'organization') { + return null; + } + return record; + } + + markRemovedRecordIfUnchanged(record: PluginMarketInstallationRecord, userId: string | null): boolean { + const current = this.installationForPlugin(record); + if (!current || canonicalJson(current) !== canonicalJson(record)) return false; + this.markRemovedRecord(current, userId); + return true; + } + + /** + * Resolve a runtime/UI id to a ledger row. + * `_ns/...` and `_ns__...` are org instance ids. A bare ghostId prefers the + * root row, then a unique remaining row (in-place org plugins). + */ + installationForLookup(id: string): PluginMarketInstallationRecord | null { + return this.recordForLookup(this.read().installations, id); + } + + installationForAuthorization(target: PluginMarketAuthorizationTarget): PluginMarketInstallationRecord | null { + const lookup = this.lookupInstallationForAuthorization(target); + if (lookup.kind === 'invalid') throw new Error('Plugin market ledger is unreadable'); + return lookup.kind === 'found' ? lookup.record : null; + } + + lookupInstallationForAuthorization(target: PluginMarketAuthorizationTarget): PluginMarketAuthorizationLookup { + const { main, custom, namespaced } = this.readFiles(); + if (main.kind === 'invalid' || custom.kind === 'invalid' || namespaced.kind === 'invalid') { + return { kind: 'invalid' }; + } + const merged = this.mergeInstallations(main, custom, namespaced).installations; + const precise = hasDeliveryNamespace(target); + const record = precise + ? merged[pluginLedgerRecordKey(target)] ?? null + : this.recordForLookup(merged, target.ghostId); + if (record) { + if (precise && (record.ghostId !== target.ghostId || + (hasDeliveryNamespace(record) && record.namespace !== target.namespace) || + (target.namespace === null && record.scope === 'organization'))) { + return { kind: 'absent' }; + } + return { kind: 'found', record }; + } + const mentionsUnparsedTarget = (file: InstallationsFileRead): boolean => { + if (!precise) return rawMentionsGhost(file, target.ghostId); + const raw = file.raw?.installations; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return false; + return Object.entries(raw).some(([key, value]) => { + if (validRecord(value) && hasDeliveryNamespace(value) && value.namespace !== target.namespace) return false; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return key === pluginLedgerRecordKey(target); + } + const candidate = value as { ghostId?: unknown; namespace?: unknown }; + return key === pluginLedgerRecordKey(target) || + (candidate.ghostId === target.ghostId && + (!hasDeliveryNamespace(candidate) || candidate.namespace === target.namespace)); + }); + }; + return [main, custom, namespaced].some(mentionsUnparsedTarget) + ? { kind: 'invalid' } : { kind: 'absent' }; } /** @@ -290,24 +456,41 @@ export class PluginMarketLedger { * corruption as "no market record". */ lookupInstallationForOidc( - ghostId: string, - ): { kind: 'absent' } | { kind: 'found'; record: PluginMarketInstallationRecord } | { kind: 'invalid' } { - const { main, custom } = this.readFiles(); - if (main.kind === 'invalid' || custom.kind === 'invalid') return { kind: 'invalid' }; - const record = this.mergeInstallations(main, custom).installations[ghostId]; - if (record) return { kind: 'found', record }; - if (rawMentionsGhost(main, ghostId) || rawMentionsGhost(custom, ghostId)) { - return { kind: 'invalid' }; - } - return { kind: 'absent' }; + target: string | PluginMarketAuthorizationTarget, + namespace?: string | null, + ): PluginMarketAuthorizationLookup { + return this.lookupInstallationForAuthorization(typeof target === 'string' + ? { ghostId: target, ...(namespace !== undefined ? { namespace } : {}) } + : target); } upsertInstallation(record: PluginMarketInstallationRecord): void { const data = this.read(); - data.installations[record.ghostId] = record; + this.putRecord(data, record); this.write(data); } + /** + * Stamp namespace onto a pre-namespace market row. Known namespace is never + * replaced; a mismatch leaves the existing value in place. + */ + stampNamespaceIfAbsent(ghostId: string, namespace: string | null): boolean { + if (namespace !== null && !isValidPluginNamespace(namespace)) return false; + const data = this.read(); + const current = uniqueInstalledRecordForGhostId(data.installations, ghostId); + if (!current) return false; + if (Object.prototype.hasOwnProperty.call(current, 'namespace')) { + return current.namespace === namespace; + } + this.replaceRecord(data, current, { ...current, namespace }); + this.write(data); + return true; + } + + hasInstalledRecordForGhostId(ghostId: string): boolean { + return recordsForGhostId(this.read().installations, ghostId).some((record) => record.installed); + } + /** * Add the serialization-exact manifest identity without changing * routing order or any legacy field. Full-record comparison makes this a CAS: @@ -319,12 +502,12 @@ export class PluginMarketLedger { ): boolean { if (!/^[a-f0-9]{64}$/.test(rawManifestSha256)) return false; const data = this.read(); - const current = data.installations[expected.ghostId]; + const current = data.installations[pluginLedgerRecordKey(expected)]; if (!current || canonicalJson(current) !== canonicalJson(expected)) return false; if (current.rawManifestSha256 !== undefined) { return current.rawManifestSha256 === rawManifestSha256; } - data.installations[current.ghostId] = { ...current, rawManifestSha256 }; + this.replaceRecord(data, current, { ...current, rawManifestSha256 }); this.write(data); return true; } @@ -344,13 +527,13 @@ export class PluginMarketLedger { return false; } const data = this.read(); - const current = data.installations[expected.ghostId]; + const current = data.installations[pluginLedgerRecordKey(expected)]; if (!current || canonicalJson(current) !== canonicalJson(expected)) return false; - data.installations[current.ghostId] = { + this.replaceRecord(data, current, { ...current, manifestDigest, rawManifestSha256, - }; + }); this.write(data); return true; } @@ -361,7 +544,7 @@ export class PluginMarketLedger { optOut?: { userId: string; suppressed: boolean }, ): void { const data = this.read(); - data.installations[record.ghostId] = record; + this.putRecord(data, record); if (optOut) { const suppressedPluginIds = data.defaultInstallOptOuts[optOut.userId] ?? []; if (optOut.suppressed) { @@ -395,7 +578,7 @@ export class PluginMarketLedger { return false; } const data = this.read(); - const current = data.installations[expected.ghostId]; + const current = data.installations[pluginLedgerRecordKey(expected)]; if ( !current || current.installed @@ -404,7 +587,7 @@ export class PluginMarketLedger { ) { return false; } - data.installations[current.ghostId] = { + this.replaceRecord(data, current, { ...current, source: current.scope === 'organization' && current.source === 'market' @@ -413,7 +596,7 @@ export class PluginMarketLedger { installed: true, updatedAt: new Date().toISOString(), ...(rawManifestSha256 !== undefined ? { rawManifestSha256 } : {}), - }; + }); const remainingOptOuts = (data.defaultInstallOptOuts[userId] ?? []).filter( (pluginId) => pluginId !== current.pluginId, ); @@ -425,16 +608,23 @@ export class PluginMarketLedger { markRemoved(ghostId: string, userId: string | null): void { const data = this.read(); - const record = data.installations[ghostId]; + const record = this.recordForLookup(data.installations, ghostId); if (!record) return; - data.installations[ghostId] = { - ...record, + this.markRemovedRecord(record, userId); + } + + markRemovedRecord(record: PluginMarketInstallationRecord, userId: string | null): void { + const data = this.read(); + const current = data.installations[pluginLedgerRecordKey(record)]; + if (!current) return; + this.replaceRecord(data, current, { + ...current, installed: false, updatedAt: new Date().toISOString(), - }; + }); if (userId) { data.defaultInstallOptOuts[userId] = [ - ...new Set([...(data.defaultInstallOptOuts[userId] ?? []), record.pluginId]), + ...new Set([...(data.defaultInstallOptOuts[userId] ?? []), current.pluginId]), ]; } this.write(data); @@ -444,23 +634,91 @@ export class PluginMarketLedger { return this.read().defaultInstallOptOuts[userId]?.includes(pluginId) ?? false; } + private recordForLookup( + installations: Record, + id: string, + ): PluginMarketInstallationRecord | null { + if ( + id.startsWith(`${PLUGIN_NS_INSTALL_ROOT}/`) || + id.startsWith(`${PLUGIN_NS_INSTALL_ROOT}__`) || + id.startsWith(`${PLUGIN_ROOT_INSTALL_ROOT}/`) || + id.startsWith(`${PLUGIN_ROOT_INSTALL_ROOT}__`) + ) { + const parsed = parsePluginInstallRelId(id) ?? parsePluginStoragePart(id); + return parsed ? installations[pluginLedgerRecordKey(parsed)] ?? null : null; + } + const root = installations[id]; + if (root && root.ghostId === id && !isOrgNamespaceRecord(root)) { + if (root.installed) return root; + return uniqueInstalledRecordForGhostId(installations, id) ?? root; + } + return uniqueRecordForGhostId(installations, id); + } + + private putRecord( + data: PluginMarketLedgerData, + record: PluginMarketInstallationRecord, + ): void { + data.installations[pluginLedgerRecordKey(record)] = record; + } + + private replaceRecord( + data: PluginMarketLedgerData, + previous: PluginMarketInstallationRecord, + next: PluginMarketInstallationRecord, + ): void { + const previousKey = pluginLedgerRecordKey(previous); + const nextKey = pluginLedgerRecordKey(next); + if (previousKey !== nextKey) delete data.installations[previousKey]; + data.installations[nextKey] = next; + } + private write(data: PluginMarketLedgerData): void { // 按 source 分仓落盘:主账本只出现旧版本认识的 source,自定义溯源全部进 // 独立文件。混写过的存量(早期开发版)由此在任意一次写入时自动归位。 + // 同 ghostId 的企业记录在会与 root 或其他 namespace 撞车时写入 ns 账本, + // 避免旧客户端按 ghostId 键覆盖掉另一份身份。 + // 撞车时企业行从 v1 搬到 sidecar:必须先写 sidecar(只加不减),再改写 v1。 + // 若先改 v1 再写 sidecar,中途崩溃会丢掉唯一一份企业溯源。 const server: Record = {}; const custom: Record = {}; - for (const [ghostId, record] of Object.entries(data.installations)) { - if (isCustomRecord(record)) custom[ghostId] = record; - else if (SERVER_SOURCES.has(record.source)) server[ghostId] = record; + const namespaced: Record = {}; + const byGhostId = new Map(); + for (const record of Object.values(data.installations)) { + const group = byGhostId.get(record.ghostId) ?? []; + group.push(record); + byGhostId.set(record.ghostId, group); + } + const place = ( + record: PluginMarketInstallationRecord, + dest: Record, + key: string, + ): void => { + dest[key] = record; + }; + for (const group of byGhostId.values()) { + const orgRecords = group.filter(isOrgNamespaceRecord); + const rootRecords = group.filter((record) => !isOrgNamespaceRecord(record)); + const collision = orgRecords.length > 0 && (rootRecords.length > 0 || orgRecords.length > 1); + if (!collision) { + for (const record of group) { + const dest = isCustomRecord(record) ? custom : SERVER_SOURCES.has(record.source) ? server : null; + if (dest) place(record, dest, record.ghostId); + } + continue; + } + for (const record of rootRecords) { + const dest = isCustomRecord(record) ? custom : SERVER_SOURCES.has(record.source) ? server : null; + if (dest) place(record, dest, record.ghostId); + } + for (const record of orgRecords) { + namespaced[pluginLedgerRecordKey(record)] = record; + } } atomicWriteFileSync( - this.filePath(), + this.nsFilePath(), `${JSON.stringify( - { - schemaVersion: LEDGER_SCHEMA_VERSION, - installations: server, - defaultInstallOptOuts: data.defaultInstallOptOuts, - }, + { schemaVersion: LEDGER_SCHEMA_VERSION, installations: namespaced }, null, 2, )}\n`, @@ -473,5 +731,17 @@ export class PluginMarketLedger { 2, )}\n`, ); + atomicWriteFileSync( + this.filePath(), + `${JSON.stringify( + { + schemaVersion: LEDGER_SCHEMA_VERSION, + installations: server, + defaultInstallOptOuts: data.defaultInstallOptOuts, + }, + null, + 2, + )}\n`, + ); } } diff --git a/apps/desktop/src/main/plugin-market/registerIpc.ts b/apps/desktop/src/main/plugin-market/registerIpc.ts index 059bc4a4e53..0951ff2ff40 100644 --- a/apps/desktop/src/main/plugin-market/registerIpc.ts +++ b/apps/desktop/src/main/plugin-market/registerIpc.ts @@ -7,6 +7,7 @@ import { isGhostInstallApprovalToken, type GhostManifest, } from '../../shared/ghost.js'; +import { installedGhostStoragePart } from '../../shared/pluginIdentity.js'; import { isPluginMarketCustomIconKey, type PluginMarketSnapshot, @@ -231,7 +232,7 @@ export function registerPluginMarketIpc(): void { !previouslyInstalled.has(result.ghost.manifest.id) ) { try { - markGhostRecommendationInstalled(result.ghost.manifest.id); + markGhostRecommendationInstalled(installedGhostStoragePart(result.ghost)); } catch { log.warn('ghost recommendation install history unavailable'); } diff --git a/apps/desktop/src/main/plugin-market/service.ts b/apps/desktop/src/main/plugin-market/service.ts index 53ceb4754a9..3b1195993fa 100644 --- a/apps/desktop/src/main/plugin-market/service.ts +++ b/apps/desktop/src/main/plugin-market/service.ts @@ -4,6 +4,7 @@ import crypto from 'node:crypto'; import { isValidPluginResourceId, + isValidPluginNamespace, PLUGIN_PREFIX_PATTERN, type PluginCurrentOrganization, type PluginRemovalNotice, @@ -75,7 +76,23 @@ import { import { readInstalledGhostManifestSnapshot, } from '../installedGhostManifest.js'; -import { withGhostInstallLock } from '../cindy-brain/ghostInstallLock.js'; +import { + withGhostInstallLock, + withPluginDeliveryInstallLock, +} from '../cindy-brain/ghostInstallLock.js'; +import { + createPluginLogicalIdentity, + deliveryNamespaceFields, + downloadIdentityMatchesPlugin, + findInstalledGhostByIdentity, + findInstalledGhostByInstanceId, + hasDeliveryNamespace, + installedGhostPhysicalRelId, + installedGhostStoragePart, + knownDeliveryNamespacesDiffer, + pluginLedgerRecordKey, + sameDeliveryNamespaceState, +} from '../../shared/pluginIdentity.js'; import { ghostBrokerRedirectPortInstallError } from '../cindy-brain/ghostBrokerRedirectPort.js'; import { isGhostInstallConsentRequiredError, @@ -198,6 +215,93 @@ function defaultInstallSubject(owner: ActiveAppSession): string { return subject; } +function installedGhostMatchingMarketPlugin(plugin: { + ghostId: string; + namespace?: string | null; +}): InstalledGhost | undefined { + if (!isValidGhostId(plugin.ghostId)) { + return getGhostManager().list().find((ghost) => ghost.manifest.id === plugin.ghostId); + } + return findInstalledGhostByIdentity( + getGhostManager().list(), + createPluginLogicalIdentity( + hasDeliveryNamespace(plugin) ? plugin.namespace : null, + plugin.ghostId, + ), + ); +} + + +function snapshotInstallation( + local: { installations: Readonly> }, + plugin: { ghostId: string; namespace?: string | null }, +): PluginMarketInstallationRecord | undefined { + return local.installations[pluginLedgerRecordKey(plugin)]; +} + +function removalIdentity( + removal: PluginRemovalNotice, + installations: Readonly>, +): PluginRemovalNotice | null { + if (hasDeliveryNamespace(removal) || removal.scope !== 'organization') return removal; + const matches = Object.values(installations).filter((record) => + typeof record.namespace === 'string' && + record.pluginId === removal.pluginId && + record.ghostId === removal.ghostId && + record.scope === 'organization' && + record.organizationId === removal.organizationId, + ); + if (matches.length > 1) return null; + return matches.length === 1 ? { ...removal, namespace: matches[0]!.namespace } : removal; +} + +function snapshotGhost( + local: LocalInstallSnapshot, + plugin: { ghostId: string; namespace?: string | null }, +): InstalledGhost | undefined { + const ghosts = local.ghostsById.get(plugin.ghostId) ?? []; + if (!isValidGhostId(plugin.ghostId)) { + return ghosts[0]; + } + return findInstalledGhostByIdentity( + ghosts, + createPluginLogicalIdentity( + hasDeliveryNamespace(plugin) ? plugin.namespace : null, + plugin.ghostId, + ), + ); +} + +function snapshotManifestIdentity( + local: LocalInstallSnapshot, + ghost: InstalledGhost | undefined, +): InstalledMarketManifestIdentity | null { + if (!ghost) return null; + return local.manifestIdentityByStoragePart.get(installedGhostStoragePart(ghost)) ?? null; +} + +function legacyOrganizationDeliveryTarget( + plugin: T, + local: LocalInstallSnapshot, +): T { + if (hasDeliveryNamespace(plugin) || plugin.scope !== 'organization') return plugin; + const ghosts = local.ghostsById.get(plugin.ghostId) ?? []; + const records = local.installedRecordsByGhostId.get(plugin.ghostId) ?? []; + if (ghosts.length !== 1 || records.length !== 1) return plugin; + const ghost = ghosts[0]!; + const record = records[0]!; + const identity = snapshotManifestIdentity(local, ghost); + if ( + !hasDeliveryNamespace(ghost) || ghost.namespace === null || + !hasDeliveryNamespace(record) || record.namespace !== ghost.namespace || + installedGhostPhysicalRelId(ghost) !== plugin.ghostId || + (record.rawManifestSha256 === undefined && record.manifestDigest === undefined) || + !serverRecordMatchesInstalledGhost(plugin.id, ghost, record, identity) || + !serverRecordMatchesSummaryRoute(plugin, record) + ) return plugin; + return { ...plugin, namespace: ghost.namespace }; +} + function recordFrom( plugin: VisiblePluginSummary | VisiblePluginDetail, source: PluginMarketInstallationRecord['source'], @@ -216,6 +320,7 @@ function recordFrom( updatedAt: new Date().toISOString(), manifestDigest: identity.legacyManifestDigest, rawManifestSha256: identity.rawManifestSha256, + ...deliveryNamespaceFields(plugin), }; } @@ -229,6 +334,7 @@ function assertDetailMatchesSummary( detail.ghostId !== summary.ghostId || detail.scope !== summary.scope || detail.organizationId !== summary.organizationId || + !sameDeliveryNamespaceState(detail, summary) || detail.defaultInstall !== summary.defaultInstall || detail.currentRelease.id !== summary.currentRelease.id || detail.currentRelease.version !== summary.currentRelease.version || @@ -265,17 +371,31 @@ export function organizationDefaultTakeoverEligibility( !summary.defaultInstall || summary.scope !== 'organization' || !currentOrganization || - summary.organizationId !== currentOrganization.organizationId || - typeof prefix !== 'string' || - !PLUGIN_PREFIX_PATTERN.test(prefix) || - !summary.ghostId.startsWith(`${prefix}-`) + summary.organizationId !== currentOrganization.organizationId ) { return { eligible: false, reason: 'not-current-organization-default' }; } - if (!facts.uniqueGhostId) return { eligible: false, reason: 'duplicate-ghost-id' }; + if (facts.record && knownDeliveryNamespacesDiffer(summary, facts.record)) { + return { eligible: false, reason: 'cross-namespace' }; + } + if (hasDeliveryNamespace(summary)) { + if (typeof summary.namespace !== 'string' || !isValidPluginNamespace(summary.namespace) || + (currentOrganization.orgSlug !== undefined && currentOrganization.orgSlug !== summary.namespace) || + !hasDeliveryNamespace(installed) || installed.namespace !== summary.namespace) { + return { eligible: false, reason: 'cross-namespace' }; + } + } else if (hasDeliveryNamespace(installed) || typeof prefix !== 'string' || + !PLUGIN_PREFIX_PATTERN.test(prefix) || !summary.ghostId.startsWith(`${prefix}-`)) { + return { eligible: false, reason: 'not-current-organization-default' }; + } + if (!facts.uniqueGhostId && !hasDeliveryNamespace(summary)) { + return { eligible: false, reason: 'duplicate-ghost-id' }; + } if (!facts.runtimeAvailable) return { eligible: false, reason: 'runtime-unavailable' }; if (facts.optedOut) return { eligible: false, reason: 'explicit-opt-out' }; - if (facts.builtinRemoved) return { eligible: false, reason: 'builtin-tombstone' }; + if (facts.builtinRemoved && summary.namespace == null) { + return { eligible: false, reason: 'builtin-tombstone' }; + } if (facts.busy) return { eligible: false, reason: 'busy' }; if (installed.approval.state !== 'approved') { return { eligible: false, reason: 'unapproved-install' }; @@ -320,6 +440,7 @@ function legacyRecordFrom( updatedAt: new Date().toISOString(), manifestDigest: identity.legacyManifestDigest, rawManifestSha256: identity.rawManifestSha256, + ...deliveryNamespaceFields(plugin), }; } @@ -331,11 +452,13 @@ function ghostIdCounts(plugins: readonly VisiblePluginSummary[]): Map; + /** Installed Ghost runtime facts for one market operation. */ + ghosts: readonly InstalledGhost[]; + ghostsById: ReadonlyMap; /** Parsed provenance records from one ledger read. */ installations: Readonly>; - /** 每个已装插件的 locale 无关 Manifest 身份(一次快照只读一遍盘)。 */ - manifestIdentityByGhostId: ReadonlyMap; + installedRecordsByGhostId: ReadonlyMap; + /** locale 无关 Manifest 身份,按实例 storage part 索引。 */ + manifestIdentityByStoragePart: ReadonlyMap; } /** 未登录浏览公开目录时不读本机账本 / 已装列表,避免带出上一账号的安装态。 */ const EMPTY_LOCAL_INSTALL_SNAPSHOT: LocalInstallSnapshot = { + ghosts: [], ghostsById: new Map(), installations: {}, - manifestIdentityByGhostId: new Map(), + installedRecordsByGhostId: new Map(), + manifestIdentityByStoragePart: new Map(), }; +function indexByGhostId(items: readonly T[], ghostId: (item: T) => string): Map { + const indexed = new Map(); + for (const item of items) { + const id = ghostId(item); + const matches = indexed.get(id) ?? []; + matches.push(item); + indexed.set(id, matches); + } + return indexed; +} + /** * 清理通告 pending 汇总的 owner 隔离键。**故意不含 generation**:同一 owner * 重新登录(换代)后,未消费的通知仍应展示,不随会话代际作废。 @@ -827,6 +968,9 @@ export class PluginMarketService { }; if (!options.discoveryOnly && !options.deferReconciliation) await reconcileCustomUpdates(); requireSameMarketOwner(owner); + if (!options.discoveryOnly) { + getGhostManager().resumePendingResidentsOffline?.(); + } const snapshot: PluginMarketSnapshot = { items: this.withUpdateConsentHolds( this.projectCustomItems(customDiscovery.entries, this.localInstallSnapshot(ledger)), @@ -885,6 +1029,13 @@ export class PluginMarketService { this.rememberCurrentOrganization(currentOrganization); await this.backfillInstalledManifestIdentities(ledger, owner); await this.adoptLegacyInstallations(plugins, ledger, owner); + try { + await getGhostManager().reconcilePendingRootNamespaces?.(true); + } catch (error) { + log.warn('namespace migration market reconcile failed', { + error: error instanceof Error ? error.message : String(error), + }); + } await this.recoverDisconnectedMarketInstallations(plugins, ledger, owner); await this.backfillOfficialCindyGithubTrust(ledger, owner); // A snapshot is passive discovery: an empty runtime list can be caused by @@ -1268,9 +1419,8 @@ export class PluginMarketService { if (plugin.currentRelease.id !== options.expectedReleaseId) { throwIpcError('PRECONDITION_FAILED', 'Plugin release changed after selection'); } - const existing = getGhostManager() - .list() - .find((ghost) => ghost.manifest.id === plugin.ghostId); + const target = legacyOrganizationDeliveryTarget(plugin, this.localInstallSnapshot(ledger)); + const existing = installedGhostMatchingMarketPlugin(target); return this.installDetail( plugin, { @@ -1314,7 +1464,14 @@ export class PluginMarketService { } const installSubject = defaultInstallSubject(owner); requireSameMarketOwner(owner); - await uninstallGhostAndCleanup(record.ghostId, { skipMarketLedger: true }); + const installed = installedGhostMatchingMarketPlugin(record); + // Instance missing: only close this ledger row. A bare ghostId fallback + // would uninstall a coexisting public sibling. + if (installed) { + await uninstallGhostAndCleanup(installedGhostPhysicalRelId(installed), { + skipMarketLedger: true, + }); + } // The package removal is already complete at this point. The session may // have changed while the runtime was stopping, so ledger reconciliation // must not turn a successful uninstall into an IPC failure. The ledger @@ -1322,7 +1479,7 @@ export class PluginMarketService { // serialized separately from the active-session check. try { await this.withCapturedLedgerMutation(ledger, () => { - ledger.markRemoved(record.ghostId, installSubject); + ledger.markRemovedRecord(record, installSubject); }); } catch (error) { log.warn('market uninstall ledger reconciliation deferred', { @@ -1338,7 +1495,7 @@ export class PluginMarketService { * Captures the active owner and ledger before a local-page uninstall starts. * The returned completion records opt-out only after the package was removed. */ - prepareLocalUninstallTracking(ghostId: string): (() => Promise) | null { + prepareLocalUninstallTracking(target: string | InstalledGhost): (() => Promise) | null { let owner: ActiveAppSession; try { owner = captureMarketOwner(); @@ -1346,12 +1503,23 @@ export class PluginMarketService { return null; } const ledger = this.ledgerForOwner(owner); - const record = ledger.installationForGhost(ghostId); - if (!record?.installed) return null; + const ghost = typeof target === 'string' + ? findInstalledGhostByInstanceId(getGhostManager().list(), target) + : target; + if (!ghost) return null; + const record = ledger.installationForLocalUninstall({ + ghostId: ghost.manifest.id, + ...deliveryNamespaceFields(ghost), + }); + if (!record) return null; + const identity = readInstalledMarketManifestIdentity(ghost.dir); + if (!identity || !verifyInstalledMarketManifest(record, identity, { + allowLegacyRecordWithoutDigest: true, + })) return null; const installSubject = defaultInstallSubject(owner); return async () => { await this.withCapturedLedgerMutation(ledger, () => { - ledger.markRemoved(ghostId, installSubject); + ledger.markRemovedRecordIfUnchanged(record, installSubject); }); }; } @@ -1507,11 +1675,9 @@ export class PluginMarketService { if (releaseId !== options.expectedReleaseId) { throwIpcError('PRECONDITION_FAILED', 'Plugin release changed after selection'); } - const existing = getGhostManager() - .list() - .find((ghost) => ghost.manifest.id === plugin.ghostId); + const existing = installedGhostMatchingMarketPlugin(plugin); const sourceKey = marketSourceKey(discovered.config.source); - const currentRecord = ledger.installationForGhost(plugin.ghostId); + const currentRecord = ledger.installationForPlugin(plugin); // 选择时刻的已装 Manifest 身份:打包窗口内不能换掉当前包。raw 字段 // 存在时只认原始字节;旧记录由集中 legacy adapter 兼容核对。 const reviewInstalledIdentity = existing @@ -1579,15 +1745,14 @@ export class PluginMarketService { let replacedRoute: PluginMarketInstallationRecord | null = null; let replacedRouteWasSuppressed = false; let packageLanded = false; + let sourceChanged = false; requireSameMarketOwner(owner); const consentDecision: GhostInstallConsentDecision = 'rejection' in packed.inspected ? { mode: 'unprompted' } : await obtainGhostInstallConsent( consent, - getGhostManager() - .list() - .find((ghost) => ghost.manifest.id === plugin.ghostId), + installedGhostMatchingMarketPlugin(plugin), packed.inspected.manifest, packed.inspected.packageSha256, ); @@ -1595,10 +1760,11 @@ export class PluginMarketService { expectedGhostId: plugin.ghostId, expectedVersion: plugin.version, consent: consentDecision, + sourceChanged: () => sourceChanged, beforeCommit: async () => { requireSameMarketOwner(owner); assertCurrent?.(); - if (automatic && isGhostBusy(plugin.ghostId)) { + if (automatic && isGhostBusy(plugin)) { throw new SilentUpgradeBusyError('Plugin is busy'); } // 所选来源必须**仍然存在且仍是同一个来源**:移除来源会先拿 @@ -1617,9 +1783,7 @@ export class PluginMarketService { // 会把"更新"降级成"首装+带电启用";反向地,窗口内新装入的同 id // 本地 .cindy 会被更新分支静默覆盖。判据与选择时刻同一份: // 在场状态一致 + 已装内容摘要未变。 - const current = getGhostManager() - .list() - .find((ghost) => ghost.manifest.id === plugin.ghostId); + const current = installedGhostMatchingMarketPlugin(plugin); if (Boolean(current) !== Boolean(existing)) { throwIpcError( 'PRECONDITION_FAILED', @@ -1644,15 +1808,23 @@ export class PluginMarketService { } // raw manifest 摘要不含 Host receipt;内容未变但批准态变化也必须拒绝。 assertCustomApprovalStateUnchanged(current ?? null); + const record = ledger.installationForPlugin(plugin); + const routeStillMatches = Boolean(current && record?.installed && + record.pluginId === pluginId && record.sourceKey === sourceKey && + currentIdentity && verifyInstalledMarketManifest(record, currentIdentity)); + if (current && !routeStillMatches && options.allowSourceReplacement !== true) { + throwIpcError('PRECONDITION_FAILED', 'Installed Plugin source changed'); + } + sourceChanged = Boolean(current && !routeStillMatches); }, expectedInstalledApproval: options.expectedInstalledApproval, beforePackagePlacement: () => { requireSameMarketOwner(owner); assertCurrent?.(); - if (automatic && isGhostBusy(plugin.ghostId)) { + if (automatic && isGhostBusy(plugin)) { throw new SilentUpgradeBusyError('Plugin is busy'); } - const record = ledger.installationForGhost(plugin.ghostId); + const record = ledger.installationForPlugin(plugin); const routeStillMatches = Boolean( existing && record?.installed && @@ -1684,7 +1856,9 @@ export class PluginMarketService { // 同 id 的本地装入/卸载插不进来(否则复核仍会在落位前过期)。 withCommitLock: (fn) => this.withMutation(customMarketPluginId(ref.marketName, ref.ghostId), () => - this.withMutation(SOURCE_MUTATION_KEY, () => withGhostInstallLock(plugin.ghostId, fn)), + this.withMutation(SOURCE_MUTATION_KEY, () => + withPluginDeliveryInstallLock({ ghostId: plugin.ghostId, namespace: null }, fn), + ), ), // 溯源写入仍在上面那把 ghost 锁内(afterCommit 由 commit 段调用): // 放到锁外时,本地装入能插在"包已落位"与"写下溯源"之间换掉同 id 的包。 @@ -1707,6 +1881,7 @@ export class PluginMarketService { sha256: 'custom-unverified', scope: 'public', organizationId: null, + namespace: null, source: sourceType === 'git' ? 'git-market' : 'local-market', installed: true, updatedAt: new Date().toISOString(), @@ -1865,9 +2040,9 @@ export class PluginMarketService { const { config, plugin } = entry; const pluginId = customMarketPluginId(config.name, plugin.ghostId); const releaseId = customMarketReleaseId(config.name, plugin.ghostId, plugin.version); - const ghost = local.ghostsById.get(plugin.ghostId); - const record = local.installations[plugin.ghostId]; - const identity = local.manifestIdentityByGhostId.get(plugin.ghostId) ?? null; + const ghost = snapshotGhost(local, plugin); + const record = snapshotInstallation(local, plugin); + const identity = snapshotManifestIdentity(local, ghost); // pluginId + 来源指纹 + 安装时 manifest 摘要全部对上时, // 该条目才是当前自动更新路由。其它同 id 条目仍可被用户显式选择替换。 const matchesUpdateRoute = Boolean( @@ -1892,6 +2067,7 @@ export class PluginMarketService { return { pluginId, ghostId: plugin.ghostId, + namespace: null, // ghost.json 来自不受信市场仓库:双向控制符可把市场卡片上的署名/说明 // 显示成另一副样子(视觉欺骗),控制字符可撑破布局。展示投影一律剥掉 // (保留换行);市场名闸在 discover,这里补齐插件侧同一口径。 @@ -1974,6 +2150,17 @@ export class PluginMarketService { ledger = this.ledgerForOwner(owner), ): Promise { requireSameMarketOwner(owner); + const serverPlugin = plugin; + const local = this.localInstallSnapshot(ledger); + plugin = legacyOrganizationDeliveryTarget(plugin, local); + if ( + plugin === serverPlugin && plugin.scope === 'organization' && + !hasDeliveryNamespace(plugin) && + local.ghosts.some((ghost) => ghost.manifest.id === plugin.ghostId && + hasDeliveryNamespace(ghost) && ghost.namespace !== null) + ) { + throwIpcError('PRECONDITION_FAILED', 'Organization Plugin namespace is ambiguous'); + } if (owner.mode === 'local' && plugin.scope !== 'public') { throwIpcError('PERMISSION_DENIED', 'Local mode can only access public Plugins'); } @@ -1985,10 +2172,8 @@ export class PluginMarketService { if (brokerPortError) { throwIpcError(brokerPortError.code, brokerPortError.reason); } - const existing = getGhostManager() - .list() - .find((ghost) => ghost.manifest.id === plugin.ghostId); - const currentRecord = ledger.installationForGhost(plugin.ghostId); + const existing = installedGhostMatchingMarketPlugin(plugin); + const currentRecord = ledger.installationForPlugin(plugin); const sourceReplacementMode = options.sourceReplacementMode ?? 'preserve-existing-source'; if ( sourceReplacementMode === 'organization-default-takeover' && @@ -2027,7 +2212,8 @@ export class PluginMarketService { requireSameMarketOwner(owner); if ( download.sha256 !== plugin.currentRelease.sha256 || - download.sizeBytes !== plugin.currentRelease.sizeBytes + download.sizeBytes !== plugin.currentRelease.sizeBytes || + !downloadIdentityMatchesPlugin(download, serverPlugin) ) { throwIpcError('PRECONDITION_FAILED', 'Plugin release metadata changed'); } @@ -2059,9 +2245,7 @@ export class PluginMarketService { ? { mode: 'unprompted' } : await obtainGhostInstallConsent( options.consent, - getGhostManager() - .list() - .find((ghost) => ghost.manifest.id === plugin.ghostId), + installedGhostMatchingMarketPlugin(plugin), inspected.manifest, inspected.packageSha256, ); @@ -2108,11 +2292,9 @@ export class PluginMarketService { owner: ActiveAppSession, ledger: PluginMarketLedger, ): Promise { - return withGhostInstallLock(plugin.ghostId, async () => { - const installedNow = getGhostManager() - .list() - .find((ghost) => ghost.manifest.id === plugin.ghostId); - const currentRecordNow = ledger.installationForGhost(plugin.ghostId); + return withPluginDeliveryInstallLock(plugin, async () => { + const installedNow = installedGhostMatchingMarketPlugin(plugin); + const currentRecordNow = ledger.installationForPlugin(plugin); if (Boolean(installedNow) !== options.expectedInstalled) { if (options.sourceReplacementMode === 'organization-default-takeover') { throw new SilentOrganizationDefaultTakeoverSupersededError( @@ -2155,19 +2337,22 @@ export class PluginMarketService { } } requireSameMarketOwner(owner); - const replacingSource = Boolean( + const sourceChanged = Boolean( installedNow && - options.sourceReplacementMode === 'user-requested-source-change' && - currentRecordNow?.installed && + (options.sourceReplacementMode === 'user-requested-source-change' || + options.sourceReplacementMode === 'organization-default-takeover') && !serverRecordMatchesInstalledGhost(plugin.id, installedNow, currentRecordNow), ); + const replacingSource = sourceChanged && + options.sourceReplacementMode === 'user-requested-source-change' && + currentRecordNow?.installed === true; let routeDetached = false; let replacedRouteWasSuppressed = false; let packageLanded = false; const detachPreviousRoute = (): void => { requireSameMarketOwner(owner); options.beforeCommitInLock?.(); - if (!replacingSource || !currentRecordNow) return; + if (!replacingSource || !currentRecordNow?.installed) return; replacedRouteWasSuppressed = this.detachMarketRouteForReplacement( ledger, currentRecordNow, @@ -2179,18 +2364,17 @@ export class PluginMarketService { ghostId: plugin.ghostId, version: plugin.currentRelease.version, consent: options.consent, - ...(plugin.ghostId === 'cindy-github' ? { officialCindyGithub: true } : {}), - ...(plugin.scope === 'organization' && plugin.organizationId - ? { - pendingMarketRecord: { - scope: plugin.scope, - organizationId: plugin.organizationId, - source: 'market', - installed: true, - sha256: plugin.currentRelease.sha256, - }, - } - : {}), + ...(sourceChanged ? { sourceChanged: true } : {}), + ...deliveryNamespaceFields(plugin), + ...(plugin.ghostId === 'cindy-github' && plugin.scope === 'public' && + plugin.namespace == null ? { officialCindyGithub: true } : {}), + pendingMarketRecord: { + scope: plugin.scope, + organizationId: plugin.organizationId ?? null, + source: 'market', + installed: true, + sha256: plugin.currentRelease.sha256, + }, ...(options.expectedInstalledApproval !== undefined ? { expectedInstalledApproval: options.expectedInstalledApproval } : {}), @@ -2241,16 +2425,20 @@ export class PluginMarketService { plugin: VisiblePluginSummary, local = this.localInstallSnapshot(), ): PluginMarketItem { - const ghost = local.ghostsById.get(plugin.ghostId); - const record = local.installations[plugin.ghostId]; - const identity = local.manifestIdentityByGhostId.get(plugin.ghostId) ?? null; + const target = legacyOrganizationDeliveryTarget(plugin, local); + const ghost = snapshotGhost(local, target); + const record = snapshotInstallation(local, target); + const identity = snapshotManifestIdentity(local, ghost); const matchesUpdateRoute = Boolean( ghost && serverRecordMatchesInstalledGhost(plugin.id, ghost, record ?? null, identity) && serverRecordMatchesSummaryRoute(plugin, record ?? null), ); // 其它同 id 条目不进入自动更新,但仍可由用户显式选择替换。 - const conflict = Boolean(ghost && !matchesUpdateRoute); + const conflict = Boolean((ghost && !matchesUpdateRoute) || + (!hasDeliveryNamespace(plugin) && plugin.scope === 'organization' && + local.ghosts.some((candidate) => candidate.manifest.id === plugin.ghostId && + hasDeliveryNamespace(candidate) && candidate.namespace !== null) && target === plugin)); const installState: PluginMarketItem['installState'] = conflict ? 'conflict' : !matchesUpdateRoute @@ -2261,6 +2449,7 @@ export class PluginMarketService { return { pluginId: plugin.id, ghostId: plugin.ghostId, + ...deliveryNamespaceFields(target), name: plugin.name, description: plugin.description, author: plugin.author, @@ -2295,18 +2484,16 @@ export class PluginMarketService { await this.withMutation(candidate.pluginId, () => withGhostInstallLock(candidate.ghostId, async () => { requireSameMarketOwner(owner); - const record = ledger.installationForGhost(candidate.ghostId); + const record = ledger.installationForPlugin(candidate); if (!record?.installed || record.rawManifestSha256 !== undefined) return; - const installed = getGhostManager() - .list() - .find((ghost) => ghost.manifest.id === record.ghostId); + const installed = installedGhostMatchingMarketPlugin(record); if (!installed) return; const identity = readInstalledMarketManifestIdentity(installed.dir); if (!identity) return; const isServerRecord = record.source === 'market' || record.source === 'legacy-adopted'; const manager = getGhostManager(); - const approvalEvidence = manager.approvedInstallEvidence?.(record.ghostId) ?? null; + const approvalEvidence = manager.approvedInstallEvidence?.(installedGhostPhysicalRelId(installed)) ?? null; // Pending or failed mutation recovery is projected as invalid. Never // mint a baseline from that intermediate directory for any source. if (installed.approval.state === 'invalid') return; @@ -2371,7 +2558,10 @@ export class PluginMarketService { const counts = ghostIdCounts(plugins); const installations = ledger.read().installations; for (const ghost of getGhostManager().list()) { - if (installations[ghost.manifest.id]) continue; + if (installations[pluginLedgerRecordKey({ + ghostId: ghost.manifest.id, + ...deliveryNamespaceFields(ghost), + })]) continue; if (!isOfficialGhostId(ghost.manifest.id)) continue; const matches = plugins.filter( (plugin) => @@ -2384,21 +2574,25 @@ export class PluginMarketService { await this.withMutation(plugin.id, () => withGhostInstallLock(ghost.manifest.id, async () => { requireSameMarketOwner(owner); - if (ledger.installationForGhost(ghost.manifest.id)) return; - const currentGhost = getGhostManager() - .list() - .find((candidate) => candidate.manifest.id === ghost.manifest.id); + if (ledger.installationForPlugin({ + ghostId: ghost.manifest.id, + ...deliveryNamespaceFields(ghost), + })) return; + const currentGhost = installedGhostMatchingMarketPlugin({ + ghostId: ghost.manifest.id, + ...deliveryNamespaceFields(ghost), + }); if (!currentGhost) return; const identity = readInstalledMarketManifestIdentity(currentGhost.dir); if (!identity) return; const record = legacyRecordFrom(plugin, currentGhost, identity); const adopted = await this.withLedgerMutation(owner, () => { - if (ledger.installationForGhost(record.ghostId)) return false; + if (ledger.installationForPlugin(record)) return false; ledger.upsertInstallation(record); return true; }); if (!adopted) return; - installations[record.ghostId] = record; + installations[pluginLedgerRecordKey(record)] = record; log.info('legacy plugin adopted into market ledger', { ghostId: ghost.manifest.id, pluginId: plugin.id, @@ -2427,8 +2621,11 @@ export class PluginMarketService { ): Promise { const pluginIdCounts = new Map(); const ghostCounts = ghostIdCounts(plugins); + const identityCounts = new Map(); for (const plugin of plugins) { pluginIdCounts.set(plugin.id, (pluginIdCounts.get(plugin.id) ?? 0) + 1); + const key = pluginLedgerRecordKey(plugin); + identityCounts.set(key, (identityCounts.get(key) ?? 0) + 1); } const summariesById = new Map(plugins.map((plugin) => [plugin.id, plugin])); const records = Object.values(ledger.read().installations); @@ -2444,18 +2641,19 @@ export class PluginMarketService { !isValidGhostId(record.ghostId) || !/^[a-f0-9]{64}$/.test(record.sha256) || pluginIdCounts.get(record.pluginId) !== 1 || - ghostCounts.get(record.ghostId) !== 1 || !summary || + (ghostCounts.get(record.ghostId) !== 1 && + !(hasDeliveryNamespace(record) && hasDeliveryNamespace(summary) && + identityCounts.get(pluginLedgerRecordKey(record)) === 1)) || summary.ghostId !== record.ghostId || + knownDeliveryNamespacesDiffer(summary, record) || summary.scope !== record.scope || summary.organizationId !== record.organizationId ) { continue; } - const installed = getGhostManager() - .list() - .find((ghost) => ghost.manifest.id === record.ghostId); + const installed = installedGhostMatchingMarketPlugin(record); if (!installed || installed.approval.state !== 'approved') continue; try { @@ -2463,11 +2661,9 @@ export class PluginMarketService { requireSameMarketOwner(owner); await withGhostInstallLock(record.ghostId, async () => { requireSameMarketOwner(owner); - const lockedRecord = ledger.installationForGhost(record.ghostId); + const lockedRecord = ledger.installationForPlugin(record); if (!sameDisconnectedMarketInstallation(lockedRecord, record)) return; - const currentInstalled = getGhostManager() - .list() - .find((ghost) => ghost.manifest.id === record.ghostId); + const currentInstalled = installedGhostMatchingMarketPlugin(record); if ( !currentInstalled || currentInstalled.approval.state !== 'approved' || @@ -2475,7 +2671,7 @@ export class PluginMarketService { ) { return; } - const approvalEvidence = getGhostManager().approvedInstallEvidence(record.ghostId); + const approvalEvidence = getGhostManager().approvedInstallEvidence(installedGhostPhysicalRelId(currentInstalled)); if (!approvalEvidence) return; if ( approvalEvidence.packageSha256 !== null && @@ -2575,11 +2771,12 @@ export class PluginMarketService { ledger: PluginMarketLedger, owner: ActiveAppSession, ): Promise { - const record = ledger.installationForGhost('cindy-github'); + const record = ledger.installationForIdentity({ namespace: null, ghostId: 'cindy-github' }); requireSameMarketOwner(owner); const installed = getGhostManager() .list() - .find((ghost) => ghost.manifest.id === 'cindy-github'); + .find((ghost) => ghost.manifest.id === 'cindy-github' && ghost.namespace == null && + ghost.namespaceMigration !== 'pending'); if (!record || !installed || !canBackfillOfficialCindyGithubTrust(record, installed)) return; const tempPath = path.join( app.getPath('temp'), @@ -2601,10 +2798,11 @@ export class PluginMarketService { requireSameMarketOwner(owner); await withGhostInstallLock('cindy-github', async () => { requireSameMarketOwner(owner); - const currentRecord = ledger.installationForGhost('cindy-github'); + const currentRecord = ledger.installationForIdentity({ namespace: null, ghostId: 'cindy-github' }); const currentInstalled = getGhostManager() .list() - .find((ghost) => ghost.manifest.id === 'cindy-github'); + .find((ghost) => ghost.manifest.id === 'cindy-github' && ghost.namespace == null && + ghost.namespaceMigration !== 'pending'); if ( !currentRecord?.installed || currentRecord.source !== 'market' || @@ -2671,6 +2869,7 @@ export class PluginMarketService { ): string | null => { if (!record) return 'ledger-record-missing'; if (record.pluginId !== removal.pluginId) return 'plugin-id-mismatch'; + if (record.organizationId !== removal.organizationId) return 'organization-id-mismatch'; if (record.source !== 'market' && record.source !== 'legacy-adopted') { return 'non-server-source'; } @@ -2682,14 +2881,19 @@ export class PluginMarketService { // runtime 在场判定与取名共用一次目录扫描(list 会读每个包的 manifest 与 // 图标),首个幸存候选时才建;清理会改目录,但每条清理都在自己的互斥段里 // 由账本复检把关,这张表只回答"清理前它在不在场、叫什么"。 - let ghostsById: Map | null = null; + let removalGhosts: InstalledGhost[] | null = null; const removedNames: Array = []; for (const removal of removals) { if (removal.action !== 'purge') { skip(removal, 'unsupported-action'); continue; } - const prefilterReason = ledgerGateReason(snapshot[removal.ghostId], removal); + const prefilterIdentity = removalIdentity(removal, snapshot); + if (!prefilterIdentity) { + skip(removal, 'ambiguous-namespace'); + continue; + } + const prefilterReason = ledgerGateReason(snapshotInstallation({ installations: snapshot }, prefilterIdentity), removal); if (prefilterReason) { skip(removal, prefilterReason); continue; @@ -2697,16 +2901,19 @@ export class PluginMarketService { try { const removed = await this.withMutation(removal.pluginId, async () => { requireSameMarketOwner(owner); - const record = ledger.installationForGhost(removal.ghostId); + const current = ledger.read().installations; + const resolved = removalIdentity(removal, current); + if (!resolved) return skip(removal, 'ambiguous-namespace'); + const record = snapshotInstallation({ installations: current }, resolved); const reason = ledgerGateReason(record, removal); if (reason) return skip(removal, reason); - ghostsById ??= new Map( - getGhostManager() - .list() - .map((ghost) => [ghost.manifest.id, ghost]), - ); - const installed = ghostsById.get(removal.ghostId); + removalGhosts ??= getGhostManager().list(); + const installed = snapshotGhost({ + ...EMPTY_LOCAL_INSTALL_SNAPSHOT, + ghosts: removalGhosts, + ghostsById: indexByGhostId(removalGhosts, (ghost) => ghost.manifest.id), + }, resolved); if (!installed) return skip(removal, 'runtime-not-installed'); // 溯源摘要闸:账本记录只证明"市场装过这个 ghostId",不证明现在占位的 // 还是那份包——本地 .cindy 可原位替换,替换不写市场账本。摘要对不上 @@ -2724,11 +2931,14 @@ export class PluginMarketService { return skip(removal, 'manifest-digest-mismatch'); } - await uninstallGhostAndCleanup(removal.ghostId, { skipMarketLedger: true }); + await uninstallGhostAndCleanup( + installedGhostPhysicalRelId(installed), + { skipMarketLedger: true }, + ); await this.withCapturedLedgerMutation(ledger, () => { // userId=null 即不写退订(拍板:purge 对 defaultInstallOptOuts 只读, // 不写也不清;重新上架后按用户既有退订状态决定是否自动装回)。 - ledger.markRemoved(removal.ghostId, null); + if (record) ledger.markRemovedRecord(record, null); }); log.info('server plugin removal applied', { pluginId: removal.pluginId, @@ -2768,14 +2978,23 @@ export class PluginMarketService { const uniqueGhostIds = new Set( plugins.filter((plugin) => counts.get(plugin.ghostId) === 1).map((plugin) => plugin.ghostId), ); + const identityCounts = new Map(); + for (const plugin of plugins) { + const key = pluginLedgerRecordKey(plugin); + identityCounts.set(key, (identityCounts.get(key) ?? 0) + 1); + } const ledgerData = ledger.read(); const local = this.localInstallSnapshot(ledger, ledgerData.installations); for (const summary of plugins) { - if (!summary.defaultInstall || !uniqueGhostIds.has(summary.ghostId)) continue; + const uniqueIdentity = uniqueGhostIds.has(summary.ghostId) || + (hasDeliveryNamespace(summary) && identityCounts.get(pluginLedgerRecordKey(summary)) === 1); + if (!summary.defaultInstall || + !uniqueIdentity) continue; if (!isGhostAvailableForActiveSession(summary.ghostId)) continue; if (ledgerData.defaultInstallOptOuts[installSubject]?.includes(summary.id)) continue; - if (isBuiltinGhostRemovedByUser(summary.ghostId)) continue; + if (summary.namespace == null && isBuiltinGhostRemovedByUser(summary.ghostId)) continue; const state = this.toItem(summary, local).installState; + if (state === 'conflict' && !uniqueIdentity) continue; if (state !== 'not-installed' && state !== 'conflict') continue; const takeoverRetryKey = this.automaticUpgradeRetryKey( owner, @@ -2785,7 +3004,7 @@ export class PluginMarketService { const releaseKey = summary.currentRelease.id; if ( state === 'conflict' && - (isGhostBusy(summary.ghostId) || + (isGhostBusy(summary) || this.shouldDeferAutomaticUpgrade(takeoverRetryKey, releaseKey)) ) { continue; @@ -2803,7 +3022,7 @@ export class PluginMarketService { if (freshState !== 'not-installed' && freshState !== 'conflict') { return; } - const freshInstalled = freshLocal.ghostsById.get(summary.ghostId); + const freshInstalled = snapshotGhost(freshLocal, summary); let expectedInstalledApproval: string | undefined; if (freshState === 'conflict') { if (!freshInstalled) return; @@ -2811,21 +3030,21 @@ export class PluginMarketService { // 失败必须上抛,不能把异常降级成可接管的 manual。 const installOrigin = freshInstalled.approval.state === 'approved' - ? getGhostManager().readApprovedInstallOriginStrict(summary.ghostId) + ? getGhostManager().readApprovedInstallOriginStrict(installedGhostPhysicalRelId(freshInstalled)) : 'manual'; const eligibility = organizationDefaultTakeoverEligibility({ summary, currentOrganization, - uniqueGhostId: uniqueGhostIds.has(summary.ghostId), + uniqueGhostId: uniqueIdentity, installed: freshInstalled, - record: freshLedgerData.installations[summary.ghostId] ?? null, + record: snapshotInstallation({ installations: freshLedgerData.installations }, summary) ?? null, installOrigin, runtimeAvailable: isGhostAvailableForActiveSession(summary.ghostId), optedOut: Boolean( freshLedgerData.defaultInstallOptOuts[installSubject]?.includes(summary.id), ), builtinRemoved: isBuiltinGhostRemovedByUser(summary.ghostId), - busy: isGhostBusy(summary.ghostId), + busy: isGhostBusy(summary), }); if (!eligibility.eligible) { if (eligibility.reason === 'busy') { @@ -2869,7 +3088,7 @@ export class PluginMarketService { if (freshState === 'not-installed') { if ( !isGhostAvailableForActiveSession(summary.ghostId) || - isBuiltinGhostRemovedByUser(summary.ghostId) || + (summary.namespace == null && isBuiltinGhostRemovedByUser(summary.ghostId)) || this.toItem(summary, commitLocal).installState !== 'not-installed' ) { throw new SilentDefaultInstallCancelledError( @@ -2878,26 +3097,26 @@ export class PluginMarketService { } return; } - const commitInstalled = commitLocal.ghostsById.get(summary.ghostId); + const commitInstalled = snapshotGhost(commitLocal, summary); if (!commitInstalled || commitInstalled.approval.state !== 'approved') { throw new SilentDefaultInstallCancelledError( 'Default Plugin install state changed', ); } const commitOrigin = getGhostManager().readApprovedInstallOriginStrict( - summary.ghostId, + installedGhostPhysicalRelId(commitInstalled), ); const eligibility = organizationDefaultTakeoverEligibility({ summary, currentOrganization, - uniqueGhostId: uniqueGhostIds.has(summary.ghostId), + uniqueGhostId: uniqueIdentity, installed: commitInstalled, - record: commitLedgerData.installations[summary.ghostId] ?? null, + record: snapshotInstallation({ installations: commitLedgerData.installations }, summary) ?? null, installOrigin: commitOrigin, runtimeAvailable: isGhostAvailableForActiveSession(summary.ghostId), optedOut: false, builtinRemoved: isBuiltinGhostRemovedByUser(summary.ghostId), - busy: isGhostBusy(summary.ghostId), + busy: isGhostBusy(summary), }); if (!eligibility.eligible) { if (eligibility.reason === 'busy') { @@ -2965,13 +3184,14 @@ export class PluginMarketService { let reconciled = true; let local = this.localInstallSnapshot(ledger); for (const summary of plugins) { + const target = legacyOrganizationDeliveryTarget(summary, local); const retryKey = this.automaticUpgradeRetryKey(owner, 'server', summary.id); const releaseKey = summary.currentRelease.id; - const record = local.installations[summary.ghostId]; + const record = snapshotInstallation(local, target); if ( (record?.source !== 'market' && record?.source !== 'legacy-adopted') || this.toItem(summary, local).installState !== 'update-available' || - isGhostBusy(summary.ghostId) || + isGhostBusy(target) || this.shouldDeferAutomaticUpgrade(retryKey, releaseKey) || this.isAutomaticUpgradeHeldForConsent(retryKey, releaseKey, releaseKey) ) { @@ -2980,19 +3200,20 @@ export class PluginMarketService { try { await this.withMutation(summary.id, async () => { requireSameMarketOwner(owner); - if (isGhostBusy(summary.ghostId)) { + if (isGhostBusy(target)) { throw new SilentUpgradeBusyError('Plugin is busy'); } const freshLocal = this.localInstallSnapshot(ledger); + const freshTarget = legacyOrganizationDeliveryTarget(summary, freshLocal); if ( - (freshLocal.installations[summary.ghostId]?.source !== 'market' && - freshLocal.installations[summary.ghostId]?.source !== 'legacy-adopted') || + (snapshotInstallation(freshLocal, freshTarget)?.source !== 'market' && + snapshotInstallation(freshLocal, freshTarget)?.source !== 'legacy-adopted') || this.toItem(summary, freshLocal).installState !== 'update-available' ) { log.debug?.('Plugin update already reconciled', { pluginId: summary.id }); return; } - const freshInstalled = freshLocal.ghostsById.get(summary.ghostId); + const freshInstalled = snapshotGhost(freshLocal, freshTarget); if (!freshInstalled) { log.warn('default plugin upgrade skipped because the installed record disappeared', { pluginId: summary.id, @@ -3022,7 +3243,7 @@ export class PluginMarketService { expectedInstalled: true, expectedInstalledApproval: ghostInstallApprovalToken(freshInstalled.approval), beforeCommitInLock: () => { - if (isGhostBusy(summary.ghostId)) { + if (isGhostBusy(freshTarget)) { throw new SilentUpgradeBusyError('Plugin is busy'); } }, @@ -3064,10 +3285,10 @@ export class PluginMarketService { const retryKey = this.automaticUpgradeRetryKey(owner, 'custom', projected.pluginId); const releaseKey = projected.releaseId; const contentKey = ghostManifestDigest(entry.plugin.manifest); - const installed = local.ghostsById.get(projected.ghostId); + const installed = snapshotGhost(local, projected); if ( projected.installState !== 'update-available' || - isGhostBusy(projected.ghostId) || + isGhostBusy(projected) || installed?.approval.state !== 'approved' || this.shouldDeferAutomaticUpgrade(retryKey, releaseKey) || this.isAutomaticUpgradeHeldForConsent(retryKey, releaseKey, contentKey) @@ -3123,12 +3344,15 @@ export class PluginMarketService { installations = ledger.read().installations, ): LocalInstallSnapshot { const ghosts = getGhostManager().list(); + const records = Object.values(installations).filter((record) => record.installed); return { - ghostsById: new Map(ghosts.map((ghost) => [ghost.manifest.id, ghost])), + ghosts, + ghostsById: indexByGhostId(ghosts, (ghost) => ghost.manifest.id), installations, - manifestIdentityByGhostId: new Map( + installedRecordsByGhostId: indexByGhostId(records, (record) => record.ghostId), + manifestIdentityByStoragePart: new Map( ghosts.map((ghost) => [ - ghost.manifest.id, + installedGhostStoragePart(ghost), readInstalledMarketManifestIdentity(ghost.dir), ]), ), @@ -3208,7 +3432,7 @@ export class PluginMarketService { ? ledger.isDefaultInstallSuppressed(installSubject, record.pluginId) : false; try { - ledger.markRemoved(record.ghostId, tracksDefaultInstall ? installSubject : null); + ledger.markRemovedRecord(record, tracksDefaultInstall ? installSubject : null); } catch (error) { this.restoreMarketRouteAfterFailedReplacement(ledger, record, installSubject, wasSuppressed); log.warn('failed to detach Plugin market route before replacement', { diff --git a/apps/desktop/src/main/plugin-market/sources/discover.ts b/apps/desktop/src/main/plugin-market/sources/discover.ts index 20443a4f746..0d92bac2a6c 100644 --- a/apps/desktop/src/main/plugin-market/sources/discover.ts +++ b/apps/desktop/src/main/plugin-market/sources/discover.ts @@ -13,6 +13,7 @@ import fs from 'node:fs'; import path from 'node:path'; +import { authorDeclaredNamespaceReason } from '@cindy/plugin-protocol'; import { validateGhostManifest, type GhostManifest } from '../../../shared/ghost.js'; import { createLogger } from '../../logger.js'; import { @@ -356,6 +357,7 @@ async function resolvePluginDir( const errno = errnoOf(error); return skippedBy(kind, manifestReadReason(kind, errno), errno); } + if (authorDeclaredNamespaceReason(raw)) return { kind: 'invalid', reason: 'manifest-invalid' }; const validated = validateGhostManifest(raw); if (!validated.ok) return { kind: 'invalid', reason: 'manifest-invalid' }; return { diff --git a/apps/desktop/src/main/plugin-publisher/__tests__/api.test.ts b/apps/desktop/src/main/plugin-publisher/__tests__/api.test.ts index 58b49b737ec..dca3634031b 100644 --- a/apps/desktop/src/main/plugin-publisher/__tests__/api.test.ts +++ b/apps/desktop/src/main/plugin-publisher/__tests__/api.test.ts @@ -26,6 +26,7 @@ describe('PluginPublisherApi', () => { it('prepare / status / list use Connection JWT and skip Access Token refresh', async () => { const fetchImpl = vi.fn(async (apiPath: string) => { if (apiPath === '/api/publisher/uploads') return okPrepare(); + if (apiPath.endsWith('/commit')) return { uploadId: 'upload-1', status: 'validating' }; if (apiPath.startsWith('/api/publisher/uploads/upload-1') && !apiPath.endsWith('/commit')) { return { uploadId: 'upload-1', @@ -41,12 +42,14 @@ describe('PluginPublisherApi', () => { return { releases: [], nextCursor: null }; }); const api = new PluginPublisherApi({ + getClientVersion: () => '1.2.3', getToken: async () => 'conn-token', invalidateToken: vi.fn(), fetchImpl: fetchImpl as never, }); await api.prepare({ sizeBytes: 12, sha256: SHA }); + await api.commit('upload-1'); await api.status('upload-1'); await api.listMine(); @@ -57,11 +60,14 @@ describe('PluginPublisherApi', () => { expect(opts.skipAutoRefresh).toBe(true); expect(opts.redactErrorDetails).toBe(true); expect(opts.logLabel).toBe('/api/publisher'); + expect(opts.headers).toEqual({ 'x-cindy-version': '1.2.3' }); + expect(opts.allowedRedactedErrorCodes).toContain('PLUGIN_NAMESPACE_CLIENT_REQUIRED'); } }); it('reads commit body.status so HTTP 202 expired is not success', async () => { const api = new PluginPublisherApi({ + getClientVersion: () => '1.2.3', getToken: async () => 'conn-token', invalidateToken: vi.fn(), fetchImpl: (async () => ({ uploadId: 'upload-1', status: 'expired' })) as never, @@ -83,6 +89,7 @@ describe('PluginPublisherApi', () => { return { uploadId: 'upload-1', status: 'validating' }; }); const api = new PluginPublisherApi({ + getClientVersion: () => '1.2.3', getToken: async () => `token-${calls + 1}`, invalidateToken, fetchImpl: fetchImpl as never, @@ -90,6 +97,9 @@ describe('PluginPublisherApi', () => { await expect(api.commit('upload-1')).resolves.toMatchObject({ status: 'validating' }); expect(invalidateToken).toHaveBeenCalledTimes(1); expect(fetchImpl).toHaveBeenCalledTimes(2); + for (const [, options] of fetchImpl.mock.calls as unknown as Array<[string, Record]>) { + expect(options.headers).toEqual({ 'x-cindy-version': '1.2.3' }); + } }); it('reports an unknown bounded failure code once without forwarding its message', async () => { @@ -128,6 +138,7 @@ describe('PluginPublisherApi', () => { nextCursor: null, }); const api = new PluginPublisherApi({ + getClientVersion: () => '1.2.3', getToken: async () => 'conn-token', invalidateToken: vi.fn(), unknownFailureCodeReporter: createUnknownFailureCodeReporter(onUnknownFailureCode), diff --git a/apps/desktop/src/main/plugin-publisher/__tests__/host.test.ts b/apps/desktop/src/main/plugin-publisher/__tests__/host.test.ts new file mode 100644 index 00000000000..513d7cc86b2 --- /dev/null +++ b/apps/desktop/src/main/plugin-publisher/__tests__/host.test.ts @@ -0,0 +1,225 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PluginPublisherApiDeps } from '../api.js'; +import type { PluginPublisherOrchestratorDeps } from '../orchestrator.js'; + +const mocks = vi.hoisted(() => ({ + auth: vi.fn(), refresh: vi.fn(), authListener: vi.fn(), + owner: vi.fn(() => ({ dataOwnerId: 'member-1', ownerGeneration: 1 })), + create: vi.fn(), start: vi.fn(() => ({ transferId: 'transfer-1', uploadId: null })), + abort: vi.fn(), getToken: vi.fn(async () => 'connection-token'), invalidate: vi.fn(), + inspect: vi.fn(), listAll: vi.fn(), +})); + +vi.mock('electron', () => ({ app: { getVersion: () => '1.0.0' } })); +vi.mock('../../plugin-market/api.js', () => ({ + PluginMarketApi: class { listAll = mocks.listAll; }, +})); +vi.mock('../../appSessionState.js', () => ({ getActiveDataOwnerPushStamp: mocks.owner })); +vi.mock('../../authManager.js', () => ({ + getAuthState: mocks.auth, refresh: mocks.refresh, onAuthStateChange: mocks.authListener, +})); +vi.mock('../../cindy-brain/index.js', () => ({ + getConnectionTokenProvider: () => ({ getToken: mocks.getToken, invalidate: mocks.invalidate }), + getGhostManager: () => ({ inspect: mocks.inspect }), sendToTrustedAppWindows: () => 1, +})); +vi.mock('../../cindy-brain/connectionAudienceResolver.js', () => ({ + isReservedConnectionPluginSlug: () => false, +})); +vi.mock('../../logger.js', () => ({ createLogger: () => ({ info: vi.fn(), warn: vi.fn() }) })); +vi.mock('../../lifecycle.js', () => ({ onQuit: vi.fn() })); +vi.mock('../api.js', () => ({ + PluginPublisherApi: class { + constructor(public deps: PluginPublisherApiDeps) {} + }, +})); +vi.mock('../orchestrator.js', () => ({ + PluginPublisherOrchestrator: class {}, + createPluginPublisherOrchestrator: mocks.create, +})); + +function member(orgSlug: string | null | undefined = undefined) { + return { + isAuthenticated: true, + user: { id: 'member-1', membershipKind: 'org', orgId: 'org-1', orgName: 'Acme', orgSlug }, + }; +} + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + mocks.auth.mockReturnValue(member()); + mocks.owner.mockReturnValue({ dataOwnerId: 'member-1', ownerGeneration: 1 }); + mocks.refresh.mockResolvedValue(false); + mocks.listAll.mockResolvedValue({ plugins: [], removals: [], currentOrganization: null }); + mocks.create.mockReturnValue({ start: mocks.start, abortAll: mocks.abort }); +}); + +async function host() { + return import('../host.js'); +} + +function dependencies(): PluginPublisherOrchestratorDeps { + return mocks.create.mock.calls[0][0] as PluginPublisherOrchestratorDeps; +} + +describe('publisher old-token organization compatibility', () => { + it('uses the validated S1 market namespace for the exact Auth organization when refresh keeps the claim absent', async () => { + mocks.listAll.mockResolvedValue({ + plugins: [], removals: [], + currentOrganization: { organizationId: 'org-1', orgSlug: 'actual-org', pluginPrefix: 'legacy' }, + }); + const publisher = await host(); + publisher.startPluginPublish('/tmp/legacy-helper.cindy'); + expect(await dependencies().identity()).toEqual({ + membershipId: 'member-1', orgSlug: 'actual-org', orgName: 'Acme', + }); + expect(mocks.auth().user.orgSlug).toBeUndefined(); + const api = dependencies().api as unknown as { deps: { getToken(): Promise } }; + await api.deps.getToken(); + expect(mocks.getToken).toHaveBeenCalledWith({ + membershipId: 'member-1', audience: 'actual-org:cindy-publisher', + }); + expect(mocks.listAll).toHaveBeenCalledTimes(1); + expect(mocks.refresh).toHaveBeenCalledTimes(1); + }); + + it('provides the running app version to the publisher HTTP client', async () => { + (await host()).getPluginPublisherOrchestrator(); + const api = dependencies().api as unknown as { deps: PluginPublisherApiDeps }; + expect(api.deps.getClientVersion()).toBe('1.0.0'); + }); + + it('rejects a valid market namespace for a foreign organization even with the same legal prefix', async () => { + mocks.listAll.mockResolvedValue({ + plugins: [], removals: [], + currentOrganization: { organizationId: 'org-foreign', orgSlug: 'foreign', pluginPrefix: 'legacy' }, + }); + const publisher = await host(); + publisher.startPluginPublish('/tmp/legacy-helper.cindy'); + expect(await dependencies().identity()).toMatchObject({ orgSlug: null }); + expect(mocks.getToken).not.toHaveBeenCalled(); + }); + + it.each( + ['refresh', 'market'].flatMap((phase) => + ['membership', 'organization', 'owner'].map((change) => ({ phase, change })), + ), + )('rejects a late namespace after $change changes during $phase', async ({ phase, change }) => { + const publisher = await host(); + const changeContext = () => { + const next = member(phase === 'refresh' ? 'actual-org' : undefined); + if (change === 'membership') next.user.id = 'member-2'; + if (change === 'organization') next.user.orgId = 'org-2'; + if (change === 'owner') mocks.owner.mockReturnValue({ dataOwnerId: 'member-1', ownerGeneration: 2 }); + mocks.auth.mockReturnValue(next); + mocks.authListener.mock.calls[0][0](); + }; + mocks.refresh.mockImplementation(async () => { + if (phase === 'refresh') changeContext(); + return true; + }); + mocks.listAll.mockImplementation(async () => { + changeContext(); + return { + plugins: [], removals: [], + currentOrganization: { organizationId: 'org-1', orgSlug: 'actual-org', pluginPrefix: 'legacy' }, + }; + }); + publisher.startPluginPublish('/tmp/legacy-helper.cindy'); + expect(await dependencies().identity()).toBeNull(); + expect(mocks.getToken).not.toHaveBeenCalled(); + expect(mocks.abort).toHaveBeenCalledTimes(1); + expect(mocks.listAll).toHaveBeenCalledTimes(phase === 'market' ? 1 : 0); + }); + + it('does not use a prefix as a namespace when the market row has no orgSlug', async () => { + mocks.listAll.mockResolvedValue({ + plugins: [], removals: [], currentOrganization: { organizationId: 'org-1', pluginPrefix: 'xd' }, + }); + const publisher = await host(); + publisher.startPluginPublish('/tmp/xd-helper.cindy'); + expect(await dependencies().identity()).toMatchObject({ orgSlug: null }); + expect(mocks.getToken).not.toHaveBeenCalled(); + }); + + it('does not reuse the market identity cache after Auth organization changes without changing membership', async () => { + mocks.listAll.mockResolvedValue({ + plugins: [], removals: [], + currentOrganization: { organizationId: 'org-1', orgSlug: 'actual-org', pluginPrefix: null }, + }); + const publisher = await host(); + publisher.startPluginPublish('/tmp/helper.cindy'); + expect(await dependencies().identity()).toMatchObject({ orgSlug: 'actual-org' }); + const next = member(); + next.user.orgId = 'org-2'; + mocks.auth.mockReturnValue(next); + expect(publisher.currentPublisherIdentity()).toMatchObject({ orgSlug: null }); + expect(await dependencies().identity()).toMatchObject({ orgSlug: null }); + expect(mocks.listAll).toHaveBeenCalledTimes(2); + }); + + it('reports unavailable namespace rather than adopting identity on a failed market lookup', async () => { + mocks.listAll.mockRejectedValue(new Error('invalid market response')); + const publisher = await host(); + publisher.startPluginPublish('/tmp/legacy-helper.cindy'); + expect(await dependencies().identity()).toMatchObject({ orgSlug: null }); + expect(mocks.getToken).not.toHaveBeenCalled(); + }); + + it.each([null, undefined])('keeps a real old organization membership with missing slug=%s', async (orgSlug) => { + mocks.auth.mockReturnValue(member(orgSlug)); + expect((await host()).currentPublisherIdentity()).toEqual({ + membershipId: 'member-1', orgSlug: null, orgName: 'Acme', + }); + }); + + it('starts old legal-prefix publication and resolves only the real refreshed namespace', async () => { + const publisher = await host(); + mocks.refresh.mockImplementation(async () => { + mocks.auth.mockReturnValue(member('actual-org')); + mocks.authListener.mock.calls[0][0](); + return true; + }); + expect(publisher.startPluginPublish('/tmp/old-prefix-helper.cindy').transferId).toBe('transfer-1'); + expect(await dependencies().identity()).toEqual({ + membershipId: 'member-1', orgSlug: 'actual-org', orgName: 'Acme', + }); + expect(mocks.refresh).toHaveBeenCalledTimes(1); + expect(mocks.abort).not.toHaveBeenCalled(); + const api = dependencies().api as unknown as { deps: { getToken(): Promise } }; + await api.deps.getToken(); + expect(mocks.getToken).toHaveBeenCalledWith({ + membershipId: 'member-1', audience: 'actual-org:cindy-publisher', + }); + }); + + it('preserves natural names with a true namespace without a prefix lookup or refresh', async () => { + mocks.auth.mockReturnValue(member('xd')); + mocks.inspect.mockResolvedValue({ canonicalManifest: { id: 'helper', name: 'Helper', version: '1.0.0' } }); + const publisher = await host(); + publisher.startPluginPublish('/tmp/helper.cindy'); + expect(await dependencies().identity()).toMatchObject({ orgSlug: 'xd' }); + expect(await dependencies().inspectPackage('/tmp/helper.cindy')).toMatchObject({ ghostId: 'helper' }); + expect(publisher.publisherAudience('xd')).toBe('xd:cindy-publisher'); + expect(mocks.refresh).not.toHaveBeenCalled(); + }); + + it('does not invent a namespace when refresh cannot provide one', async () => { + const publisher = await host(); + publisher.startPluginPublish('/tmp/xd-helper.cindy'); + expect(await dependencies().identity()).toEqual({ + membershipId: 'member-1', orgSlug: null, orgName: 'Acme', + }); + expect(() => publisher.publisherAudience(null as unknown as string)).toThrow(); + expect(mocks.getToken).not.toHaveBeenCalled(); + }); + + it('still rejects personal membership and an explicitly invalid namespace', async () => { + const publisher = await host(); + mocks.auth.mockReturnValue({ isAuthenticated: true, user: { ...member().user, membershipKind: 'personal' } }); + expect(publisher.currentPublisherIdentity()).toBeNull(); + mocks.auth.mockReturnValue(member('bad:slug')); + expect(publisher.currentPublisherIdentity()).toBeNull(); + expect(() => publisher.publisherAudience('bad:slug')).toThrow(); + }); +}); diff --git a/apps/desktop/src/main/plugin-publisher/__tests__/orchestrator.test.ts b/apps/desktop/src/main/plugin-publisher/__tests__/orchestrator.test.ts index 640d7e9266c..c22830b6735 100644 --- a/apps/desktop/src/main/plugin-publisher/__tests__/orchestrator.test.ts +++ b/apps/desktop/src/main/plugin-publisher/__tests__/orchestrator.test.ts @@ -80,6 +80,44 @@ const prepared = { }; describe('PluginPublisherOrchestrator', () => { + it('waits for trusted identity refresh before inspecting and confirming an old legal prefix package', async () => { + const filePath = await packagePath(); + let resolveIdentity!: (value: { membershipId: string; orgSlug: string; orgName: string }) => void; + const identity = new Promise<{ membershipId: string; orgSlug: string; orgName: string }>((resolve) => { + resolveIdentity = resolve; + }); + const inspectPackage = vi.fn(async () => ({ ghostId: 'legacy-helper', name: 'Helper', version: '1.0.0' })); + const confirm = vi.fn(async () => false); + const orch = createPluginPublisherOrchestrator({ + api: { prepare: vi.fn(), commit: vi.fn(), status: vi.fn() } as unknown as PluginPublisherApi, + identity: () => identity, inspectPackage, confirm, + }); + const started = orch.start(filePath); + await Promise.resolve(); + expect(inspectPackage).not.toHaveBeenCalled(); + resolveIdentity({ membershipId: 'm1', orgSlug: 'actual-org', orgName: 'Acme' }); + await vi.waitFor(() => expect(orch.snapshot(started.transferId)?.errorCode).toBe('CONFIRM_UNAVAILABLE')); + expect(inspectPackage).toHaveBeenCalledWith(filePath); + expect(confirm).toHaveBeenCalledWith(expect.objectContaining({ + orgSlug: 'actual-org', ghostId: 'legacy-helper', + }), expect.any(AbortSignal)); + }); + + it('reports missing namespace separately from non-organization membership and does not inspect or upload', async () => { + const filePath = await packagePath(); + const inspectPackage = vi.fn(async () => ({ ghostId: 'legacy-helper', name: 'Helper', version: '1.0.0' })); + const prepare = vi.fn(); + const orch = createPluginPublisherOrchestrator({ + api: { prepare, commit: vi.fn(), status: vi.fn() } as unknown as PluginPublisherApi, + identity: async () => ({ membershipId: 'm1', orgSlug: null, orgName: 'Acme' }), + inspectPackage, confirm: vi.fn(async () => true), + }); + const started = orch.start(filePath); + await vi.waitFor(() => expect(orch.snapshot(started.transferId)?.errorCode).toBe('PUBLISHER_IDENTITY_UNAVAILABLE')); + expect(inspectPackage).not.toHaveBeenCalled(); + expect(prepare).not.toHaveBeenCalled(); + }); + it('caps pending confirmations per owner before opening another package', async () => { const filePath = await packagePath(); const snapshots: PluginPublisherProgress[] = []; @@ -690,6 +728,7 @@ describe('PluginPublisherOrchestrator', () => { { status: 503, code: 'INTERNAL_ERROR', expected: 'PUBLISH_UNSUPPORTED' }, { status: 503, code: 'RATE_LIMIT_UNAVAILABLE', expected: 'RATE_LIMIT_UNAVAILABLE' }, { status: 503, code: 'STORAGE_UNAVAILABLE', expected: 'STORAGE_UNAVAILABLE' }, + { status: 409, code: 'PLUGIN_NAMESPACE_CLIENT_REQUIRED', expected: 'PLUGIN_NAMESPACE_CLIENT_REQUIRED' }, ])('maps prepare $status/$code to $expected', async ({ status, code, expected }) => { const filePath = await packagePath(); const snapshots: PluginPublisherProgress[] = []; @@ -709,5 +748,8 @@ describe('PluginPublisherOrchestrator', () => { orch.start(filePath); const failed = await waitFor(snapshots, (progress) => progress.stage === 'failed'); expect(failed.errorCode).toBe(expected); + if (code === 'PLUGIN_NAMESPACE_CLIENT_REQUIRED') { + expect(failed.message).toBe('请更新 Cindy 后再发布此组织的插件'); + } }); }); diff --git a/apps/desktop/src/main/plugin-publisher/__tests__/registerIpc.test.ts b/apps/desktop/src/main/plugin-publisher/__tests__/registerIpc.test.ts index 6fb830aca94..29be3fcc86b 100644 --- a/apps/desktop/src/main/plugin-publisher/__tests__/registerIpc.test.ts +++ b/apps/desktop/src/main/plugin-publisher/__tests__/registerIpc.test.ts @@ -9,6 +9,7 @@ const { snapshotForOwnerMock, cancelForOwnerMock, trustedRendererMock, + publisherApiMock, } = vi.hoisted(() => ({ handlers: new Map(), boundaryPendingMock: vi.fn(() => false), @@ -20,6 +21,7 @@ const { snapshotForOwnerMock: vi.fn(() => ({ transferId: 'transfer-1', stage: 'confirming' })), cancelForOwnerMock: vi.fn(() => ({ cancelled: true })), trustedRendererMock: vi.fn(), + publisherApiMock: vi.fn(() => ({ listMine: async () => ({ releases: [], nextCursor: null }) })), })); vi.mock('electron', () => ({ @@ -35,6 +37,7 @@ vi.mock('../../security/trustedAppRenderer.js', () => ({ assertTrustedAppRendererEvent: trustedRendererMock, })); vi.mock('../host.js', () => ({ + createPluginPublisherApi: publisherApiMock, currentPublisherIdentity: vi.fn(() => ({ membershipId: 'member-1', orgSlug: 'acme', @@ -45,19 +48,11 @@ vi.mock('../host.js', () => ({ snapshotForOwner: snapshotForOwnerMock, cancelForOwner: cancelForOwnerMock, })), - publisherAudience: vi.fn((orgSlug: string) => `${orgSlug}:publisher`), trackPublisherConfirmRequester: vi.fn(), })); vi.mock('../api.js', () => ({ - PluginPublisherApi: class {}, PluginPublisherApiError: class extends Error {}, })); -vi.mock('../../cindy-brain/index.js', () => ({ - getConnectionTokenProvider: vi.fn(() => ({ - getToken: vi.fn(), - invalidate: vi.fn(), - })), -})); const { registerPluginPublisherIpc } = await import('../registerIpc.js'); @@ -78,9 +73,15 @@ beforeEach(() => { snapshotForOwnerMock.mockClear(); cancelForOwnerMock.mockClear(); trustedRendererMock.mockClear(); + publisherApiMock.mockClear(); }); describe('plugin-publisher IPC owner boundary', () => { + it('uses the shared Host HTTP client for publication-history requests', async () => { + await expect(handler('plugin-publisher:list-mine')(event, null)).resolves.toEqual({ releases: [], nextCursor: null }); + expect(publisherApiMock).toHaveBeenCalledOnce(); + }); + it('fails closed when an untrusted Renderer submits a publish file path', async () => { let caught: unknown; try { diff --git a/apps/desktop/src/main/plugin-publisher/api.ts b/apps/desktop/src/main/plugin-publisher/api.ts index f70596bbb59..91e129e0ec7 100644 --- a/apps/desktop/src/main/plugin-publisher/api.ts +++ b/apps/desktop/src/main/plugin-publisher/api.ts @@ -6,6 +6,7 @@ * body.status (expired is not a success). */ import { + CINDY_CLIENT_VERSION_HEADER, parseCommitPluginMemberUploadResponse, parseListMyPluginMemberReleasesResponse, parsePluginMemberUploadStatusResponse, @@ -41,6 +42,7 @@ export class PluginPublisherApiError extends Error { } export interface PluginPublisherApiDeps { + getClientVersion(): string; getToken(): Promise; invalidateToken(): void; unknownFailureCodeReporter?: UnknownFailureCodeReporter; @@ -128,8 +130,10 @@ export class PluginPublisherApi { 'CONNECTION_UNAUTHORIZED', 'INVALID_CONNECTION_TOKEN', 'CONNECTION_TOKEN_EXPIRED', + 'PLUGIN_NAMESPACE_CLIENT_REQUIRED', ], ...options, + headers: { ...options.headers, [CINDY_CLIENT_VERSION_HEADER]: this.deps.getClientVersion() }, token, skipAutoRefresh: true, }); diff --git a/apps/desktop/src/main/plugin-publisher/host.ts b/apps/desktop/src/main/plugin-publisher/host.ts index a7baad2c99d..eb0b144ff3a 100644 --- a/apps/desktop/src/main/plugin-publisher/host.ts +++ b/apps/desktop/src/main/plugin-publisher/host.ts @@ -4,10 +4,10 @@ * Identity is the current org membership. Audience is Host-minted * `:cindy-publisher` and never goes through the plugin resolver. */ -import type { WebContents } from 'electron'; +import { app, type WebContents } from 'electron'; import { getActiveDataOwnerPushStamp } from '../appSessionState.js'; -import { getAuthState, onAuthStateChange } from '../authManager.js'; +import { getAuthState, onAuthStateChange, refresh } from '../authManager.js'; import { getConnectionTokenProvider, getGhostManager, @@ -16,6 +16,7 @@ import { import { isReservedConnectionPluginSlug } from '../cindy-brain/connectionAudienceResolver.js'; import { createLogger } from '../logger.js'; import { onQuit } from '../lifecycle.js'; +import { PluginMarketApi } from '../plugin-market/api.js'; import { PluginPublisherApi } from './api.js'; import { PluginPublisherConfirmBridge } from './confirmBridge.js'; import { @@ -23,7 +24,7 @@ import { PluginPublisherOrchestrator, type PluginPublisherSourceBinding, } from './orchestrator.js'; -import { PLUGIN_MEMBER_PUBLISHER_GHOST_ID, type PluginPublisherProgress } from './types.js'; +import { PLUGIN_MEMBER_PUBLISHER_GHOST_ID, type PluginPublisherIdentity, type PluginPublisherProgress } from './types.js'; const log = createLogger('plugin-publisher'); const ORG_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,31}$/; @@ -35,36 +36,72 @@ const trackedConfirmRequesters = new WeakSet(); let orchestratorSingleton: PluginPublisherOrchestrator | null = null; let quitHooked = false; let authHooked = false; +let resolvedMarketIdentity: { contextKey: string; orgSlug: string } | null = null; export function getPluginPublisherConfirmBridge(): PluginPublisherConfirmBridge { return confirmBridge; } -export function currentPublisherIdentity(): { - membershipId: string; - orgSlug: string; - orgName: string | null; -} | null { +function publisherIdentityContextKey(): string | null { + const state = getAuthState(); + const user = state.isAuthenticated ? state.user : null; + if (!user || user.membershipKind !== 'org' || !user.orgId) return null; + const owner = getActiveDataOwnerPushStamp(); + return JSON.stringify([user.id, user.orgId, owner.dataOwnerId, owner.ownerGeneration]); +} + +export function currentPublisherIdentity(): PluginPublisherIdentity | null { const state = getAuthState(); const user = state.isAuthenticated ? state.user : null; if (!user || user.membershipKind !== 'org') return null; - if (!user.orgSlug || !ORG_SLUG_RE.test(user.orgSlug)) return null; + if (user.orgSlug != null && !ORG_SLUG_RE.test(user.orgSlug)) return null; + const contextKey = publisherIdentityContextKey(); + const marketOrgSlug = contextKey !== null && resolvedMarketIdentity?.contextKey === contextKey + ? resolvedMarketIdentity.orgSlug : null; return { membershipId: user.id, - orgSlug: user.orgSlug, + orgSlug: user.orgSlug ?? marketOrgSlug, orgName: user.orgName, }; } -export function publisherAudience(orgSlug: string): string { +async function resolvePublisherIdentity(): Promise { + const identity = currentPublisherIdentity(); + if (!identity || identity.orgSlug !== null) return identity; + const contextKey = publisherIdentityContextKey(); + const organizationId = getAuthState().user?.orgId; + if (contextKey === null || !organizationId) return identity; + await refresh(); + if (publisherIdentityContextKey() !== contextKey) return null; + const refreshed = currentPublisherIdentity(); + if (!refreshed || refreshed.membershipId !== identity.membershipId) return null; + if (refreshed.orgSlug !== null) return refreshed; + try { + const { currentOrganization } = await new PluginMarketApi(undefined, () => app.getVersion()).listAll(); + if (publisherIdentityContextKey() !== contextKey) return null; + const current = currentPublisherIdentity(); + if (!current || current.membershipId !== identity.membershipId) return null; + if (current.orgSlug !== null) return current; + if (currentOrganization?.organizationId !== organizationId || + !currentOrganization.orgSlug || !ORG_SLUG_RE.test(currentOrganization.orgSlug)) return current; + resolvedMarketIdentity = { contextKey, orgSlug: currentOrganization.orgSlug }; + return { ...current, orgSlug: currentOrganization.orgSlug }; + } catch { + return publisherIdentityContextKey() === contextKey ? currentPublisherIdentity() : null; + } +} + +export function publisherAudience(orgSlug: string | null): string { + if (!orgSlug || !ORG_SLUG_RE.test(orgSlug)) throw new Error('无法确认发布组织 namespace,请刷新登录后重试'); return `${orgSlug}:${PLUGIN_MEMBER_PUBLISHER_GHOST_ID}`; } -function createApi(): PluginPublisherApi { +export function createPluginPublisherApi(): PluginPublisherApi { return new PluginPublisherApi({ + getClientVersion: () => app.getVersion(), async getToken() { - const identity = currentPublisherIdentity(); - if (!identity) throw new Error('需要组织身份才能发布插件'); + const identity = await resolvePublisherIdentity(); + if (!identity?.orgSlug) throw new Error('无法确认发布组织身份,请刷新登录后重试'); return getConnectionTokenProvider().getToken({ membershipId: identity.membershipId, audience: publisherAudience(identity.orgSlug), @@ -72,7 +109,7 @@ function createApi(): PluginPublisherApi { }, invalidateToken() { const identity = currentPublisherIdentity(); - if (!identity) return; + if (!identity?.orgSlug) return; getConnectionTokenProvider().invalidate({ membershipId: identity.membershipId, audience: publisherAudience(identity.orgSlug), @@ -96,8 +133,8 @@ export function trackPublisherConfirmRequester(contents: WebContents): void { export function getPluginPublisherOrchestrator(): PluginPublisherOrchestrator { if (!orchestratorSingleton) { orchestratorSingleton = createPluginPublisherOrchestrator({ - api: createApi(), - identity: currentPublisherIdentity, + api: createPluginPublisherApi(), + identity: resolvePublisherIdentity, async inspectPackage(filePath) { const inspected = await getGhostManager().inspect(filePath); if ('rejection' in inspected) { @@ -142,11 +179,19 @@ export function getPluginPublisherOrchestrator(): PluginPublisherOrchestrator { } if (!authHooked) { authHooked = true; - let lastKey = publisherIdentityKey(); + let lastIdentity = currentPublisherIdentity(); + let lastContextKey = publisherIdentityContextKey(); onAuthStateChange(() => { - const nextKey = publisherIdentityKey(); - if (nextKey === lastKey) return; - lastKey = nextKey; + const nextIdentity = currentPublisherIdentity(); + const nextContextKey = publisherIdentityContextKey(); + const contextChanged = nextContextKey !== lastContextKey; + if (!contextChanged && publisherIdentityKey(nextIdentity) === publisherIdentityKey(lastIdentity)) return; + const namespaceEnriched = !contextChanged && lastIdentity?.orgSlug === null && + nextIdentity?.orgSlug != null && nextIdentity.membershipId === lastIdentity.membershipId; + lastIdentity = nextIdentity; + lastContextKey = nextContextKey; + if (namespaceEnriched) return; + resolvedMarketIdentity = null; orchestratorSingleton?.abortAll(); confirmBridge.cancelAll(); }); @@ -155,8 +200,7 @@ export function getPluginPublisherOrchestrator(): PluginPublisherOrchestrator { return orchestratorSingleton; } -function publisherIdentityKey(): string { - const identity = currentPublisherIdentity(); +function publisherIdentityKey(identity: PluginPublisherIdentity | null): string { return identity ? `${identity.membershipId}:${identity.orgSlug}` : ''; } diff --git a/apps/desktop/src/main/plugin-publisher/orchestrator.ts b/apps/desktop/src/main/plugin-publisher/orchestrator.ts index 7da6c113c5a..508563cc3f0 100644 --- a/apps/desktop/src/main/plugin-publisher/orchestrator.ts +++ b/apps/desktop/src/main/plugin-publisher/orchestrator.ts @@ -32,6 +32,7 @@ import { PLUGIN_PUBLISHER_POLL_TRANSIENT_BACKOFF_MS, putDeadlineAtMs, type PluginPublisherProgress, + type PluginPublisherIdentity, type PluginPublisherStage, type PluginPublisherStartResult, } from './types.js'; @@ -56,7 +57,7 @@ export interface PluginPublisherOrchestratorDeps { }, signal: AbortSignal, ): Promise; - identity(): { membershipId: string; orgSlug: string; orgName: string | null } | null; + identity(): PluginPublisherIdentity | null | Promise; owner?: () => ActiveAppSession; now?: () => number; sleep?: (ms: number, signal: AbortSignal) => Promise; @@ -337,11 +338,19 @@ export class PluginPublisherOrchestrator { this.fail(record, 'INVALID_PARAMS', '只能发布 .cindy 插件包'); return; } - const identity = this.deps.identity(); + const identity = await this.deps.identity(); + if (signal.aborted || !sameActiveAppSessionOwner(record.owner, this.owner())) { + this.update(record, { stage: 'cancelled' }); + return; + } if (!identity) { this.fail(record, 'NOT_ORG_MEMBER', '需要组织身份才能发布插件'); return; } + if (!identity.orgSlug) { + this.fail(record, 'PUBLISHER_IDENTITY_UNAVAILABLE', '无法确认发布组织 namespace,请刷新登录后重试'); + return; + } await this.inspectGate.acquire(signal); let inspected: { ghostId: string; name: string; version: string }; @@ -702,6 +711,7 @@ function isTerminalPublisherClientError(error: PluginPublisherApiError): boolean } function mapPublisherApiMessage(error: PluginPublisherApiError): string { + if (error.code === 'PLUGIN_NAMESPACE_CLIENT_REQUIRED') return '请更新 Cindy 后再发布此组织的插件'; if (error.status === 403 && error.code === 'FORBIDDEN') { return '本企业未开启成员发布,请联系管理员'; } diff --git a/apps/desktop/src/main/plugin-publisher/registerIpc.ts b/apps/desktop/src/main/plugin-publisher/registerIpc.ts index 01eb0457fb5..0058bf83825 100644 --- a/apps/desktop/src/main/plugin-publisher/registerIpc.ts +++ b/apps/desktop/src/main/plugin-publisher/registerIpc.ts @@ -6,39 +6,17 @@ import { } from '../appSessionState.js'; import { assertTrustedAppRendererEvent } from '../security/trustedAppRenderer.js'; import { requireObject, requireString, throwIpcError } from '../utils/ipcValidate.js'; -import { PluginPublisherApi, PluginPublisherApiError } from './api.js'; +import { PluginPublisherApiError } from './api.js'; import { currentPublisherIdentity, + createPluginPublisherApi, getPluginPublisherConfirmBridge, getPluginPublisherOrchestrator, - publisherAudience, trackPublisherConfirmRequester, } from './host.js'; -import { getConnectionTokenProvider } from '../cindy-brain/index.js'; let registered = false; -function publisherApi(): PluginPublisherApi { - return new PluginPublisherApi({ - async getToken() { - const identity = currentPublisherIdentity(); - if (!identity) throwIpcError('PERMISSION_DENIED', '需要组织身份才能查看发布'); - return getConnectionTokenProvider().getToken({ - membershipId: identity.membershipId, - audience: publisherAudience(identity.orgSlug), - }); - }, - invalidateToken() { - const identity = currentPublisherIdentity(); - if (!identity) return; - getConnectionTokenProvider().invalidate({ - membershipId: identity.membershipId, - audience: publisherAudience(identity.orgSlug), - }); - }, - }); -} - function mapListError(error: unknown): never { if (error instanceof PluginPublisherApiError) { if (error.status === 403 && error.code === 'FORBIDDEN') { @@ -53,7 +31,7 @@ export function registerPluginPublisherIpc(): void { if (registered) return; registered = true; - ipcMain.handle('plugin-publisher:start', (event, _filePath: unknown) => { + ipcMain.handle('plugin-publisher:start', (event) => { assertTrustedAppRendererEvent(event); // 临时 fail closed:Renderer 自报绝对路径不构成用户授权(XSS 可伪造)。下期重新 // 开放“我的发布”前,必须先由 Main 文件选择器签发一次性 grant,再由 start 消费; @@ -93,7 +71,7 @@ export function registerPluginPublisherIpc(): void { ? (raw as { cursor: string }).cursor : undefined; try { - return await publisherApi().listMine(cursor); + return await createPluginPublisherApi().listMine(cursor); } catch (error) { mapListError(error); } diff --git a/apps/desktop/src/main/plugin-publisher/types.ts b/apps/desktop/src/main/plugin-publisher/types.ts index d14d7976b25..cfd9a3f9574 100644 --- a/apps/desktop/src/main/plugin-publisher/types.ts +++ b/apps/desktop/src/main/plugin-publisher/types.ts @@ -6,6 +6,12 @@ import type { export const PLUGIN_MEMBER_PUBLISHER_GHOST_ID = 'cindy-publisher'; +export interface PluginPublisherIdentity { + membershipId: string; + orgSlug: string | null; + orgName: string | null; +} + export type PluginPublisherStage = | 'confirming' | 'hashing' diff --git a/apps/desktop/src/main/scheduler-host/__tests__/scriptCapabilityBroker.test.ts b/apps/desktop/src/main/scheduler-host/__tests__/scriptCapabilityBroker.test.ts index f0c8a336188..87748a85559 100644 --- a/apps/desktop/src/main/scheduler-host/__tests__/scriptCapabilityBroker.test.ts +++ b/apps/desktop/src/main/scheduler-host/__tests__/scriptCapabilityBroker.test.ts @@ -9,6 +9,7 @@ import { GhostCardService } from '../../cindy-brain/cardService.js'; import { GhostFsSlot } from '../../cindy-brain/fsSlot.js'; import { GhostPipeDispatcher } from '../../cindy-brain/pipeDispatcher.js'; import type { GhostPipeToolCall, InstalledGhost } from '../../../shared/ghost.js'; +import { installedGhostStoragePart, resolveInstalledGhost } from '../../../shared/pluginIdentity.js'; import { SchedulerScriptCapabilityBroker } from '../script-capability-broker'; const sendToSessionMock = vi.hoisted(() => vi.fn()); @@ -27,8 +28,12 @@ const callGhostToolMock = vi.hoisted(() => // cardService 账本:缺省 void spy(生命周期断言用);端到端用例转发到真实实例。 const registerCallMock = vi.hoisted(() => vi.fn()); const finalizeCallMock = vi.hoisted(() => vi.fn()); +const findAvailableGhostMock = vi.hoisted(() => vi.fn()); +const findTrustedXdGhostMock = vi.hoisted(() => vi.fn()); vi.mock('../../cindy-brain/index.js', () => ({ + findAvailableGhostForAuthorization: findAvailableGhostMock, + findTrustedXdGhostForScript: findTrustedXdGhostMock, getGhostPipeDispatcher: () => ({ callGhostTool: callGhostToolMock }), getGhostCardService: () => ({ registerCall: registerCallMock, finalizeCall: finalizeCallMock }), })); @@ -77,6 +82,93 @@ describe('SchedulerScriptCapabilityBroker', () => { callGhostToolMock.mockImplementation(async (request: unknown) => ({ ok: true, result: request })); registerCallMock.mockReset(); finalizeCallMock.mockReset(); + mockAvailableGhosts(['xd-atlassian', 'xd-feishu'].map((id) => ({ + ...makeInstalledGhost(id), namespaceMigration: 'pending', + })), ['xd-atlassian', 'xd-feishu']); + }); + + it.each([ + ['xd-atlassian', 'jira.get', { issue_key: 'DING-1' }, 'jira.read'], + ['xd-feishu', 'feishu.recent_chats', {}, 'feishu.read'], + ] as const)('binds %s to XD instead of a same-name root', async (id, method, params, capability) => { + const enterprise = { ...makeInstalledGhost(id), namespace: 'xd', dir: '/fake/ghosts/_ns/xd/' + id }; + mockAvailableGhosts([{ ...makeInstalledGhost(id), namespace: null }, enterprise]); + await new SchedulerScriptCapabilityBroker().call( + { method, params }, new Set([capability]), { schedule: schedule() }, + ); + expect(callGhostToolMock).toHaveBeenCalledWith(expect.objectContaining({ ghostId: installedGhostStoragePart(enterprise) })); + expect(registerCallMock).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ ghostId: installedGhostStoragePart(enterprise) })); + expect(findTrustedXdGhostMock).toHaveBeenCalledWith(id); + expect(findAvailableGhostMock).not.toHaveBeenCalled(); + }); + + it.each(['xd-atlassian', 'xd-feishu'])('rejects a confirmed root impersonating %s before registration', async (id) => { + mockAvailableGhosts([{ ...makeInstalledGhost(id), namespace: null }]); + await expect(new SchedulerScriptCapabilityBroker().call( + id === 'xd-atlassian' + ? { method: 'jira.get', params: { issue_key: 'DING-1' } } + : { method: 'feishu.recent_chats', params: {} }, + new Set(['jira.read', 'feishu.read']), { schedule: schedule() }, + )).rejects.toMatchObject({ code: 'GHOST_NOT_FOUND' }); + expect(registerCallMock).not.toHaveBeenCalled(); + expect(callGhostToolMock).not.toHaveBeenCalled(); + }); + + it.each([ + { namespace: 'other-org' }, + {}, + { namespace: null, namespaceMigration: 'pending' as const }, + ])('rejects an unverified or foreign install: %j', async (identity) => { + mockAvailableGhosts([{ ...makeInstalledGhost('xd-atlassian'), ...identity }]); + await expect(new SchedulerScriptCapabilityBroker().call( + { method: 'jira.get', params: { issue_key: 'DING-1' } }, + new Set(['jira.read']), { schedule: schedule() }, + )).rejects.toMatchObject({ code: 'GHOST_NOT_FOUND' }); + expect(registerCallMock).not.toHaveBeenCalled(); + expect(callGhostToolMock).not.toHaveBeenCalled(); + }); + + it('preserves an approved pre-namespace XD install captured as pending', async () => { + mockAvailableGhosts([{ ...makeInstalledGhost('xd-atlassian'), namespaceMigration: 'pending' }], ['xd-atlassian']); + await new SchedulerScriptCapabilityBroker().call( + { method: 'jira.get', params: { issue_key: 'DING-1' } }, + new Set(['jira.read']), { schedule: schedule() }, + ); + expect(callGhostToolMock).toHaveBeenCalledWith(expect.objectContaining({ ghostId: 'xd-atlassian' })); + }); + + it.each(['xd-atlassian', 'xd-feishu'])('does not authorize an approved pending ordinary root %s', async (id) => { + mockAvailableGhosts([{ ...makeInstalledGhost(id), namespaceMigration: 'pending' }]); + await expect(new SchedulerScriptCapabilityBroker().call( + id === 'xd-atlassian' + ? { method: 'jira.get', params: { issue_key: 'DING-1' } } + : { method: 'feishu.recent_chats', params: {} }, + new Set(['jira.read', 'feishu.read']), { schedule: schedule() }, + )).rejects.toMatchObject({ code: 'GHOST_NOT_FOUND' }); + expect(registerCallMock).not.toHaveBeenCalled(); + expect(callGhostToolMock).not.toHaveBeenCalled(); + }); + + it.each(['legacy-unapproved', 'invalid'] as const)('rejects a pending install with %s approval', async (state) => { + mockAvailableGhosts([{ + ...makeInstalledGhost('xd-atlassian'), namespaceMigration: 'pending', approval: { state }, + }]); + await expect(new SchedulerScriptCapabilityBroker().call( + { method: 'jira.get', params: { issue_key: 'DING-1' } }, + new Set(['jira.read']), { schedule: schedule() }, + )).rejects.toMatchObject({ code: 'GHOST_NOT_FOUND' }); + expect(registerCallMock).not.toHaveBeenCalled(); + expect(callGhostToolMock).not.toHaveBeenCalled(); + }); + + it('checks the resolved manifest rather than trusting a mismatched lookup result', async () => { + findTrustedXdGhostMock.mockReturnValue({ ...makeInstalledGhost('other-plugin'), namespace: 'xd' }); + await expect(new SchedulerScriptCapabilityBroker().call( + { method: 'jira.get', params: { issue_key: 'DING-1' } }, + new Set(['jira.read']), { schedule: schedule() }, + )).rejects.toMatchObject({ code: 'GHOST_NOT_FOUND' }); + expect(registerCallMock).not.toHaveBeenCalled(); + expect(callGhostToolMock).not.toHaveBeenCalled(); }); it('maps Jira reads to the current xd-atlassian argument contract', async () => { @@ -652,8 +744,7 @@ describe('SchedulerScriptCapabilityBroker', () => { const { fsSlot } = wireRealChannel(tmp); // 真实 dispatcher:资格审 + callId 配对 + 错误折叠全真,只有「意识进程」 // 本身由 sendToGhost 内联模拟(先经 fs 槽写盘,再 handleToolResult 交卷)。 - let dispatcher!: GhostPipeDispatcher; - dispatcher = new GhostPipeDispatcher({ + const dispatcher = new GhostPipeDispatcher({ getGhost: (id) => (id === 'xd-atlassian' ? makeInstalledGhost(id) : null), runtimeStateOf: () => 'running', spawn: async () => ({ ok: true }), @@ -693,6 +784,57 @@ describe('SchedulerScriptCapabilityBroker', () => { } }); + it('writes namespaced out_file through the real dispatcher, card service and fs slot', async () => { + const tmp = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'broker-ns-pipe-')); + try { + const installed = { ...makeInstalledGhost('xd-atlassian'), namespace: 'xd', dir: path.join(tmp, '_ns', 'xd', 'xd-atlassian') }; + mockAvailableGhosts([installed]); + const instanceId = installedGhostStoragePart(installed); + const { fsSlot } = wireRealChannel(tmp, installed); + let offPathResult: { ok: boolean } | undefined; + const dispatcher: GhostPipeDispatcher = new GhostPipeDispatcher({ + getGhost: (id) => id === instanceId || id === installed.manifest.id ? installed : null, + runtimeStateOf: () => 'running', + spawn: async () => ({ ok: true }), + sendToGhost: (ghostId, payload) => { + void (async () => { + const outFile = (payload.args as Record).out_file as string; + const result = await fsSlot.handleFsRequest(ghostId, { + op: 'write', root: 'workdir', callId: payload.callId, path: outFile, content: 'namespaced result', + }); + offPathResult = await fsSlot.handleFsRequest(ghostId, { + op: 'write', root: 'workdir', callId: payload.callId, path: 'other.json', content: 'forbidden', + }); + dispatcher.handleToolResult(ghostId, result.ok + ? { callId: payload.callId, ok: true, result: { saved_to: outFile } } + : { callId: payload.callId, ok: false, errorCode: 'INTERNAL', message: result.message }); + })(); + return true; + }, + timeoutMs: 1_000, + }); + callGhostToolMock.mockImplementation((request: unknown) => dispatcher.callGhostTool( + request as { ghostId: string; tool: string; args: Record }, + )); + + await expect(new SchedulerScriptCapabilityBroker().call( + { method: 'jira.get', params: { issue_key: 'DING-1', out_file: 'reports/result.json' } }, + new Set(['jira.read']), { schedule: schedule({ workingDir: tmp }) }, + )).resolves.toEqual({ saved_to: 'reports/result.json' }); + expect(await fs.promises.readFile(path.join(tmp, 'reports/result.json'), 'utf8')).toBe('namespaced result'); + expect(offPathResult).toMatchObject({ ok: false }); + expect(fs.existsSync(path.join(tmp, 'other.json'))).toBe(false); + const callId = registerCallMock.mock.calls[0][0] as string; + expect(registerCallMock.mock.calls[0][1]).toMatchObject({ ghostId: instanceId }); + expect(await fsSlot.handleFsRequest(instanceId, { + op: 'write', root: 'workdir', callId, path: 'reports/result.json', content: 'late result', + })).toMatchObject({ ok: false }); + expect(await fs.promises.readFile(path.join(tmp, 'reports/result.json'), 'utf8')).toBe('namespaced result'); + } finally { + await fs.promises.rm(tmp, { recursive: true, force: true }); + } + }); + it('传输层异常(callGhostTool reject)同样 finalize,旧 callId 立即失在途资格', async () => { const tmp = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'broker-reject-')); try { @@ -828,12 +970,29 @@ function makeInstalledGhost(id: string): InstalledGhost { }; } +function mockAvailableGhosts(ghosts: InstalledGhost[], trustedLegacyIds: string[] = []): void { + findAvailableGhostMock.mockReset(); + findAvailableGhostMock.mockImplementation((id: string, namespace?: string | null) => { + const resolved = resolveInstalledGhost(ghosts, id, namespace); + return resolved.status === 'unique' ? resolved.ghost : null; + }); + findTrustedXdGhostMock.mockReset(); + findTrustedXdGhostMock.mockImplementation((id: string) => { + const explicit = resolveInstalledGhost(ghosts, id, 'xd'); + if (explicit.status === 'unique') return explicit.ghost; + const legacy = resolveInstalledGhost(ghosts, id); + return legacy.status === 'unique' && trustedLegacyIds.includes(installedGhostStoragePart(legacy.ghost)) + ? legacy.ghost + : null; + }); +} + /** * 端到端 harness:真实 GhostCardService + 真实 GhostFsSlot,把 broker 的 * mock 边界(register/finalize)接到真实账本——用例只需替换意识行为 * (callGhostToolMock 的实现),其余链路全真。 */ -function wireRealChannel(tmp: string): { cardService: GhostCardService; fsSlot: GhostFsSlot } { +function wireRealChannel(tmp: string, installed = makeInstalledGhost('xd-atlassian')): { cardService: GhostCardService; fsSlot: GhostFsSlot } { const cardService = new GhostCardService({ hasCardSlot: () => false, sanitize: (html: string) => ({ ok: true, html }), @@ -841,7 +1000,7 @@ function wireRealChannel(tmp: string): { cardService: GhostCardService; fsSlot: broadcast: () => {}, }); const fsSlot = new GhostFsSlot({ - getGhost: (id) => (id === 'xd-atlassian' ? makeInstalledGhost(id) : null), + getGhost: (id) => id === installedGhostStoragePart(installed) ? installed : null, dataRootDir: () => path.join(tmp, 'ghost-fs'), callInfo: (callId) => cardService.callInfoOf(callId), inFlightCallInfo: (callId) => cardService.inFlightCallInfoOf(callId), diff --git a/apps/desktop/src/main/scheduler-host/script-capability-broker.ts b/apps/desktop/src/main/scheduler-host/script-capability-broker.ts index 21eaa08052c..186edeadfef 100644 --- a/apps/desktop/src/main/scheduler-host/script-capability-broker.ts +++ b/apps/desktop/src/main/scheduler-host/script-capability-broker.ts @@ -4,7 +4,12 @@ import { isAbsolute } from 'node:path'; import type { Schedule, ScriptCapability } from '@cindy/maker-scheduler'; import type { GhostToolCallResult } from '../../shared/ghost.js'; -import { getGhostCardService, getGhostPipeDispatcher } from '../cindy-brain/index.js'; +import { hasDeliveryNamespace, installedGhostStoragePart } from '../../shared/pluginIdentity.js'; +import { + findTrustedXdGhostForScript, + getGhostCardService, + getGhostPipeDispatcher, +} from '../cindy-brain/index.js'; import { validateFsRelPath } from '../cindy-brain/fsSlot.js'; import { tryGetOrcaCollabService } from '../maker-ipc/register.js'; import type { ScriptCapabilityBroker, ScriptCapabilityCall } from './script-runner'; @@ -73,6 +78,14 @@ async function callGhostForScript( active: Map>, writePath: string | null, ): Promise { + const ghost = findTrustedXdGhostForScript(request.ghostId); + const pendingLegacy = ghost?.namespaceMigration === 'pending' + && !hasDeliveryNamespace(ghost) && ghost.approval.state === 'approved'; + if (!ghost || ghost.manifest.id !== request.ghostId || + (ghost.namespace !== 'xd' && !pendingLegacy)) { + fail('GHOST_NOT_FOUND', 'XD plugin ' + request.ghostId + ' is not available in the current session'); + } + const ghostId = installedGhostStoragePart(ghost); const callId = randomUUID(); // 登记值与 script-runner 的 spawn cwd 严格同源(同一字符串,不 trim 改写): // POSIX 允许首尾空白的目录名,trim 后登记会让授权根与脚本实际 cwd 分叉 @@ -87,7 +100,7 @@ async function callGhostForScript( const scriptWorkdir = writePath !== null && rawWorkdir.trim() && isAbsolute(rawWorkdir) ? rawWorkdir : null; const cardService = getGhostCardService(); cardService.registerCall(callId, { - ghostId: request.ghostId, + ghostId, toolUseId: null, sessionId: null, scriptWorkdir, @@ -101,7 +114,7 @@ async function callGhostForScript( } bucket.add(callId); try { - return await getGhostPipeDispatcher().callGhostTool({ ...request, callId }); + return await getGhostPipeDispatcher().callGhostTool({ ...request, ghostId, callId }); } finally { bucket.delete(callId); if (bucket.size === 0) active.delete(runId); diff --git a/apps/desktop/src/main/secrets/__tests__/providerSecretStore.test.ts b/apps/desktop/src/main/secrets/__tests__/providerSecretStore.test.ts index a0b4ea865ee..27db505d68e 100644 --- a/apps/desktop/src/main/secrets/__tests__/providerSecretStore.test.ts +++ b/apps/desktop/src/main/secrets/__tests__/providerSecretStore.test.ts @@ -51,6 +51,8 @@ import { readCustomProviderKeyForMutation, readGhostSecretStrict, readGhostSecretTailFromIo, + migrateGhostSecrets, + setMivoSecretAliasVerifier, resolveOwnerScopedSecretStorageKey, setProviderSecretsClearedListener, UNRECOVERABLE_PROVIDER_CREDENTIAL, @@ -91,6 +93,47 @@ function createMemoryIo(): SecretStorageIo & { store: Map } { }; } +describe('ghost secret relocation rollback', () => { + const source = ghostSecretStorageKey('helper', 'one'); + const destination = ghostSecretStorageKey('_ns__acme__helper', 'one'); + const secondSource = ghostSecretStorageKey('helper', 'two'); + const secondDestination = ghostSecretStorageKey('_ns__acme__helper', 'two'); + + it('keeps the destination when restoring the only old copy fails', () => { + const io = createMemoryIo(); + io.store.set(source, 'fake-one'); + io.store.set(secondSource, 'fake-two'); + const write = io.write; + const remove = io.remove; + io.write = (key, value) => key === source ? false : write(key, value); + io.remove = (key) => key === secondSource ? { success: false } : remove(key); + expect(() => migrateGhostSecrets('helper', '_ns__acme__helper', io)).toThrow(); + expect(io.store.get(destination)).toBe('fake-one'); + expect(io.store.has(source)).toBe(false); + expect(io.store.get(secondSource)).toBe('fake-two'); + expect(io.store.has(secondDestination)).toBe(false); + }); + + it('rolls back the current write if removing its source fails', () => { + const io = createMemoryIo(); + io.store.set(source, 'fake-one'); + io.remove = (key) => key === source ? { success: false } : (io.store.delete(key), { success: true }); + expect(() => migrateGhostSecrets('helper', '_ns__acme__helper', io)).toThrow(); + expect(io.store.get(source)).toBe('fake-one'); + expect(io.store.has(destination)).toBe(false); + }); + + it('does not delete a same-valued destination that predates a later rollback', () => { + const io = createMemoryIo(); + io.store.set(source, 'fake-one'); + io.store.set(destination, 'fake-one'); + const undo = migrateGhostSecrets('helper', '_ns__acme__helper', io); + undo(); + expect(io.store.get(source)).toBe('fake-one'); + expect(io.store.get(destination)).toBe('fake-one'); + }); +}); + describe('providerSecrets registry', () => { it('maps known providers to their stable storage keys', () => { expect(providerSecretStorageKey('xd')).toBe('api_key'); @@ -153,6 +196,25 @@ describe('providerSecrets registry', () => { expect(() => ghostSecretStorageKey('ok', 'k.ey')).toThrow(/illegal characters/); }); + it('企业实例 storage part 与 root 凭证键隔离,且不继承官方别名', () => { + expect(ghostSecretStorageKey('helper', 'token')).toBe('ghost_secret_helper_token'); + expect(ghostSecretStorageKey('_ns__acme__helper', 'token')).toBe( + 'ghost_secret__ns__acme__helper_token', + ); + expect(ghostSecretHintStorageKey('_ns__acme__helper', 'token')).toBe( + 'ghost_hint__ns__acme__helper_token', + ); + expect(ghostSecretStorageKey('_ns__xd__xd-mivo', 'mivo_api_key', true)).toBe( + ghostSecretStorageKey('xd-mivo', 'mivo_api_key', true), + ); + expect(ghostSecretStorageKey('_ns__acme__xd-mivo', 'mivo_api_key')).toBe( + 'ghost_secret__ns__acme__xd-mivo_mivo_api_key', + ); + expect(ghostSecretStorageKey('_ns__acme__xd-mivo', 'mivo_api_key')).not.toBe( + ghostSecretStorageKey('xd-mivo', 'mivo_api_key', true), + ); + }); + it('官方别名:cindy-web-search 的凭证映射到历史 brave/tavily 存储键(老用户零迁移)', () => { // 与「工具密钥」时代同一 .enc 文件:老用户已填 key 对意识立即生效, // lizi_web_search MCP 也照读同一份。 @@ -164,7 +226,9 @@ describe('providerSecrets registry', () => { }); it('官方别名:xd-mivo 的 mivo_api_key 映射到历史 mivo 存储键(老用户零迁移)', () => { - expect(ghostSecretStorageKey('xd-mivo', 'mivo_api_key')).toBe(providerSecretStorageKey('mivo')); + expect(ghostSecretStorageKey('xd-mivo', 'mivo_api_key', true)).toBe(providerSecretStorageKey('mivo')); + expect(ghostSecretStorageKey('xd-mivo', 'mivo_api_key')).not.toBe(providerSecretStorageKey('mivo')); + expect(ghostSecretStorageKey('_ns__xd__xd-mivo', 'mivo_api_key')).not.toBe(providerSecretStorageKey('mivo')); expect(ghostSecretStorageKey('xd-mivo', 'other_key')).toBe('ghost_secret_xd-mivo_other_key'); expect(ghostSecretStorageKey('third-party', 'mivo_api_key')).toBe('ghost_secret_third-party_mivo_api_key'); }); @@ -359,9 +423,21 @@ describe('readGhostSecretTailFromIo(尾指纹读取 + 老键懒回填)', () => { }); it('官方别名键(xd-mivo 老用户)同样能懒回填', () => { - io.store.set(ghostSecretStorageKey('xd-mivo', 'mivo_api_key'), 'mivo_legacy_key_9999'); + setMivoSecretAliasVerifier((id) => id === 'xd-mivo'); + io.store.set(ghostSecretStorageKey('xd-mivo', 'mivo_api_key', true), 'mivo_legacy_key_9999'); expect(readGhostSecretTailFromIo(io, 'xd-mivo', 'mivo_api_key')).toBe('9999'); expect(io.store.get(ghostSecretHintStorageKey('xd-mivo', 'mivo_api_key'))).toBe('9999'); + setMivoSecretAliasVerifier(null); + }); + + it('a name-only Mivo cannot read the historical secret or its cached hint', () => { + io.store.set(ghostSecretStorageKey('xd-mivo', 'mivo_api_key', true), 'mivo_legacy_key_9999'); + io.store.set(ghostSecretHintStorageKey('xd-mivo', 'mivo_api_key'), '9999'); + setMivoSecretAliasVerifier(null); + expect(readGhostSecretTailFromIo(io, 'xd-mivo', 'mivo_api_key')).toBeNull(); + setMivoSecretAliasVerifier((id) => id === 'xd-mivo'); + expect(readGhostSecretTailFromIo(io, 'xd-mivo', 'mivo_api_key')).toBe('9999'); + setMivoSecretAliasVerifier(null); }); it('没存过 / 值太短不产指纹 → null 且不落回填键', () => { diff --git a/apps/desktop/src/main/secrets/providerSecretStore.ts b/apps/desktop/src/main/secrets/providerSecretStore.ts index 0320d983478..f028781ab76 100644 --- a/apps/desktop/src/main/secrets/providerSecretStore.ts +++ b/apps/desktop/src/main/secrets/providerSecretStore.ts @@ -38,6 +38,29 @@ import { hasLegacyOwnerNamespaceClaim } from '../ownerNamespaceMigration.js'; const log = createLogger('providerSecretStore'); +let canUseMivoAlias: ((ghostId: string) => boolean) | null = null; + +export function setMivoSecretAliasVerifier(verifier: ((ghostId: string) => boolean) | null): void { + canUseMivoAlias = verifier; +} + +function approvedGhostSecretStorageKey(ghostId: string, secretKey: string): string { + return ghostSecretStorageKey(ghostId, secretKey, canUseMivoAlias?.(ghostId) === true); +} + +function isUntrustedMivoKey(ghostId: string, secretKey: string): boolean { + return secretKey === 'mivo_api_key' && + (ghostId === 'xd-mivo' || ghostId === '_ns__xd__xd-mivo') && + canUseMivoAlias?.(ghostId) !== true; +} + +function approvedGhostSecretHintKey(ghostId: string, secretKey: string): string { + const key = ghostSecretHintStorageKey(ghostId, secretKey); + return isUntrustedMivoKey(ghostId, secretKey) + ? 'ghost_hint__local__' + ghostId + '_' + secretKey + : key; +} + /** * 记录「本机这批 provider 密钥归属哪个账号」的标记键(非密钥,存同目录便于统一管理)。 * 用于账号边界:登录 / 冷启动确立 userId 后,若 owner 与之不同(同机换账号),清掉 @@ -210,9 +233,11 @@ const electronSecretIo: SecretStorageIo = { .readdirSync(secretDir()) .filter((f) => f.startsWith(prefix) && f.endsWith('.enc')) .map((f) => f.slice(prefix.length, -'.enc'.length)); - } catch { - // 目录不存在(尚无任何密钥落盘)等 → 空列表。 - return []; + } catch (err) { + if (err instanceof Error && (err as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + throw err; } }, }; @@ -682,7 +707,7 @@ export function readCustomMcpToken(mcpId: string): string | null { */ export function readGhostSecret(ghostId: string, secretKey: string): string | null { try { - return electronSecretIo.read(ghostSecretStorageKey(ghostId, secretKey)); + return electronSecretIo.read(approvedGhostSecretStorageKey(ghostId, secretKey)); } catch (err) { log.warn( { ghostId, secretKey, err: err instanceof Error ? err.message : String(err) }, @@ -699,7 +724,7 @@ export function readGhostSecret(ghostId: string, secretKey: string): string | nu */ export function readGhostSecretStrict(ghostId: string, secretKey: string): string | null { const physicalKey = resolveOwnerScopedSecretStorageKey( - ghostSecretStorageKey(ghostId, secretKey), + approvedGhostSecretStorageKey(ghostId, secretKey), ); if (!physicalKey) return null; const filepath = path.join(secretDir(), `${physicalKey}.enc`); @@ -728,7 +753,7 @@ export function readGhostSecretStrict(ghostId: string, secretKey: string): strin export function ghostSecretSaved(ghostId: string, secretKey: string): boolean { try { const physicalKey = resolveOwnerScopedSecretStorageKey( - ghostSecretStorageKey(ghostId, secretKey), + approvedGhostSecretStorageKey(ghostId, secretKey), ); if (!physicalKey) return false; fs.statSync(path.join(secretDir(), `${physicalKey}.enc`)); @@ -747,13 +772,13 @@ export function ghostSecretSaved(ghostId: string, secretKey: string): boolean { */ export function storeGhostSecret(ghostId: string, secretKey: string, value: string): boolean { try { - const ok = electronSecretIo.write(ghostSecretStorageKey(ghostId, secretKey), value); + const ok = electronSecretIo.write(approvedGhostSecretStorageKey(ghostId, secretKey), value); if (ok) { // 入库即截尾 4 位指纹(分键保管,读路径永不碰明文);值太短不产指纹, // 且要清掉旧值可能留下的指纹。指纹写失败不连坐主凭证(best-effort)。 try { const tail = deriveGhostSecretTail(value); - const hintKey = ghostSecretHintStorageKey(ghostId, secretKey); + const hintKey = approvedGhostSecretHintKey(ghostId, secretKey); if (tail) electronSecretIo.write(hintKey, tail); else electronSecretIo.remove(hintKey); } catch (err) { @@ -787,10 +812,10 @@ export function readGhostSecretTailFromIo( secretKey: string, ): string | null { try { - const hintKey = ghostSecretHintStorageKey(ghostId, secretKey); + const hintKey = approvedGhostSecretHintKey(ghostId, secretKey); const existing = io.read(hintKey); if (existing !== null) return existing; - const value = io.read(ghostSecretStorageKey(ghostId, secretKey)); + const value = io.read(approvedGhostSecretStorageKey(ghostId, secretKey)); if (value === null) return null; const tail = deriveGhostSecretTail(value); if (tail) io.write(hintKey, tail); @@ -812,8 +837,8 @@ export function readGhostSecretTail(ghostId: string, secretKey: string): string /** 清除某意识的单条 network 槽凭证(/secrets DELETE 用;幂等,连同尾指纹)。 */ export function removeGhostSecret(ghostId: string, secretKey: string): void { try { - electronSecretIo.remove(ghostSecretStorageKey(ghostId, secretKey)); - electronSecretIo.remove(ghostSecretHintStorageKey(ghostId, secretKey)); + electronSecretIo.remove(approvedGhostSecretStorageKey(ghostId, secretKey)); + electronSecretIo.remove(approvedGhostSecretHintKey(ghostId, secretKey)); } catch (err) { log.warn( { ghostId, secretKey, err: err instanceof Error ? err.message : String(err) }, @@ -828,7 +853,11 @@ export function removeGhostSecret(ghostId: string, secretKey: string): void { * 声明——旧版本声明过、新版本删掉的孤儿键也一并清。 */ export function removeGhostSecrets(ghostId: string): void { - const prefixes = [`${GHOST_SECRET_PREFIX}${ghostId}_`, `${GHOST_SECRET_HINT_PREFIX}${ghostId}_`]; + const prefixes = [ + `${GHOST_SECRET_PREFIX}${ghostId}_`, + `${GHOST_SECRET_HINT_PREFIX}${ghostId}_`, + `ghost_hint__local__${ghostId}_`, + ]; try { for (const key of electronSecretIo.list()) { if (prefixes.some((prefix) => key.startsWith(prefix))) electronSecretIo.remove(key); @@ -841,6 +870,93 @@ export function removeGhostSecrets(ghostId: string): void { } } +function ghostSecretRelocationPrefixes(fromGhostId: string, toGhostId: string): Array<[string, string]> { + const localHintTarget = toGhostId === 'xd-mivo' && fromGhostId !== '_ns__xd__xd-mivo'; + return [ + [`${GHOST_SECRET_PREFIX}${fromGhostId}_`, `${GHOST_SECRET_PREFIX}${toGhostId}_`], + [`${GHOST_SECRET_HINT_PREFIX}${fromGhostId}_`, localHintTarget + ? `ghost_hint__local__${toGhostId}_` + : `${GHOST_SECRET_HINT_PREFIX}${toGhostId}_`], + [`ghost_hint__local__${fromGhostId}_`, toGhostId === '_ns__xd__xd-mivo' || toGhostId === 'xd-mivo' + ? `ghost_hint__local__${toGhostId}_` + : `${GHOST_SECRET_HINT_PREFIX}${toGhostId}_`], + ]; +} + +/** Rename ghost_secret_/ghost_hint_ keys after a physical relocate. Conflicts fail closed. */ +export function assertGhostSecretsCanRelocate(fromGhostId: string, toGhostId: string): void { + if (fromGhostId === toGhostId) return; + for (const key of electronSecretIo.list()) { + for (const [sourcePrefix, destinationPrefix] of ghostSecretRelocationPrefixes(fromGhostId, toGhostId)) { + if (!key.startsWith(sourcePrefix)) continue; + const destination = `${destinationPrefix}${key.slice(sourcePrefix.length)}`; + const value = electronSecretIo.read(key); + if (value === null) throw new Error('relocate source secret is unreadable'); + const existing = electronSecretIo.read(destination); + if (existing !== null && existing !== value) { + throw new Error('relocate secret destination already exists'); + } + } + } +} + +export function migrateGhostSecrets( + fromGhostId: string, + toGhostId: string, + io: SecretStorageIo = electronSecretIo, +): () => void { + if (fromGhostId === toGhostId) return () => {}; + const pairs = ghostSecretRelocationPrefixes(fromGhostId, toGhostId); + const moves: Array<{ from: string; to: string; value: string; destinationExisted: boolean }> = []; + for (const key of io.list()) { + for (const [fromPrefix, toPrefix] of pairs) { + if (!key.startsWith(fromPrefix)) continue; + const dest = `${toPrefix}${key.slice(fromPrefix.length)}`; + const value = io.read(key); + if (value === null) throw new Error('relocate source secret is unreadable'); + const existing = io.read(dest); + if (existing !== null && existing !== value) { + throw new Error(`relocate secret destination already exists: ${dest}`); + } + moves.push({ from: key, to: dest, value, destinationExisted: existing !== null }); + break; + } + } + const attempted: typeof moves = []; + const rollback = () => { + let failure: unknown; + for (const move of [...attempted].reverse()) { + try { + const sourceRestored = io.read(move.from) === move.value || io.write(move.from, move.value); + if (!sourceRestored) throw new Error('failed to restore relocated secret: ' + move.from); + if (!move.destinationExisted && !io.remove(move.to).success) { + throw new Error('failed to remove relocated secret: ' + move.to); + } + } catch (error) { + failure ??= error; + } + } + if (failure) throw failure; + }; + try { + for (const move of moves) { + attempted.push(move); + if (io.read(move.to) !== move.value && !io.write(move.to, move.value)) { + throw new Error(`failed to write relocated secret: ${move.to}`); + } + if (!io.remove(move.from).success) { + throw new Error(`failed to remove relocated secret: ${move.from}`); + } + } + } catch (error) { + try { rollback(); } catch (rollbackError) { + void rollbackError; + } + throw error; + } + return rollback; +} + export const genericOAuthSecretIo = { read(providerId: string): string | null { try { diff --git a/apps/desktop/src/main/webview-security.ts b/apps/desktop/src/main/webview-security.ts index 88ba90e9c76..fdd57be5dbd 100644 --- a/apps/desktop/src/main/webview-security.ts +++ b/apps/desktop/src/main/webview-security.ts @@ -32,7 +32,8 @@ import { LOGIN_CAPTCHA_PAGE_PATH, LOGIN_CAPTCHA_PARTITION, } from '../shared/webviewPartition'; -import { GHOST_PARTITION_PREFIX } from '../shared/ghost'; +import { GHOST_PARTITION_PREFIX, ghostInstallApprovalToken, type GhostPanelMediaTarget } from '../shared/ghost'; +import { installedGhostStoragePart } from '../shared/pluginIdentity'; import { getActiveAppSession, type AppSessionMode } from './appSessionState.js'; import { matchesElectronInput, @@ -47,6 +48,7 @@ import { resolveGhostWebviewAttach, } from './cindy-brain/index.js'; import { classifyGhostPanelNavigation } from './cindy-brain/previewGate.js'; +import { ghostMediaHandoverDragScript, ghostMediaHandoverTargetTracker } from './cindy-brain/ghostMediaHandoverTargetTracker.js'; import { registerGhostWebContents } from './cindy-brain/runtime/electronSandboxAdapter.js'; import { attributeRsbNativePopupSurface, @@ -177,7 +179,7 @@ export function authorizeGhostWebviewAttach( webPreferences: Record, params: Record, resolver: GhostWebviewAttachResolver = resolveGhostWebviewAttach, -): { id: string; owner: { mode: AppSessionMode; dataOwnerId: string } } | null { +): { id: string; instanceId: string; owner: { mode: AppSessionMode; dataOwnerId: string }; isCurrent(): boolean } | null { let resolved: ReturnType = null; try { resolved = resolver(params.partition, params.src); @@ -186,13 +188,38 @@ export function authorizeGhostWebviewAttach( } if (!resolved) return null; + const partitionClaim = params.partition; + const entryUrl = params.src; + const generation = getActiveAppSession().generation; + const instanceId = installedGhostStoragePart(resolved.ghost); + const approval = ghostInstallApprovalToken(resolved.ghost.approval); + const directory = resolved.ghost.dir; + const version = resolved.ghost.manifest.version; + const owner = resolved.owner; + // Electron 在触发 will-attach-webview 前已经把 params.partition 复制进 // webPreferences.partition,之后创建 guest 时读取的是后者。两边都必须由 // Main 的核准结果覆盖,否则只改 attribute dict 不会改变真实 session。 params.partition = resolved.partition; webPreferences.partition = resolved.partition; applyGhostWebviewHardening(webPreferences, params); - return { id: resolved.ghost.manifest.id, owner: resolved.owner }; + return { + id: resolved.ghost.manifest.id, + instanceId, + owner: resolved.owner, + isCurrent: () => { + const active = getActiveAppSession(); + if (active.generation !== generation || active.mode !== owner.mode || active.dataOwnerId !== owner.dataOwnerId) return false; + try { + const current = resolver(partitionClaim, entryUrl); + return current !== null && current.owner.mode === owner.mode && current.owner.dataOwnerId === owner.dataOwnerId + && installedGhostStoragePart(current.ghost) === instanceId && current.ghost.dir === directory + && current.ghost.manifest.version === version && ghostInstallApprovalToken(current.ghost.approval) === approval; + } catch { + return false; + } + }, + }; } /** @@ -806,7 +833,14 @@ export function installBrowserGuestHandlers( interface GhostGuestNavigationHandlers { gesture?: typeof noteGhostUserGesture; - preview: typeof handleGhostPreviewNavigation; + preview: ( + ghostId: string, + url: string, + hostContents: WebContents, + guestContents: WebContents, + isOwnerActive: () => boolean, + instanceId?: string, + ) => void; external: typeof handleGhostExternalLinkNavigation; } @@ -834,11 +868,17 @@ export function installGhostGuestNavigationHandlers( preview: handleGhostPreviewNavigation, external: handleGhostExternalLinkNavigation, }, + instanceId?: string, + isAttachCurrent?: () => boolean, ): void { + if (instanceId && isAttachCurrent) { + installGhostMediaHandoverSource(hostContents, guestContents, { ghostId, instanceId }, () => isOwnerActive() && isAttachCurrent()); + } guestContents.setWindowOpenHandler(() => ({ action: 'deny' })); + const isCurrent = () => isOwnerActive() && (!isAttachCurrent || isAttachCurrent()); const noteGesture = () => { - if (!isOwnerActive()) return; - (handlers.gesture ?? noteGhostUserGesture)(ghostId); + if (!isCurrent()) return; + (handlers.gesture ?? noteGhostUserGesture)(instanceId ?? ghostId); }; guestContents.on('before-mouse-event', (_event, mouse) => { if (mouse.type === 'mouseDown') noteGesture(); @@ -849,7 +889,7 @@ export function installGhostGuestNavigationHandlers( guestContents.on('will-navigate', (event, url) => { // owner commit 后、Renderer 卸载旧 guest 前仍可能收到导航事件。旧页 // 不能借新 owner 的同名插件声明或授权代开外链。 - if (!isOwnerActive()) { + if (!isCurrent()) { event.preventDefault(); return; } @@ -857,13 +897,44 @@ export function installGhostGuestNavigationHandlers( if (nav === 'allow') return; event.preventDefault(); if (nav === 'preview') { - handlers.preview(ghostId, url, hostContents, guestContents, isOwnerActive); + handlers.preview(ghostId, url, hostContents, guestContents, isCurrent, instanceId); } else if (nav === 'external') { - handlers.external(ghostId, url, hostContents, guestContents, isOwnerActive); + handlers.external(ghostId, url, hostContents, guestContents, isCurrent, instanceId); } }); } +export function installGhostMediaHandoverSource( + hostContents: WebContents, + guestContents: WebContents, + target: GhostPanelMediaTarget & { instanceId: string }, + isAttachCurrent: () => boolean, +): void { + let sourceToken: string | null = null; + const revoke = () => { + if (sourceToken) ghostMediaHandoverTargetTracker.revoke(sourceToken); + sourceToken = null; + }; + const isCurrent = () => !hostContents.isDestroyed() && !guestContents.isDestroyed() && isAttachCurrent(); + guestContents.on('dom-ready', () => { + revoke(); + if (!isCurrent()) return; + const token = ghostMediaHandoverTargetTracker.register({ ...target, isCurrent }); + sourceToken = token; + void guestContents.executeJavaScript(ghostMediaHandoverDragScript(token)).catch(() => { + ghostMediaHandoverTargetTracker.revoke(token); + if (sourceToken === token) sourceToken = null; + }); + }); + guestContents.on('did-navigate', revoke); + guestContents.on('render-process-gone', revoke); + guestContents.once('destroyed', () => { + revoke(); + hostContents.removeListener('destroyed', revoke); + }); + hostContents.once('destroyed', revoke); +} + export function installWebviewHardener(): void { app.on('web-contents-created', (_event, contents) => { // will-attach → did-attach 对同一个 guest 同步成对触发;用闭包变量把 @@ -946,6 +1017,9 @@ export function installWebviewHardener(): void { guestContents, ghostId, () => isGhostGuestOwnerActive(authorized.owner), + undefined, + authorized.instanceId, + authorized.isCurrent, ); return; } diff --git a/apps/desktop/src/preload/__tests__/ghostInspectTarget.test.ts b/apps/desktop/src/preload/__tests__/ghostInspectTarget.test.ts new file mode 100644 index 00000000000..37e4eea226a --- /dev/null +++ b/apps/desktop/src/preload/__tests__/ghostInspectTarget.test.ts @@ -0,0 +1,45 @@ +import fs from 'node:fs'; +import ts from 'typescript'; +import { describe, expect, it, vi } from 'vitest'; + +function inspectBridge(invoke: ReturnType) { + const source = fs.readFileSync(new URL('../preload.ts', import.meta.url), 'utf8'); + const ast = ts.createSourceFile('preload.ts', source, ts.ScriptTarget.Latest, true); + let initializer: ts.Expression | undefined; + const visit = (node: ts.Node): void => { + if ( + ts.isPropertyAssignment(node) && + node.name.getText(ast) === 'inspect' && + node.initializer.getText(ast).includes('ghosts:inspect') + ) + initializer = node.initializer; + ts.forEachChild(node, visit); + }; + visit(ast); + if (!initializer) throw new Error('Ghost inspect bridge missing'); + const compiled = ts.transpileModule('const inspect = ' + initializer.getText(ast), { + compilerOptions: { target: ts.ScriptTarget.ES2022 }, + }).outputText; + return new Function('ipcRenderer', compiled + ';return inspect;')({ invoke }) as ( + path: string, + target?: { expectedInstalledInstanceId: string; expectedInstalledApproval: string }, + ) => Promise; +} + +describe('Ghost inspect receiver bridge', () => { + it('preserves a legacy one-argument call', async () => { + const invoke = vi.fn(); + await inspectBridge(invoke)('/tmp/plugin.cindy'); + expect(invoke).toHaveBeenCalledExactlyOnceWith('ghosts:inspect', '/tmp/plugin.cindy'); + }); + + it('forwards the exact instance and receipt token, not a privilege flag', async () => { + const invoke = vi.fn(); + const target = { + expectedInstalledInstanceId: '_ns__xd__helper', + expectedInstalledApproval: 'approved:receipt', + }; + await inspectBridge(invoke)('/tmp/plugin.cindy', target); + expect(invoke).toHaveBeenCalledExactlyOnceWith('ghosts:inspect', '/tmp/plugin.cindy', target); + }); +}); diff --git a/apps/desktop/src/preload/__tests__/ghostPanelMedia.test.ts b/apps/desktop/src/preload/__tests__/ghostPanelMedia.test.ts new file mode 100644 index 00000000000..34368c548a6 --- /dev/null +++ b/apps/desktop/src/preload/__tests__/ghostPanelMedia.test.ts @@ -0,0 +1,94 @@ +import fs from 'node:fs'; +import ts from 'typescript'; +import { describe, expect, it, vi } from 'vitest'; + +function loadResolvePanelMedia(invoke: ReturnType, filename = 'preload.ts') { + const source = fs.readFileSync(new URL('../' + filename, import.meta.url), 'utf8'); + const ast = ts.createSourceFile('preload.ts', source, ts.ScriptTarget.Latest, true); + let initializer: ts.Expression | undefined; + const visit = (node: ts.Node): void => { + if (ts.isPropertyAssignment(node) && node.name.getText(ast) === 'resolvePanelMedia') { + initializer = node.initializer; + } + ts.forEachChild(node, visit); + }; + visit(ast); + if (!initializer) throw new Error('resolvePanelMedia bridge not found'); + const compiled = ts.transpileModule('const resolvePanelMedia = ' + initializer.getText(ast), { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, + }).outputText; + return new Function('ipcRenderer', compiled + '\nreturn resolvePanelMedia;')({ invoke }) as ( + uri: string, purpose?: 'menu' | 'attach', instanceId?: string, sourceToken?: string, + ) => Promise; +} + +describe('Ghost panel media IPC bridge', () => { + it('declares the optional instance in the renderer API contract', () => { + const source = fs.readFileSync(new URL('../../renderer/vite-env.d.ts', import.meta.url), 'utf8'); + const ast = ts.createSourceFile('vite-env.d.ts', source, ts.ScriptTarget.Latest, true); + let signature: ts.FunctionTypeNode | undefined; + const visit = (node: ts.Node): void => { + if (ts.isPropertySignature(node) && node.name.getText(ast) === 'resolvePanelMedia' + && node.type && ts.isFunctionTypeNode(node.type)) { + signature = node.type; + } + ts.forEachChild(node, visit); + }; + visit(ast); + expect(signature).toBeDefined(); + expect(signature?.parameters).toHaveLength(4); + const instance = signature?.parameters[2]; + expect(instance?.name.getText(ast)).toBe('instanceId'); + expect(instance?.questionToken).toBeDefined(); + expect(instance?.type?.kind).toBe(ts.SyntaxKind.StringKeyword); + const sourceToken = signature?.parameters[3]; + expect(sourceToken?.name.getText(ast)).toBe('sourceToken'); + expect(sourceToken?.questionToken).toBeDefined(); + expect(sourceToken?.type?.kind).toBe(ts.SyntaxKind.StringKeyword); + }); + + it('preserves the old root and old controller argument layout', async () => { + const result = { url: 'cindy-media://blobs/image.png' }; + const invoke = vi.fn().mockResolvedValue(result); + const resolve = loadResolvePanelMedia(invoke); + expect(await resolve('cindy-ghost://helper/media/image.png')).toBe(result); + expect(invoke).toHaveBeenCalledExactlyOnceWith( + 'ghosts:resolve-panel-media', 'cindy-ghost://helper/media/image.png', undefined, + ); + }); + + it.each(['menu', 'attach'] as const)('forwards the optional instance for %s', async (purpose) => { + const invoke = vi.fn().mockResolvedValue({ url: 'cindy-media://blobs/image.png' }); + const resolve = loadResolvePanelMedia(invoke); + await resolve('cindy-ghost://helper/media/image.png', purpose, '_ns__acme__helper'); + expect(invoke).toHaveBeenCalledExactlyOnceWith( + 'ghosts:resolve-panel-media', 'cindy-ghost://helper/media/image.png', purpose, '_ns__acme__helper', + ); + }); +}); + +describe.each(['preload.ts', 'ghostPanelWindowPreload.ts', 'sidebarWindowPreload.ts'])('%s media bridge', (filename) => { + it('retains the legacy argument layout', async () => { + const invoke = vi.fn().mockResolvedValue({}); + await loadResolvePanelMedia(invoke, filename)('cindy-ghost://helper/media/image.png'); + expect(invoke).toHaveBeenCalledExactlyOnceWith( + 'ghosts:resolve-panel-media', 'cindy-ghost://helper/media/image.png', undefined, + ); + }); + + it.each(['menu', 'attach'] as const)('forwards the physical instance for %s', async (purpose) => { + const invoke = vi.fn().mockResolvedValue({}); + await loadResolvePanelMedia(invoke, filename)('cindy-ghost://helper/media/image.png', purpose, '_ns__acme__helper'); + expect(invoke).toHaveBeenCalledExactlyOnceWith( + 'ghosts:resolve-panel-media', 'cindy-ghost://helper/media/image.png', purpose, '_ns__acme__helper', + ); + }); + + it('forwards the opaque drag source without declaring an instance', async () => { + const invoke = vi.fn().mockResolvedValue({}); + await loadResolvePanelMedia(invoke, filename)('cindy-ghost://helper/preview/image.png', 'attach', undefined, 'opaque-source'); + expect(invoke).toHaveBeenCalledExactlyOnceWith( + 'ghosts:resolve-panel-media', 'cindy-ghost://helper/preview/image.png', 'attach', undefined, 'opaque-source', + ); + }); +}); diff --git a/apps/desktop/src/preload/ghostPanelWindowPreload.ts b/apps/desktop/src/preload/ghostPanelWindowPreload.ts index 67f32065f83..fe896ac8fc3 100644 --- a/apps/desktop/src/preload/ghostPanelWindowPreload.ts +++ b/apps/desktop/src/preload/ghostPanelWindowPreload.ts @@ -20,7 +20,7 @@ import { contextBridge, ipcRenderer } from 'electron'; import type { AppearanceSettings } from '../shared/appearanceSettings'; import type { LocalThemesResult } from '../shared/local-themes'; import type { GhostPanelWindowsState } from '../shared/ghostPanelWindow'; -import { isValidGhostId } from '../shared/ghost'; +import { isValidPluginStoragePart } from '../shared/pluginIdentity'; import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type SupportedLocale } from '../shared/locale'; import { GHOST_PANEL_WINDOW_CLOSE_REQUESTED_CHANNEL, @@ -54,7 +54,7 @@ function onPayload(channel: string, cb: (payload: T) => void): Unsub { const currentGhostPanelId = (() => { const raw = new URLSearchParams(window.location.search).get('ghostPanelWindow'); - return isValidGhostId(raw) ? raw : null; + return isValidPluginStoragePart(raw) ? raw : null; })(); function mutationErrorForGhostPanel(id: string): Error | null { @@ -255,10 +255,16 @@ contextBridge.exposeInMainWorld('electronAPI', { resolvePanelMedia: ( uri: string, purpose?: 'attach' | 'menu', + instanceId?: string, + sourceToken?: string, ): Promise< | { url: string; kind?: 'image' } | { url: string; kind: 'video'; absPath: string; size: number; name: string; ext: string; mimeType: string } - > => ipcRenderer.invoke('ghosts:resolve-panel-media', uri, purpose), + > => sourceToken !== undefined + ? ipcRenderer.invoke('ghosts:resolve-panel-media', uri, purpose, instanceId, sourceToken) + : instanceId === undefined + ? ipcRenderer.invoke('ghosts:resolve-panel-media', uri, purpose) + : ipcRenderer.invoke('ghosts:resolve-panel-media', uri, purpose, instanceId), /** 运行时状态快照(面板崩溃/熔断错误态接管)。 */ runtimeStates: (): Promise<{ states: Record }> => ipcRenderer.invoke('ghosts:runtime-states'), diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 6e1d4310803..9e02d75fe77 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1296,6 +1296,7 @@ contextBridge.exposeInMainWorld('electronAPI', { opts: { expectedPackageSha256: string; expectedInstalledApproval: string; + expectedInstalledInstanceId: string; }, ): Promise<{ ghost: unknown }> => ipcRenderer.invoke('ghosts:update', lizFilePath, opts), cindyPrefsSync: ( @@ -1356,13 +1357,16 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('ghosts:pick-file'), inspect: ( lizFilePath: string, + opts?: { expectedInstalledInstanceId: string; expectedInstalledApproval: string }, ): Promise<{ manifest: unknown; trust: unknown; packageSha256: string; unsupportedSlots: string[]; iconDataUrl?: string; - }> => ipcRenderer.invoke('ghosts:inspect', lizFilePath), + }> => opts === undefined + ? ipcRenderer.invoke('ghosts:inspect', lizFilePath) + : ipcRenderer.invoke('ghosts:inspect', lizFilePath, opts), uninstall: (id: string): Promise<{ ok: true }> => ipcRenderer.invoke('ghosts:uninstall', id), /** 详情页「导出 .cindy」:main 打包安装目录 → 系统保存对话框落盘。 */ export: ( @@ -1469,6 +1473,8 @@ contextBridge.exposeInMainWorld('electronAPI', { resolvePanelMedia: ( uri: string, purpose?: 'attach' | 'menu', + instanceId?: string, + sourceToken?: string, ): Promise< | { url: string; kind?: 'image' } | { @@ -1480,7 +1486,11 @@ contextBridge.exposeInMainWorld('electronAPI', { ext: string; mimeType: string; } - > => ipcRenderer.invoke('ghosts:resolve-panel-media', uri, purpose), + > => sourceToken !== undefined + ? ipcRenderer.invoke('ghosts:resolve-panel-media', uri, purpose, instanceId, sourceToken) + : instanceId === undefined + ? ipcRenderer.invoke('ghosts:resolve-panel-media', uri, purpose) + : ipcRenderer.invoke('ghosts:resolve-panel-media', uri, purpose, instanceId), runtimeStates: (): Promise<{ states: Record }> => ipcRenderer.invoke('ghosts:runtime-states'), reload: (id: string): Promise<{ state: string }> => ipcRenderer.invoke('ghosts:reload', id), diff --git a/apps/desktop/src/preload/sidebarWindowPreload.ts b/apps/desktop/src/preload/sidebarWindowPreload.ts index ca66b63593c..5d82c148e5f 100644 --- a/apps/desktop/src/preload/sidebarWindowPreload.ts +++ b/apps/desktop/src/preload/sidebarWindowPreload.ts @@ -280,7 +280,13 @@ contextBridge.exposeInMainWorld('electronAPI', { resolvePanelMedia: ( uri: string, purpose?: 'attach' | 'menu', - ): Promise => ipcRenderer.invoke('ghosts:resolve-panel-media', uri, purpose), + instanceId?: string, + sourceToken?: string, + ): Promise => sourceToken !== undefined + ? ipcRenderer.invoke('ghosts:resolve-panel-media', uri, purpose, instanceId, sourceToken) + : instanceId === undefined + ? ipcRenderer.invoke('ghosts:resolve-panel-media', uri, purpose) + : ipcRenderer.invoke('ghosts:resolve-panel-media', uri, purpose, instanceId), runtimeStates: (): Promise<{ states: Record }> => ipcRenderer.invoke('ghosts:runtime-states'), onChanged: (cb: (payload: unknown) => void): (() => void) => onPayload('ghosts:changed', cb), diff --git a/apps/desktop/src/renderer/__tests__/chatInputSessionFocus.test.ts b/apps/desktop/src/renderer/__tests__/chatInputSessionFocus.test.ts index a599c250f1e..11f5e2cbbc7 100644 --- a/apps/desktop/src/renderer/__tests__/chatInputSessionFocus.test.ts +++ b/apps/desktop/src/renderer/__tests__/chatInputSessionFocus.test.ts @@ -173,8 +173,8 @@ describe('ChatInput session switch focus contract', () => { 'const handleComposerSuggestionSelect = useCallback(', ); - expect(pluginPageSource).toContain('pendingGhostId: ghost.manifest.id'); - expect(pluginPageSource).toContain('pendingHostCapabilityGhostId: ghost.manifest.id'); + expect(pluginPageSource).toContain('pendingGhostId: installedGhostStoragePart(ghost)'); + expect(pluginPageSource).toContain('pendingHostCapabilityGhostId: installedGhostStoragePart(ghost)'); expect(pluginPageSource.match(/focusAtEnd: true/g)).toHaveLength(1); expect( chatInputSource.match(/placeGhostAtComposerStart\(editor, ghost, installedGhosts\)/g), @@ -214,7 +214,7 @@ expect(capabilitySelectionBlock).toContain('!ghost?.enabled'); 'worktreeCreationStore.clear(newSession.id);', ); - expect(chatInputSource).toContain('findGhostByCommand(eligibleGhosts, ghostCommandWord)'); + expect(chatInputSource).toContain('findGhostByCommand(eligibleGhosts, ghostCommandToken.word, ghostCommandToken.namespace)'); expect(chatInputSource).toContain('onAccepted: markRecentPluginUsage'); expect(successfulSendBlock).toContain('markRecentPluginUsage();'); expect(newMakerDraftRouteSource.match(/opts\?\.onAccepted\?\.\(\);/g)).toHaveLength(3); diff --git a/apps/desktop/src/renderer/__tests__/collectGhostCallsByUserTurn.test.ts b/apps/desktop/src/renderer/__tests__/collectGhostCallsByUserTurn.test.ts index b4a589c2832..fe56e306596 100644 --- a/apps/desktop/src/renderer/__tests__/collectGhostCallsByUserTurn.test.ts +++ b/apps/desktop/src/renderer/__tests__/collectGhostCallsByUserTurn.test.ts @@ -25,6 +25,26 @@ const ghostCall = (clientId: string, ghostId: string, toolName = 'mcp__cindy__gh }) as ChatMessage; describe('collectGhostCallsByUserTurn', () => { + it('does not merge root and organization calls with the same ghost id', () => { + const root = { ...ghostCall('t1', 'helper'), toolInput: { ghost_id: 'helper', namespace: null, tool: 'run' } }; + const organization = { ...ghostCall('t2', 'helper'), toolInput: { ghost_id: 'helper', namespace: 'acme', tool: 'run' } }; + const map = collectGhostCallsByUserTurn([user('u1'), root, organization]); + expect([...(map.get('u1') ?? [])].sort()).toEqual(['_ns__acme__helper', 'helper']); + }); + + it('keeps legacy unqualified calls and ignores malformed namespace inputs', () => { + const legacy = ghostCall('legacy', 'helper'); + const malformed = { ...ghostCall('bad', 'helper'), toolInput: { ghost_id: 'helper', namespace: 'bad namespace', tool: 'run' } }; + expect([...(collectGhostCallsByUserTurn([user('u1'), legacy]).get('u1') ?? [])]).toEqual(['helper']); + expect(collectGhostCallsByUserTurn([user('u1'), malformed]).has('u1')).toBe(false); + }); + it('attributes a valid encoded instance id without an extra namespace', () => { + const encoded = ghostCall('org', '_ns__acme__helper'); + expect([...(collectGhostCallsByUserTurn([user('u1'), encoded]).get('u1') ?? [])]) + .toEqual(['_ns__acme__helper']); + const invalid = { ...encoded, toolInput: { ghost_id: '_ns__acme__helper', namespace: 'globex', tool: 'run' } }; + expect(collectGhostCallsByUserTurn([user('u1'), invalid]).has('u1')).toBe(false); + }); it('把 ghost_call 归到其所在 turn 的 user 消息名下', () => { const map = collectGhostCallsByUserTurn([user('u1'), ghostCall('t1', 'art')]); expect([...(map.get('u1') ?? [])]).toEqual(['art']); diff --git a/apps/desktop/src/renderer/__tests__/ghostCardRenderItems.test.ts b/apps/desktop/src/renderer/__tests__/ghostCardRenderItems.test.ts index b49f7b6fc90..aaaf2309d6c 100644 --- a/apps/desktop/src/renderer/__tests__/ghostCardRenderItems.test.ts +++ b/apps/desktop/src/renderer/__tests__/ghostCardRenderItems.test.ts @@ -54,6 +54,14 @@ function itemsOf(messages: ChatMessage[], snap?: GhostCardSnapshot): RenderItem[ } describe('ghost_card · settled 配对', () => { + it('keeps the card store physical id for a namespaced settled call', () => { + const call = { ...mkGhostCall('org', 'helper'), toolInput: { ghost_id: 'helper', namespace: 'acme', tool: 'run' } }; + const cards = itemsOf([call, mkResult('result-org', 'tu-org', { xdt_card_id: 'org-card' })], + snapshot({ 'org-card': readyEntry('_ns__acme__helper') })); + expect(cards.find((item) => item.type === 'ghost_card')).toMatchObject({ + ghostId: '_ns__acme__helper', callId: 'org-card', settled: true, + }); + }); it('xdt_card_id + ready → ghost_card item,自身媒体被抑制', () => { const items = itemsOf( [ @@ -144,6 +152,66 @@ describe('ghost_card · in-flight 锚定', () => { ...over, }); + it('does not claim a root card for an enterprise call sharing its ghost id', () => { + const call = { ...mkGhostCall('org', 'helper'), toolInput: { ghost_id: 'helper', namespace: 'acme', tool: 'run' } }; + const cards = itemsOf([call], snapshot({ 'root-card': readyEntry('helper') }, [ + live('root-card', { ghostId: 'helper' }), + ])); + expect(cards.some((item) => item.type === 'ghost_card')).toBe(false); + }); + + it('claims an enterprise card by physical id and retains its identity', () => { + const call = { ...mkGhostCall('org', 'helper'), toolInput: { ghost_id: 'helper', namespace: 'acme', tool: 'run' } }; + const cards = itemsOf([call], snapshot({ 'org-card': readyEntry('_ns__acme__helper') }, [ + live('org-card', { ghostId: '_ns__acme__helper' }), + ])); + expect(cards.find((item) => item.type === 'ghost_card')).toMatchObject({ + ghostId: '_ns__acme__helper', callId: 'org-card', settled: false, + }); + }); + + it('claims an encoded enterprise call without a toolUseId only for its matching live card', () => { + const call = { ...mkGhostCall('org', '_ns__acme__helper'), toolUseId: undefined }; + const cards = itemsOf([call], snapshot({ 'org-card': readyEntry('_ns__acme__helper') }, [ + live('org-card', { ghostId: '_ns__acme__helper' }), + ])); + expect(cards).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'ghost_card', ghostId: '_ns__acme__helper', callId: 'org-card' }), + ])); + }); + + it('uses exact tool-use correlation for an in-place namespace stamp', () => { + const call = { ...mkGhostCall('org', 'helper'), toolInput: { ghost_id: 'helper', namespace: 'acme', tool: 'run' } }; + const cards = itemsOf([call], snapshot({ 'org-card': readyEntry('helper') }, [ + live('org-card', { ghostId: 'helper', toolUseId: 'tu-org' }), + ])); + expect(cards.find((item) => item.type === 'ghost_card')).toMatchObject({ + ghostId: 'helper', callId: 'org-card', settled: false, + }); + }); + + it('matches an in-place organization card without a tool-use id but never anchors it to root', () => { + const card = live('org-card', { ghostId: 'helper', logicalGhostId: '_ns__acme__helper' }); + const cards = snapshot({ 'org-card': readyEntry('helper') }, [card]); + const organization = { ...mkGhostCall('org', 'helper'), toolUseId: undefined, + toolInput: { ghost_id: 'helper', namespace: 'acme', tool: 'run' } }; + const root = { ...mkGhostCall('root', 'helper'), toolUseId: undefined, + toolInput: { ghost_id: 'helper', namespace: null, tool: 'run' } }; + expect(itemsOf([organization], cards).find((item) => item.type === 'ghost_card')) + .toMatchObject({ callId: 'org-card', settled: false }); + expect(itemsOf([root], cards).some((item) => item.type === 'ghost_card')).toBe(false); + const implicitRoot = { ...root, toolInput: { ghost_id: 'helper', tool: 'run' } }; + expect(itemsOf([implicitRoot], cards).some((item) => item.type === 'ghost_card')).toBe(false); + }); + + it('ignores malformed plugin ids instead of crashing the message stream', () => { + const call = { ...mkGhostCall('invalid'), toolInput: { ghost_id: 'invalid id', namespace: 'acme', tool: 'run' } }; + const cards = itemsOf([call], snapshot({ 'org-card': readyEntry('_ns__acme__helper') }, [ + live('org-card', { ghostId: '_ns__acme__helper' }), + ])); + expect(cards.some((item) => item.type === 'ghost_card')).toBe(false); + }); + it('renders a Pi card before its tool returns and restores the settled card from old history', () => { const sessionId = 'pi-card-regression'; const content = { toolUseId: 'tu-pi', toolName: 'cindy_mcp_call_tool', input: { @@ -225,6 +293,16 @@ describe('ghost_card · in-flight 锚定', () => { }); describe('ghost_card · 媒体回锚(xdt_anchor_card_id)', () => { + it('does not attach enterprise media to a root card with the same id', () => { + const rootCall = { ...mkGhostCall('root', 'helper'), toolInput: { ghost_id: 'helper', namespace: null, tool: 'run' } }; + const orgPoll = { ...mkGhostCall('org', 'helper'), toolInput: { ghost_id: 'helper', namespace: 'acme', tool: 'poll' } }; + const items = itemsOf([rootCall, mkResult('root-result', 'tu-root', { xdt_card_id: 'root-card' }), + orgPoll, mkResult('org-result', 'tu-org', { xdt_anchor_card_id: 'root-card', xdt_image_urls: [IMG] })], + snapshot({ 'root-card': readyEntry('helper') })); + expect(items.some((item) => item.type === 'tool_media')).toBe(true); + const card = items.find((item) => item.type === 'ghost_card'); + expect(card && card.type === 'ghost_card' ? card.media : undefined).toBeUndefined(); + }); const VIDEO = `cindy-media://blobs/${'c'.repeat(64)}.mp4`; // 提交调用(开卡)+ 轮询调用(出媒体带锚)的标准两段式消息流。 const submitAndPoll = (pollBody: Record, pollGhostId = 'xd-mivo') => [ diff --git a/apps/desktop/src/renderer/__tests__/ghostCardStore.test.ts b/apps/desktop/src/renderer/__tests__/ghostCardStore.test.ts index ad21f6fd303..7ab26e51c71 100644 --- a/apps/desktop/src/renderer/__tests__/ghostCardStore.test.ts +++ b/apps/desktop/src/renderer/__tests__/ghostCardStore.test.ts @@ -48,6 +48,13 @@ const push = (callId: string, over: Partial[0] }); describe('ghostCardStore', () => { + it('preserves the logical identity of an in-place plugin on its live card', () => { + ingestCardPush(push('org', { ghostId: 'helper', logicalGhostId: '_ns__acme__helper' })); + expect(getGhostCardSnapshot().liveCards[0]).toMatchObject({ + ghostId: 'helper', logicalGhostId: '_ns__acme__helper', + }); + }); + it('推送入库:ready 条目 + 活卡登记;换海报只刷内容不重复登记', () => { ingestCardPush(push('c1', { toolUseId: 'tu1' })); let snap = getGhostCardSnapshot(); diff --git a/apps/desktop/src/renderer/__tests__/ghostCommand.test.ts b/apps/desktop/src/renderer/__tests__/ghostCommand.test.ts index 1e02bf8b973..2298c37cb23 100644 --- a/apps/desktop/src/renderer/__tests__/ghostCommand.test.ts +++ b/apps/desktop/src/renderer/__tests__/ghostCommand.test.ts @@ -11,17 +11,27 @@ import { COMMAND_TOOLS_JSON_MAX_BYTES, commandDirectiveSegments, expandGhostCommand, + findGhostByCommand, mentionDirectiveSegments, + formatGhostCommandInsertion, + formatGhostCommandToken, + parseGhostCommandToken, parseGhostCommandWord, splitGhostDirective, } from '../cindy-brain/ghostCommand'; import type { InstalledGhost } from '../../shared/ghost'; -function ghost(command: string | undefined, enabled = true): InstalledGhost { +function ghost( + command: string | undefined, + enabled = true, + opts: { id?: string; namespace?: string | null } = {}, +): InstalledGhost { + const id = opts.id ?? 'art'; + const namespace = opts.namespace; return { manifest: { schemaVersion: 2, - id: 'art', + id, name: '画图', version: '1.0.0', kind: 'chip', @@ -29,8 +39,9 @@ function ghost(command: string | undefined, enabled = true): InstalledGhost { tools: [{ name: 'gen_image', description: 'x' }], ...(command !== undefined ? { command } : {}), }, - dir: '/fake', + dir: namespace ? `/fake/_ns/${namespace}/${id}` : '/fake', enabled, + ...(namespace !== undefined ? { namespace } : {}), } as InstalledGhost; } @@ -43,6 +54,19 @@ describe('parseGhostCommandWord', () => { expect(parseGhostCommandWord('$ 画图')).toBeNull(); }); + it('optional /namespace qualifier is not part of the command word', () => { + expect(parseGhostCommandWord('$draw/acme a cat')).toBe('draw'); + expect(parseGhostCommandToken('$draw/acme a cat')).toEqual({ word: 'draw', namespace: 'acme' }); + expect(parseGhostCommandToken('$draw/@root a cat')).toEqual({ word: 'draw', namespace: '@root' }); + expect(parseGhostCommandToken('$画图/acme 一只猫')).toEqual({ word: '画图', namespace: 'acme' }); + expect(parseGhostCommandToken('$draw')).toEqual({ word: 'draw', namespace: null }); + expect(parseGhostCommandToken('$draw/')).toBeNull(); + expect(parseGhostCommandToken('$draw/ACME')).toBeNull(); + const longNs = 'o' + 'r'.repeat(126) + 'g'; + expect(longNs).toHaveLength(128); + expect(parseGhostCommandToken('$draw/' + longNs + ' a cat')).toEqual({ word: 'draw', namespace: longNs }); + }); + it('全角变体触发符同权(中文输入法 Shift+4 产出 ¥ 不必切半角)', () => { expect(parseGhostCommandWord('¥画图 一只猫')).toBe('画图'); // U+FFE5 全角人民币 expect(parseGhostCommandWord('$画图 一只猫')).toBe('画图'); // U+FF04 全角美元 @@ -52,6 +76,15 @@ describe('parseGhostCommandWord', () => { }); }); +describe('formatGhostCommandToken', () => { + it('qualifies organization instances and leaves root unqualified', () => { + expect(formatGhostCommandToken(ghost('draw'))).toBe('draw'); + expect(formatGhostCommandToken(ghost('draw', true, { namespace: null }))).toBe('draw'); + expect(formatGhostCommandToken(ghost('draw', true, { namespace: 'acme' }))).toBe('draw/acme'); + expect(formatGhostCommandInsertion(ghost('draw', true, { namespace: 'acme' }))).toBe('$draw/acme'); + }); +}); + describe('expandGhostCommand', () => { it('命中已唤醒意识 → 追加机器指令(原文保留)', () => { const out = expandGhostCommand('$画图 一只猫', [ghost('画图')]); @@ -84,6 +117,30 @@ describe('expandGhostCommand', () => { it('未提及意识的普通消息零改动', () => { expect(expandGhostCommand('画一张猫', [ghost('画图')])).toBe('画一张猫'); }); + + it('$draw/acme selects the namespaced instance; bare $draw stays unique-or-ambiguous', () => { + const root = ghost('draw', true, { id: 'art', namespace: null }); + const org = ghost('draw', true, { id: 'art', namespace: 'acme' }); + const qualified = expandGhostCommand('$draw/acme a cat', [root, org]); + expect(qualified).toContain('[插件指令]'); + expect(qualified).toContain('id: _ns__acme__art'); + const split = splitGhostDirective(qualified); + expect(split?.directive).toMatchObject({ kind: 'command', ghostId: '_ns__acme__art', command: 'draw' }); + expect(findGhostByCommand([root, org], 'draw', 'acme')).toBe(org); + expect(findGhostByCommand([root, org], 'draw')).toBeNull(); + const ambiguous = expandGhostCommand('$draw a cat', [root, org]); + expect(ambiguous).toContain('存在多个实例'); + expect(expandGhostCommand('$draw/globex a cat', [root, org])).toBe('$draw/globex a cat'); + }); + it('preserves a selected root and an in-place organization identity with the same command', () => { + const root = ghost('draw', true, { id: 'art', namespace: null }); + const org = { ...ghost('draw', true, { id: 'art', namespace: 'acme' }), dir: '/fake/art' }; + expect(formatGhostCommandInsertion(root, [root, org])).toBe('$draw/@root'); + expect(expandGhostCommand('$draw/@root a cat', [root, org])).toContain('id: art'); + expect(expandGhostCommand('$draw/@root a cat', [root, org])).toContain('namespace:null'); + expect(expandGhostCommand('$draw/@root a cat', [root, org])).not.toContain('存在多个实例'); + expect(expandGhostCommand('$draw/acme a cat', [root, org])).toContain('id: _ns__acme__art'); + }); }); describe('语言提及软提示已移除(2026-07-14 定案:不再追加、不再出胶囊)', () => { @@ -109,6 +166,17 @@ describe('语言提及软提示已移除(2026-07-14 定案:不再追加、不再 }); describe('splitGhostDirective(召唤卡片渲染层解析,与生成端同模板 round-trip)', () => { + it('parses the exact previously persisted direct-tool hint in both command forms', () => { + for (const tools of [undefined, [{ name: 'gen_image', description: 'x' }]]) { + const current = expandGhostCommand('$画图 一只猫', [ + { ...ghost('画图'), manifest: { ...ghost('画图').manifest, tools } }, + ]); + const prior = current.replace('若指令带 /@root,ghost_call 必须显式传 namespace:null。', ''); + expect(prior).not.toBe(current); + expect(splitGhostDirective(prior)).toMatchObject({ body: '$画图 一只猫', directive: { kind: 'command', ghostId: 'art' } }); + expect(splitGhostDirective(current)).toMatchObject({ body: '$画图 一只猫', directive: { kind: 'command', ghostId: 'art' } }); + } + }); it('硬指令 round-trip:expand → split 还原正文与结构化字段', () => { const text = '$画图 用nano 画一张 心动小镇'; const out = expandGhostCommand(text, [ghost('画图')]); diff --git a/apps/desktop/src/renderer/__tests__/ghostCommandDecoration.test.ts b/apps/desktop/src/renderer/__tests__/ghostCommandDecoration.test.ts index ced4acc8e94..bccae71492b 100644 --- a/apps/desktop/src/renderer/__tests__/ghostCommandDecoration.test.ts +++ b/apps/desktop/src/renderer/__tests__/ghostCommandDecoration.test.ts @@ -49,7 +49,7 @@ const doc = (...paras: PMNode[]) => schema.nodes.doc.create(null, paras); function makeGhost( command: string, - opts: { enabled?: boolean; icon?: string; id?: string } = {}, + opts: { enabled?: boolean; icon?: string; id?: string; namespace?: string | null } = {}, ): InstalledGhost { const manifest: GhostManifest = { schemaVersion: 2, @@ -66,6 +66,7 @@ function makeGhost( enabled: opts.enabled ?? true, approval: { state: 'approved', revision: '00000000-0000-4000-8000-000000000001' }, ...(opts.icon ? { iconDataUrl: opts.icon } : {}), + ...(opts.namespace !== undefined ? { namespace: opts.namespace } : {}), }; } @@ -79,6 +80,15 @@ describe('findGhostCommandMatch — 位置语义与发送期同源', () => { expect(m?.ghost.manifest.id).toBe('ghost-画图'); }); + it('`$draw/acme` covers the qualifier and selects that namespace', () => { + const root = makeGhost('draw', { id: 'art', namespace: null }); + const org = makeGhost('draw', { id: 'art', namespace: 'acme' }); + const m = findGhostCommandMatch(doc(p(txt('$draw/acme a cat'))), [root, org]); + expect(m).toMatchObject({ from: 1, to: 11 }); + expect(m?.ghost.namespace).toBe('acme'); + expect(findGhostCommandMatch(doc(p(txt('$draw a cat'))), [root, org])).toBeNull(); + }); + it('全角触发符(¥/$)同权命中', () => { expect(findGhostCommandMatch(doc(p(txt('¥画图 猫'))), [画图])).not.toBeNull(); expect(findGhostCommandMatch(doc(p(txt('$画图 猫'))), [画图])).not.toBeNull(); diff --git a/apps/desktop/src/renderer/__tests__/ghostComposerPlacement.test.ts b/apps/desktop/src/renderer/__tests__/ghostComposerPlacement.test.ts index 3840631fbdd..42943dcbfe8 100644 --- a/apps/desktop/src/renderer/__tests__/ghostComposerPlacement.test.ts +++ b/apps/desktop/src/renderer/__tests__/ghostComposerPlacement.test.ts @@ -87,6 +87,15 @@ describe('placeGhostAtComposerStart', () => { expect(editor.state.selection.to).toBe(editor.state.doc.content.size - 1); }); + it('qualifies a namespaced Plugin command', () => { + const editor = editorWith('keep going'); + const selected = ghost('draw', 'art'); + selected.namespace = 'acme'; + selected.dir = '/tmp/_ns/acme/art'; + expect(placeGhostAtComposerStart(editor, selected, [selected])).toBe(true); + expect(editor.getText()).toBe('$draw/acme keep going'); + }); + it('replaces an existing Plugin command instead of stacking commands', () => { const current = ghost('mivo'); const selected = ghost('feishu'); @@ -282,6 +291,18 @@ describe('placeHostCapabilityAtComposerStart', () => { expect(editor.state.selection.to).toBe(editor.state.doc.content.size - 1); }); + it('stores a namespaced Host-capability pluginId as the physical instance id', () => { + const selected = hostCapabilityGhost(); + selected.namespace = 'acme'; + selected.dir = '/tmp/_ns/acme/ios-simulator'; + const editor = editorWith('帮我调试登录流程'); + + expect(placeHostCapabilityAtComposerStart(editor, selected, [selected])).toBe(true); + expect(findHostCapabilityChipMatch(editor.state.doc)?.attrs.pluginId).toBe( + '_ns__acme__ios-simulator', + ); + }); + it('replaces an existing command and keeps a single invocation at message start', () => { const oldPlugin = ghost('mivo'); const selected = hostCapabilityGhost(); diff --git a/apps/desktop/src/renderer/__tests__/ghostSummonChip.test.tsx b/apps/desktop/src/renderer/__tests__/ghostSummonChip.test.tsx index b2a69752e85..73fa8babd6d 100644 --- a/apps/desktop/src/renderer/__tests__/ghostSummonChip.test.tsx +++ b/apps/desktop/src/renderer/__tests__/ghostSummonChip.test.tsx @@ -20,9 +20,16 @@ import i18n from '@/i18n'; import { GhostFulfillmentContext, GhostSummonCard } from '@/components/chat/GhostSummonCard'; import type { GhostDirectiveDisplay } from '@/cindy-brain/ghostCommand'; import type { HostCapabilityDirectiveDisplay } from '@/cindy-brain/hostCapabilityInvocation'; +import type { InstalledGhost } from '../../shared/ghost'; +import { collectGhostCallsByUserTurn } from '@/components/chat/MessageStream'; +import type { ChatMessage } from '@/lib/makerChatStore'; + +const { installedGhostsMock } = vi.hoisted(() => ({ + installedGhostsMock: vi.fn(() => [] as InstalledGhost[]), +})); vi.mock('@/cindy-brain/useInstalledGhosts', () => ({ - useInstalledGhosts: () => [], + useInstalledGhosts: () => installedGhostsMock(), })); const commandDirective: GhostDirectiveDisplay = { @@ -72,12 +79,167 @@ beforeEach(async () => { afterEach(() => { cleanup(); + installedGhostsMock.mockReturnValue([]); vi.useRealTimers(); // biome-ignore lint/performance/noDelete: 还原 jsdom 默认(无 matchMedia)。 delete (window as { matchMedia?: unknown }).matchMedia; }); describe('GhostSummonCard(chip 形态)', () => { + it('does not attach a newly installed root version to an ambiguous historical bare id', () => { + installedGhostsMock.mockReturnValue([ + { manifest: { id: 'helper', name: 'New Root', version: '99' }, + dir: '/tmp/helper', namespace: null, iconDataUrl: 'data:image/png;base64,AAAA' }, + ] as InstalledGhost[]); + const { container } = render( + , + ); + expect(screen.queryByText('v99')).toBeNull(); + expect(container.querySelector('img[src="data:image/png;base64,AAAA"]')).toBeNull(); + }); + + it('renders an organization semantic call instead of its same-id root neighbor', () => { + installedGhostsMock.mockReturnValue([ + { manifest: { id: 'helper', name: 'Root', version: '1' }, dir: '/tmp/helper', namespace: null }, + { manifest: { id: 'helper', name: 'Organization', version: '2' }, dir: '/tmp/_ns/acme/helper', namespace: 'acme' }, + ] as InstalledGhost[]); + const messages = [ + { clientId: 'm1', role: 'user', content: 'help' }, + { clientId: 'call', role: 'tool_use', toolName: 'mcp__cindy__ghost_call', toolInput: { ghost_id: 'helper', namespace: 'acme', tool: 'run' } }, + ] as ChatMessage[]; + const fulfilled = collectGhostCallsByUserTurn(messages); + render( + + + , + ); + expect(screen.getByText('Organization')).toBeTruthy(); + expect(screen.queryByText('Root')).toBeNull(); + }); + + it('resolves the logical organization after an in-place namespace stamp', () => { + installedGhostsMock.mockReturnValue([ + { manifest: { id: 'helper', name: 'Organization', version: '2' }, dir: '/tmp/helper', namespace: 'acme' }, + ] as InstalledGhost[]); + render( + + + , + ); + expect(screen.getByText('Organization')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { expanded: false })); + expect(screen.getByText('v2')).toBeTruthy(); + }); + + it('counts an explicit organization command after its in-place install relocates', () => { + installedGhostsMock.mockReturnValue([ + { manifest: { id: 'helper', name: 'Root', version: '1' }, dir: '/tmp/helper', namespace: null }, + { manifest: { id: 'helper', name: 'Organization', version: '2' }, dir: '/tmp/_ns/acme/helper', namespace: 'acme' }, + ] as InstalledGhost[]); + render( + + + , + ); + expect(screen.getByText('已调用')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { expanded: false })); + expect(screen.getByText('v2')).toBeTruthy(); + }); + + it('counts a legacy bare call when an in-place organization is the only matching install', () => { + installedGhostsMock.mockReturnValue([ + { manifest: { id: 'helper', name: 'Organization', version: '2' }, dir: '/tmp/helper', namespace: 'acme' }, + ] as InstalledGhost[]); + render( + + + , + ); + expect(screen.getByText('已调用')).toBeTruthy(); + }); + + it('does not claim a bare call for an organization when root also exists', () => { + installedGhostsMock.mockReturnValue([ + { manifest: { id: 'helper', name: 'Root', version: '1' }, dir: '/tmp/helper', namespace: null }, + { manifest: { id: 'helper', name: 'Organization', version: '2' }, dir: '/tmp/_ns/acme/helper', namespace: 'acme' }, + ] as InstalledGhost[]); + render( + + + , + ); + expect(screen.getByText('已完成')).toBeTruthy(); + expect(screen.queryByText('已调用')).toBeNull(); + }); + + it('does not mark a root command as called by an organization of the same id', () => { + installedGhostsMock.mockReturnValue([ + { manifest: { id: 'helper', name: 'Root', version: '1' }, dir: '/tmp/helper', namespace: null }, + { manifest: { id: 'helper', name: 'Organization', version: '2' }, dir: '/tmp/_ns/acme/helper', namespace: 'acme' }, + ] as InstalledGhost[]); + render( + + + , + ); + expect(screen.getByText('已完成')).toBeTruthy(); + expect(screen.queryByText('已调用')).toBeNull(); + }); + it('resolves an explicit root command without attributing an organization call to it', () => { + installedGhostsMock.mockReturnValue([ + { manifest: { id: 'helper', name: 'Root', version: '1' }, dir: '/tmp/helper', namespace: null }, + { manifest: { id: 'helper', name: 'Organization', version: '2' }, dir: '/tmp/_ns/acme/helper', namespace: 'acme' }, + ] as InstalledGhost[]); + const directive = { kind: 'command', command: 'draw/@root', name: 'Root', ghostId: 'helper', raw: '' } as const; + const { rerender } = render( + + + , + ); + expect(screen.getByText('已完成')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { expanded: false })); + expect(screen.getByText('v1')).toBeTruthy(); + rerender( + + + , + ); + expect(screen.getByText('已调用')).toBeTruthy(); + }); + it('resolves a namespaced $command instance id to the live install version', () => { + installedGhostsMock.mockReturnValue([ + { + manifest: { + schemaVersion: 2, + id: 'art', + name: 'Art', + version: '2.0.0', + kind: 'chip', + entry: 'main.js', + command: 'draw', + tools: [{ name: 'run', description: 'Run.' }], + }, + dir: '/tmp/_ns/acme/art', + namespace: 'acme', + enabled: true, + approval: { state: 'approved', revision: '00000000-0000-4000-8000-000000000001' }, + } as InstalledGhost, + ]); + render( + , + ); + fireEvent.click(screen.getByRole('button', { expanded: false })); + expect(screen.getByText('v2.0.0')).toBeTruthy(); + }); + it('renders the seal chip with plugin name and no overline/prompt slot', () => { render(); expect(screen.getByText('XD Feishu')).toBeTruthy(); diff --git a/apps/desktop/src/renderer/__tests__/pluginRecommendationRecovery.test.tsx b/apps/desktop/src/renderer/__tests__/pluginRecommendationRecovery.test.tsx index 37d68b989ac..cc14605ae6b 100644 --- a/apps/desktop/src/renderer/__tests__/pluginRecommendationRecovery.test.tsx +++ b/apps/desktop/src/renderer/__tests__/pluginRecommendationRecovery.test.tsx @@ -6,6 +6,7 @@ import { act, renderHook } from '@testing-library/react'; import ts from 'typescript'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { pluginSuggestionComposerText } from '../features/cc-agent/pluginHomeSuggestions'; +import { findInstalledGhostByInstanceId, installedGhostStoragePart } from '../../shared/pluginIdentity'; // Execute the production callbacks without mounting the unrelated full desktop shell. function compile(source: string, bindings: Record) { @@ -31,7 +32,7 @@ describe('plugin recommendation recovery', () => { expect(block).not.toContain('handleSend'); const suggestion = { id: 'one', pluginId: 'mail', prompt: 'Review my mail' }; const ghost = { manifest: { id: 'mail', name: 'Mail', command }, enabled: true }; - const fillComposerWithSuggestion = vi.fn((_text: string) => true); + const fillComposerWithSuggestion = vi.fn((text: string) => text.length > 0); const markUsed = vi.fn(async () => ({ ids: ['mail'] })); vi.stubGlobal('electronAPI', {}); Object.assign(window, { @@ -50,6 +51,7 @@ describe('plugin recommendation recovery', () => { filterGhostsForWorkdir: (ghosts: unknown[]) => ghosts, fillComposerWithSuggestion, pluginSuggestionComposerText, + installedGhostStoragePart, i18n: { language: 'en' }, t: () => 'Use plugin mail via ghost_info and ghost_call', isRemoteProjectDraft: false, @@ -76,6 +78,74 @@ describe('plugin recommendation recovery', () => { }, ); + it('fills the selected organization recommendation rather than the same-name root plugin', async () => { + const source = readFileSync(resolve(__dirname, '../features/cc-agent/NewMakerDraftRoute.tsx'), 'utf8'); + const block = source.slice(source.indexOf(' const runPluginSuggestion ='), source.indexOf(' const handlePluginSuggestion =')); + const suggestion = { id: 'plugin:_ns__acme__helper:task', pluginId: '_ns__acme__helper', prompt: 'Org task' }; + const root = { manifest: { id: 'helper', name: 'Root', command: 'root' }, dir: '/ghosts/helper', enabled: true }; + const org = { manifest: { id: 'helper', name: 'Org', command: 'org' }, dir: '/ghosts/_ns/acme/helper', namespace: 'acme', enabled: true }; + const fillComposerWithSuggestion = vi.fn(() => true); + const markUsed = vi.fn(async () => ({ ids: ['_ns__acme__helper'] })); + Object.assign(window, { electronAPI: { ghosts: { listSync: () => ({ ghosts: [root, org] }), markUsed } } }); + const run = compile(block + String.fromCharCode(10) + 'return runPluginSuggestion;', { + useCallback: (callback: unknown) => callback, + sendInFlightRef: { current: false }, + pluginSuggestionFlight: { current: false }, + pluginSuggestionMounted: { current: true }, + currentPluginSuggestionContext: { current: { generation: 1, dataOwnerId: 'owner', targetKey: 'local' } }, + readPluginRecommendationSnapshot: () => ({ ownerId: 'owner' }), + buildHomeTaskCatalog: () => [suggestion], + filterGhostsForWorkdir: (ghosts: unknown[]) => ghosts, + fillComposerWithSuggestion, + pluginSuggestionComposerText, + installedGhostStoragePart, + i18n: { language: 'en' }, + t: () => 'Use Org', + isRemoteProjectDraft: false, + isDeviceLinkDraft: false, + navigate: vi.fn(), + toast: { error: vi.fn() }, + }); + await run({ suggestion, ownerId: 'owner', targetKey: 'local', workingDir: '/project' }); + expect(fillComposerWithSuggestion).toHaveBeenCalledWith('$org/acme Org task'); + expect(markUsed).toHaveBeenCalledWith('_ns__acme__helper'); + }); + + it('retains project scope for the recommended organization alongside its root sibling', () => { + const source = readFileSync(resolve(__dirname, '../features/plugin/GhostPluginPage.tsx'), 'utf8'); + const block = source.slice(source.indexOf(' const [scopeDir, setScopeDir]'), source.indexOf(' const [recentGhostIds, setRecentGhostIds]')); + const workdirPrefsSync = vi.fn(() => ({ disabled: ['_ns__acme__helper'] })); + vi.stubGlobal('electronAPI', { ghosts: { workdirPrefsSync } }); + const useScope = compile('return function useScope() { ' + block + '\nreturn {scopeDir, effectiveEnabled}; }', { + useState, useRef, useEffect, useCallback, findInstalledGhostByInstanceId, + recommendation: { nonce: 'org', workingDir: '/project', suggestion: { pluginId: '_ns__acme__helper' } }, + ghosts: [ + { manifest: { id: 'helper' }, dir: '/ghosts/helper', namespace: null, enabled: true }, + { manifest: { id: 'helper' }, dir: '/ghosts/_ns/acme/helper', namespace: 'acme', enabled: true }, + ], + }); + const { result } = renderHook(() => useScope()); + expect(workdirPrefsSync).toHaveBeenCalledWith('/project'); + expect(result.current.scopeDir).toBe('/project'); + expect(result.current.effectiveEnabled('_ns__acme__helper', true)).toBe(false); + expect(result.current.effectiveEnabled('helper', true)).toBe(true); + }); + + it('names the organization instance in commandless suggestion previews and composer text', () => { + const root = { manifest: { id: 'helper', name: 'Root' }, dir: '/ghosts/helper', namespace: null }; + const org = { manifest: { id: 'helper', name: 'Org' }, dir: '/ghosts/_ns/acme/helper', namespace: 'acme' }; + const translate = (_key: string, options?: Record) => 'Use ' + options?.id; + expect(pluginSuggestionComposerText('Org task', org, translate)).toBe('Org task\n\nUse _ns__acme__helper'); + expect(pluginSuggestionComposerText('Root task', root, translate)).toBe('Root task\n\nUse helper'); + }); + + it('qualifies a selected root command when a namespaced sibling shares it', () => { + const root = { manifest: { id: 'helper', name: 'Root', command: 'draw' }, namespace: null }; + const org = { manifest: { id: 'helper', name: 'Org', command: 'draw' }, namespace: 'acme' }; + expect(pluginSuggestionComposerText('Root task', root, () => '', [root, org])).toBe('$draw/@root Root task'); + expect(pluginSuggestionComposerText('Org task', org, () => '', [root, org])).toBe('$draw/acme Org task'); + }); + it('loads project overrides on entry and clears them when choosing global scope', () => { const source = readFileSync( resolve(__dirname, '../features/plugin/GhostPluginPage.tsx'), @@ -94,6 +164,7 @@ describe('plugin recommendation recovery', () => { useRef, useEffect, useCallback, + findInstalledGhostByInstanceId, recommendation: { nonce: 'one', workingDir: '/project', suggestion: { pluginId: 'mail' } }, ghosts: [{ manifest: { id: 'mail' }, enabled: true }], }, diff --git a/apps/desktop/src/renderer/cindy-brain/GhostPanelBubbleLayer.tsx b/apps/desktop/src/renderer/cindy-brain/GhostPanelBubbleLayer.tsx index a8454754915..5f0f7243ecf 100644 --- a/apps/desktop/src/renderer/cindy-brain/GhostPanelBubbleLayer.tsx +++ b/apps/desktop/src/renderer/cindy-brain/GhostPanelBubbleLayer.tsx @@ -37,6 +37,7 @@ import { Ghost } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { GhostManifest } from '../../shared/ghost'; +import { installedGhostStoragePart } from '../../shared/pluginIdentity'; import { WINDOW_NO_DRAG_STYLE } from '../components/layout/windowDrag'; import { restoreGhostPanel } from '../lib/ghostPanelBubbleState'; import { useGhostPanelRestoreMode } from '../hooks/useGhostPanelRestoreMode'; @@ -233,6 +234,7 @@ function useBubbleDrag({ interface BubbleProps { manifest: GhostManifest; + instanceId: string; iconDataUrl: string | undefined; /** 渲染基准位(已按锚点排布 + clamp)。 */ pos: { x: number; y: number }; @@ -241,7 +243,7 @@ interface BubbleProps { } /** 展开出的子气泡:不可拖(位置由幽灵球锚定),点击恢复对应面板。 */ -function Bubble({ manifest, iconDataUrl, pos, registerEl }: BubbleProps): ReactNode { +function Bubble({ manifest, instanceId, iconDataUrl, pos, registerEl }: BubbleProps): ReactNode { const { t } = useTranslation(); const [imgBroken, setImgBroken] = useState(false); /** 点击后进入"缩没退场"态:播 .ghost-bubble-exit,计时器到点才 restore。 */ @@ -256,7 +258,7 @@ function Bubble({ manifest, iconDataUrl, pos, registerEl }: BubbleProps): ReactN // 展开的"过程感":幽灵先跳走、圆圈再渐隐(共 EXIT_MS),到点才真正恢复 // 面板(面板侧再接宽度展开,见 ghostPanels.tsx 的 ghost-panel-enter)。 setExiting(true); - exitTimerRef.current = window.setTimeout(() => restoreGhostPanel(manifest.id), EXIT_MS); + exitTimerRef.current = window.setTimeout(() => restoreGhostPanel(instanceId), EXIT_MS); }; const name = manifest.panel?.title ?? manifest.name; @@ -264,7 +266,7 @@ function Bubble({ manifest, iconDataUrl, pos, registerEl }: BubbleProps): ReactN