Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 16 additions & 0 deletions apps/desktop/src/main/__tests__/mobileClientPromptNote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,11 @@ describe('stripMainOnlySendOpts(直连路径消毒)', () => {
.toEqual({ messageUuid: 'u' });
});

it('剥掉客户端自报的 fromDeviceLinkClient', () => {
expect(stripMainOnlySendOpts({ messageUuid: 'u', fromDeviceLinkClient: true }))
.toEqual({ messageUuid: 'u' });
});

it('剥掉客户端伪造的 generation 与 turn 身份,但保留待 IPC 校验的 clear token', () => {
expect(
stripMainOnlySendOpts({
Expand Down Expand Up @@ -387,11 +392,22 @@ describe('排队 / 插入两条路径的接线(源码级守卫)', () => {
expect(register).toContain('isMobileControllerInvoke(),');
});

it('device-link provenance is stamped at both queue input boundaries', () => {
const stamps = register.match(/stampTrustedDeviceLinkQueuedOrigin\(/g) ?? [];
expect(stamps.length).toBe(2);
expect(register).toContain('deviceLinkInvoke,');
});

it('coordinator 在 drain 与 steer 两处都透传', () => {
const passes = coordinator.match(/fromMobileClient: true \} : \{\}\)/g) ?? [];
expect(passes.length).toBe(2);
});

it('coordinator drain carries device-link provenance into the send transaction', () => {
expect(coordinator).toContain('fromDeviceLinkClient: true } : {})');
expect(transaction).toContain('requestedSendOpts.fromDeviceLinkClient === true');
});

it('send 事务认 async context 与透传值两个来源', () => {
expect(transaction).toContain(
"deps.isMobileClientInvoke?.() === true || so.fromMobileClient === true",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,24 @@ describe('AgentInputCoordinator Orca priority queue transactions', () => {
origin: { kind: 'orca', senderLabel: 'Lead', displayText: text },
});

it('forwards main-stamped device-link provenance from enqueue to send', async () => {
const h = createHarness();
const sid = 'device-link-lead';
await h.coordinator.ensureQueueRestored(sid);

h.coordinator.enqueue(sid, makeItem('device-link-input', 'hello', {
fromDeviceLinkClient: true,
}));
await flush();

expect(h.sendToAgent).toHaveBeenCalledWith(
sid,
expect.anything(),
expect.anything(),
expect.objectContaining({ fromDeviceLinkClient: true }),
);
});

it('restores first, reserves at the head with a host stamp, deduplicates, and emits once', async () => {
const h = createHarness();
const sid = 'priority-worker';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { describe, expect, it, vi } from 'vitest';
import {
createMakerSendTransaction,
restoreTrustedDesktopQueuedOrigin,
stampTrustedDeviceLinkQueuedOrigin,
stampTrustedDesktopQueuedOrigin,
TRUSTED_DESKTOP_PI_COMMAND_SNAPSHOT,
TRUSTED_DESKTOP_QUEUE_ORIGIN,
Expand Down Expand Up @@ -85,6 +86,17 @@ function createDeps(overrides: Partial<MakerSendTransactionDeps> = {}) {
}

describe('maker SEND transaction', () => {
it('stamps device-link provenance at the enqueue boundary and rejects forged local values', () => {
const item = { clientId: 'input-1', text: 'hello' } as unknown as AgentInputQueuedMessage;
expect(stampTrustedDeviceLinkQueuedOrigin(item, true)).toMatchObject({
fromDeviceLinkClient: true,
});
expect(stampTrustedDeviceLinkQueuedOrigin({
...item,
fromDeviceLinkClient: true,
}, false)).not.toHaveProperty('fromDeviceLinkClient');
});

it('rejects invalid sessionId before touching transaction dependencies', async () => {
const { deps } = createDeps();
const transaction = createMakerSendTransaction(deps);
Expand Down Expand Up @@ -1466,6 +1478,160 @@ describe('maker SEND transaction', () => {
expect(newSession.send).toHaveBeenCalled();
});

it('drops a DB sdk id for a fresh remote Codex Lead whose old runtime accepted no turn', async () => {
const oldSession = createSession({
id: 'orca-session',
workDir: 'C:\\repo',
codexThreadMayHaveRollout: false,
});
const newSession = createSession({ id: 'orca-session', workDir: 'C:\\repo' });
const reconcileCreateOptsWithDb = vi.fn(async (_sessionId: string, co: MakerSessionCreateOpts) => {
co.resumeSessionId = 'fresh-thread-id';
});
const { deps } = createDeps({
getSession: vi.fn(() => oldSession),
isOrcaMcpHydrated: vi.fn(() => false),
synthesizeOrcaVendorOptionsFromDb: vi.fn(async () => true),
reconcileCreateOptsWithDb,
bootstrapSession: vi.fn(async (opts: MakerSessionCreateOpts) => ({
session: newSession,
didInjectOrcaInstructions: true,
didInjectProjectContext: false,
})),
});
const transaction = createMakerSendTransaction(deps);

await expect(transaction.sendToAgentAccepted('orca-session', 'hello', {
id: 'orca-session',
agentKind: 'codex',
workingDir: 'C:\\repo',
model: 'gpt-5.4',
remoteHostId: null,
orcaRole: 'lead',
}, { fromDeviceLinkClient: true })).resolves.toMatchObject({ accepted: true });

expect(deps.bootstrapSession).toHaveBeenCalledWith(expect.objectContaining({
resumeSessionId: undefined,
}));
expect(reconcileCreateOptsWithDb.mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(deps.closeSession).mock.invocationCallOrder[0]!,
);
expect(vi.mocked(deps.closeSession).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(deps.bootstrapSession).mock.invocationCallOrder[0]!,
);
expect(vi.mocked(deps.bootstrapSession).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(newSession.send).mock.invocationCallOrder[0]!,
);
expect(oldSession.send).not.toHaveBeenCalled();
expect(newSession.send).toHaveBeenCalled();
expect(deps.log.info).toHaveBeenCalledWith(
'send: fresh remote Codex Lead rehydrate starts a new thread',
{ evidence: 'no-provider-turn-accepted' },
);
});

type ResumePreservationScenario = {
fromDeviceLinkClient: boolean;
codexThreadMayHaveRollout?: boolean;
orcaRole?: 'lead' | 'worker';
remoteHostId: string | null;
};
const resumePreservationScenarios: Array<[string, ResumePreservationScenario]> = [
['device-link Lead with true evidence', { fromDeviceLinkClient: true, codexThreadMayHaveRollout: true, orcaRole: 'lead' as const, remoteHostId: null }],
['device-link Lead with unknown evidence', { fromDeviceLinkClient: true, orcaRole: 'lead' as const, remoteHostId: null }],
['device-link Worker', { fromDeviceLinkClient: true, codexThreadMayHaveRollout: false, orcaRole: 'worker' as const, remoteHostId: null }],
['device-link non-Orca session', { fromDeviceLinkClient: true, codexThreadMayHaveRollout: false, orcaRole: undefined, remoteHostId: null }],
['local ordinary Lead', { fromDeviceLinkClient: false, codexThreadMayHaveRollout: false, orcaRole: 'lead' as const, remoteHostId: null }],
['SSH historical Lead', { fromDeviceLinkClient: false, codexThreadMayHaveRollout: true, orcaRole: 'lead' as const, remoteHostId: 'ssh-host' }],
];
it.each(resumePreservationScenarios)('preserves the DB sdk id for %s', async (_name, scenario) => {
const oldSession = createSession({
id: 'orca-session',
workDir: 'C:\\repo',
...(scenario.remoteHostId ? { remoteHostId: scenario.remoteHostId } : {}),
...(scenario.codexThreadMayHaveRollout === undefined
? {}
: { codexThreadMayHaveRollout: scenario.codexThreadMayHaveRollout }),
});
const newSession = createSession({
id: 'orca-session',
workDir: 'C:\\repo',
...(scenario.remoteHostId ? { remoteHostId: scenario.remoteHostId } : {}),
});
const reconcileCreateOptsWithDb = vi.fn(async (_sessionId: string, co: MakerSessionCreateOpts) => {
co.resumeSessionId = 'historical-thread-id';
});
const { deps } = createDeps({
getSession: vi.fn(() => oldSession),
isOrcaMcpHydrated: vi.fn(() => false),
synthesizeOrcaVendorOptionsFromDb: vi.fn(async () => true),
reconcileCreateOptsWithDb,
bootstrapSession: vi.fn(async () => ({
session: newSession,
didInjectOrcaInstructions: true,
didInjectProjectContext: false,
})),
});
const transaction = createMakerSendTransaction(deps);
const createOpts: MakerSessionCreateOpts = {
id: 'orca-session',
agentKind: 'codex',
workingDir: 'C:\\repo',
model: 'gpt-5.4',
...(scenario.remoteHostId ? { remoteHostId: scenario.remoteHostId } : {}),
...(scenario.orcaRole ? { orcaRole: scenario.orcaRole } : {}),
};
const sendOpts = scenario.fromDeviceLinkClient ? { fromDeviceLinkClient: true } : undefined;

await expect(transaction.sendToAgentAccepted('orca-session', 'hello', createOpts, sendOpts))
.resolves.toMatchObject({ accepted: true });

expect(deps.bootstrapSession).toHaveBeenCalledWith(expect.objectContaining({
resumeSessionId: 'historical-thread-id',
}));
});

it('keeps the DB sdk id for a remote Codex Lead whose old runtime accepted a rollout', async () => {
const oldSession = createSession({
id: 'orca-session',
workDir: 'C:\\repo',
codexThreadMayHaveRollout: true,
});
const newSession = createSession({ id: 'orca-session', workDir: 'C:\\repo' });
const reconcileCreateOptsWithDb = vi.fn(async (_sessionId: string, co: MakerSessionCreateOpts) => {
co.resumeSessionId = 'historical-thread-id';
});
const { deps } = createDeps({
getSession: vi.fn(() => oldSession),
isOrcaMcpHydrated: vi.fn(() => false),
synthesizeOrcaVendorOptionsFromDb: vi.fn(async () => true),
reconcileCreateOptsWithDb,
bootstrapSession: vi.fn(async () => ({
session: newSession,
didInjectOrcaInstructions: true,
didInjectProjectContext: false,
})),
});
const transaction = createMakerSendTransaction(deps);

await expect(transaction.sendToAgentAccepted('orca-session', 'hello', {
id: 'orca-session',
agentKind: 'codex',
workingDir: 'C:\\repo',
model: 'gpt-5.4',
remoteHostId: null,
orcaRole: 'lead',
}, { fromDeviceLinkClient: true })).resolves.toMatchObject({ accepted: true });

expect(deps.bootstrapSession).toHaveBeenCalledWith(expect.objectContaining({
resumeSessionId: 'historical-thread-id',
}));
expect(deps.log.info).not.toHaveBeenCalledWith(
'send: fresh remote Codex Lead rehydrate starts a new thread',
expect.anything(),
);
});

it('fails rehydrate without closing the old runtime when DB reconciliation throws (#2882)', async () => {
const oldSession = createSession({ id: 'orca-session', workDir: 'C:\\repo' });
const { deps } = createDeps({
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/main/maker-ipc/agent-input-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ export interface AgentInputSendOpts {
* 最终 wire 消息。**由 main 构造,不是 wire 输入。**
*/
fromMobileClient?: boolean;
/** Queue provenance stamped by the controlled desktop at device-link input IPC entry. */
fromDeviceLinkClient?: boolean;
/** Main-owned clear token captured when this input became active. */
expectedClearBoundaryMs?: number | null;
/** Main-owned input generation captured before async preparation. */
Expand Down Expand Up @@ -3759,6 +3761,7 @@ export class AgentInputCoordinator {
private toProjectedItem(item: AgentInputQueuedMessage): AgentInputQueuedMessage {
const projected = { ...item };
delete projected.hostAcceptedAtMs;
delete projected.fromDeviceLinkClient;
delete projected.trustedSessionReferenceContexts;
delete projected.sessionReferencesRequireTrustedSnapshot;
delete (projected as Record<string, unknown>)[TRUSTED_DESKTOP_PI_COMMAND_SNAPSHOT];
Expand Down Expand Up @@ -4325,6 +4328,7 @@ export class AgentInputCoordinator {
...(head.origin?.kind === 'scheduler' ? { origin: head.origin } : {}),
// 手机来源透传到 send 事务:drain 已脱离入队时的 async context。
...(head.fromMobileClient ? { fromMobileClient: true } : {}),
...(head.fromDeviceLinkClient ? { fromDeviceLinkClient: true } : {}),
persistUserMessage: {
clientId: head.clientId,
content: head.persistedContent,
Expand Down
52 changes: 50 additions & 2 deletions apps/desktop/src/main/maker-ipc/makerSendTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,21 @@ export function stampTrustedDesktopQueuedOrigin(
} as unknown as AgentInputQueuedMessage;
}

/**
* Stamp device-link provenance at the trusted input IPC boundary. The queue
* drains after that AsyncLocalStorage context has ended, so the marker must
* travel with the main-owned item into the send transaction.
*/
export function stampTrustedDeviceLinkQueuedOrigin(
item: AgentInputQueuedMessage,
deviceLinkInvoke: boolean,
): AgentInputQueuedMessage {
const stamped = { ...item };
if (deviceLinkInvoke) stamped.fromDeviceLinkClient = true;
else delete stamped.fromDeviceLinkClient;
return stamped;
}

export function restoreTrustedDesktopQueuedOrigin(item: AgentInputQueuedMessage): AgentInputQueuedMessage {
const queued = item as QueuedMessageWithDesktopAuthorization;
const receipt = queued[TRUSTED_DESKTOP_PI_COMMAND_SNAPSHOT];
Expand Down Expand Up @@ -206,6 +221,8 @@ type MakerSendOptions = {
* 入队时的 async context 早已结束,只靠 isMobileClientInvoke() 实际读不到来源。
*/
fromMobileClient?: boolean;
/** Coordinator-transmitted provenance for device-link input.enqueue. */
fromDeviceLinkClient?: boolean;
persistUserMessage?: {
clientId?: unknown;
content?: unknown;
Expand Down Expand Up @@ -266,6 +283,8 @@ export interface MakerSendTransactionSession {
remoteHostId: string | null;
/** Error sessions stay registered while their underlying handle cleanup is retried. */
getStatus?(): 'active' | 'aborting' | 'closed' | 'error';
/** Codex host-owned evidence: a provider turn crossed acceptance on this runtime. */
codexThreadMayHaveRollout?: boolean;
isTurnRunning(): boolean;
send(message: UserMessage | string, opts?: SessionSendOptions): Promise<SessionSendResult>;
}
Expand Down Expand Up @@ -626,6 +645,7 @@ export function createMakerSendTransaction(deps: MakerSendTransactionDeps): Make
async function rehydrateActiveOrcaSession(
sessionId: string,
createOpts: CreateOpts,
fromDeviceLinkClient: boolean,
): Promise<ResolveSessionResult> {
const okRehydrate = await ensureWorkDirWithDbFallback(sessionId, createOpts);
if (!okRehydrate) {
Expand All @@ -647,6 +667,24 @@ export function createMakerSendTransaction(deps: MakerSendTransactionDeps): Make
// 中途 start_team 后丢失全部对话历史)。DB 读失败时 reconcile 抛错 → 落入下方
// REHYDRATE_FAILED,此时尚未 closeSession,旧 runtime 不受损。
await deps.reconcileCreateOptsWithDb?.(sessionId, createOpts);
// A newly created device-link Codex Lead has a real sdk_session_id as soon as
// thread/start returns, but that id is not resumable until a provider turn
// is accepted. The live Session is the only trustworthy local evidence at
// this boundary: generation 0 means no turn crossed provider acceptance.
// Keep the historical DB resume path for non-Orca sessions, workers, and
// already-used Leads.
if (
fromDeviceLinkClient &&
createOpts.agentKind === 'codex' &&
createOpts.orcaRole === 'lead' &&
createOpts.resumeSessionId &&
oldSessionCodexThreadMayHaveRollout(deps.getSession(sessionId)) === false
) {
createOpts.resumeSessionId = undefined;
deps.log.info('send: fresh remote Codex Lead rehydrate starts a new thread', {
evidence: 'no-provider-turn-accepted',
});
}
const session = await deps.withRehydrateCloseSuppressed(sessionId, async () => {
await deps.closeSession(sessionId);
// close 后重新 bootstrap,避免旧 SDK handle 缺 Orca MCP vendorOptions。
Expand Down Expand Up @@ -705,6 +743,12 @@ export function createMakerSendTransaction(deps: MakerSendTransactionDeps): Make
}
}

function oldSessionCodexThreadMayHaveRollout(
session: MakerSendTransactionSession | null | undefined,
): boolean | undefined {
return session?.codexThreadMayHaveRollout;
}

async function lazyCreateSession(
sessionId: string,
createOpts: CreateOpts,
Expand Down Expand Up @@ -788,6 +832,7 @@ export function createMakerSendTransaction(deps: MakerSendTransactionDeps): Make
sendOpts,
): Promise<DesktopMakerSendResult> {
if (typeof sessionId !== 'string') throwIpcError('INVALID_PARAMS', 'sessionId required');
const requestedSendOpts = (sendOpts ?? {}) as MakerSendOptions;
// session-agent-switch:pending 切换在发送时刻生效(用户语义:「消息真正发出
// 去时才切」)。必须在 getSession 之前——apply 会 close 旧引擎的 live session,
// 让下方走 lazy-create 按 DB 新值 spawn 新引擎。
Expand Down Expand Up @@ -835,7 +880,11 @@ export function createMakerSendTransaction(deps: MakerSendTransactionDeps): Make
sessionId,
});
} else {
const rehydrated = await rehydrateActiveOrcaSession(sessionId, co);
const rehydrated = await rehydrateActiveOrcaSession(
sessionId,
co,
requestedSendOpts.fromDeviceLinkClient === true,
);
if (rehydrated.kind === 'failure') return rehydrated.result;
sess = rehydrated.session;
}
Expand All @@ -856,7 +905,6 @@ export function createMakerSendTransaction(deps: MakerSendTransactionDeps): Make
if (sess.isTurnRunning()) {
throwIpcError('SESSION_RUNNING', `Session ${sessionId} is already running a turn`);
}
const requestedSendOpts = (sendOpts ?? {}) as MakerSendOptions;
if (
requestedSendOpts.ackInterruptedTurnOnDispatch !== undefined &&
typeof requestedSendOpts.ackInterruptedTurnOnDispatch !== 'boolean'
Expand Down
Loading