diff --git a/.github/workflows/windows-recovery.yml b/.github/workflows/windows-recovery.yml index ce46dd16036..875346adf0b 100644 --- a/.github/workflows/windows-recovery.yml +++ b/.github/workflows/windows-recovery.yml @@ -53,14 +53,14 @@ on: pull_request: branches: [main] paths: - - 'package-lock.json' - - 'patches/**' - - 'scripts/apply-dependency-patches.mjs' - - 'scripts/install-electron-with-retry.mjs' - - 'scripts/run-electron-installer.cjs' - - 'scripts/clean-build.mjs' - - 'scripts/clean-paths.mjs' - - 'scripts/windows-runtime-host-local-ipc-trust.ps1' + - "package-lock.json" + - "patches/**" + - "scripts/apply-dependency-patches.mjs" + - "scripts/install-electron-with-retry.mjs" + - "scripts/run-electron-installer.cjs" + - "scripts/clean-build.mjs" + - "scripts/clean-paths.mjs" + - "scripts/windows-runtime-host-local-ipc-trust.ps1" # Every file in the import closure of the suites below that branches on # `process.platform === 'win32'`. Generated, not curated: # `scripts/windows-package-source-closure.test.mjs` recomputes this set @@ -92,6 +92,7 @@ on: - 'packages/runtime-host/src/protocol/skill-catalog.ts' - 'packages/runtime-host/src/server/access-credential-store.ts' - 'packages/runtime-host/src/server/host-resource-probe.ts' + - 'packages/runtime-host/src/server/runtime-resource-coordinator.ts' - 'packages/runtime-host/src/server/skill-catalog-repository.ts' - 'packages/runtime-host/src/server/skill-catalog-transaction.ts' - 'packages/runtime/src/__tests__/owned-child-process.test.ts' @@ -141,7 +142,7 @@ on: branches: [main] schedule: # Offset from windows-sandbox-w0 so the Windows lanes do not overlap. - - cron: '17 8 * * *' + - cron: "17 8 * * *" workflow_dispatch: # Pull request pushes supersede each other, keyed on the pull request number diff --git a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts index 6af1ddbf37d..f8ba8a72515 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts @@ -35,6 +35,7 @@ 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): { jsonSchema: Record; @@ -42,6 +43,33 @@ function jsonSchema(schema: Record): { 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((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({ diff --git a/apps/desktop/src/main/__tests__/runtime-host-shell-runs-private-surface.test.ts b/apps/desktop/src/main/__tests__/runtime-host-shell-runs-private-surface.test.ts new file mode 100644 index 00000000000..0d7f7f1b64a --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-shell-runs-private-surface.test.ts @@ -0,0 +1,105 @@ +/* + * 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 { test } from "node:test"; +import type { IpcHandler } from "../ipc-reconnect-policy.js"; +import { + clearPrivateTerminalSurfaces, + hasPrivateTerminalSurface, +} from "../private-terminal-surfaces.js"; +import { registerRuntimeHostShellRunsIpc } from "../runtime-host-shell-runs-ipc-main.js"; + +const identity = { + action: "ready" as const, + sessionId: "session-1", + requestId: "request-1", + controllerId: "controller-1", +}; + +function harness(controlTerminalHandoff: (input: unknown) => Promise) { + const handlers = new Map(); + const registration = registerRuntimeHostShellRunsIpc( + { + client: { controlTerminalHandoff } as never, + terminalCloses: {} as never, + sessionObserver: { + async observe() {}, + async unobserve() {}, + }, + }, + { + handle(channel, handler) { + handlers.set(channel, handler); + }, + }, + ); + const handler = handlers.get("shell-runs:handoff"); + assert.ok(handler); + const sender = { + id: 5309, + once() {}, + on() {}, + }; + return { + invoke: (input: unknown) => handler({ sender } as never, input), + close: () => registration.close(), + }; +} + +test("ready rolls back the private-surface fence when Host admission fails", async () => { + clearPrivateTerminalSurfaces(5309); + const fixture = harness(async () => { + throw new Error("controller conflict"); + }); + try { + await assert.rejects(fixture.invoke(identity), /controller conflict/); + assert.equal(hasPrivateTerminalSurface(), false); + } finally { + await fixture.close(); + clearPrivateTerminalSurfaces(5309); + } +}); + +test("ready retains the fence for human and resumed private displays", async () => { + clearPrivateTerminalSurfaces(5309); + const replies = [ + { status: "closed", phase: "closed", nextSequence: 1, closure: "exited" }, + { status: "ready", phase: "human", nextSequence: 1 }, + { status: "observed", phase: "human", nextSequence: 1 }, + { status: "ready", phase: "resumed", nextSequence: 1 }, + { status: "observed", phase: "resumed", nextSequence: 1 }, + ]; + const fixture = harness(async () => replies.shift()); + try { + await fixture.invoke(identity); + assert.equal(hasPrivateTerminalSurface(), false); + await fixture.invoke(identity); + assert.equal(hasPrivateTerminalSurface(), true); + await fixture.invoke({ ...identity, action: "release" }); + assert.equal(hasPrivateTerminalSurface(), false); + await fixture.invoke(identity); + assert.equal(hasPrivateTerminalSurface(), true); + await fixture.invoke({ ...identity, action: "release" }); + assert.equal(hasPrivateTerminalSurface(), false); + } finally { + await fixture.close(); + clearPrivateTerminalSurfaces(5309); + } +}); diff --git a/apps/desktop/src/main/__tests__/terminal-handoff-feedback.test.ts b/apps/desktop/src/main/__tests__/terminal-handoff-feedback.test.ts new file mode 100644 index 00000000000..6a5964e3dfa --- /dev/null +++ b/apps/desktop/src/main/__tests__/terminal-handoff-feedback.test.ts @@ -0,0 +1,408 @@ +/* + * 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 { registerHooks } from 'node:module'; +import { afterEach, test } from 'node:test'; +import { act, createElement } from 'react'; +import { deferred } from '@maka/core/test-only/async-primitives'; +import type { RuntimeResourceHandoffResult } from '@maka/runtime-host/protocol'; +import { LocaleProvider } from '@maka/ui'; +import { terminalFeedback, isValidPrivateTerminalInput, TerminalHandoffPanel, loadSessionTerminalPanelForTest, createFakeWorkbarServices, WorkbarServicesProvider } from '../../renderer/features/workbar/testing.js'; +import type { SessionEvent } from '@maka/core/events'; +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('restoration waits for Workbar registration and authoritative phase without flashing an input card', async () => { + const { root, container } = installReactRenderer(); + const surface = deferred(); + const ready = deferred(); + const operations: string[] = []; + const services = createFakeWorkbarServices({ terminal: { ...createFakeWorkbarServices().terminal, + handoff: async (operation) => { operations.push(operation.action); return ready.promise; }, + } }); + 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, + prepareHandoff: () => surface.promise, + request: { requestId: 'request-1', ref: 'terminal-1', message: 'Authenticate', command: 'sh' }, + })), + }))); + const assertLoading = () => { + assert.equal(descendants(container).some((node) => ['HEADER', 'INPUT', 'BUTTON', 'DETAILS'].includes(node.tagName)), false); + assert.ok(descendants(container).some((node) => node.getAttribute('aria-busy') === 'true')); + }; + assertLoading(); + assert.deepEqual(operations, []); + await act(async () => surface.resolve()); + assert.deepEqual(operations, ['ready']); + assertLoading(); + await act(async () => ready.resolve({ status: 'observed', phase: 'resumed', nextSequence: 2, display: { text: 'restored-result' } })); + assert.match(container.textContent, /restored-result/); + assert.equal(descendants(container).some((node) => node.getAttribute('data-testid') === 'terminal-handoff'), false); + await act(async () => root.unmount()); +}); + +test('leaving during Workbar registration never acquires a hidden terminal controller', async () => { + const { root } = installReactRenderer(); + const surface = deferred(); + const operations: string[] = []; + const services = createFakeWorkbarServices({ terminal: { ...createFakeWorkbarServices().terminal, + handoff: async (operation) => { operations.push(operation.action); return { status: 'ready', phase: 'human', nextSequence: 1 }; }, + } }); + 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, + prepareHandoff: () => surface.promise, + request: { requestId: 'request-1', ref: 'terminal-1', message: 'Authenticate', command: 'sh' }, + })), + }))); + await act(async () => root.unmount()); + await act(async () => surface.resolve()); + assert.deepEqual(operations, ['release']); +}); + +test('failed Workbar registration offers reconnect and can recover without submitting input', async () => { + const { root, container } = installReactRenderer(); + let registrations = 0; + const operations: string[] = []; + const services = createFakeWorkbarServices({ terminal: { ...createFakeWorkbarServices().terminal, + handoff: async (operation) => { operations.push(operation.action); return { status: 'ready', phase: 'human', nextSequence: 1 }; }, + } }); + 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, + prepareHandoff: async () => { if (++registrations === 1) throw new Error('disconnected'); }, + request: { requestId: 'request-1', ref: 'terminal-1', message: 'Authenticate', command: 'sh' }, + })), + }))); + assert.match(container.textContent, /Connection lost/); + assert.equal(operations.includes('ready'), false); + const reconnect = descendants(container).find((node) => node.tagName === 'BUTTON' && node.textContent === 'Reconnect to original terminal')!; + const key = Object.keys(reconnect).find((name) => name.startsWith('__reactProps$'))!; + await act(async () => (reconnect as unknown as Record)[key]!.onClick({ preventDefault() {} })); + assert.equal(registrations, 2); + assert.equal(operations.includes('ready'), true); + assert.equal(operations.includes('input'), false); + assert.doesNotMatch(container.textContent, /Connection lost/); + await act(async () => root.unmount()); +}); + +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 lost/); + assert.match(container.textContent, /Reconnect to original terminal/); + assert.equal(descendants(container).some((node) => node.tagName === 'BUTTON' && node.textContent === 'Done, continue task'), 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: { text: "Permission denied, please try again.\nuser@host's password: " } }; + }, + } }); + 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, /Delivery is uncertain/); + assert.doesNotMatch(container.textContent, /SSH rejected authentication|The agent is waiting/); + const buttons = descendants(container).filter((node) => node.tagName === 'BUTTON'); + for (const label of ['Submit', 'Done, continue task']) { + 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: { text: 'private-sentinel' } }; + }, + } }); + 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 lost/); + assert.doesNotMatch(container.textContent, /private-sentinel/); + for (const node of descendants(container)) { + if (node.tagName === 'INPUT' || (node.tagName === 'BUTTON' && ['Submit', 'Done, continue task'].includes(node.textContent))) { + assert.notEqual(node.getAttribute('disabled'), null); + } + } +}); + +test('reconnection cannot clear an uncertain local submission or replay it', async () => { + const { root, container } = installReactRenderer(); + let inputs = 0; + const base = createFakeWorkbarServices(); + const services = createFakeWorkbarServices({ terminal: { ...base.terminal, + handoff: async (operation) => { + if (operation.action === 'input') { inputs++; throw new Error('response lost'); } + return { status: 'observed', phase: 'human', nextSequence: 2, display: { text: '$ ' } }; + }, + } }); + 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 click = async (label: string) => { + const button = descendants(container).find((node) => node.tagName === 'BUTTON' && node.textContent === label)!; + assert.ok(button, label); + const key = Object.keys(button).find((name) => name.startsWith('__reactProps$'))!; + await act(async () => (button as unknown as Record)[key]!.onClick({ preventDefault() {} })); + }; + const field = descendants(container).find((node) => node.tagName === 'INPUT')!; + const fieldKey = Object.keys(field).find((name) => name.startsWith('__reactProps$'))!; + await act(async () => { + (field as FakeElement & { value: string }).value = 'private-draft'; + (field as unknown as Record)[fieldKey]!.onChange({ target: { value: 'private-draft' } }); + }); + await click('Submit'); + await click('Reconnect to original terminal'); + assert.equal(inputs, 1); + assert.match(container.textContent, /Delivery is uncertain/); + assert.doesNotMatch(container.textContent, /Connection lost|Submitted|The agent is waiting/); + for (const label of ['Submit', 'Done, continue task']) { + const button = descendants(container).find((node) => node.tagName === 'BUTTON' && node.textContent === label)!; + assert.notEqual(button.getAttribute('disabled'), null, label); + } +}); + +test('one explicit completion click resumes the original handoff; prompt rendering never resumes it', async () => { + const { root, container } = installReactRenderer(); + const answers: unknown[] = []; + const services = createFakeWorkbarServices({ terminal: { ...createFakeWorkbarServices().terminal, + handoff: async () => ({ status: 'ready', phase: 'human', nextSequence: 1, display: { text: '$ ' } }), + answerHandoff: async (answer) => { answers.push(answer); }, + } }); + 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 === 'Done, continue task'); + assert.ok(button); + assert.equal(button.getAttribute('disabled'), null); + assert.deepEqual(answers, []); + assert.doesNotMatch(container.textContent, /I checked the terminal/); + const details = descendants(container).find((node) => node.tagName === 'DETAILS'); + assert.ok(details); + assert.equal(details.getAttribute('open'), null); + assert.match(details.textContent, /host-1/); + assert.match(details.textContent, /terminal-1/); + const header = descendants(container).find((node) => node.tagName === 'HEADER'); + assert.ok(header); + assert.match(header.textContent, /sh/); + assert.doesNotMatch(header.textContent, /host-1|terminal-1/); + const propsKey = Object.keys(button).find((key) => key.startsWith('__reactProps$')); + assert.ok(propsKey); + const props = (button as unknown as Record)[propsKey]!; + await act(async () => props.onClick()); + assert.equal(answers.length, 1); + assert.deepEqual(answers[0], { + sessionId: desktopSessionKey({ hostId: 'host-1', sessionId: 'session-1' }), + requestId: 'request-1', controllerId: (answers[0] as { controllerId: string }).controllerId, action: 'resume', + }); +}); + +test('revealing a private draft never sends it and submission or hiding resets visibility', async () => { + const { root, container } = installReactRenderer(); + const submitted: string[] = []; + const services = createFakeWorkbarServices({ terminal: { ...createFakeWorkbarServices().terminal, + handoff: async (operation) => { + if (operation.action === 'input') submitted.push(operation.input); + return { status: operation.action === 'input' ? 'written' : 'observed', phase: 'human', nextSequence: 1, display: { text: '$ ' } }; + }, + } }); + const render = async (active: boolean) => act(async () => root.render(createElement(LocaleProvider, { locale: 'en', children: + createElement(WorkbarServicesProvider, { services }, createElement(TerminalHandoffPanel, { + sessionId: desktopSessionKey({ hostId: 'host-1', sessionId: 'session-1' }), active, + request: { requestId: 'request-1', ref: 'terminal-1', message: 'Authenticate', command: 'sh' }, + })), + }))); + const propsOf = (node: FakeElement) => { + const key = Object.keys(node).find((candidate) => candidate.startsWith('__reactProps$')); + assert.ok(key); + return (node as unknown as Record)[key]!; + }; + const field = () => descendants(container).find((node) => node.tagName === 'INPUT')!; + const click = async (label: string) => { + const button = descendants(container).find((node) => node.tagName === 'BUTTON' && (node.textContent === label || node.getAttribute('aria-label') === label)); + assert.ok(button, label); + await act(async () => propsOf(button).onClick()); + }; + const edit = async () => act(async () => { + (field() as FakeElement & { value: string }).value = 'private-draft'; + propsOf(field()).onChange({ target: { value: 'private-draft' } }); + }); + await render(true); + assert.equal(propsOf(field()).type, 'password'); + await edit(); + const resume = descendants(container).find((node) => node.tagName === 'BUTTON' && node.textContent === 'Done, continue task')!; + assert.notEqual(resume.getAttribute('disabled'), null, 'a draft still blocks completion'); + await click('Show input'); + assert.equal(propsOf(field()).type, 'text'); + assert.deepEqual(submitted, []); + assert.ok(descendants(container).some((node) => node.getAttribute('data-maka-assistant-exclude') !== null)); + await click('Submit'); + assert.deepEqual(submitted, ['private-draft']); + assert.equal(propsOf(field()).type, 'password'); + assert.equal(propsOf(field()).value, ''); + await edit(); + await click('Show input'); + await render(false); + await render(true); + assert.equal(propsOf(field()).type, 'password'); + assert.equal(propsOf(field()).value, ''); +}); + +test('an existing terminal tab refreshes a new handoff request from its canonical event', async () => { + const { root, container } = installReactRenderer(); + // The handoff path never instantiates xterm. Stub its browser-only UMD imports + // while rendering the real wrapper/card and exercising their subscriptions. + const browserOnly = new Map([['@xterm/xterm', 'Terminal'], ['@xterm/addon-fit', 'FitAddon'], ['@xterm/addon-web-links', 'WebLinksAddon']]); + const hooks = registerHooks({ resolve(specifier, context, next) { + if (specifier === './browser-storage' && context.parentURL?.endsWith('/theme.js')) return next('./browser-storage.js', context); + const name = browserOnly.get(specifier); + return name ? { url: `data:text/javascript,export class ${name} {}`, shortCircuit: true } : next(specifier, context); + } }); + let SessionTerminalPanel; + try { ({ SessionTerminalPanel } = await loadSessionTerminalPanelForTest()); } + finally { hooks.deregister(); } + let receive: ((event: SessionEvent) => void) | undefined; + let requestId = 'first-request'; + const base = createFakeWorkbarServices(); + const services = createFakeWorkbarServices({ + review: { ...base.review, subscribeSessionEvents: (_id, handler) => { receive = handler; return () => { receive = undefined; }; } }, + terminal: { ...base.terminal, handoff: async () => ({ status: 'available', phase: 'waiting', nextSequence: 1, + request: { requestId, ref: 'terminal-1', command: 'sh', message: requestId } }) }, + }); + await act(async () => root.render(createElement(LocaleProvider, { locale: 'en', children: + createElement(WorkbarServicesProvider, { services }, createElement(SessionTerminalPanel, { + sessionId: desktopSessionKey({ hostId: 'host-1', sessionId: 'session-1' }), active: true, terminalRef: 'terminal-1', + })), + }))); + assert.match(container.textContent, /first-request/); + requestId = 'second-request'; + assert.ok(receive); + await act(async () => receive?.({ type: 'terminal_handoff_request', id: 'event-2', turnId: 'turn-1', ts: 2, + requestId, toolUseId: 'tool-2', ref: 'terminal-1', message: requestId })); + assert.match(container.textContent, /second-request/); + assert.doesNotMatch(container.textContent, /first-request/); +}); + +test('completion leaves only the private terminal display and restores it on return', async () => { + const { root, container } = installReactRenderer(); + const operations: string[] = []; + const services = createFakeWorkbarServices({ terminal: { ...createFakeWorkbarServices().terminal, + handoff: async (operation) => { + operations.push(operation.action); + return { status: 'observed', phase: 'resumed', nextSequence: 2, + ...(operation.action === 'observe' ? { display: { text: 'private-result' } } : {}) }; + }, + } }); + const render = async (active: boolean) => act(async () => root.render(createElement(LocaleProvider, { locale: 'en', children: + createElement(WorkbarServicesProvider, { services }, createElement(TerminalHandoffPanel, { + sessionId: desktopSessionKey({ hostId: 'host-1', sessionId: 'session-1' }), active, + request: { requestId: 'request-1', ref: 'terminal-1', message: 'Authenticate', command: 'sh' }, + })), + }))); + await render(true); + assert.match(container.textContent, /private-result/); + assert.equal(descendants(container).some((node) => node.getAttribute('data-testid') === 'terminal-handoff'), false); + assert.equal(descendants(container).some((node) => node.getAttribute('data-testid') === 'private-terminal'), true); + assert.equal(descendants(container).some((node) => ['INPUT', 'HEADER', 'DETAILS'].includes(node.tagName)), false); + await render(false); + assert.doesNotMatch(container.textContent, /private-result/); + await render(true); + assert.match(container.textContent, /private-result/); + assert.doesNotMatch(container.textContent, /share|select.*text/i); + assert.equal(descendants(container).some((node) => node.tagName === 'BUTTON'), false); + assert.deepEqual(operations, ['ready', 'observe', 'release', 'ready', 'observe']); +}); diff --git a/apps/desktop/src/main/__tests__/workbar-controller.test.ts b/apps/desktop/src/main/__tests__/workbar-controller.test.ts index 6656247033c..82f11159e3d 100644 --- a/apps/desktop/src/main/__tests__/workbar-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-controller.test.ts @@ -92,11 +92,14 @@ let controllerRenderSnapshots: Array<{ terminalOwnerIds: Array; }> = []; -type ControllerProbeInput = UseWorkbarControllerInput & { openOnActivation?: boolean }; +type ControllerProbeInput = UseWorkbarControllerInput & { openOnActivation?: boolean; prepareHandoffOnMount?: boolean }; function ControllerProbe(props: ControllerProbeInput) { const workbar = useWorkbarController(props); latestController = workbar; + useLayoutEffect(() => { + if (props.prepareHandoffOnMount) void workbar.host.prepareTerminalHandoff?.().catch(() => {}); + }, [props.prepareHandoffOnMount, workbar.host.prepareTerminalHandoff]); const visiblePanels = projectWorkbarPanelsForSession( workbar.host.panelsState, workbar.host.activeId, new Set(workbar.host.quotes?.map((quote) => `side-chat:${quote.id}`)), @@ -334,6 +337,41 @@ function renderWorkBoardComposition( const installedRailLayoutPort = shellRailLayoutPort.current; describe('useWorkbarController', () => { + it('owns one terminal surface registration across early cards, reconnect and Session changes', async () => { + const { root } = installReactRenderer(); + const gate = deferred(); + const operations: Array<{ sessionId: string; available: boolean }> = []; + const listeners = new Set<(event: { sessionId: string }) => void>(); + const base = createFakeWorkbarServices(); + const services = createFakeWorkbarServices({ terminal: { ...base.terminal, + subscribeResync: (handler) => { listeners.add(handler); return () => { listeners.delete(handler); }; }, + handoff: async (operation) => { + assert.equal(operation.action, 'surface'); + if (operation.action !== 'surface') throw new Error('unexpected operation'); + operations.push(operation); + if (operation.available) await gate.promise; + return { status: 'available', phase: 'waiting', nextSequence: 1 }; + }, + } }); + await act(async () => renderController(root, services, { ...input(session('a')), prepareHandoffOnMount: true })); + const first = controller().host.prepareTerminalHandoff!(); + assert.equal(controller().host.prepareTerminalHandoff!(), first); + assert.equal(operations.length, 1); + await act(async () => gate.resolve()); + await first; + await controller().host.prepareTerminalHandoff!(); + assert.equal(operations.length, 1); + await act(async () => listeners.forEach((handler) => handler({ sessionId: 'a' }))); + await controller().host.prepareTerminalHandoff!(); + assert.equal(operations.length, 2); + await act(async () => renderController(root, services, input(session('b')))); + await act(async () => root.unmount()); + assert.deepEqual(operations.map(({ sessionId, available }) => [sessionId, available]), [ + ['a', true], ['a', true], ['a', false], ['b', true], ['b', false], + ]); + assert.equal(listeners.size, 0); + }); + afterEach(() => { shellRailLayoutPort.current = installedRailLayoutPort; latestController = undefined; diff --git a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts index c95887a5f76..e9c050b823c 100644 --- a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts @@ -83,7 +83,7 @@ function createBridgeRecorder(answerOverrides: Record = {}): { } describe('createDesktopWorkbarServices', () => { - it('recovers only live local desktop PTYs from the Host inventory and updates', async () => { + it('restores live handoffs before manual PTYs regardless of enumeration order, excluding inherited resources', async () => { const { bridge } = createBridgeRecorder(); const manual = { sessionId: 's', ownership: { kind: 'local' }, @@ -92,23 +92,32 @@ 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; + let phase: 'human' | 'resumed' = 'human'; bridge.shellRuns = { ...bridge.shellRuns, recover: async () => ({ resources: updates, closes: [] }), + handoff: async (input) => ({ status: 'available', nextSequence: 1, phase, + ...(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: [] }); + const expected = { resources: [updates[5], manual], closes: [] }; + assert.deepEqual(await services.terminal.recover('s'), expected); + phase = 'resumed'; + assert.deepEqual(await services.terminal.recover('s'), expected); + bridge.shellRuns.recover = async () => ({ resources: [...updates].reverse(), closes: [] }); + assert.deepEqual(await services.terminal.recover('s'), expected); 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); }); diff --git a/apps/desktop/src/main/private-terminal-surfaces.ts b/apps/desktop/src/main/private-terminal-surfaces.ts new file mode 100644 index 00000000000..d8523c7145a --- /dev/null +++ b/apps/desktop/src/main/private-terminal-surfaces.ts @@ -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>(); +const captures = new Set>(); + +/** Register before yielding so mounting a private surface fences in-flight capture too. */ +export function trackDesktopCapture(task: Promise): Promise { + captures.add(task); + void task.then(() => captures.delete(task), () => captures.delete(task)); + return task; +} + +export async function drainDesktopCaptures(): Promise { + await Promise.allSettled([...captures]); +} + +export function setPrivateTerminalSurface(windowId: number, controllerId: string, visible: boolean): void { + const controllers = surfaces.get(windowId) ?? new Set(); + 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; +} diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index f7471d228d3..12a3c2d7e7a 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -1662,6 +1662,10 @@ export class DesktopRuntimeHostClient { return this.request("runtime.resource.controller.control", input); } + controlTerminalHandoff(input: OperationInput<'runtime.resource.handoff'>): Promise> { + return this.request('runtime.resource.handoff', input); + } + releaseRuntimeResourceController( input: OperationInput<"runtime.resource.controller.release">, ): Promise> { diff --git a/apps/desktop/src/main/runtime-host-native-capabilities.ts b/apps/desktop/src/main/runtime-host-native-capabilities.ts index a68909f3d50..39debd5963f 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -48,6 +48,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"; @@ -220,6 +221,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( @@ -240,7 +245,7 @@ export function createDesktopNativeCapabilityProvider( settled, }); void settled.finally(() => activeInvocations.delete(invocation)); - return task; + return capturesDesktop ? trackDesktopCapture(task) : task; }, callService: (frame, options) => { if (closed) diff --git a/apps/desktop/src/main/runtime-host-session-observer.ts b/apps/desktop/src/main/runtime-host-session-observer.ts index faee216c80e..7aa5203aeb4 100644 --- a/apps/desktop/src/main/runtime-host-session-observer.ts +++ b/apps/desktop/src/main/runtime-host-session-observer.ts @@ -649,6 +649,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", diff --git a/apps/desktop/src/main/runtime-host-shell-runs-ipc-main.ts b/apps/desktop/src/main/runtime-host-shell-runs-ipc-main.ts index d3dfe318baf..ff2e5aeb2cc 100644 --- a/apps/desktop/src/main/runtime-host-shell-runs-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-shell-runs-ipc-main.ts @@ -17,33 +17,47 @@ * under the License. */ -import { randomUUID } from 'node:crypto'; -import { DESKTOP_TERMINAL_LAUNCH_PREFIX } from '@maka/core/shell-run'; -import type { ShellRunUpdate } from '@maka/core/events'; -import type { ShellRunPtySnapshot } from '@maka/runtime/shell-run-contract'; -import type { SessionDomainChange } from '@maka/runtime-host/protocol'; -import { RuntimeHostOperationError } from '@maka/runtime-host/client'; +import { randomUUID } from "node:crypto"; +import { DESKTOP_TERMINAL_LAUNCH_PREFIX } from "@maka/core/shell-run"; +import type { ShellRunUpdate } from "@maka/core/events"; +import type { ShellRunPtySnapshot } from "@maka/runtime/shell-run-contract"; +import { + decodeRuntimeResourceHandoffInput, + type SessionDomainChange, +} from "@maka/runtime-host/protocol"; +import { + setPrivateTerminalSurface, + clearPrivateTerminalSurfaces, + drainDesktopCaptures, +} from "./private-terminal-surfaces.js"; +import { RuntimeHostOperationError } from "@maka/runtime-host/client"; import { handleReconnectableRead, type ReconnectableReadIpcMain, -} from './ipc-reconnect-policy.js'; -import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; -import type { RuntimeHostSessionObserverTarget } from './runtime-host-session-observer.js'; -import type { TerminalCloseIntents } from './terminal-close-intents.js'; +} from "./ipc-reconnect-policy.js"; +import type { DesktopRuntimeHostClient } from "./runtime-host-client.js"; +import type { RuntimeHostSessionObserverTarget } from "./runtime-host-session-observer.js"; +import type { TerminalCloseIntents } from "./terminal-close-intents.js"; export type RuntimeHostShellRunsClient = Pick< DesktopRuntimeHostClient, - | 'acquireRuntimeResourceController' - | 'controlRuntimeResource' - | 'listRuntimeResources' - | 'releaseRuntimeResourceController' - | 'startRuntimeResource' - | 'stopRuntimeResource' ->; + | "acquireRuntimeResourceController" + | "controlRuntimeResource" + | "listRuntimeResources" + | "releaseRuntimeResourceController" + | "startRuntimeResource" + | "stopRuntimeResource" +> & + Partial< + Pick< + DesktopRuntimeHostClient, + "controlTerminalHandoff" | "answerInteraction" + > + >; export type RuntimeHostShellRunQueriesClient = Pick< DesktopRuntimeHostClient, - 'getRuntimeResource' | 'listRuntimeResources' + "getRuntimeResource" | "listRuntimeResources" >; export interface RuntimeHostShellRunQueriesIpcHandle { @@ -59,16 +73,19 @@ export function registerRuntimeHostShellRunQueriesIpc( }, ipcMain: ReconnectableReadIpcMain, ): RuntimeHostShellRunQueriesIpcHandle { - handleReconnectableRead(ipcMain, 'shell-runs:list', (_event, sessionId: unknown) => - deps.client.listRuntimeResources(requiredId(sessionId, 'Session')), + handleReconnectableRead( + ipcMain, + "shell-runs:list", + (_event, sessionId: unknown) => + deps.client.listRuntimeResources(requiredId(sessionId, "Session")), ); return { sessionDomainChanged(change) { - if (change.domain !== 'runtime_resource') return; + if (change.domain !== "runtime_resource") return; void refreshRuntimeResources(deps, change.sessionId, change.resources); }, sessionSubscriptionRecovered(sessionId) { - deps.sendToRenderer?.('shell-runs:resync', { sessionId }); + deps.sendToRenderer?.("shell-runs:resync", { sessionId }); }, }; } @@ -98,8 +115,71 @@ export function registerRuntimeHostShellRunsIpc( newId, deps.sessionObserver, ); - ipcMain.handle('shell-runs:start', async (_event, sessionId: unknown) => { - const normalizedSessionId = requiredId(sessionId, 'Session'); + // Private controls deliberately bypass reconnectable reads and command journals. + // A transport failure must never replay a user's input. + const privateWindows = new Set(); + ipcMain.handle("shell-runs:handoff", async (event, value: unknown) => { + if (!deps.client.controlTerminalHandoff) + throw new Error("Terminal handoff is unavailable"); + const input = decodeRuntimeResourceHandoffInput(value); + const sender = event.sender as import("electron").WebContents; + if (input.action === "ready") { + setPrivateTerminalSurface(sender.id, input.controllerId, true); + if (!privateWindows.has(sender.id)) { + privateWindows.add(sender.id); + sender.once("destroyed", () => { + privateWindows.delete(sender.id); + clearPrivateTerminalSurfaces(sender.id); + }); + // Clear after navigation commits, when the old private document is gone. + // Starting navigation (or a renderer crash) can leave its pixels visible. + sender.on("did-navigate", () => + clearPrivateTerminalSurfaces(sender.id), + ); + } + await drainDesktopCaptures(); + } + if (input.action === "release") + setPrivateTerminalSurface(sender.id, input.controllerId, false); + try { + const result = await deps.client.controlTerminalHandoff(input); + if (input.action === "ready" && result.phase !== "human" && result.phase !== "resumed") { + setPrivateTerminalSurface(sender.id, input.controllerId, false); + } + return result; + } catch (error) { + if (input.action === "ready") { + setPrivateTerminalSurface(sender.id, input.controllerId, false); + } + throw error; + } + }); + ipcMain.handle( + "shell-runs:handoff-answer", + async (_event, value: unknown) => { + if (!deps.client.answerInteraction || !value || typeof value !== "object") + throw new Error("Terminal handoff is unavailable"); + const input = value as { + sessionId?: unknown; + requestId?: unknown; + controllerId?: unknown; + action?: unknown; + }; + if (input.action !== "resume" && input.action !== "cancel") + throw new Error("Invalid terminal handoff decision"); + await deps.client.answerInteraction({ + sessionId: requiredId(input.sessionId, "Session"), + interactionId: requiredId(input.requestId, "Request"), + answer: { + kind: "terminal_handoff", + action: input.action, + controllerId: requiredId(input.controllerId, "Controller"), + }, + }); + }, + ); + ipcMain.handle("shell-runs:start", async (_event, sessionId: unknown) => { + const normalizedSessionId = requiredId(sessionId, "Session"); const launchId = `${DESKTOP_TERMINAL_LAUNCH_PREFIX}${newId()}`; const started = await deps.client.startRuntimeResource({ sessionId: normalizedSessionId, @@ -107,40 +187,49 @@ export function registerRuntimeHostShellRunsIpc( }); const update: ShellRunUpdate = { sessionId: normalizedSessionId, - ownership: { kind: 'local' }, + ownership: { kind: "local" }, sourceTurnId: launchId, sourceToolCallId: launchId, result: started.resource, }; return update; }); - ipcMain.handle('shell-runs:attach', (event, value: unknown) => + ipcMain.handle("shell-runs:attach", (event, value: unknown) => controllers.attach( - runtimeResourceIdentity(value, 'attach'), + runtimeResourceIdentity(value, "attach"), event.sender as RuntimeHostSessionObserverTarget, ), ); - ipcMain.handle('shell-runs:detach', (_event, value: unknown) => - controllers.detach(runtimeResourceIdentity(value, 'detach')), + ipcMain.handle("shell-runs:detach", (_event, value: unknown) => + controllers.detach(runtimeResourceIdentity(value, "detach")), ); - ipcMain.handle('shell-runs:write', (_event, value: unknown) => + ipcMain.handle("shell-runs:write", (_event, value: unknown) => controllers.control(runtimeResourceControl(value)), ); - handleReconnectableRead(ipcMain, 'shell-runs:recover', (_event, sessionId: unknown) => { - const id = requiredId(sessionId, 'Session'); - return closes.recover(id, () => deps.client.listRuntimeResources(id)); - }); - ipcMain.handle('shell-runs:stop', (_event, value: unknown) => { - const input = runtimeResourceIdentity(value, 'stop'); + handleReconnectableRead( + ipcMain, + "shell-runs:recover", + (_event, sessionId: unknown) => { + const id = requiredId(sessionId, "Session"); + return closes.recover(id, () => deps.client.listRuntimeResources(id)); + }, + ); + ipcMain.handle("shell-runs:stop", (_event, value: unknown) => { + const input = runtimeResourceIdentity(value, "stop"); return closes.stop(input, () => controllers.stop(input)); }); - return { close: () => controllers.close() }; + return { + close: async () => { + for (const id of privateWindows) clearPrivateTerminalSurfaces(id); + await controllers.close(); + }, + }; } async function refreshRuntimeResources( deps: { - client: Pick; + client: Pick; sendToRenderer?(channel: string, payload: unknown): void; onError?(error: unknown): void; }, @@ -149,8 +238,11 @@ async function refreshRuntimeResources( ): Promise { for (const resource of resources) { try { - const update = await deps.client.getRuntimeResource(sessionId, resource.ref); - if (update) deps.sendToRenderer?.('shell-runs:update', update); + const update = await deps.client.getRuntimeResource( + sessionId, + resource.ref, + ); + if (update) deps.sendToRenderer?.("shell-runs:update", update); } catch (error) { deps.onError?.(error); } @@ -214,7 +306,13 @@ class RuntimeResourceControllers { ): Promise { return this.#run(input, async () => { const state = this.#state(input); - await this.#sessionObserver.observe(input.sessionId, state.observerId, target, false, input.ref); + await this.#sessionObserver.observe( + input.sessionId, + state.observerId, + target, + false, + input.ref, + ); return this.#acquire(input, state); }); } @@ -226,7 +324,8 @@ class RuntimeResourceControllers { state = this.#state(input); await this.#acquire(input, state); } - if (!state) throw new Error('Runtime Resource controller was not acquired'); + if (!state) + throw new Error("Runtime Resource controller was not acquired"); const sequence = state.nextSequence; await this.#client.controlRuntimeResource({ sessionId: input.sessionId, @@ -278,12 +377,15 @@ class RuntimeResourceControllers { }), ); const failed = results.find( - (result): result is PromiseRejectedResult => result.status === 'rejected', + (result): result is PromiseRejectedResult => result.status === "rejected", ); if (failed) throw failed.reason; } - async #run(input: RuntimeResourceIdentity, operation: () => Promise): Promise { + async #run( + input: RuntimeResourceIdentity, + operation: () => Promise, + ): Promise { const key = resourceIdentity(input); const previous = this.#tails.get(key) ?? Promise.resolve(); let release!: () => void; @@ -321,7 +423,7 @@ class RuntimeResourceControllers { ): Promise { const key = resourceIdentity(input); let acquired: Awaited< - ReturnType + ReturnType >; try { acquired = await this.#client.acquireRuntimeResourceController({ @@ -330,7 +432,10 @@ class RuntimeResourceControllers { controllerId: state.controllerId, }); } catch (error) { - if (error instanceof RuntimeHostOperationError && this.#states.get(key) === state) { + if ( + error instanceof RuntimeHostOperationError && + this.#states.get(key) === state + ) { this.#states.delete(key); await this.#releaseObservation(state).catch(() => undefined); } @@ -340,63 +445,78 @@ class RuntimeResourceControllers { return acquired.pty; } - async #releaseObservation(state: RuntimeResourceControllerState): Promise { + async #releaseObservation( + state: RuntimeResourceControllerState, + ): Promise { await this.#sessionObserver.unobserve(state.observerId); } } function protocolControl(input: RuntimeResourceControl) { if (input.input !== undefined && input.size !== undefined) { - return { kind: 'input_and_resize' as const, input: input.input, ...input.size }; + return { + kind: "input_and_resize" as const, + input: input.input, + ...input.size, + }; } - if (input.input !== undefined) return { kind: 'input' as const, input: input.input }; - if (input.size !== undefined) return { kind: 'resize' as const, ...input.size }; - throw new Error('Terminal control is empty'); + if (input.input !== undefined) + return { kind: "input" as const, input: input.input }; + if (input.size !== undefined) + return { kind: "resize" as const, ...input.size }; + throw new Error("Terminal control is empty"); } -function runtimeResourceIdentity(value: unknown, action: string): RuntimeResourceIdentity { - if (!value || typeof value !== 'object' || Array.isArray(value)) { +function runtimeResourceIdentity( + value: unknown, + action: string, +): RuntimeResourceIdentity { + if (!value || typeof value !== "object" || Array.isArray(value)) { throw new TypeError(`Invalid terminal ${action} input`); } const record = value as Record; - if (Object.keys(record).some((key) => key !== 'sessionId' && key !== 'ref')) { + if (Object.keys(record).some((key) => key !== "sessionId" && key !== "ref")) { throw new TypeError(`Invalid terminal ${action} input`); } return { - sessionId: requiredId(record.sessionId, 'Session'), - ref: requiredId(record.ref, 'terminal ref'), + sessionId: requiredId(record.sessionId, "Session"), + ref: requiredId(record.ref, "terminal ref"), }; } function runtimeResourceControl(value: unknown): RuntimeResourceControl { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new TypeError('Invalid terminal control input'); + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError("Invalid terminal control input"); } const record = value as Record; if ( Object.keys(record).some( - (key) => key !== 'sessionId' && key !== 'ref' && key !== 'input' && key !== 'size', + (key) => + key !== "sessionId" && + key !== "ref" && + key !== "input" && + key !== "size", ) ) { - throw new TypeError('Invalid terminal control input'); + throw new TypeError("Invalid terminal control input"); } const input = record.input; const size = record.size; - if (input !== undefined && typeof input !== 'string') { - throw new TypeError('Invalid terminal input'); + if (input !== undefined && typeof input !== "string") { + throw new TypeError("Invalid terminal input"); } let normalizedSize: { cols: number; rows: number } | undefined; if (size !== undefined) { - if (!size || typeof size !== 'object' || Array.isArray(size)) { - throw new TypeError('Invalid terminal size'); + if (!size || typeof size !== "object" || Array.isArray(size)) { + throw new TypeError("Invalid terminal size"); } const dimensions = size as Record; if ( - Object.keys(dimensions).some((key) => key !== 'cols' && key !== 'rows') - || !Number.isInteger(dimensions.cols) - || !Number.isInteger(dimensions.rows) + Object.keys(dimensions).some((key) => key !== "cols" && key !== "rows") || + !Number.isInteger(dimensions.cols) || + !Number.isInteger(dimensions.rows) ) { - throw new TypeError('Invalid terminal size'); + throw new TypeError("Invalid terminal size"); } normalizedSize = { cols: dimensions.cols as number, @@ -404,12 +524,12 @@ function runtimeResourceControl(value: unknown): RuntimeResourceControl { }; } if (input === undefined && normalizedSize === undefined) { - throw new TypeError('Terminal control is empty'); + throw new TypeError("Terminal control is empty"); } return { ...runtimeResourceIdentity( { sessionId: record.sessionId, ref: record.ref }, - 'control', + "control", ), ...(input === undefined ? {} : { input }), ...(normalizedSize === undefined ? {} : { size: normalizedSize }), @@ -420,19 +540,22 @@ function resourceIdentity(input: RuntimeResourceIdentity): string { return `${input.sessionId}\0${input.ref}`; } -function parseResourceIdentity(identity: string): [sessionId: string, ref: string] { - const separator = identity.indexOf('\0'); - if (separator < 0) throw new Error('Invalid Runtime Resource controller identity'); +function parseResourceIdentity( + identity: string, +): [sessionId: string, ref: string] { + const separator = identity.indexOf("\0"); + if (separator < 0) + throw new Error("Invalid Runtime Resource controller identity"); return [identity.slice(0, separator), identity.slice(separator + 1)]; } function requiredId(value: unknown, name: string, maxLength = 512): string { if ( - typeof value !== 'string' - || value.length === 0 - || value.length > maxLength - || value.trim() !== value - || /[\u0000-\u001f\u007f]/.test(value) + typeof value !== "string" || + value.length === 0 || + value.length > maxLength || + value.trim() !== value || + /[\u0000-\u001f\u007f]/.test(value) ) { throw new TypeError(`Invalid ${name} id`); } diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 5287b9e34f0..468e401e1a2 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1552,6 +1552,8 @@ export interface MakaBridge { restore(projectId: string, host?: DesktopRuntimeHostRef): Promise; }; shellRuns: { + handoff(input: import('@maka/runtime-host/protocol').RuntimeResourceHandoffInput): Promise; + answerHandoff(input: { sessionId: string; requestId: string; controllerId: string; action: 'resume' | 'cancel' }): Promise; recover(sessionId: string): Promise; subscribeCloseChanges(handler: (change: import('../shared/runtime-host-identity.js').TerminalCloseChange) => void): () => void; list(sessionId: string): Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index aba743dc2cd..384411b75f8 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -3109,6 +3109,12 @@ const makaBridge = { }, }, shellRuns: { + handoff(input: import('@maka/runtime-host/protocol').RuntimeResourceHandoffInput): Promise { + return invokeSessionInput('shell-runs:handoff', input); + }, + answerHandoff(input: { sessionId: string; requestId: string; action: 'resume' | 'cancel' }): Promise { + return invokeSessionInput('shell-runs:handoff-answer', input); + }, async recover(sessionId: string) { const session = await runtimeHostSessionRef(sessionId); const result = await invokeWhenReady('shell-runs:recover', session.scope, session.sessionId) as diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx index 6e5c2dfe9c1..3c14c9f3e77 100644 --- a/apps/desktop/src/renderer/chat-composer-region.tsx +++ b/apps/desktop/src/renderer/chat-composer-region.tsx @@ -196,6 +196,7 @@ export function ChatComposerRegion({ activeInteraction?.type === 'client_capability_request' ? activeInteraction : undefined; const activeQuestion = activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined; const activeForm = activeInteraction?.type === 'form_request' ? activeInteraction : undefined; + const activeTerminalHandoff = activeInteraction?.type === 'terminal_handoff_request' ? activeInteraction : undefined; const activeModelChoice = composerRest.activeModel ? composerRest.modelChoices?.find( (choice) => @@ -391,6 +392,7 @@ export function ChatComposerRegion({ onRespond={respondToUserForm} /> )} + {activeTerminalHandoff && } {LiveContextUsageProbe ? ( { + function settleAssistantStreaming(sessionId: string, messageId?: string) { return handoffAssistantStreaming(sessionId, messageId, true); } @@ -327,6 +327,7 @@ export function createAppShellSessionEventHandlers(options: { case 'client_capability_request': case 'user_question_request': case 'form_request': + case 'terminal_handoff_request': onInteractionChanged?.(sessionId); break; // The runtime drops its owner on this ack, not on the tool result that @@ -335,6 +336,7 @@ export function createAppShellSessionEventHandlers(options: { case 'user_question_answer_ack': case 'client_capability_decision_ack': case 'form_answer_ack': + case 'terminal_handoff_answer_ack': onInteractionChanged?.(sessionId); break; case 'sandbox_boundary_decision_ack': diff --git a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts index d693ccdf097..b74487768b6 100644 --- a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts +++ b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts @@ -197,7 +197,7 @@ export function useWorkbarController( viteEnv?.VITE_MAKA_WORK_BOARD_START_TASK === '1' && Boolean(input.openNewTaskSurface && input.resolveWorkBoardTarget && input.prepareWorkBoardDraft); const terminalCopy = getDesktopConversationCopy(locale).terminalPanel; - const { browser, sideChat, terminal, workBoard } = useWorkbarServices(); + const { browser, sideChat, terminal, workBoard, review } = useWorkbarServices(); const compact = useMediaQuery(SHELL_WORKBAR_COMPACT_QUERY); const layout = useWorkbarLayoutState(input.layoutSessionId, input.authoritativeSessionIds, compact); const sideConversations = useSideConversationWorkspace(); @@ -532,6 +532,48 @@ export function useWorkbarController( [concealRailIfNoWorkbarRoom, layout.setBottomPanelOpen, layout.setWorkbarCollapsed], ); + // One registration owner. Cards await this same promise, including when a + // restored child mounts before the controller's passive effect has run. + const handoffSurface = useMemo(() => { + let pending: Promise | undefined; + return { + prepare: () => { + if (!pending) { + const attempt = terminal.handoff!({ action: 'surface', sessionId: activeSessionId!, available: true }).then(() => {}); + pending = attempt; + void attempt.catch(() => { if (pending === attempt) pending = undefined; }); + } + return pending; + }, + reset: () => { pending = undefined; }, + }; + }, [activeSessionId, terminal]); + + useEffect(() => { + if (!activeSessionId || !terminal.handoff || workspace !== 'session') return; + let disposed = false; + const advertise = () => { + if (!disposed) void handoffSurface.prepare().catch(() => {}); + }; + advertise(); + const unsubscribeResync = terminal.subscribeResync((event) => { + if (event.sessionId === activeSessionId) { handoffSurface.reset(); advertise(); } + }); + const unsubscribe = review.subscribeSessionEvents(activeSessionId, (event) => { + if (event.type !== 'terminal_handoff_request') return; + layout.openDynamicWorkbarTab({ id: terminalSessionWorkbarTabId(event.ref), kind: 'terminal', + resourceRef: event.ref, ownerSessionId: activeSessionId, ordinal: reserveOrdinal('terminal') }, 'right'); + revealPlacement('right'); + }); + return () => { + disposed = true; + unsubscribe(); + unsubscribeResync(); + handoffSurface.reset(); + void terminal.handoff!({ action: 'surface', sessionId: activeSessionId, available: false }).catch(() => {}); + }; + }, [activeSessionId, workspace, terminal, review, layout.openDynamicWorkbarTab, reserveOrdinal, revealPlacement, handoffSurface]); + const openNewSideConversation = useCallback( (placement: SessionWorkbarPlacement, initialPrompt?: string) => { const sourceSessionId = activeSessionIdRef.current; @@ -1023,6 +1065,7 @@ export function useWorkbarController( host: { workspace: workspace, activeId: input.available ? activeSessionId : undefined, + prepareTerminalHandoff: terminal.handoff && workspace === 'session' ? handoffSurface.prepare : undefined, projectId: input.projectId, projectAliases: input.projectAliases, rightCollapsed: layout.workbarCollapsed, diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts index f74b022e461..17f3877237f 100644 --- a/apps/desktop/src/renderer/features/workbar/ports.ts +++ b/apps/desktop/src/renderer/features/workbar/ports.ts @@ -89,6 +89,8 @@ export interface WorkbarReviewService { } export interface WorkbarTerminalService { + handoff?(input: import('@maka/runtime-host/protocol').RuntimeResourceHandoffInput): Promise; + answerHandoff?(input: { sessionId: string; requestId: string; controllerId: string; action: 'resume' | 'cancel' }): Promise; /** Live, locally owned manual terminals; excludes model tools and inherited resources. */ recover(sessionId: string): Promise; subscribeCloseChanges(handler: (change: import('../../../shared/runtime-host-identity.js').TerminalCloseChange) => void): WorkbarUnsubscribe; diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts index a6fe3f775a4..0cc01daa3d7 100644 --- a/apps/desktop/src/renderer/features/workbar/testing.ts +++ b/apps/desktop/src/renderer/features/workbar/testing.ts @@ -56,6 +56,9 @@ export * from './tools/terminal/terminal-interaction-policy.js'; export * from './tools/terminal/session-terminal-hydration.js'; export * from './tools/terminal/session-terminal-query.js'; export * from './tools/terminal/session-terminal-frame.js'; +export * from './tools/terminal/terminal-handoff-feedback.js'; +export { TerminalHandoffPanel } from './tools/terminal/terminal-handoff-panel.js'; +export const loadSessionTerminalPanelForTest = () => import('./tools/terminal/session-terminal-panel.js'); export * from '../../application/contracts/session-inspector/use-session-trace.js'; export { useWorkbarController } from './controller/use-workbar-controller.js'; export type { diff --git a/apps/desktop/src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx index e3d066148c3..dc44c9ecb0b 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx @@ -26,13 +26,15 @@ import { useUiLocale } from '@maka/ui'; import { ICON_SIZE, Terminal as TerminalIcon } from '@maka/ui/icons'; import { FitAddon } from '@xterm/addon-fit'; import { Terminal } from '@xterm/xterm'; -import { getDesktopConversationCopy } from '../../../../application/contracts/conversation-copy'; -import { SessionTerminalHydration, SessionTerminalRenderQueue } from './session-terminal-hydration'; -import { suppressTerminalQueryReplies } from './session-terminal-query'; -import { scheduleTerminalFrame } from './session-terminal-frame'; -import { loadTerminalWebLinks } from './terminal-web-links'; +import { getDesktopConversationCopy } from '../../../../application/contracts/conversation-copy.js'; +import { SessionTerminalHydration, SessionTerminalRenderQueue } from './session-terminal-hydration.js'; +import { suppressTerminalQueryReplies } from './session-terminal-query.js'; +import { scheduleTerminalFrame } from './session-terminal-frame.js'; +import { loadTerminalWebLinks } from './terminal-web-links.js'; import { useWorkbarServices } from '../../services-context.js'; -import { getTerminalFontSize, subscribeTerminalFontSize } from '../../../../theme'; +import { getTerminalFontSize, subscribeTerminalFontSize } from '../../../../theme.js'; +import { TerminalHandoffPanel } from './terminal-handoff-panel.js'; +import type { RuntimeResourceHandoffResult } from '@maka/runtime-host/protocol'; function terminalTheme(element: HTMLElement) { const styles = getComputedStyle(element); @@ -48,6 +50,45 @@ function terminalTheme(element: HTMLElement) { } export function SessionTerminalPanel(props: { + sessionId: string; + prepareHandoff?: () => Promise; + terminalRef: string | null; + active: boolean; +}) { + const { terminal, review } = useWorkbarServices(); + const [handoff, setHandoff] = useState(); + const [checked, setChecked] = useState(!terminal.handoff); + useEffect(() => { + if (!terminal.handoff || !props.terminalRef) { setChecked(true); return; } + let disposed = false; + setChecked(false); + setHandoff(undefined); + const refresh = async () => { + try { + const result = await terminal.handoff!({ action: 'lookup', sessionId: props.sessionId, ref: props.terminalRef! }); + if (!disposed) { setHandoff(result.request); setChecked(true); } + } catch { if (!disposed) setChecked(true); } + }; + void refresh(); + const unsubscribe = terminal.subscribeUpdates((update) => { + if (update.sessionId === props.sessionId && update.result.ref === props.terminalRef) void refresh(); + }); + const unsubscribeResync = terminal.subscribeResync(({ sessionId }) => { + if (sessionId === props.sessionId) void refresh(); + }); + // A second handoff on the same PTY changes the request, not the tab/ref. + // Refresh from its canonical event even if the resource projection is unchanged. + const unsubscribeHandoff = review.subscribeSessionEvents(props.sessionId, (event) => { + if (event.type === 'terminal_handoff_request' && event.ref === props.terminalRef) void refresh(); + }); + return () => { disposed = true; unsubscribe(); unsubscribeResync(); unsubscribeHandoff(); }; + }, [terminal, review, props.sessionId, props.terminalRef]); + if (handoff) return ; + if (!checked) return
; + return ; +} + +function StandardSessionTerminalPanel(props: { sessionId: string; terminalRef: string | null; active: boolean; diff --git a/apps/desktop/src/renderer/features/workbar/tools/terminal/terminal-handoff-feedback.ts b/apps/desktop/src/renderer/features/workbar/tools/terminal/terminal-handoff-feedback.ts new file mode 100644 index 00000000000..3085dac1b2e --- /dev/null +++ b/apps/desktop/src/renderer/features/workbar/tools/terminal/terminal-handoff-feedback.ts @@ -0,0 +1,37 @@ +/* + * 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 { isWellFormedTerminalInput } from '@maka/core/terminal-input'; + +/** Renderer-only hints. Never return raw output, publish it, or infer success. */ +type TerminalHint = 'password' | 'authentication_retry'; +export function terminalFeedback(command: string, text: string): TerminalHint | undefined { + // The only verified program today is OpenSSH. Unsupported commands/locales + // keep their raw response; add tested recognizers when another program needs one. + if (!/^(?:\/usr\/bin\/)?ssh\s/.test(command.trim()) || /[;&|`\n]/.test(command)) return; + const lines = text.trimEnd().split('\n'); + if (!/^[^\r\n]*'s password:\s*$/.test(lines.at(-1) ?? '')) return; + return lines.at(-2)?.trim() === 'Permission denied, please try again.' + ? 'authentication_retry' : 'password'; +} + +export function isValidPrivateTerminalInput(value: string): boolean { + return Boolean(value) && isWellFormedTerminalInput(value) && !/[\x00-\x1f\x7f]/.test(value) && + new TextEncoder().encode(value).length <= 32 * 1024; +} diff --git a/apps/desktop/src/renderer/features/workbar/tools/terminal/terminal-handoff-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/terminal/terminal-handoff-panel.tsx new file mode 100644 index 00000000000..b6bf2796bc6 --- /dev/null +++ b/apps/desktop/src/renderer/features/workbar/tools/terminal/terminal-handoff-panel.tsx @@ -0,0 +1,218 @@ +/* + * 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 { useEffect, useRef, useState } from 'react'; +import { Button, IconButton, InputGroup, InputGroupText, useUiLocale } from '@maka/ui'; +import { Eye, EyeOff, ICON_SIZE } from '@maka/ui/icons'; +import type { UiCatalog } from '@maka/core/ui-locale'; +import { Banner, ChatComposer, TextInput, isImeKeyEvent } from '@astryxdesign/core'; +import type { RuntimeResourceHandoffResult } from '@maka/runtime-host/protocol'; +import { useWorkbarServices } from '../../services-context.js'; +import { parseDesktopSessionKey } from '../../../../../shared/runtime-host-identity.js'; +import { isValidPrivateTerminalInput, terminalFeedback } from './terminal-handoff-feedback.js'; + +const COPY = { + en: { + waiting: 'The agent is waiting for your private input.', details: 'Connection details', + input: 'Private terminal input', submit: 'Submit', resume: 'Done, continue task', cancel: 'Cancel and stop', + show: 'Show input', hide: 'Hide input', + sent: 'Submitted. Check the terminal response.', + unknown: 'Delivery is uncertain. Stop this terminal to avoid duplicate input.', + disconnected: 'Connection lost. Reconnect to the original terminal; input will not be resent.', + reconnect: 'Reconnect to original terminal', invalid: 'Not sent. Enter one line without control characters, up to 32 KB.', + target: 'Execution host', + password: 'Enter the SSH password below.', authentication_retry: 'SSH rejected authentication. Check the password and try again.', + exited: 'The original terminal process exited.', + cancelled: 'Terminal stopped.', unavailable: 'The original terminal is unavailable.', + }, + 'zh-CN': { + waiting: 'Agent 正在等待你的私密输入。', details: '连接详情', + input: '私密终端输入', submit: '提交', resume: '已完成,继续任务', cancel: '取消并停止', + show: '显示输入', hide: '隐藏输入', + sent: '已提交,请查看终端回应。', + unknown: '无法确认是否送达,请停止此终端,避免重复输入。', + disconnected: '连接已中断,请重新连接原终端。输入不会重发。', + reconnect: '重新连接原终端', invalid: '尚未发送。请输入不含控制字符的单行内容,最多 32 KB。', + target: '执行主机', + password: '请在下方输入 SSH 密码。', authentication_retry: 'SSH 验证未通过,请核对密码后重试。', + exited: '原终端进程已退出。', cancelled: '已停止终端。', unavailable: '原终端已不可用。', + }, + 'zh-TW': { + waiting: 'Agent 正在等待你的私密輸入。', details: '連線詳情', + input: '私密終端輸入', submit: '提交', resume: '已完成,繼續任務', cancel: '取消並停止', + show: '顯示輸入', hide: '隱藏輸入', + sent: '已提交,請查看終端回應。', + unknown: '無法確認是否送達,請停止此終端,避免重複輸入。', + disconnected: '連線已中斷,請重新連線原終端。輸入不會重送。', + reconnect: '重新連線原終端', invalid: '尚未傳送。請輸入不含控制字元的單行內容,最多 32 KB。', + target: '執行主機', + password: '請在下方輸入 SSH 密碼。', authentication_retry: 'SSH 驗證未通過,請核對密碼後重試。', + exited: '原終端行程已結束。', cancelled: '已停止終端。', unavailable: '原終端已無法使用。', + }, +} satisfies UiCatalog>; +type Notice = '' | 'sent' | 'unknown' | 'invalid'; + +export function TerminalHandoffPanel(props: { + sessionId: string; + request: NonNullable; + active: boolean; + prepareHandoff?: () => Promise; +}) { + const { terminal } = useWorkbarServices(); + const locale = useUiLocale(); + const copy = COPY[locale]; + const [controllerId] = useState(() => crypto.randomUUID()); + const [state, setState] = useState(); + const [notice, setNotice] = useState(''); + const [busy, setBusy] = useState(false); + const [connection, setConnection] = useState<'connecting' | 'connected' | 'disconnected'>('connecting'); + const [revision, setRevision] = useState(0); + // Component-local only: never reuse chat drafts or generic form responses. + const [privateInput, setPrivateInput] = useState(''); + const [visible, setVisible] = useState(false); + const input = useRef(null); + const sending = useRef(false); + const displayEpoch = useRef(0); + const identity = { sessionId: props.sessionId, requestId: props.request.requestId, controllerId }; + const connected = connection === 'connected'; + const disconnected = connection === 'disconnected'; + // An ambiguous local send stays latched even if a later poll looks healthy. + const uncertain = notice === 'unknown' || state?.status === 'outcome_unknown'; + const human = state?.phase === 'human'; + const hint = human && connected && state?.display ? terminalFeedback(props.request.command, state.display.text) : undefined; + const canInput = human && connected && !busy && !uncertain; + const canResume = canInput && !privateInput && !hint && Boolean(state?.display); + + function accept(result: RuntimeResourceHandoffResult) { + setState(result); + if (result.status === 'outcome_unknown') setNotice('unknown'); + if (result.status === 'closed') { setNotice(''); setVisible(false); } + if (result.rejection === 'controller_expired') { setConnection('disconnected'); setVisible(false); } + if (result.rejection === 'invalid_input') setNotice((previous) => previous === 'unknown' ? previous : 'invalid'); + } + + useEffect(() => { + if (!terminal.handoff || !props.active) return; + let disposed = false; + let timer: ReturnType; + const failed = () => { + if (disposed) return; + setConnection('disconnected'); setVisible(false); + if (input.current) input.current.value = ''; + setPrivateInput(''); setState((previous) => previous ? { ...previous, display: undefined } : previous); + }; + const poll = async () => { + const epoch = displayEpoch.current; + try { + const result = await terminal.handoff!({ ...identity, action: 'observe' }); + if (!disposed && epoch === displayEpoch.current) accept(result); + if (!disposed && result.phase !== 'closed' && result.rejection !== 'controller_expired') timer = setTimeout(poll, 300); + } catch { failed(); } + }; + // Reclaim only the original resource. Input is never replayed on recovery. + void (props.prepareHandoff?.() ?? Promise.resolve()) + .then(async () => { + if (disposed) return; + const result = await terminal.handoff!({ ...identity, action: 'ready' }); + if (disposed) { void terminal.handoff!({ ...identity, action: 'release' }).catch(() => {}); return; } + setConnection('connected'); accept(result); + if (result.phase !== 'closed') void poll(); + }).catch(failed); + return () => { + disposed = true; displayEpoch.current++; clearTimeout(timer); + if (input.current) input.current.value = ''; + setPrivateInput(''); setVisible(false); setState((previous) => previous ? { ...previous, display: undefined } : previous); setConnection('connecting'); + void terminal.handoff!({ ...identity, action: 'release' }).catch(() => {}); + }; + }, [terminal, props.sessionId, props.request.requestId, props.active, props.prepareHandoff, controllerId, revision]); + + async function submit() { + const value = input.current?.value ?? ''; + if (sending.current || !canInput || !state) return; + if (!isValidPrivateTerminalInput(value)) { setNotice('invalid'); return; } + sending.current = true; setBusy(true); + input.current!.value = ''; setPrivateInput(''); setVisible(false); + const epoch = ++displayEpoch.current; + try { + const result = await terminal.handoff!({ ...identity, action: 'input', sequence: state.nextSequence, input: value }); + if (epoch !== displayEpoch.current) return; + accept(result); + if (result.status === 'written') setNotice((previous) => previous === 'unknown' ? previous : 'sent'); + } catch { + if (epoch !== displayEpoch.current) return; + setNotice('unknown'); setConnection('disconnected'); + } finally { sending.current = false; setBusy(false); } + } + + async function answer(action: 'resume' | 'cancel') { + if (sending.current || (action === 'resume' && !canResume)) return; + sending.current = true; setBusy(true); + if (input.current) input.current.value = ''; + setPrivateInput(''); setVisible(false); + const epoch = ++displayEpoch.current; + setState((previous) => previous ? { ...previous, display: undefined } : previous); + try { + await terminal.answerHandoff!({ ...identity, action }); + const result = await terminal.handoff!({ ...identity, action: 'observe' }); + if (epoch !== displayEpoch.current) return; + displayEpoch.current++; accept(result); setNotice(''); + } catch { + if (epoch === displayEpoch.current) setConnection('disconnected'); + } finally { sending.current = false; setBusy(false); } + } + + const closed = state?.phase === 'closed'; + const host = parseDesktopSessionKey(props.sessionId)?.hostId ?? props.sessionId; + const problem = uncertain ? 'unknown' : disconnected ? 'disconnected' : notice === 'invalid' ? 'invalid' : hint === 'authentication_retry' ? hint : undefined; + const response = ; + const reconnect = disconnected && !closed &&