Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
6603cd1
feat(plugin): add namespace delivery and identity foundation
kelv1nq1an Sep 21, 2026
25d85d0
feat(plugin): install and run plugins with namespace identity
kelv1nq1an Sep 22, 2026
5496eb6
fix(plugin): omit namespace on install when delivery does not provide…
kelv1nq1an Sep 22, 2026
97c4009
fix(plugin): look up remote authorization by physical instance id
kelv1nq1an Sep 23, 2026
696715a
fix(plugin): clear namespaced install journals by physical instance id
kelv1nq1an Sep 23, 2026
66a7a23
fix(plugin): resolve namespaced plugins by physical instance id
kelv1nq1an Sep 23, 2026
dce91aa
fix(plugin): unblock desktop typecheck for namespace identity
kelv1nq1an Sep 23, 2026
1ac955d
chore(repo): retrigger CI after unrelated Maestro timeout
kelv1nq1an Sep 23, 2026
13a32c8
fix(plugin): bind update-all to the namespaced installed instance
kelv1nq1an Sep 24, 2026
c2dbc4e
fix(plugin): delete namespaced plugin libraries by physical instance id
kelv1nq1an Sep 25, 2026
b5a5181
fix(plugin): pass install consent context in namespace drift test
kelv1nq1an Sep 27, 2026
d3511dd
fix(plugin): grant org broker by identity and target the matching ins…
kelv1nq1an Sep 28, 2026
f0c0fb4
fix(plugin): pass install-time namespace to broker and storage identity
kelv1nq1an Sep 28, 2026
3e25183
fix(plugin): keep pending namespace census during update backups
kelv1nq1an Sep 28, 2026
35a4532
fix(plugin): bind runtime identity to receipts and isolate relocated …
kelv1nq1an Sep 28, 2026
1078be7
fix(plugin): preserve physical identity during relocation
kelv1nq1an Sep 28, 2026
5c37052
fix(plugin): preserve namespace relocation ownership and privileges
kelv1nq1an Sep 28, 2026
d2d4840
fix(plugin): recover namespace migration across interrupted writes
kelv1nq1an Sep 28, 2026
4edb88c
fix(plugin): keep namespace migration pending on unreadable market le…
kelv1nq1an Sep 28, 2026
0c8533b
fix(plugin): fail closed on namespace migration and recovery errors
kelv1nq1an Sep 28, 2026
9b8f2a4
fix(plugin): serialize recovery and preserve legacy Forge connection …
kelv1nq1an Sep 28, 2026
dd59b00
fix(plugin): bind legacy market cards to the physical root
kelv1nq1an Sep 28, 2026
1b265ab
fix(plugin): bind Connection market grants to installed namespace
kelv1nq1an Sep 28, 2026
2ac79f8
fix(plugin): migrate workdir disables with namespaced installs
kelv1nq1an Sep 28, 2026
d2ec6ca
fix(plugin): isolate recommendations and panels on namespace relocation
kelv1nq1an Sep 28, 2026
7bd438c
fix(desktop): defer optional ghost history relocation safely
kelv1nq1an Sep 28, 2026
df75f3b
fix(plugin): preserve namespace identity across legacy market and rec…
kelv1nq1an Sep 29, 2026
ef2109c
fix(plugin): preserve namespace in removals and mobile activity
kelv1nq1an Sep 29, 2026
a079cba
fix(desktop): keep plugin card and setup events instance-scoped
kelv1nq1an Sep 29, 2026
75acc25
fix(desktop): scope ghost summon fulfillment to plugin namespace
kelv1nq1an Sep 29, 2026
09ce357
fix(plugin): close namespace identity and relocation gaps
kelv1nq1an Sep 29, 2026
7071be7
fix(plugin): guard namespace identities across install and authorization
kelv1nq1an Sep 29, 2026
95a6597
fix(plugin): keep legacy residents running offline until safe namespa…
kelv1nq1an Sep 29, 2026
bd1c8ca
fix(plugin): guard namespace library ownership and migration recovery
kelv1nq1an Sep 29, 2026
051b712
refactor(plugin): simplify namespace migration and market projections
kelv1nq1an Sep 29, 2026
970ff94
fix(plugin): recover namespace after downgraded receipt rewrite
kelv1nq1an Sep 29, 2026
1ecfc44
test(desktop): expect instance identity on local plugin install
kelv1nq1an Sep 29, 2026
0a8d3ff
fix(desktop): isolate namespaced plugin instances across client flows
kelv1nq1an Sep 29, 2026
933cf11
fix(desktop): preserve namespaced plugin identity across relocation a…
kelv1nq1an Sep 29, 2026
a7922bc
fix(desktop): avoid attributing pending plugins to market namespaces
kelv1nq1an Sep 30, 2026
489713c
fix(plugins): isolate namespace state across rollout stages
kelv1nq1an Sep 30, 2026
931a173
fix(plugins): bind instance state across namespace migration
kelv1nq1an Sep 30, 2026
45362ca
refactor(plugins): simplify namespace storage and tests
kelv1nq1an Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ describe('sidebarSettingsStore', () => {
});

afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(harness.root, { recursive: true, force: true });
});

Expand Down
189 changes: 187 additions & 2 deletions apps/desktop/src/main/__tests__/webview-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -52,6 +54,7 @@ import {
authorizeGhostWebviewAttach,
hardenLoginCaptchaSession,
installGhostGuestNavigationHandlers,
installGhostMediaHandoverSource,
installBrowserGuestHandlers,
installDeferredPopupRouter,
installLoginCaptchaGuestHandlers,
Expand Down Expand Up @@ -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',
Expand All @@ -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<string, string> = {
src: 'cindy-ghost://same-ghost/panel.html',
Expand Down Expand Up @@ -529,17 +567,123 @@ 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<typeof vi.fn>;
};
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();
Expand All @@ -549,6 +693,8 @@ describe('installGhostGuestNavigationHandlers(Ghost settingsHtml / panel 共用
'xd-sites',
() => ownerActive,
{ gesture, preview, external },
instanceId,
() => attachCurrent,
);
return {
guest,
Expand All @@ -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 <a> 的 will-navigate 被拦下并带真实 host/guest 交给外链处理', () => {
const harness = makeHarness();
const event = { preventDefault: vi.fn() };
Expand All @@ -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() };
Expand All @@ -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/']) {
Expand Down
13 changes: 11 additions & 2 deletions apps/desktop/src/main/bootstrap-electron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading