Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
fa83431
feat(desktop): hand off live terminals for private user input
testikun Sep 26, 2026
4256bfc
fix(desktop): clarify private terminal feedback and agent discovery
testikun Sep 26, 2026
c7f5473
merge: refresh terminal handoff on main
testikun Sep 28, 2026
6a8e6f3
style: format terminal handoff sources
testikun Sep 28, 2026
4adb5b1
test(runtime-host): refresh terminal handoff tool contract
testikun Sep 28, 2026
39601af
fix(runtime): fence private PTY writes on fd reuse
testikun Sep 28, 2026
b87ec54
Merge remote-tracking branch 'upstream/main' into codex/issue-5309-te…
testikun Sep 28, 2026
43f952b
test(runtime): register PTY fd reuse coverage
testikun Sep 28, 2026
1ced798
refactor(desktop): keep session event debt flat
testikun Sep 28, 2026
83e6972
fix: bind private terminal input to PTY lifetime and retain review su…
testikun Sep 28, 2026
77be37d
fix(desktop): simplify terminal completion and let users inspect priv…
testikun Sep 28, 2026
18400ac
Merge upstream/main and regenerate the Windows test inventory
testikun Sep 28, 2026
9df75fc
fix(desktop): simplify terminal handoff and remove manual sharing
testikun Sep 28, 2026
5017885
fix(desktop): simplify terminal handoff restoration and controls
testikun Sep 28, 2026
f6d1fe7
test(desktop): capture handoff details and follow-up turns
testikun Sep 28, 2026
5d90d87
docs: refresh terminal handoff PR interaction screenshots
testikun Sep 28, 2026
0ae24f1
Merge main and fix terminal handoff review races
testikun Oct 8, 2026
a08c6af
Merge upstream/main into codex/issue-5309-terminal-handoff
testikun Oct 13, 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
2 changes: 1 addition & 1 deletion apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -597,7 +597,7 @@
"@maka/ui": 1
},
"importSpecifiers": 7,
"nonTriviaTokens": 2681
"nonTriviaTokens": 2669
},
"src/renderer/app-shell-session-ui-state.ts": {
"importDeclarations": 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,41 @@ import { buildManagedArtifactPreviewTools } from '../managed-artifact-preview-to
import { browserOriginAdmission } from '../browser/browser-origin-admission.js';
import { buildRiveWorkflowTool } from '../rive-workflow-tool.js';
import { createDesktopNativeCapabilityProvider } from '../runtime-host-native-capabilities.js';
import { clearPrivateTerminalSurfaces, drainDesktopCaptures, setPrivateTerminalSurface } from '../private-terminal-surfaces.js';

function jsonSchema(schema: Record<string, unknown>): {
jsonSchema: Record<string, unknown>;
} {
return { jsonSchema: schema };
}

test('private terminal visibility fences new computer calls and waits for in-flight capture', async () => {
let finish!: () => void;
const barrier = new Promise<void>((resolve) => { finish = resolve; });
let calls = 0;
const provider = createDesktopNativeCapabilityProvider({
browserTools: [], resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {},
computerUseTools: computerTools(async () => { calls++; await barrier; return { text: 'captured before private input' }; }),
releaseDesktopInteractionSession() {},
});
try {
const active = call(provider, computerFrame());
setPrivateTerminalSurface(5309, 'private-card', true);
let drained = false;
const drain = drainDesktopCaptures().then(() => { drained = true; });
await Promise.resolve();
assert.equal(drained, false);
await assert.rejects(call(provider, computerFrame()), /private terminal/);
finish();
await active;
await drain;
assert.equal(calls, 1);
clearPrivateTerminalSurfaces(5309);
await call(provider, computerFrame());
assert.equal(calls, 2);
} finally { finish(); clearPrivateTerminalSurfaces(5309); }
});

test('Artifact preview is discoverable and admitted without a pre-existing browser origin', async () => {
let invoked = false;
const provider = createDesktopNativeCapabilityProvider({
Expand Down
152 changes: 152 additions & 0 deletions apps/desktop/src/main/__tests__/terminal-handoff-feedback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import { afterEach, test } from 'node:test';
import { act, createElement } from 'react';
import { LocaleProvider } from '@maka/ui';
import { terminalFeedback, isValidPrivateTerminalInput, TerminalHandoffPanel, createFakeWorkbarServices, WorkbarServicesProvider } from '../../renderer/features/workbar/testing.js';
import { installReactRenderer, cleanupFakeDom, FakeElement } from './fake-dom.js';
import { desktopSessionKey } from '../../shared/runtime-host-identity.js';

afterEach(cleanupFakeDom);

test('OpenSSH adapter recognizes a current retry but never infers success from output', () => {
const command = '/usr/bin/ssh -tt fixture@localhost';
assert.equal(terminalFeedback(command, "fixture@localhost's password: "), 'password');
const retry = "fixture@localhost's password: \nPermission denied, please try again.\nfixture@localhost's password: \n\n";
assert.equal(terminalFeedback(command, retry), 'authentication_retry');
assert.equal(terminalFeedback(command, `${retry}Verification code: `), undefined);
assert.equal(terminalFeedback(command, `${retry}AUTHENTICATED\n$ `), undefined);
assert.equal(terminalFeedback(command, 'The password was wrong'), undefined);
});

test('unknown programs, compound commands and unsupported prompts use raw private output', () => {
const retry = "Permission denied, please try again.\nfixture@localhost's password: ";
for (const command of ['my-ssh-wrapper host', 'sudo ssh host', 'echo ssh host', 'ssh host; other-command']) {
assert.equal(terminalFeedback(command, retry), undefined);
}
assert.equal(terminalFeedback('ssh host', '输入密码:'), undefined);
assert.equal(terminalFeedback('ssh host', 'Verification code incorrect'), undefined);
});

test('private input validation uses bytes and rejects embedded terminal controls before submission', () => {
assert.equal(isValidPrivateTerminalInput('密码 with spaces'), true);
assert.equal(isValidPrivateTerminalInput('a'.repeat(32 * 1024)), true);
for (const value of ['', 'a\nb', 'a\rb', 'a\tb', '\x1b[A', 'a\x7fb', '\ud800', '密'.repeat(11_000)]) {
assert.equal(isValidPrivateTerminalInput(value), false);
}
});

function descendants(element: FakeElement): FakeElement[] {
return [element, ...element.childNodes.flatMap((child) => child instanceof FakeElement ? descendants(child) : [])];
}

test('a disconnected private card explains recovery and cannot offer an enabled Resume', async () => {
const { root, container } = installReactRenderer();
const services = createFakeWorkbarServices({ terminal: { ...createFakeWorkbarServices().terminal, handoff: async () => { throw new Error('transport lost'); } } });
await act(async () => root.render(createElement(LocaleProvider, { locale: 'en', children:
createElement(WorkbarServicesProvider, { services }, createElement(TerminalHandoffPanel, {
sessionId: desktopSessionKey({ hostId: 'host-1', sessionId: 'session-1' }), active: true,
request: { requestId: 'request-1', ref: 'terminal-1', message: 'Authenticate', command: 'ssh host' },
})),
})));
assert.match(container.textContent, /Connection to this terminal was lost/);
assert.match(container.textContent, /Reconnect to original terminal/);
assert.equal(descendants(container).some((node) => node.tagName === 'BUTTON' && node.textContent === 'Let the agent continue'), false);
});

test('a closed card shows the process outcome without retaining a private input or success claim', async () => {
const { root, container } = installReactRenderer();
const services = createFakeWorkbarServices({ terminal: { ...createFakeWorkbarServices().terminal, handoff: async () => ({ status: 'closed', phase: 'closed', closure: 'exited', nextSequence: 2 }) } });
await act(async () => root.render(createElement(LocaleProvider, { locale: 'en', children:
createElement(WorkbarServicesProvider, { services }, createElement(TerminalHandoffPanel, {
sessionId: desktopSessionKey({ hostId: 'host-1', sessionId: 'session-1' }), active: true,
request: { requestId: 'request-1', ref: 'terminal-1', message: 'Authenticate', command: 'ssh host' },
})),
})));
assert.match(container.textContent, /original terminal process exited/);
assert.equal(descendants(container).some((node) => node.tagName === 'INPUT'), false);
assert.doesNotMatch(container.textContent, /Control returned to the agent/);
});

test('recovering an uncertain receipt keeps Submit and Resume disabled', async () => {
const { root, container } = installReactRenderer();
const operations: string[] = [];
const services = createFakeWorkbarServices({ terminal: { ...createFakeWorkbarServices().terminal,
handoff: async (operation) => {
operations.push(operation.action);
return { status: 'outcome_unknown', phase: 'human', nextSequence: 2, display: { sequence: 1, text: 'private', inputOpen: true } };
},
} });
await act(async () => root.render(createElement(LocaleProvider, { locale: 'en', children:
createElement(WorkbarServicesProvider, { services }, createElement(TerminalHandoffPanel, {
sessionId: desktopSessionKey({ hostId: 'host-1', sessionId: 'session-1' }), active: true,
request: { requestId: 'request-1', ref: 'terminal-1', message: 'Authenticate', command: 'sh' },
})),
})));
assert.match(container.textContent, /Delivery could not be confirmed/);
const buttons = descendants(container).filter((node) => node.tagName === 'BUTTON');
for (const label of ['Submit', 'Let the agent continue']) {
const button = buttons.find((node) => node.textContent === label);
assert.ok(button, label);
assert.notEqual(button.getAttribute('disabled'), null, label);
}
assert.equal(operations.includes('input'), false);
});

test('losing observation while human input is open clears private display and disables input', async () => {
const { root, container } = installReactRenderer();
const services = createFakeWorkbarServices({ terminal: { ...createFakeWorkbarServices().terminal,
handoff: async (operation) => {
if (operation.action === 'observe') throw new Error('disconnected');
return { status: 'ready', phase: 'human', nextSequence: 1, display: { sequence: 1, text: 'private-sentinel', inputOpen: true } };
},
} });
await act(async () => root.render(createElement(LocaleProvider, { locale: 'en', children:
createElement(WorkbarServicesProvider, { services }, createElement(TerminalHandoffPanel, {
sessionId: desktopSessionKey({ hostId: 'host-1', sessionId: 'session-1' }), active: true,
request: { requestId: 'request-1', ref: 'terminal-1', message: 'Authenticate', command: 'sh' },
})),
})));
assert.match(container.textContent, /Connection to this terminal was lost/);
assert.doesNotMatch(container.textContent, /private-sentinel/);
for (const node of descendants(container)) {
if (node.tagName === 'INPUT' || (node.tagName === 'BUTTON' && ['Submit', 'Let the agent continue'].includes(node.textContent))) {
assert.notEqual(node.getAttribute('disabled'), null);
}
}
});

test('even a ready-looking generic terminal requires explicit user confirmation before Resume', async () => {
const { root, container } = installReactRenderer();
const services = createFakeWorkbarServices({ terminal: { ...createFakeWorkbarServices().terminal,
handoff: async () => ({ status: 'ready', phase: 'human', nextSequence: 1, display: { sequence: 1, text: '$ ', inputOpen: true } }),
} });
await act(async () => root.render(createElement(LocaleProvider, { locale: 'en', children:
createElement(WorkbarServicesProvider, { services }, createElement(TerminalHandoffPanel, {
sessionId: desktopSessionKey({ hostId: 'host-1', sessionId: 'session-1' }), active: true,
request: { requestId: 'request-1', ref: 'terminal-1', message: 'Authenticate', command: 'sh' },
})),
})));
const button = descendants(container).find((node) => node.tagName === 'BUTTON' && node.textContent === 'Let the agent continue');
assert.ok(button);
assert.notEqual(button.getAttribute('disabled'), null);
assert.match(container.textContent, /I checked the terminal/);
});
11 changes: 7 additions & 4 deletions apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ function createBridgeRecorder(): {
}

describe('createDesktopWorkbarServices', () => {
it('recovers only live local desktop PTYs from the Host inventory and updates', async () => {
it('recovers live local desktop PTYs and handed-off model PTYs, excluding inherited resources', async () => {
const { bridge } = createBridgeRecorder();
const manual = {
sessionId: 's', ownership: { kind: 'local' },
Expand All @@ -89,23 +89,26 @@ describe('createDesktopWorkbarServices', () => {
} as ShellRunUpdate;
const updates = [
manual,
{ ...manual, sourceTurnId: 'agent-turn' },
{ ...manual, sourceTurnId: 'agent-turn', result: { ...manual.result, ref: 'agent' } },
{ ...manual, result: { ...manual.result, mode: 'pipes' } },
{ ...manual, result: { ...manual.result, status: 'completed' } },
{ ...manual, ownership: { kind: 'source_unavailable', sourceSessionId: 'source' } },
{ ...manual, sourceTurnId: 'agent-turn', result: { ...manual.result, ref: 'agent-handoff' } },
] as ShellRunUpdate[];
let listener: ((update: ShellRunUpdate) => void) | undefined;
bridge.shellRuns = {
...bridge.shellRuns,
recover: async () => ({ resources: updates, closes: [] }),
handoff: async (input) => ({ status: 'available', nextSequence: 1, phase: 'human',
...(input.action === 'lookup' && input.ref === 'agent-handoff' ? { request: { requestId: 'request', ref: input.ref, message: 'Authenticate', command: 'ssh fixture' } } : {}) }),
subscribeUpdates: (handler) => { listener = handler; return () => { listener = undefined; }; },
};
const services = createDesktopWorkbarServices(bridge);
assert.deepEqual(await services.terminal.recover('s'), { resources: [manual], closes: [] });
assert.deepEqual(await services.terminal.recover('s'), { resources: [manual, updates[5]], closes: [] });
const received: ShellRunUpdate[] = [];
const dispose = services.terminal.subscribeUpdates((update) => received.push(update));
for (const update of updates) listener?.(update);
assert.deepEqual(received, [manual, updates[3]]);
assert.deepEqual(received, [manual, updates[1], updates[3], updates[5]]);
dispose();
assert.equal(listener, undefined);
});
Expand Down
49 changes: 49 additions & 0 deletions apps/desktop/src/main/private-terminal-surfaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

// Contains identities only. Never retain terminal text or human input here.
const surfaces = new Map<number, Set<string>>();
const captures = new Set<Promise<unknown>>();

/** Register before yielding so mounting a private surface fences in-flight capture too. */
export function trackDesktopCapture<T>(task: Promise<T>): Promise<T> {
captures.add(task);
void task.then(() => captures.delete(task), () => captures.delete(task));
return task;
}

export async function drainDesktopCaptures(): Promise<void> {
await Promise.allSettled([...captures]);
}

export function setPrivateTerminalSurface(windowId: number, controllerId: string, visible: boolean): void {
const controllers = surfaces.get(windowId) ?? new Set<string>();
if (visible) controllers.add(controllerId);
else controllers.delete(controllerId);
if (controllers.size) surfaces.set(windowId, controllers);
else surfaces.delete(windowId);
}

export function clearPrivateTerminalSurfaces(windowId: number): void {
surfaces.delete(windowId);
}

export function hasPrivateTerminalSurface(): boolean {
return surfaces.size > 0;
}
4 changes: 4 additions & 0 deletions apps/desktop/src/main/runtime-host-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1641,6 +1641,10 @@ export class DesktopRuntimeHostClient {
return this.request("runtime.resource.controller.control", input);
}

controlTerminalHandoff(input: OperationInput<'runtime.resource.handoff'>): Promise<OperationOutput<'runtime.resource.handoff'>> {
return this.request('runtime.resource.handoff', input);
}

releaseRuntimeResourceController(
input: OperationInput<"runtime.resource.controller.release">,
): Promise<OperationOutput<"runtime.resource.controller.release">> {
Expand Down
7 changes: 6 additions & 1 deletion apps/desktop/src/main/runtime-host-native-capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { clientCapabilityEntityId } from "@maka/runtime-host/client-capability-e
import { toJSONSchema, z } from "zod";
import { withBrowserOriginAdmission } from './browser/browser-origin-admission.js';
import type { DesktopTargetScope } from '../shared/runtime-host-identity.js';
import { hasPrivateTerminalSurface, trackDesktopCapture } from './private-terminal-surfaces.js';

const CAPABILITY_VERSION = "0";
const BROWSER_OFFER_ID = "desktop_browser";
Expand Down Expand Up @@ -218,6 +219,10 @@ export function createDesktopNativeCapabilityProvider(
}
const binding = bindings.get(bindingKey(frame));
if (!binding) throw new Error("Desktop native capability is not offered");
const capturesDesktop = frame.offerId === COMPUTER_USE_OFFER_ID || binding.tool.categoryHint === 'computer_use';
if (hasPrivateTerminalSurface() && capturesDesktop) {
throw new Error('Desktop capture and computer use are unavailable while a private terminal is visible. Hide the private terminal before continuing.');
}

const invocation = new AbortController();
const task = invokeNativeTool(
Expand All @@ -238,7 +243,7 @@ export function createDesktopNativeCapabilityProvider(
settled,
});
void settled.finally(() => activeInvocations.delete(invocation));
return task;
return capturesDesktop ? trackDesktopCapture(task) : task;
},
callService: (frame, options) => {
if (closed)
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/runtime-host-session-observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,8 @@ export class RuntimeHostSessionObserver {
type: "user_question_answer_ack",
...base,
});
} else if (answered.outcome.kind === 'terminal_handoff_answer') {
this.#broadcast(answered.sessionId, { type: 'terminal_handoff_answer_ack', ...base });
} else if (answered.outcome.kind === "form_answer") {
this.#broadcast(answered.sessionId, {
type: "form_answer_ack",
Expand Down
Loading
Loading