From 1299b84671d4e8ee6422da3ac05221487d4c1b33 Mon Sep 17 00:00:00 2001 From: Anton Date: Thu, 24 Sep 2026 19:25:26 +0200 Subject: [PATCH 1/2] feat(t3): add T3 Connect controls for local workspaces --- src/main/ipc/t3Agent.ts | 32 +++++++- src/main/t3Agent/T3HarnessManager.test.ts | 27 +++++++ src/main/t3Agent/T3HarnessManager.ts | 99 +++++++++++++++++++++++ src/preload/index.ts | 8 ++ src/renderer/settings/AgentSettings.tsx | 4 +- src/renderer/settings/T3RemoteAccess.tsx | 76 +++++++++++++++++ src/shared/electron-api.d.ts | 5 ++ src/shared/ipc-channels.ts | 4 + src/shared/t3Agent.ts | 10 +++ 9 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 src/renderer/settings/T3RemoteAccess.tsx diff --git a/src/main/ipc/t3Agent.ts b/src/main/ipc/t3Agent.ts index 4641831b..56085145 100644 --- a/src/main/ipc/t3Agent.ts +++ b/src/main/ipc/t3Agent.ts @@ -15,8 +15,12 @@ import { AGENT_PROVIDER_AUTH_CANCEL, AGENT_PROVIDER_STATUS_GET, AGENT_PROVIDER_SETTINGS, + AGENT_REMOTE_START, + AGENT_REMOTE_GET, + AGENT_REMOTE_WRITE, + AGENT_REMOTE_CANCEL, } from '../../shared/ipc-channels' -import type { AgentHarnessPanelRequest, AgentProviderAuthRequest, AgentProviderId, AgentProviderStatusRequest } from '../../shared/t3Agent' +import type { AgentHarnessPanelRequest, AgentProviderAuthRequest, AgentProviderId, AgentProviderStatusRequest, T3RemoteOperation } from '../../shared/t3Agent' import { t3HarnessManager } from '../t3Agent/T3HarnessManager' import { broadcastToAll, windowFromEvent } from '../windowRegistry' @@ -77,6 +81,32 @@ function requireWindowId(event: IpcMainInvokeEvent): number { } export function registerT3AgentHandlers(): void { + ipcMain.handle(AGENT_REMOTE_START, async (event, input: unknown) => { + try { + const request = validateProviderStatusRequest(input) + const operation = (input as { operation?: unknown }).operation + if (operation !== 'status' && operation !== 'link' && operation !== 'unlink') throw new Error('Invalid T3 Connect operation') + return await t3HarnessManager.startRemote({ ...request, operation: operation as T3RemoteOperation }, requireWindowId(event)) + } catch (error) { return { error: error instanceof Error ? error.message : String(error) } } + }) + ipcMain.handle(AGENT_REMOTE_GET, (event, input: unknown) => { + try { + return t3HarnessManager.getRemote(requireText((input as { id?: unknown } | null)?.id, 'id'), requireWindowId(event)) + } catch (error) { return { error: error instanceof Error ? error.message : String(error) } } + }) + ipcMain.handle(AGENT_REMOTE_WRITE, (event, input: unknown) => { + try { + const request = input as { id?: unknown; data?: unknown } | null + t3HarnessManager.writeRemote(requireText(request?.id, 'id'), requireWindowId(event), typeof request?.data === 'string' ? request.data : '') + return { ok: true } + } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) } } + }) + ipcMain.handle(AGENT_REMOTE_CANCEL, (event, input: unknown) => { + try { + t3HarnessManager.cancelRemote(requireText((input as { id?: unknown } | null)?.id, 'id'), requireWindowId(event)) + return { ok: true } + } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) } } + }) ipcMain.handle(AGENT_HARNESS_RENAME_CONVERSATION, async (event, input: unknown) => { try { const request = validateProviderStatusRequest(input) diff --git a/src/main/t3Agent/T3HarnessManager.test.ts b/src/main/t3Agent/T3HarnessManager.test.ts index 6d46b888..7bae627d 100644 --- a/src/main/t3Agent/T3HarnessManager.test.ts +++ b/src/main/t3Agent/T3HarnessManager.test.ts @@ -22,6 +22,7 @@ function runtime() { function instance(key: string, rt: ReturnType) { return { key, runtimeId: key.startsWith('local:') ? 'local' : 'remote', runtime: rt, environmentId: 'env', proxyPort: 4321, + entryPath: '/bundled/t3/bin.mjs', baseDir: `/app/harness/instances/${key}`, serverId: key, panels: new Set(), proxy: { close: vi.fn((done: () => void) => done()) }, } @@ -183,6 +184,32 @@ describe('T3 provider sign-in ownership', () => { }) }) +describe('T3 Connect', () => { + it('runs the bundled CLI against the selected checkout and restarts after linking', async () => { + const session = await manager.startRemote({ workspaceId: 'ws', cwd: '/alias', operation: 'link' }, 1) + expect(local.process.create).toHaveBeenCalledWith(expect.objectContaining({ + cwd: '/repo', scopeId: 'ws', + command: expect.objectContaining({ args: ['/bundled/t3/bin.mjs', 'connect', 'link', '--base-dir', '/app/harness/instances/local:/repo', '--headless'] }), + }), expect.any(Function), expect.any(Function)) + expect(() => manager.getRemote(session.id, 2)).toThrow('not found') + expect(() => manager.writeRemote(session.id, 2, 'yes')).toThrow('not found') + manager.writeRemote(session.id, 1, 'yes\n') + expect(local.process.write).toHaveBeenCalledWith(session.id, 'yes\n') + local.process.create.mock.calls[0][2](session.id, 0) + await vi.waitFor(() => expect(manager.getRemote(session.id, 1).phase).toBe('succeeded')) + expect(local.server.stop).toHaveBeenCalledWith('local:/repo') + expect(start).toHaveBeenCalledTimes(2) + }) + + it('rejects remote workspaces and concurrent operations', async () => { + await expect(manager.startRemote({ workspaceId: 'ws', cwd: 'ssh:/repo', operation: 'link' }, 1)).rejects.toThrow('local workspaces') + const session = await manager.startRemote({ workspaceId: 'ws', cwd: '/repo', operation: 'status' }, 1) + await expect(manager.startRemote({ workspaceId: 'ws', cwd: '/alias', operation: 'unlink' }, 1)).rejects.toThrow('already running') + manager.cancelRemote(session.id, 1) + expect(local.process.kill).toHaveBeenCalledWith(session.id) + }) +}) + it('copies provider secrets through canonical directory aliases and still rejects collision-renamed files', async () => { const secret = 'provider-env-Y29kZXg-VE9LRU4.bin' const rt = { diff --git a/src/main/t3Agent/T3HarnessManager.ts b/src/main/t3Agent/T3HarnessManager.ts index 8a713d98..eefa0d28 100644 --- a/src/main/t3Agent/T3HarnessManager.ts +++ b/src/main/t3Agent/T3HarnessManager.ts @@ -37,6 +37,8 @@ import type { AgentProviderId, AgentProviderStatus, AgentProviderStatusRequest, + T3RemoteOperation, + T3RemoteSession, } from '../../shared/t3Agent' const READY_PATH = '/.well-known/t3/environment' @@ -77,6 +79,13 @@ interface ProviderAuthState extends AgentProviderAuthSession { rawOutput: string } +interface RemoteSessionState extends T3RemoteSession { + ownerWindowId: number + key: string + processId?: string + runtime: Runtime +} + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } @@ -146,6 +155,7 @@ export class T3HarnessManager { private readonly panelRoute = new Map() private readonly locatorHarness = new Map() private readonly providerAuth = new Map() + private readonly remoteSessions = new Map() constructor() { const onDisconnected = runtimes.onDisconnected?.bind(runtimes) @@ -166,9 +176,94 @@ export class T3HarnessManager { this.cancelProviderAuth(auth.id, windowId) } } + for (const remote of this.remoteSessions.values()) { + if (remote.ownerWindowId === windowId && remote.phase === 'running') this.cancelRemote(remote.id, windowId) + } }) } + async startRemote(request: AgentProviderStatusRequest & { operation: T3RemoteOperation }, ownerWindowId: number): Promise { + const resolved = resolveLocator(request.cwd) + if (resolved.runtimeId !== 'local') throw new Error('T3 Connect is currently available for local workspaces only') + const cwd = await resolved.runtime.validatePathStrict(resolved.path, ownerWindowId, request.workspaceId) + const key = harnessKey(resolved.runtimeId, cwd) + if ([...this.remoteSessions.values()].some((session) => session.key === key && session.phase === 'running')) { + throw new Error('A T3 Connect operation is already running for this checkout') + } + const instance = await this.ensureInstance(key, resolved.runtimeId, resolved.runtime, cwd, request.workspaceId) + const id = `t3-remote-${randomUUID()}` + const state: RemoteSessionState = { + id, operation: request.operation, phase: 'running', output: '', ownerWindowId, + key, runtime: resolved.runtime, + } + this.remoteSessions.set(id, state) + try { + const handle = await resolved.runtime.process.create({ + id, cols: 96, rows: 30, cwd, scopeId: request.workspaceId, + command: { + executable: harnessNodeExecutable(resolved.runtimeId, app.isPackaged), + args: [instance.entryPath, 'connect', request.operation, '--base-dir', instance.baseDir, + ...(request.operation === 'link' ? ['--headless'] : [])], + }, + env: { TERM: 'xterm-256color' }, + }, (_id, chunk) => { + const current = this.remoteSessions.get(id) + if (current) current.output = cleanProviderAuthOutput(`${current.output}${chunk}`).slice(-24_000) + }, (_id, exitCode) => { + const current = this.remoteSessions.get(id) + if (!current || current.phase !== 'running') return + if (exitCode !== 0) { + current.phase = 'failed' + current.message = `T3 Connect exited with code ${exitCode}.` + } else if (request.operation === 'link') { + void this.restart(cwd).then(() => this.ensureInstance(key, resolved.runtimeId, resolved.runtime, cwd, request.workspaceId)).then(() => { + current.phase = 'succeeded' + current.message = 'T3 Connect is enabled for this checkout.' + }).catch((error) => { + current.phase = 'failed' + current.message = `T3 Connect was authorized, but the server could not restart: ${errorMessage(error)}` + }) + } else { + current.phase = 'succeeded' + current.message = request.operation === 'unlink' ? 'T3 Connect is disabled for this checkout.' : undefined + } + }) + state.processId = handle.id + if (state.phase === 'cancelled') resolved.runtime.process.kill(handle.id) + return this.remoteSnapshot(state) + } catch (error) { + this.remoteSessions.delete(id) + throw error + } + } + + getRemote(id: string, ownerWindowId: number): T3RemoteSession { + return this.remoteSnapshot(this.ownedRemote(id, ownerWindowId)) + } + + writeRemote(id: string, ownerWindowId: number, data: string): void { + const state = this.ownedRemote(id, ownerWindowId) + if (state.phase === 'running' && state.processId) state.runtime.process.write(state.processId, data) + } + + cancelRemote(id: string, ownerWindowId: number): void { + const state = this.ownedRemote(id, ownerWindowId) + if (state.phase !== 'running') return + state.phase = 'cancelled' + if (state.processId) state.runtime.process.kill(state.processId) + } + + private ownedRemote(id: string, ownerWindowId: number): RemoteSessionState { + const state = this.remoteSessions.get(id) + if (!state || state.ownerWindowId !== ownerWindowId) throw new Error('T3 Connect session was not found') + return state + } + + private remoteSnapshot(state: RemoteSessionState): T3RemoteSession { + return { id: state.id, operation: state.operation, phase: state.phase, output: state.output, + ...(state.message ? { message: state.message } : {}) } + } + async startProviderAuth( request: AgentProviderAuthRequest, ownerWindowId: number, @@ -480,6 +575,10 @@ export class T3HarnessManager { async disposeAll(): Promise { this.panelAcquisitions.clear() + for (const remote of this.remoteSessions.values()) { + if (remote.phase === 'running' && remote.processId) remote.runtime.process.kill(remote.processId) + } + this.remoteSessions.clear() for (const auth of this.providerAuth.values()) { if (auth.phase === 'running' && auth.processId) auth.runtime.process.kill(auth.processId) } diff --git a/src/preload/index.ts b/src/preload/index.ts index 402edc88..f6ccb8ae 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -241,6 +241,10 @@ import { AGENT_HARNESS_PANEL_CLOSED, AGENT_HARNESS_RESTART, AGENT_HARNESS_GET_STATUS, + AGENT_REMOTE_START, + AGENT_REMOTE_GET, + AGENT_REMOTE_WRITE, + AGENT_REMOTE_CANCEL, AGENT_PROVIDER_AUTH_START, AGENT_PROVIDER_AUTH_GET, AGENT_PROVIDER_AUTH_WRITE, @@ -509,6 +513,10 @@ const invokeForwarders = { agentHarnessGetPanelUrl: makeInvoker<'agentHarnessGetPanelUrl'>(AGENT_HARNESS_GET_PANEL_URL), agentHarnessRestart: makeInvoker<'agentHarnessRestart'>(AGENT_HARNESS_RESTART), agentHarnessGetStatus: makeInvoker<'agentHarnessGetStatus'>(AGENT_HARNESS_GET_STATUS), + agentRemoteStart: makeInvoker<'agentRemoteStart'>(AGENT_REMOTE_START), + agentRemoteGet: makeInvoker<'agentRemoteGet'>(AGENT_REMOTE_GET), + agentRemoteWrite: makeInvoker<'agentRemoteWrite'>(AGENT_REMOTE_WRITE), + agentRemoteCancel: makeInvoker<'agentRemoteCancel'>(AGENT_REMOTE_CANCEL), agentProviderAuthStart: makeInvoker<'agentProviderAuthStart'>(AGENT_PROVIDER_AUTH_START), agentProviderAuthGet: makeInvoker<'agentProviderAuthGet'>(AGENT_PROVIDER_AUTH_GET), agentProviderAuthWrite: makeInvoker<'agentProviderAuthWrite'>(AGENT_PROVIDER_AUTH_WRITE), diff --git a/src/renderer/settings/AgentSettings.tsx b/src/renderer/settings/AgentSettings.tsx index b0dcc781..d3478c77 100644 --- a/src/renderer/settings/AgentSettings.tsx +++ b/src/renderer/settings/AgentSettings.tsx @@ -9,6 +9,7 @@ import { AgentHooksSettings } from './AgentHooksSettings' import { AGENT_PROVIDER_LOGINS, type AgentProviderLogin } from './providerAuthentication' import type { AgentProviderAuthSession, AgentProviderStatus } from '../../shared/t3Agent' import { errorMessage } from '../lib/errorMessage' +import { T3RemoteAccess } from './T3RemoteAccess' export function AgentSettings() { const [authProvider, setAuthProvider] = useState(null) @@ -132,7 +133,7 @@ export function AgentSettings() { }, [authSession?.phase, authSession?.providerId, refreshProviderStatuses]) return ( - +
(
@@ -182,6 +183,7 @@ export function AgentSettings() { })}
)} /> +

Agent hooks

diff --git a/src/renderer/settings/T3RemoteAccess.tsx b/src/renderer/settings/T3RemoteAccess.tsx new file mode 100644 index 00000000..35c68d7b --- /dev/null +++ b/src/renderer/settings/T3RemoteAccess.tsx @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { T3RemoteOperation, T3RemoteSession } from '../../shared/t3Agent' +import { errorMessage } from '../lib/errorMessage' +import { SecondaryButton } from './SettingsComponents' + +export function T3RemoteAccess({ workspaceId, cwd }: { workspaceId: string; cwd: string }) { + const [session, setSession] = useState(null) + const [error, setError] = useState('') + const [input, setInput] = useState('') + const runningId = useRef(null) + const start = useCallback(async (operation: T3RemoteOperation) => { + setError('') + setSession(null) + try { + const result = await window.electronAPI.agentRemoteStart({ workspaceId, cwd, operation }) + if ('error' in result) setError(result.error) + else setSession(result) + } catch (cause) { setError(errorMessage(cause, 'T3 Connect could not start.')) } + }, [workspaceId, cwd]) + + useEffect(() => { + if (!workspaceId || !cwd) return + void start('status') + }, [workspaceId, cwd, start]) + + const sessionId = session?.id + const sessionPhase = session?.phase + useEffect(() => { + if (!sessionId || sessionPhase !== 'running') return + const id = sessionId + const timer = window.setInterval(() => { + void window.electronAPI.agentRemoteGet({ id }).then((result) => { + if ('error' in result) setError(result.error) + else setSession((current) => current?.id === id ? result : current) + }).catch((cause) => setError(errorMessage(cause, 'Could not read T3 Connect status.'))) + }, 500) + return () => window.clearInterval(timer) + }, [sessionId, sessionPhase]) + + useEffect(() => { + runningId.current = session?.phase === 'running' ? session.id : null + }, [session]) + useEffect(() => () => { + if (runningId.current) void window.electronAPI.agentRemoteCancel({ id: runningId.current }) + }, []) + + const send = (data: string) => { + if (!session || session.phase !== 'running') return + void window.electronAPI.agentRemoteWrite({ id: session.id, data }) + setInput('') + } + + const busy = session?.phase === 'running' + const authorizationUrl = session?.operation === 'link' + ? session.output.match(/https:\/\/[^\s<>"']+/)?.[0].replace(/[),.;]+$/, '') + : undefined + return
+

T3 Connect

+

Reach this checkout’s T3 conversations from your phone. Keep this Mac awake, online, and running Cate. T3 Connect uses a managed remote tunnel; no VPS or port forwarding is needed.

+
+ void start('link')}>Enable T3 Connect + void start('unlink')}>Disable + void start('status')}>Refresh status + {busy && void window.electronAPI.agentRemoteCancel({ id: session.id })}>Cancel} +
+ {!cwd &&

Open a local workspace first.

} + {error &&

{error}

} + {session?.message &&

{session.message}

} + {authorizationUrl && window.electronAPI.openExternalUrl(authorizationUrl)}>Open authorization page} + {session?.output &&
{session.output}
} + {busy && session.operation === 'link' &&
+ setInput(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') send(`${input}\n`) }} placeholder="Reply to a T3 prompt, if needed" /> + send(`${input}\n`)}>Send +
} +
+} diff --git a/src/shared/electron-api.d.ts b/src/shared/electron-api.d.ts index 18b4b648..30785687 100644 --- a/src/shared/electron-api.d.ts +++ b/src/shared/electron-api.d.ts @@ -1103,6 +1103,11 @@ export interface ElectronAPI { agentHarnessGetStatus(request: { cwd: string }): Promise + agentRemoteStart(request: AgentProviderStatusRequest & { operation: import('./t3Agent').T3RemoteOperation }): Promise + agentRemoteGet(request: { id: string }): Promise + agentRemoteWrite(request: { id: string; data: string }): Promise<{ ok: boolean; error?: string }> + agentRemoteCancel(request: { id: string }): Promise<{ ok: boolean; error?: string }> + agentProviderAuthStart( request: AgentProviderAuthRequest, ): Promise diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 743c6bd0..47f93cc9 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -365,6 +365,10 @@ export const AGENT_HARNESS_RENAME_CONVERSATION = 'agentHarness:renameConversatio export const AGENT_HARNESS_PANEL_CLOSED = 'agentHarness:panelClosed' export const AGENT_HARNESS_RESTART = 'agentHarness:restart' export const AGENT_HARNESS_GET_STATUS = 'agentHarness:getStatus' +export const AGENT_REMOTE_START = 'agentRemote:start' +export const AGENT_REMOTE_GET = 'agentRemote:get' +export const AGENT_REMOTE_WRITE = 'agentRemote:write' +export const AGENT_REMOTE_CANCEL = 'agentRemote:cancel' export const AGENT_PROVIDER_AUTH_START = 'agentProviderAuth:start' export const AGENT_PROVIDER_AUTH_GET = 'agentProviderAuth:get' export const AGENT_PROVIDER_AUTH_WRITE = 'agentProviderAuth:write' diff --git a/src/shared/t3Agent.ts b/src/shared/t3Agent.ts index 7cda021d..ae4ccb7e 100644 --- a/src/shared/t3Agent.ts +++ b/src/shared/t3Agent.ts @@ -28,6 +28,16 @@ export interface AgentHarnessStatus { message?: string } +export type T3RemoteOperation = 'status' | 'link' | 'unlink' + +export interface T3RemoteSession { + id: string + operation: T3RemoteOperation + phase: 'running' | 'succeeded' | 'failed' | 'cancelled' + output: string + message?: string +} + export type AgentProviderId = import('./agents').T3ProviderId export type AgentProviderAuthPhase = 'running' | 'succeeded' | 'failed' | 'cancelled' From 0e75db555caad959a98216f360408e5a0b3e17d4 Mon Sep 17 00:00:00 2001 From: Anton Date: Fri, 25 Sep 2026 18:17:06 +0200 Subject: [PATCH 2/2] Fix T3 Connect authorization and inline setup guidance --- src/main/t3Agent/T3HarnessManager.test.ts | 26 +++- src/main/t3Agent/T3HarnessManager.ts | 18 ++- .../t3Agent/remoteAuthorizationUrl.test.ts | 19 +++ src/main/t3Agent/remoteAuthorizationUrl.ts | 18 +++ src/renderer/settings/T3RemoteAccess.test.tsx | 54 ++++++++ src/renderer/settings/T3RemoteAccess.tsx | 121 +++++++++++++----- src/shared/t3Agent.ts | 1 + 7 files changed, 222 insertions(+), 35 deletions(-) create mode 100644 src/main/t3Agent/remoteAuthorizationUrl.test.ts create mode 100644 src/main/t3Agent/remoteAuthorizationUrl.ts create mode 100644 src/renderer/settings/T3RemoteAccess.test.tsx diff --git a/src/main/t3Agent/T3HarnessManager.test.ts b/src/main/t3Agent/T3HarnessManager.test.ts index 7bae627d..a00b37f7 100644 --- a/src/main/t3Agent/T3HarnessManager.test.ts +++ b/src/main/t3Agent/T3HarnessManager.test.ts @@ -189,7 +189,7 @@ describe('T3 Connect', () => { const session = await manager.startRemote({ workspaceId: 'ws', cwd: '/alias', operation: 'link' }, 1) expect(local.process.create).toHaveBeenCalledWith(expect.objectContaining({ cwd: '/repo', scopeId: 'ws', - command: expect.objectContaining({ args: ['/bundled/t3/bin.mjs', 'connect', 'link', '--base-dir', '/app/harness/instances/local:/repo', '--headless'] }), + command: expect.objectContaining({ args: ['/bundled/t3/bin.mjs', 'connect', 'link', '--base-dir', '/app/harness/instances/local:/repo'] }), }), expect.any(Function), expect.any(Function)) expect(() => manager.getRemote(session.id, 2)).toThrow('not found') expect(() => manager.writeRemote(session.id, 2, 'yes')).toThrow('not found') @@ -201,6 +201,30 @@ describe('T3 Connect', () => { expect(start).toHaveBeenCalledTimes(2) }) + it('returns a complete authorization URL and requests machine-readable status', async () => { + const link = await manager.startRemote({ workspaceId: 'ws', cwd: '/repo', operation: 'link' }, 1) + const output = local.process.create.mock.calls[0][1] + output(link.id, 'Open this URL: https://app.t3.codes/connect') + expect(manager.getRemote(link.id, 1).authorizationUrl).toBeUndefined() + output(link.id, `#state=${'a'.repeat(22)}&challenge=${'b'.repeat(43)}&port=34338\r\n`) + expect(manager.getRemote(link.id, 1).authorizationUrl).toBe(`https://app.t3.codes/connect#state=${'a'.repeat(22)}&challenge=${'b'.repeat(43)}&port=34338`) + manager.cancelRemote(link.id, 1) + + await manager.startRemote({ workspaceId: 'ws', cwd: '/repo', operation: 'status' }, 1) + expect(local.process.create).toHaveBeenLastCalledWith(expect.objectContaining({ + cols: 1024, + command: expect.objectContaining({ args: expect.arrayContaining(['status', '--json']) }), + }), expect.any(Function), expect.any(Function)) + }) + + it('does not enable Connect when relay installation is skipped', async () => { + const session = await manager.startRemote({ workspaceId: 'ws', cwd: '/repo', operation: 'link' }, 1) + local.process.create.mock.calls[0][1](session.id, 'T3 Connect setup cancelled. The relay client was not installed.\r\n') + local.process.create.mock.calls[0][2](session.id, 0) + expect(manager.getRemote(session.id, 1)).toMatchObject({ phase: 'cancelled', message: 'T3 Connect setup was cancelled.' }) + expect(local.server.stop).not.toHaveBeenCalled() + }) + it('rejects remote workspaces and concurrent operations', async () => { await expect(manager.startRemote({ workspaceId: 'ws', cwd: 'ssh:/repo', operation: 'link' }, 1)).rejects.toThrow('local workspaces') const session = await manager.startRemote({ workspaceId: 'ws', cwd: '/repo', operation: 'status' }, 1) diff --git a/src/main/t3Agent/T3HarnessManager.ts b/src/main/t3Agent/T3HarnessManager.ts index eefa0d28..0d98a44a 100644 --- a/src/main/t3Agent/T3HarnessManager.ts +++ b/src/main/t3Agent/T3HarnessManager.ts @@ -26,6 +26,7 @@ import { isProviderSecretFile, } from './providerProfile' import { cleanProviderAuthOutput, providerAuthCode, providerAuthCommand, providerAuthUrl } from './providerAuth' +import { remoteAuthorizationUrl } from './remoteAuthorizationUrl' import { PROVIDER_STATUS_CACHE, providerStatusFromSnapshot } from './providerStatus' import { settingsRpc } from './settingsRpc' import type { @@ -84,6 +85,7 @@ interface RemoteSessionState extends T3RemoteSession { key: string processId?: string runtime: Runtime + rawOutput: string } function errorMessage(error: unknown): string { @@ -193,28 +195,35 @@ export class T3HarnessManager { const instance = await this.ensureInstance(key, resolved.runtimeId, resolved.runtime, cwd, request.workspaceId) const id = `t3-remote-${randomUUID()}` const state: RemoteSessionState = { - id, operation: request.operation, phase: 'running', output: '', ownerWindowId, + id, operation: request.operation, phase: 'running', output: '', rawOutput: '', ownerWindowId, key, runtime: resolved.runtime, } this.remoteSessions.set(id, state) try { const handle = await resolved.runtime.process.create({ - id, cols: 96, rows: 30, cwd, scopeId: request.workspaceId, + id, cols: 1024, rows: 30, cwd, scopeId: request.workspaceId, command: { executable: harnessNodeExecutable(resolved.runtimeId, app.isPackaged), args: [instance.entryPath, 'connect', request.operation, '--base-dir', instance.baseDir, - ...(request.operation === 'link' ? ['--headless'] : [])], + ...(request.operation === 'status' ? ['--json'] : [])], }, env: { TERM: 'xterm-256color' }, }, (_id, chunk) => { const current = this.remoteSessions.get(id) - if (current) current.output = cleanProviderAuthOutput(`${current.output}${chunk}`).slice(-24_000) + if (current) { + current.rawOutput = `${current.rawOutput}${chunk}`.slice(-32_768) + current.output = cleanProviderAuthOutput(current.rawOutput).slice(-24_000) + current.authorizationUrl = remoteAuthorizationUrl(current.rawOutput) + } }, (_id, exitCode) => { const current = this.remoteSessions.get(id) if (!current || current.phase !== 'running') return if (exitCode !== 0) { current.phase = 'failed' current.message = `T3 Connect exited with code ${exitCode}.` + } else if (request.operation === 'link' && current.output.includes('T3 Connect setup cancelled.')) { + current.phase = 'cancelled' + current.message = 'T3 Connect setup was cancelled.' } else if (request.operation === 'link') { void this.restart(cwd).then(() => this.ensureInstance(key, resolved.runtimeId, resolved.runtime, cwd, request.workspaceId)).then(() => { current.phase = 'succeeded' @@ -261,6 +270,7 @@ export class T3HarnessManager { private remoteSnapshot(state: RemoteSessionState): T3RemoteSession { return { id: state.id, operation: state.operation, phase: state.phase, output: state.output, + ...(state.authorizationUrl ? { authorizationUrl: state.authorizationUrl } : {}), ...(state.message ? { message: state.message } : {}) } } diff --git a/src/main/t3Agent/remoteAuthorizationUrl.test.ts b/src/main/t3Agent/remoteAuthorizationUrl.test.ts new file mode 100644 index 00000000..d9f8db86 --- /dev/null +++ b/src/main/t3Agent/remoteAuthorizationUrl.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { remoteAuthorizationUrl } from './remoteAuthorizationUrl' + +describe('remoteAuthorizationUrl', () => { + const url = `https://app.t3.codes/connect#state=${'a'.repeat(22)}&challenge=${'b'.repeat(43)}&port=34338` + it('accepts the complete request printed by the pinned T3 CLI', () => { + expect(remoteAuthorizationUrl(`Open this URL: ${url}`)).toBeUndefined() + expect(remoteAuthorizationUrl(`Headless authorization\r\nOpen this URL on a device with a browser:\r\n ${url}\r\n`)).toBe(url) + }) + + it('never opens a partial or unrelated URL', () => { + expect(remoteAuthorizationUrl('https://app.t3.codes/connect')).toBeUndefined() + expect(remoteAuthorizationUrl(`https://app.t3.codes/connect#state=${'a'.repeat(22)}&challenge=b\n`)).toBeUndefined() + expect(remoteAuthorizationUrl(`https://app.t3.codes/connect#state=${'a'.repeat(22)}&challenge=${'b'.repeat(43)}\n`)).toBeUndefined() + expect(remoteAuthorizationUrl(`${url.replace('port=34338', 'port=70000')}\n`)).toBeUndefined() + expect(remoteAuthorizationUrl(`https://example.test/connect#state=${'a'.repeat(22)}&challenge=${'b'.repeat(43)}\n${url}\n`)) + .toBe(url) + }) +}) diff --git a/src/main/t3Agent/remoteAuthorizationUrl.ts b/src/main/t3Agent/remoteAuthorizationUrl.ts new file mode 100644 index 00000000..8189bc92 --- /dev/null +++ b/src/main/t3Agent/remoteAuthorizationUrl.ts @@ -0,0 +1,18 @@ +import { cleanProviderAuthOutput } from './providerAuth' + +/** Only offer complete loopback requests; the hosted page requires a callback port. */ +export function remoteAuthorizationUrl(output: string): string | undefined { + const urls = cleanProviderAuthOutput(output).match(/https:\/\/[^\s<>"']+(?=\s)/g) ?? [] + for (const raw of urls) { + try { + const url = new URL(raw.replace(/[),.;]+$/, '')) + if (url.protocol !== 'https:' || url.hostname !== 'app.t3.codes' || url.pathname !== '/connect') continue + const request = new URLSearchParams(url.hash.slice(1)) + const port = request.get('port') ?? '' + if (/^[A-Za-z0-9_-]{22}$/.test(request.get('state') ?? '') + && /^[A-Za-z0-9_-]{43}$/.test(request.get('challenge') ?? '') + && /^\d{1,5}$/.test(port) && Number(port) >= 1 && Number(port) <= 65535) return url.toString() + } catch { /* Ignore incomplete terminal output. */ } + } + return undefined +} diff --git a/src/renderer/settings/T3RemoteAccess.test.tsx b/src/renderer/settings/T3RemoteAccess.test.tsx new file mode 100644 index 00000000..4ddf6893 --- /dev/null +++ b/src/renderer/settings/T3RemoteAccess.test.tsx @@ -0,0 +1,54 @@ +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { T3RemoteAccess } from './T3RemoteAccess' + +;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true +let host: HTMLDivElement +let root: Root +const url = `https://app.t3.codes/connect#state=${'a'.repeat(22)}&challenge=${'b'.repeat(43)}&port=34338` +const start = vi.fn() +const get = vi.fn() +const write = vi.fn().mockResolvedValue({ ok: true }) + +beforeEach(() => { + host = document.createElement('div'); document.body.append(host); root = createRoot(host) + start.mockReset(); get.mockReset(); write.mockClear() + start.mockResolvedValueOnce({ id: 'status', operation: 'status', phase: 'succeeded', output: '{"desired":false,"authenticated":false,"linked":false}' }) + Object.assign(window.electronAPI, { + agentRemoteStart: start, + agentRemoteGet: get, + agentRemoteWrite: write, + agentRemoteCancel: vi.fn().mockResolvedValue({ ok: true }), + terminalClipboardWrite: vi.fn().mockResolvedValue(undefined), + }) +}) +afterEach(async () => { await act(async () => root.unmount()); host.remove() }) + +it('keeps the connect flow inline and asks the CLI to open its loopback link', async () => { + await act(async () => root.render()) + expect(host.textContent).toContain('Off') + expect(host.textContent).not.toContain('"desired"') + + start.mockResolvedValueOnce({ id: 'link', operation: 'link', phase: 'running', output: 'Preparing' }) + await act(async () => [...host.querySelectorAll('button')].find((button) => button.textContent === 'Enable')!.click()) + expect(document.querySelector('[role="dialog"]')).toBeNull() + + get.mockResolvedValue({ id: 'link', operation: 'link', phase: 'running', output: `Open this URL: ${url}\n`, authorizationUrl: url }) + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 550)) }) + expect(host.textContent).toContain('Authorize T3 Connect') + await act(async () => [...host.querySelectorAll('button')].find((button) => button.textContent?.includes('Open sign-in page'))!.click()) + expect(write).toHaveBeenCalledWith({ id: 'link', data: '\r' }) + expect(host.textContent).toContain('Waiting for browser sign-in') + expect(host.querySelector('details')).not.toBeNull() +}) + +it('explains how to find a linked environment on the phone', async () => { + start.mockReset() + start.mockResolvedValue({ id: 'status', operation: 'status', phase: 'succeeded', output: '{"desired":true,"authenticated":true,"linked":true}' }) + await act(async () => root.render()) + expect(host.textContent).toContain('Connect your phone') + expect(host.textContent).toContain('same T3 Connect account') + expect(host.textContent).toContain('direct network pairing') + expect(document.querySelector('[role="dialog"]')).toBeNull() +}) diff --git a/src/renderer/settings/T3RemoteAccess.tsx b/src/renderer/settings/T3RemoteAccess.tsx index 35c68d7b..179d01d2 100644 --- a/src/renderer/settings/T3RemoteAccess.tsx +++ b/src/renderer/settings/T3RemoteAccess.tsx @@ -1,33 +1,56 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { ArrowUpRight, Check, Copy, Link2, RefreshCw } from 'lucide-react' import type { T3RemoteOperation, T3RemoteSession } from '../../shared/t3Agent' import { errorMessage } from '../lib/errorMessage' import { SecondaryButton } from './SettingsComponents' +interface ConnectStatus { + desired: boolean + authenticated: boolean + linked: boolean +} + +function readConnectStatus(output: string): ConnectStatus | null { + try { + const value = JSON.parse(output) as Partial + if (typeof value.desired !== 'boolean' || typeof value.authenticated !== 'boolean' || typeof value.linked !== 'boolean') return null + return { desired: value.desired, authenticated: value.authenticated, linked: value.linked } + } catch { return null } +} + export function T3RemoteAccess({ workspaceId, cwd }: { workspaceId: string; cwd: string }) { const [session, setSession] = useState(null) + const [status, setStatus] = useState(null) const [error, setError] = useState('') - const [input, setInput] = useState('') + const [starting, setStarting] = useState(false) + const [relayChoiceSent, setRelayChoiceSent] = useState(false) + const [browserOpened, setBrowserOpened] = useState(false) + const [linkCopied, setLinkCopied] = useState(false) const runningId = useRef(null) + const start = useCallback(async (operation: T3RemoteOperation) => { setError('') setSession(null) + setStarting(true) + setRelayChoiceSent(false) + setBrowserOpened(false) + setLinkCopied(false) + if (operation !== 'status') setStatus(null) try { const result = await window.electronAPI.agentRemoteStart({ workspaceId, cwd, operation }) if ('error' in result) setError(result.error) else setSession(result) } catch (cause) { setError(errorMessage(cause, 'T3 Connect could not start.')) } + finally { setStarting(false) } }, [workspaceId, cwd]) useEffect(() => { - if (!workspaceId || !cwd) return - void start('status') + if (workspaceId && cwd) void start('status') }, [workspaceId, cwd, start]) - const sessionId = session?.id - const sessionPhase = session?.phase useEffect(() => { - if (!sessionId || sessionPhase !== 'running') return - const id = sessionId + if (!session || session.phase !== 'running') return + const id = session.id const timer = window.setInterval(() => { void window.electronAPI.agentRemoteGet({ id }).then((result) => { if ('error' in result) setError(result.error) @@ -35,42 +58,80 @@ export function T3RemoteAccess({ workspaceId, cwd }: { workspaceId: string; cwd: }).catch((cause) => setError(errorMessage(cause, 'Could not read T3 Connect status.'))) }, 500) return () => window.clearInterval(timer) - }, [sessionId, sessionPhase]) + }, [session]) useEffect(() => { - runningId.current = session?.phase === 'running' ? session.id : null - }, [session]) + if (session?.operation === 'status' && session.phase === 'succeeded') setStatus(readConnectStatus(session.output)) + if (session?.phase === 'succeeded' && session.operation !== 'status') void start('status') + }, [session, start]) + + useEffect(() => { runningId.current = session?.phase === 'running' ? session.id : null }, [session]) useEffect(() => () => { if (runningId.current) void window.electronAPI.agentRemoteCancel({ id: runningId.current }) }, []) const send = (data: string) => { if (!session || session.phase !== 'running') return - void window.electronAPI.agentRemoteWrite({ id: session.id, data }) - setInput('') + void window.electronAPI.agentRemoteWrite({ id: session.id, data }).then((result) => { + if (result.error) setError(result.error) + }).catch((cause) => setError(errorMessage(cause, 'Could not send response.'))) } - const busy = session?.phase === 'running' - const authorizationUrl = session?.operation === 'link' - ? session.output.match(/https:\/\/[^\s<>"']+/)?.[0].replace(/[),.;]+$/, '') - : undefined + const busy = starting || session?.phase === 'running' + const needsRelayApproval = session?.operation === 'link' && session.phase === 'running' + && /Download and install version [^?]+\?/.test(session.output) + && !session.authorizationUrl && !relayChoiceSent + const awaitingBrowser = session?.operation === 'link' && session.phase === 'running' && Boolean(session.authorizationUrl) + const statusLabel = status?.linked ? 'Enabled' : status?.desired ? 'Finishing setup' : status?.authenticated ? 'Ready to enable' : status ? 'Off' + : session?.operation === 'status' && session.phase !== 'running' ? 'Status unavailable' : 'Checking…' + return
-

T3 Connect

-

Reach this checkout’s T3 conversations from your phone. Keep this Mac awake, online, and running Cate. T3 Connect uses a managed remote tunnel; no VPS or port forwarding is needed.

-
- void start('link')}>Enable T3 Connect - void start('unlink')}>Disable - void start('status')}>Refresh status - {busy && void window.electronAPI.agentRemoteCancel({ id: session.id })}>Cancel} +
+
+ +
+

T3 Connect

+

{statusLabel} · Reach this checkout from your phone while Cate is running on this Mac.

+
+
+
+ void start('link')}>{status?.desired ? 'Reconnect' : 'Enable'} + {status?.desired && void start('unlink')}>Disable} + void start('status')}> +
{!cwd &&

Open a local workspace first.

} - {error &&

{error}

} - {session?.message &&

{session.message}

} - {authorizationUrl && window.electronAPI.openExternalUrl(authorizationUrl)}>Open authorization page} - {session?.output &&
{session.output}
} - {busy && session.operation === 'link' &&
- setInput(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') send(`${input}\n`) }} placeholder="Reply to a T3 prompt, if needed" /> - send(`${input}\n`)}>Send + {error &&

{error}

} + {session?.message &&

{session.message}

} + {status?.linked &&
+

Connect your phone

+

Open T3 Code on your phone, sign in to the same T3 Connect account, then select this Mac’s environment. A QR code is only used for direct network pairing.

+
} + + {needsRelayApproval &&
+

Install the relay client

+

T3 Connect uses a managed relay client to reach this Mac.

+
+ { send('y'); setRelayChoiceSent(true) }}>Install and continue + { send('n'); setRelayChoiceSent(true) }}>Cancel setup +
} + + {awaitingBrowser &&
+

Authorize T3 Connect

+

Open the sign-in page in your browser. Cate will finish connecting after you sign in.

+
+ { send('\r'); setBrowserOpened(true) }}>{browserOpened ? 'Open again' : 'Open sign-in page'} + { void window.electronAPI.terminalClipboardWrite(session.authorizationUrl!); setLinkCopied(true) }}>{linkCopied ? : }{linkCopied ? 'Copied' : 'Copy link'} + void window.electronAPI.agentRemoteCancel({ id: session.id })}>Cancel +
+ {browserOpened &&

Waiting for browser sign-in…

} +
} + + {session?.phase === 'running' && !needsRelayApproval && !awaitingBrowser &&

{session.operation === 'link' ? 'Preparing T3 Connect…' : 'Checking T3 Connect…'}

} + {session?.output && (session.operation !== 'status' || !status) &&
+ Connection details +
{session.output}
+
}
} diff --git a/src/shared/t3Agent.ts b/src/shared/t3Agent.ts index ae4ccb7e..f5730d4d 100644 --- a/src/shared/t3Agent.ts +++ b/src/shared/t3Agent.ts @@ -35,6 +35,7 @@ export interface T3RemoteSession { operation: T3RemoteOperation phase: 'running' | 'succeeded' | 'failed' | 'cancelled' output: string + authorizationUrl?: string message?: string }