From 2b1abfd9efd31c22b2e3909b64a703719d100dc5 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Fri, 24 Jul 2026 20:30:11 -0700 Subject: [PATCH 01/11] [eas-cli] add workflow:ssh command --- CHANGELOG.md | 1 + packages/eas-cli/graphql.schema.json | 59 +++ .../commands/workflow/__tests__/ssh.test.ts | 397 ++++++++++++++++++ packages/eas-cli/src/commands/workflow/ssh.ts | 250 +++++++++++ packages/eas-cli/src/graphql/generated.ts | 20 + .../graphql/queries/WorkflowJobSshQuery.ts | 141 +++++++ .../__tests__/WorkflowJobSshQuery-test.ts | 82 ++++ 7 files changed, 950 insertions(+) create mode 100644 packages/eas-cli/src/commands/workflow/__tests__/ssh.test.ts create mode 100644 packages/eas-cli/src/commands/workflow/ssh.ts create mode 100644 packages/eas-cli/src/graphql/queries/WorkflowJobSshQuery.ts create mode 100644 packages/eas-cli/src/graphql/queries/__tests__/WorkflowJobSshQuery-test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 119a66e5fe..4c1964629d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,7 @@ This is the log of notable changes to EAS CLI and related packages. - [eas-cli] Add experimental `--resource-class` flag to `eas simulator`. ([#4268](https://github.com/expo/eas-cli/pull/4268) by [@gwdp](https://github.com/gwdp)) - [build-tools] Add worker-side SSH session helpers (upterm relay + session create/report/close). ([#4030](https://github.com/expo/eas-cli/pull/4030) by [@gwdp](https://github.com/gwdp)) - [worker] Wire an SSH session into the build lifecycle behind the workflow ssh flag. ([#4031](https://github.com/expo/eas-cli/pull/4031) by [@gwdp](https://github.com/gwdp)) +- [eas-cli] Add `eas workflow:ssh [command...]` to open an ssh session on the worker running a workflow job. ([#4032](https://github.com/expo/eas-cli/pull/4032) by [@gwdp](https://github.com/gwdp)) ### 🐛 Bug fixes diff --git a/packages/eas-cli/graphql.schema.json b/packages/eas-cli/graphql.schema.json index dfaad56c31..9d65b0ddd9 100644 --- a/packages/eas-cli/graphql.schema.json +++ b/packages/eas-cli/graphql.schema.json @@ -96614,6 +96614,18 @@ "defaultValue": null, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "ssh", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "WorkflowRunSshInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null } ], "interfaces": null, @@ -96816,6 +96828,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "ssh", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "WorkflowRunSshInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "workflowRevisionId", "description": null, @@ -96861,6 +96885,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "ssh", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "WorkflowRunSshInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "workflowRunId", "description": null, @@ -96940,6 +96976,29 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "WorkflowRunSshInput", + "description": "Enables ssh on the run's VM jobs. Presence turns ssh on; idleTimeoutSeconds is optional,\ndefaults server-side, and is validated against a supported range.", + "fields": null, + "inputFields": [ + { + "name": "idleTimeoutSeconds", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "WorkflowRunSshSettings", diff --git a/packages/eas-cli/src/commands/workflow/__tests__/ssh.test.ts b/packages/eas-cli/src/commands/workflow/__tests__/ssh.test.ts new file mode 100644 index 0000000000..015cbf290d --- /dev/null +++ b/packages/eas-cli/src/commands/workflow/__tests__/ssh.test.ts @@ -0,0 +1,397 @@ +import spawnAsync from '@expo/spawn-async'; +import { Config } from '@oclif/core'; +import fs from 'node:fs'; + +import { WorkflowJobSshQuery } from '../../../graphql/queries/WorkflowJobSshQuery'; +import Log from '../../../log'; +import { sleepAsync } from '../../../utils/promise'; +import WorkflowSsh, { + CONNECTION_HOST_REGEX, + CONNECTION_SECRET_REGEX, + parseSshArgv, + resolveSshConnectStatus, + splitConnectionHost, +} from '../ssh'; + +jest.mock('@expo/spawn-async', () => ({ __esModule: true, default: jest.fn() })); +jest.mock('../../../graphql/queries/WorkflowJobSshQuery'); +jest.mock('../../../log', () => ({ + __esModule: true, + default: { log: jest.fn(), error: jest.fn(), newLine: jest.fn() }, +})); +jest.mock('../../../ora', () => ({ + ora: jest.fn(() => { + const spinner = { start: jest.fn(), fail: jest.fn(), succeed: jest.fn(), stop: jest.fn() }; + spinner.start.mockReturnValue(spinner); + return spinner; + }), +})); +jest.mock('../../../utils/promise', () => ({ sleepAsync: jest.fn() })); +jest.mock('node:fs', () => { + const actual = jest.requireActual('node:fs'); + return { + ...actual, + promises: { ...actual.promises, mkdtemp: jest.fn(), writeFile: jest.fn(), rm: jest.fn() }, + }; +}); + +describe(parseSshArgv, () => { + it('takes the first token as the resource id and the rest as the passthrough command', () => { + expect(parseSshArgv(['job-id', 'ls', '-la'])).toEqual({ + showConnect: false, + resourceId: 'job-id', + command: ['ls', '-la'], + }); + }); + + it('detects --show-connect before the id and strips it', () => { + expect(parseSshArgv(['--show-connect', 'job-id'])).toEqual({ + showConnect: true, + resourceId: 'job-id', + command: [], + }); + }); + + it('detects --show-connect=true before the id', () => { + expect(parseSshArgv(['--show-connect=true', 'job-id'])).toEqual({ + showConnect: true, + resourceId: 'job-id', + command: [], + }); + }); + + it('leaves --show-connect after the id in the passthrough command', () => { + expect(parseSshArgv(['job-id', '--show-connect'])).toEqual({ + showConnect: false, + resourceId: 'job-id', + command: ['--show-connect'], + }); + }); + + it('throws on an unknown flag before the id', () => { + expect(() => parseSshArgv(['--show-conect', 'job-id'])).toThrow('Unknown flag'); + }); + + it('reports an undefined resource id when none is given', () => { + expect(parseSshArgv([])).toEqual({ showConnect: false, resourceId: undefined, command: [] }); + expect(parseSshArgv(['--show-connect'])).toEqual({ + showConnect: true, + resourceId: undefined, + command: [], + }); + }); +}); + +describe(resolveSshConnectStatus, () => { + it('is unknown when the resource did not resolve', () => { + expect(resolveSshConnectStatus(null)).toBe('unknown'); + }); + + it('is not-enabled when the job did not request ssh', () => { + expect( + resolveSshConnectStatus({ sshRequested: false, jobCompleted: false, session: null }) + ).toBe('not-enabled'); + }); + + it('is ended when ssh was requested, the job finished, and no session remains', () => { + expect(resolveSshConnectStatus({ sshRequested: true, jobCompleted: true, session: null })).toBe( + 'ended' + ); + }); + + it('is pending when ssh was requested and the job is still running without a session', () => { + expect( + resolveSshConnectStatus({ sshRequested: true, jobCompleted: false, session: null }) + ).toBe('pending'); + }); + + it('is pending while the worker is reconnecting', () => { + expect( + resolveSshConnectStatus({ + sshRequested: true, + jobCompleted: false, + session: { + connectionConfig: { host: 'uptermd.upterm.dev', secret: 'tok', reconnecting: true }, + }, + }) + ).toBe('pending'); + }); + + it('is ready when the session has a connection config', () => { + expect( + resolveSshConnectStatus({ + sshRequested: true, + jobCompleted: false, + session: { + connectionConfig: { host: 'uptermd.upterm.dev', secret: 'tok', reconnecting: false }, + }, + }) + ).toBe('ready'); + }); +}); + +describe('workflow:ssh connection validation', () => { + describe('CONNECTION_SECRET_REGEX', () => { + it.each(['TOKENabc123', 'sessionId:dGhpcytpcy9iYXNlNjQ9', 'a.b_c~d-e'])( + 'accepts the upterm token %s', + token => { + expect(CONNECTION_SECRET_REGEX.test(token)).toBe(true); + } + ); + + it.each(['tok en', 'tok\nUser attacker', 'tok@host', 'tok"x', "tok'x", 'tok`x', ''])( + 'rejects %j so it cannot inject an ssh config directive', + token => { + expect(CONNECTION_SECRET_REGEX.test(token)).toBe(false); + } + ); + }); + + describe('CONNECTION_HOST_REGEX', () => { + it('accepts a plain hostname', () => { + expect(CONNECTION_HOST_REGEX.test('uptermd.upterm.dev')).toBe(true); + }); + + it.each(['host name', 'host\nHostName evil', 'host@x', 'host/x', ''])('rejects %j', host => { + expect(CONNECTION_HOST_REGEX.test(host)).toBe(false); + }); + + it('accepts a hostname with a port', () => { + expect(CONNECTION_HOST_REGEX.test('relay.expo.dev:8022')).toBe(true); + }); + }); +}); + +describe(splitConnectionHost, () => { + it('returns the host with no port for a plain hostname', () => { + expect(splitConnectionHost('relay.expo.dev')).toEqual({ host: 'relay.expo.dev' }); + }); + + it('splits a host:port into host and numeric port', () => { + expect(splitConnectionHost('relay.expo.dev:8022')).toEqual({ + host: 'relay.expo.dev', + port: 8022, + }); + }); +}); + +describe(WorkflowSsh, () => { + const graphqlClient = {} as never; + const mockConnectInfo = jest.mocked(WorkflowJobSshQuery.connectInfoForWorkflowJobAsync); + const mockSpawn = jest.mocked(spawnAsync); + const mockSleep = jest.mocked(sleepAsync); + const mockMkdtemp = jest.mocked(fs.promises.mkdtemp); + const mockWriteFile = jest.mocked(fs.promises.writeFile); + const mockRm = jest.mocked(fs.promises.rm); + + let mockConfig: Config; + let previousExitCode: typeof process.exitCode; + + const readyInfo = { + sshRequested: true, + jobCompleted: false, + session: { + id: 'ts-1', + connectionConfig: { host: 'relay.expo.dev', secret: 'TOKENx', reconnecting: false }, + }, + }; + const pendingInfo = { sshRequested: true, jobCompleted: false, session: null }; + const endedInfo = { sshRequested: true, jobCompleted: true, session: null }; + const notEnabledInfo = { sshRequested: false, jobCompleted: false, session: null }; + + beforeAll(async () => { + mockConfig = new Config({ root: __dirname }); + mockConfig.runHook = async () => ({ failures: [], successes: [] }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + previousExitCode = process.exitCode; + process.exitCode = undefined; + mockMkdtemp.mockResolvedValue('/tmp/eas-ssh-1' as never); + mockWriteFile.mockResolvedValue(undefined as never); + mockRm.mockResolvedValue(undefined as never); + mockSpawn.mockResolvedValue({ stdout: '', stderr: '' } as never); + mockSleep.mockResolvedValue(undefined); + }); + + afterEach(() => { + process.exitCode = previousExitCode; + }); + + function createCommand(argv: string[]): WorkflowSsh { + const command = new WorkflowSsh(argv, mockConfig); + // @ts-expect-error getContextAsync/parse are protected + jest.spyOn(command, 'getContextAsync').mockResolvedValue({ loggedIn: { graphqlClient } }); + // @ts-expect-error parse is protected on the oclif base + jest.spyOn(command, 'parse').mockResolvedValue({}); + return command; + } + + it('throws when no workflow job id is given', async () => { + await expect(createCommand([]).runAsync()).rejects.toThrow('Provide a workflow job id'); + }); + + it('reports unknown and exits 1 when the id does not resolve', async () => { + mockConnectInfo.mockResolvedValue(null); + await createCommand(['job-1']).runAsync(); + expect(Log.error).toHaveBeenCalledWith(expect.stringContaining('No workflow job found')); + expect(process.exitCode).toBe(1); + }); + + it('reports not-enabled and exits 1 when the job did not request ssh', async () => { + mockConnectInfo.mockResolvedValue(notEnabledInfo as never); + await createCommand(['job-1']).runAsync(); + expect(Log.error).toHaveBeenCalledWith(expect.stringContaining('was not enabled')); + expect(process.exitCode).toBe(1); + }); + + it('reports ended and exits 1 when the job finished without a live session', async () => { + mockConnectInfo.mockResolvedValue(endedInfo as never); + await createCommand(['job-1']).runAsync(); + expect(Log.error).toHaveBeenCalledWith(expect.stringContaining('has ended')); + expect(process.exitCode).toBe(1); + }); + + it('prints the connect command for --show-connect without spawning ssh', async () => { + mockConnectInfo.mockResolvedValue(readyInfo as never); + await createCommand(['--show-connect', 'job-1']).runAsync(); + expect(Log.log).toHaveBeenCalledWith( + 'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null TOKENx@relay.expo.dev' + ); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('prints --show-connect with -p and a hostname-only wss proxy for non-default SSH ports', async () => { + mockConnectInfo.mockResolvedValue({ + ...readyInfo, + session: { + id: 'ts-1', + connectionConfig: { + host: 'relay.expo.dev:8022', + secret: 'TOKENx', + reconnecting: false, + }, + }, + } as never); + await createCommand(['--show-connect', 'job-1']).runAsync(); + expect(Log.log).toHaveBeenCalledWith( + 'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 8022 TOKENx@relay.expo.dev' + ); + expect(Log.log).toHaveBeenCalledWith( + ' ssh -o ProxyCommand="upterm proxy wss://TOKENx@relay.expo.dev" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 8022 TOKENx@relay.expo.dev' + ); + }); + + it('writes a 0600 config and opens ssh, then removes the config dir', async () => { + mockConnectInfo.mockResolvedValue(readyInfo as never); + await createCommand(['job-1', 'ls', '-la']).runAsync(); + expect(mockWriteFile).toHaveBeenCalledWith( + '/tmp/eas-ssh-1/config', + expect.stringContaining('HostName relay.expo.dev'), + { mode: 0o600 } + ); + expect(mockSpawn).toHaveBeenCalledWith( + 'ssh', + ['-F', '/tmp/eas-ssh-1/config', 'eas-workflow-ssh', 'ls', '-la'], + { stdio: 'inherit' } + ); + expect(mockRm).toHaveBeenCalledWith('/tmp/eas-ssh-1', { recursive: true, force: true }); + }); + + it('waits for a pending session to open, then connects', async () => { + mockConnectInfo + .mockResolvedValueOnce(pendingInfo as never) + .mockResolvedValue(readyInfo as never); + await createCommand(['job-1']).runAsync(); + expect(mockSleep).toHaveBeenCalled(); + expect(mockSpawn).toHaveBeenCalled(); + }); + + it('exits 1 when a pending session ends before it opens', async () => { + mockConnectInfo + .mockResolvedValueOnce(pendingInfo as never) + .mockResolvedValue(endedInfo as never); + await createCommand(['job-1']).runAsync(); + expect(mockSpawn).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it('exits 1 when waiting for the session to open times out', async () => { + jest.useFakeTimers(); + jest.setSystemTime(0); + try { + mockConnectInfo.mockResolvedValue(pendingInfo as never); + mockSleep.mockImplementation(async () => { + jest.setSystemTime(Date.now() + 6 * 60 * 1000); + }); + await createCommand(['job-1']).runAsync(); + expect(mockSpawn).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + } finally { + jest.useRealTimers(); + } + }); + + it('throws on an unexpected connection host', async () => { + mockConnectInfo.mockResolvedValue({ + sshRequested: true, + jobCompleted: false, + session: { + connectionConfig: { host: 'bad host', secret: 'TOKENx', reconnecting: false }, + }, + } as never); + await expect(createCommand(['job-1']).runAsync()).rejects.toThrow('connection host'); + }); + + it('throws on an unexpected connection token', async () => { + mockConnectInfo.mockResolvedValue({ + sshRequested: true, + jobCompleted: false, + session: { + connectionConfig: { host: 'relay.expo.dev', secret: 'bad token', reconnecting: false }, + }, + } as never); + await expect(createCommand(['job-1']).runAsync()).rejects.toThrow('connection token'); + }); + + it('propagates the ssh exit code from the catch handler', async () => { + const command = createCommand(['job-1']); + // @ts-expect-error isRunningSubprocess is private + command.isRunningSubprocess = true; + const err = Object.assign(new Error('ssh exited'), { status: 42 }); + // @ts-expect-error catch is protected + await command.catch(err); + expect(process.exitCode).toBe(42); + }); + + it('reports a missing ssh client (ENOENT) from the catch handler', async () => { + const command = createCommand(['job-1']); + // @ts-expect-error isRunningSubprocess is private + command.isRunningSubprocess = true; + const err = Object.assign(new Error('spawn ssh ENOENT'), { code: 'ENOENT' }); + // @ts-expect-error catch is protected + await command.catch(err); + expect(process.exitCode).toBe(1); + expect(Log.error).toHaveBeenCalledWith(expect.stringContaining('Install an OpenSSH client')); + }); + + it('delegates non-subprocess errors to the base handler', async () => { + const command = createCommand(['job-1']); + let threw = false; + try { + // @ts-expect-error catch is protected + await command.catch(new Error('boom')); + } catch { + threw = true; + } + expect(threw).toBe(true); + }); + + it('stops the spinner and rethrows when polling fails while waiting', async () => { + mockConnectInfo + .mockResolvedValueOnce(pendingInfo as never) + .mockRejectedValue(new Error('network blip')); + await expect(createCommand(['job-1']).runAsync()).rejects.toThrow('network blip'); + }); +}); diff --git a/packages/eas-cli/src/commands/workflow/ssh.ts b/packages/eas-cli/src/commands/workflow/ssh.ts new file mode 100644 index 0000000000..dd60c886c4 --- /dev/null +++ b/packages/eas-cli/src/commands/workflow/ssh.ts @@ -0,0 +1,250 @@ +import spawnAsync from '@expo/spawn-async'; +import { Flags } from '@oclif/core'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import EasCommand from '../../commandUtils/EasCommand'; +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { + WorkflowJobSshConnectInfo, + WorkflowJobSshQuery, + WorkflowJobSshSession, +} from '../../graphql/queries/WorkflowJobSshQuery'; +import Log from '../../log'; +import { ora } from '../../ora'; +import { sleepAsync } from '../../utils/promise'; + +export const CONNECTION_HOST_REGEX = /^[A-Za-z0-9.-]+(?::\d+)?$/; +export const CONNECTION_SECRET_REGEX = /^[A-Za-z0-9._~:/+=-]+$/; + +export function splitConnectionHost(connectionHost: string): { host: string; port?: number } { + const match = connectionHost.match(/^(.+):(\d+)$/); + if (!match) { + return { host: connectionHost }; + } + return { host: match[1], port: Number(match[2]) }; +} + +const SSH_INSECURE_OPTS = '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'; + +const SESSION_OPEN_TIMEOUT_MS = 5 * 60 * 1000; +const SESSION_OPEN_POLL_INTERVAL_MS = 3000; + +export type SshConnectStatus = 'unknown' | 'not-enabled' | 'ended' | 'pending' | 'ready'; + +export function resolveSshConnectStatus( + connectInfo: + | (Pick & { + session?: Pick | null; + }) + | null +): SshConnectStatus { + if (!connectInfo) { + return 'unknown'; + } + const { sshRequested, jobCompleted, session } = connectInfo; + if (session?.connectionConfig && !session.connectionConfig.reconnecting) { + return 'ready'; + } + if (session?.connectionConfig?.reconnecting) { + return 'pending'; + } + if (!sshRequested) { + return 'not-enabled'; + } + return jobCompleted ? 'ended' : 'pending'; +} + +export function parseSshArgv(rawArgv: readonly string[]): { + showConnect: boolean; + resourceId: string | undefined; + command: string[]; +} { + let showConnect = false; + let index = 0; + while (index < rawArgv.length && rawArgv[index].startsWith('-')) { + if (rawArgv[index] === '--show-connect' || rawArgv[index] === '--show-connect=true') { + showConnect = true; + } else { + throw new Error( + `Unknown flag "${rawArgv[index]}" before the id. The only supported flag is --show-connect; everything after the id is passed to the remote shell.` + ); + } + index += 1; + } + const [resourceId, ...command] = rawArgv.slice(index); + return { showConnect, resourceId, command }; +} + +export default class WorkflowSsh extends EasCommand { + static override hidden = true; + + static override description = + '[EXPERIMENTAL] open an ssh session on the worker running a workflow job'; + + static override strict = false; + + static override flags = { + 'show-connect': Flags.boolean({ + description: 'Print the ssh connection command (host and token) instead of opening a session', + default: false, + }), + }; + + static override contextDefinition = { + ...this.ContextOptions.LoggedIn, + }; + + private isRunningSubprocess = false; + + async runAsync(): Promise { + const rawArgv = [...this.argv]; + await this.parse(WorkflowSsh, []); + const { showConnect, resourceId, command } = parseSshArgv(rawArgv); + + if (typeof resourceId !== 'string' || resourceId.length === 0) { + throw new Error('Provide a workflow job id: eas workflow:ssh [command...]'); + } + + const { + loggedIn: { graphqlClient }, + } = await this.getContextAsync(WorkflowSsh, { nonInteractive: true }); + + const connectInfo = await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync( + graphqlClient, + resourceId + ); + const status = resolveSshConnectStatus(connectInfo); + if (status === 'unknown') { + Log.error( + `No workflow job found for "${resourceId}". Pass a workflow job id from a run started with \`eas workflow:run --ssh\`.` + ); + process.exitCode = 1; + return; + } + if (status === 'not-enabled') { + Log.error( + `SSH was not enabled for "${resourceId}". Start the run with \`eas workflow:run --ssh\` to enable it.` + ); + process.exitCode = 1; + return; + } + if (status === 'ended') { + Log.error('This ssh session has ended.'); + process.exitCode = 1; + return; + } + + const connectionConfig = + (status === 'ready' ? connectInfo?.session?.connectionConfig : null) ?? + (await waitForSessionToOpenAsync(graphqlClient, resourceId)); + if (!connectionConfig) { + process.exitCode = 1; + return; + } + + const { host: connectionHost, secret } = connectionConfig; + if (!CONNECTION_HOST_REGEX.test(connectionHost)) { + throw new Error( + 'Unexpected connection host reported for this ssh session. Update eas-cli and try again, or contact support if it persists.' + ); + } + if (!CONNECTION_SECRET_REGEX.test(secret)) { + throw new Error( + 'Unexpected connection token reported for this ssh session. Update eas-cli and try again, or contact support if it persists.' + ); + } + + const { host, port } = splitConnectionHost(connectionHost); + const portOption = port !== undefined ? ` -p ${port}` : ''; + + if (showConnect) { + Log.log(`ssh ${SSH_INSECURE_OPTS}${portOption} ${secret}@${host}`); + Log.newLine(); + Log.log( + 'If your network blocks the direct SSH connection, reach the session through the WebSocket relay with upterm (https://upterm.dev):' + ); + // WSS terminates on the relay hostname (default 443). Do not paste an SSH + // :port into the wss:// URL — that port is only for the ssh destination. + Log.log( + ` ssh -o ProxyCommand="upterm proxy wss://${secret}@${host}" ${SSH_INSECURE_OPTS}${portOption} ${secret}@${host}` + ); + return; + } + + const configDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-ssh-')); + try { + const configPath = path.join(configDir, 'config'); + await fs.promises.writeFile( + configPath, + [ + 'Host eas-workflow-ssh', + ` HostName ${host}`, + ...(port !== undefined ? [` Port ${port}`] : []), + ` User ${secret}`, + ' StrictHostKeyChecking no', + ' UserKnownHostsFile /dev/null', + '', + ].join('\n'), + { mode: 0o600 } + ); + + this.isRunningSubprocess = true; + await spawnAsync('ssh', ['-F', configPath, 'eas-workflow-ssh', ...command], { + stdio: 'inherit', + }); + } finally { + await fs.promises.rm(configDir, { recursive: true, force: true }); + } + } + + protected override catch(err: Error): Promise { + if (this.isRunningSubprocess) { + if ((err as Error & { code?: string }).code === 'ENOENT') { + Log.error( + 'Could not run `ssh`. Install an OpenSSH client and make sure `ssh` is on your PATH, then try again.' + ); + process.exitCode = 1; + return Promise.resolve(); + } + const status = (err as Error & { status?: number | null }).status; + process.exitCode = process.exitCode ?? status ?? 1; + return Promise.resolve(); + } + return super.catch(err); + } +} + +async function waitForSessionToOpenAsync( + graphqlClient: ExpoGraphqlClient, + workflowJobId: string +): Promise | null> { + const spinner = ora('Waiting for the worker to open the ssh session').start(); + const deadline = Date.now() + SESSION_OPEN_TIMEOUT_MS; + try { + while (Date.now() < deadline) { + await sleepAsync(SESSION_OPEN_POLL_INTERVAL_MS); + const connectInfo = await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync( + graphqlClient, + workflowJobId + ); + const status = resolveSshConnectStatus(connectInfo); + if (status === 'unknown' || status === 'not-enabled' || status === 'ended') { + spinner.fail('The ssh session ended before it opened.'); + return null; + } + if (status === 'ready') { + spinner.succeed('The ssh session is ready.'); + return connectInfo?.session?.connectionConfig ?? null; + } + } + spinner.fail( + 'Timed out waiting for the ssh session to open. The worker may still be starting up; try again in a moment.' + ); + return null; + } catch (err) { + spinner.stop(); + throw err; + } +} diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index dccc8b2a0a..20d380276a 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -13506,6 +13506,7 @@ export type WorkflowRun = ActivityTimelineProjectActivity & { retriedWorkflowRun?: Maybe; retries: Array; sourceExpiresAt?: Maybe; + /** SSH settings for this run's VM jobs when SSH was requested; otherwise null. */ sshSettings?: Maybe; status: WorkflowRunStatus; triggerEventType: WorkflowRunTriggerEventType; @@ -13552,6 +13553,7 @@ export type WorkflowRunGitBranchFilterInput = { export type WorkflowRunInput = { inputs?: InputMaybe; projectSource: WorkflowProjectSourceInput; + ssh?: InputMaybe; }; export type WorkflowRunMutation = { @@ -13586,12 +13588,14 @@ export type WorkflowRunMutationCreateWorkflowRunArgs = { export type WorkflowRunMutationCreateWorkflowRunFromGitRefArgs = { gitRef: Scalars['String']['input']; inputs?: InputMaybe; + ssh?: InputMaybe; workflowRevisionId: Scalars['ID']['input']; }; export type WorkflowRunMutationRetryWorkflowRunArgs = { fromFailedJobs?: InputMaybe; + ssh?: InputMaybe; workflowRunId: Scalars['ID']['input']; }; @@ -13605,6 +13609,14 @@ export type WorkflowRunQueryByIdArgs = { workflowRunId: Scalars['ID']['input']; }; +/** + * Enables ssh on the run's VM jobs. Presence turns ssh on; idleTimeoutSeconds is optional, + * defaults server-side, and is validated against a supported range. + */ +export type WorkflowRunSshInput = { + idleTimeoutSeconds?: InputMaybe; +}; + export type WorkflowRunSshSettings = { __typename?: 'WorkflowRunSshSettings'; idleTimeoutSeconds: Scalars['Int']['output']; @@ -14893,6 +14905,7 @@ export type CreateWorkflowRunFromGitRefMutationVariables = Exact<{ workflowRevisionId: Scalars['ID']['input']; gitRef: Scalars['String']['input']; inputs?: InputMaybe; + ssh?: InputMaybe; }>; @@ -15671,6 +15684,13 @@ export type WorkflowJobByIdQuery = { __typename?: 'RootQuery', workflowJobs: { _ | { __typename: 'User', id: string, displayName: string } | null, app: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, updateChannel?: { __typename?: 'UpdateChannel', id: string, name: string } | null, runtime?: { __typename?: 'Runtime', id: string, version: string } | null, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } | null, errors: Array<{ __typename?: 'WorkflowJobError', title: string, message: string }> } } }; +export type WorkflowJobSshPollQueryVariables = Exact<{ + workflowJobId: Scalars['ID']['input']; +}>; + + +export type WorkflowJobSshPollQuery = { __typename?: 'RootQuery', workflowJobs: { __typename?: 'WorkflowJobQuery', byId: { __typename?: 'WorkflowJob', id: string, status: WorkflowJobStatus, type: WorkflowJobType, workflowRun: { __typename?: 'WorkflowRun', id: string, sshSettings?: { __typename?: 'WorkflowRunSshSettings', idleTimeoutSeconds: number } | null }, turtleJobRun?: { __typename?: 'JobRun', id: string, sshSession?: { __typename?: 'TurtleSshSession', id: string, connectionConfig: { __typename?: 'TurtleSshConnectionConfig', host: string, secret: string, reconnecting: boolean } } | null } | null, turtleBuild?: { __typename?: 'Build', id: string, sshSession?: { __typename?: 'TurtleSshSession', id: string, connectionConfig: { __typename?: 'TurtleSshConnectionConfig', host: string, secret: string, reconnecting: boolean } } | null } | null } } }; + export type ExpoGoSupportedSdkVersionsQueryVariables = Exact<{ [key: string]: never; }>; diff --git a/packages/eas-cli/src/graphql/queries/WorkflowJobSshQuery.ts b/packages/eas-cli/src/graphql/queries/WorkflowJobSshQuery.ts new file mode 100644 index 0000000000..0c6eb181d5 --- /dev/null +++ b/packages/eas-cli/src/graphql/queries/WorkflowJobSshQuery.ts @@ -0,0 +1,141 @@ +import gql from 'graphql-tag'; + +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { GraphqlError, withErrorHandlingAsync } from '../client'; +import { WorkflowJobStatus, WorkflowJobType } from '../generated'; + +const FINAL_WORKFLOW_JOB_STATUSES = new Set([ + WorkflowJobStatus.Success, + WorkflowJobStatus.Failure, + WorkflowJobStatus.Canceled, + WorkflowJobStatus.Skipped, +]); + +export type WorkflowJobSshConnectionConfig = { + host: string; + secret: string; + reconnecting: boolean; +}; + +export type WorkflowJobSshSession = { + id: string; + connectionConfig: WorkflowJobSshConnectionConfig; +}; + +export type WorkflowJobSshConnectInfo = { + sshRequested: boolean; + jobCompleted: boolean; + session: WorkflowJobSshSession | null; +}; + +type WorkflowJobSshPollQuery = { + workflowJobs: { + byId: { + id: string; + status: WorkflowJobStatus; + type: WorkflowJobType; + workflowRun: { + id: string; + sshSettings: { idleTimeoutSeconds: number } | null; + }; + turtleJobRun: { id: string; sshSession: WorkflowJobSshSession | null } | null; + turtleBuild: { id: string; sshSession: WorkflowJobSshSession | null } | null; + }; + }; +}; + +type WorkflowJobSshPollQueryVariables = { + workflowJobId: string; +}; + +function toConnectInfo( + job: WorkflowJobSshPollQuery['workflowJobs']['byId'] +): WorkflowJobSshConnectInfo { + const hasTurtleTarget = job.turtleJobRun != null || job.turtleBuild != null; + return { + sshRequested: + job.type !== WorkflowJobType.GetBuild && + hasTurtleTarget && + job.workflowRun.sshSettings != null, + jobCompleted: FINAL_WORKFLOW_JOB_STATUSES.has(job.status), + session: job.turtleJobRun?.sshSession ?? job.turtleBuild?.sshSession ?? null, + }; +} + +function isNotFoundError(error: unknown): boolean { + if (!(error instanceof GraphqlError)) { + return false; + } + return error.graphQLErrors.some(e => { + const code = e?.extensions?.errorCode ?? e?.extensions?.code; + return ( + code === 'ENTITY_NOT_FOUND' || + /Entity not found/i.test(e?.message ?? '') || + /not found/i.test(e?.message ?? '') + ); + }); +} + +export const WorkflowJobSshQuery = { + async connectInfoForWorkflowJobAsync( + graphqlClient: ExpoGraphqlClient, + workflowJobId: string + ): Promise { + let data: WorkflowJobSshPollQuery; + try { + data = await withErrorHandlingAsync( + graphqlClient + .query( + gql` + query WorkflowJobSshPoll($workflowJobId: ID!) { + workflowJobs { + byId(workflowJobId: $workflowJobId) { + id + status + type + workflowRun { + id + sshSettings { + idleTimeoutSeconds + } + } + turtleJobRun { + id + sshSession { + id + connectionConfig { + host + secret + reconnecting + } + } + } + turtleBuild { + id + sshSession { + id + connectionConfig { + host + secret + reconnecting + } + } + } + } + } + } + `, + { workflowJobId }, + { requestPolicy: 'network-only' } + ) + .toPromise() + ); + } catch (error) { + if (isNotFoundError(error)) { + return null; + } + throw error; + } + return toConnectInfo(data.workflowJobs.byId); + }, +}; diff --git a/packages/eas-cli/src/graphql/queries/__tests__/WorkflowJobSshQuery-test.ts b/packages/eas-cli/src/graphql/queries/__tests__/WorkflowJobSshQuery-test.ts new file mode 100644 index 0000000000..597e4f44f0 --- /dev/null +++ b/packages/eas-cli/src/graphql/queries/__tests__/WorkflowJobSshQuery-test.ts @@ -0,0 +1,82 @@ +import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { GraphqlError } from '../../client'; +import { WorkflowJobStatus, WorkflowJobType } from '../../generated'; +import { WorkflowJobSshQuery } from '../WorkflowJobSshQuery'; + +describe(WorkflowJobSshQuery.connectInfoForWorkflowJobAsync.name, () => { + function makeClient(byId: unknown): { + graphqlClient: ExpoGraphqlClient; + query: jest.Mock; + } { + const query = jest.fn().mockReturnValue({ + toPromise: async () => ({ data: { workflowJobs: { byId } } }), + }); + return { graphqlClient: { query } as unknown as ExpoGraphqlClient, query }; + } + + it('maps workflowJobs.byId into connect info', async () => { + const { graphqlClient, query } = makeClient({ + id: 'job-1', + status: WorkflowJobStatus.InProgress, + type: WorkflowJobType.Custom, + workflowRun: { sshSettings: { idleTimeoutSeconds: 0 } }, + turtleJobRun: { + sshSession: { + id: 'ts-1', + connectionConfig: { host: 'relay.expo.dev', secret: 'TOKENx', reconnecting: false }, + }, + }, + turtleBuild: null, + }); + + expect( + await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, 'job-1') + ).toEqual({ + sshRequested: true, + jobCompleted: false, + session: { + id: 'ts-1', + connectionConfig: { host: 'relay.expo.dev', secret: 'TOKENx', reconnecting: false }, + }, + }); + expect(query).toHaveBeenCalledWith( + expect.anything(), + { workflowJobId: 'job-1' }, + { requestPolicy: 'network-only' } + ); + }); + + it('treats GET_BUILD as not ssh-requested even when the run has sshSettings', async () => { + const { graphqlClient } = makeClient({ + id: 'job-1', + status: WorkflowJobStatus.InProgress, + type: WorkflowJobType.GetBuild, + workflowRun: { sshSettings: { idleTimeoutSeconds: 0 } }, + turtleJobRun: null, + turtleBuild: { sshSession: null }, + }); + + expect( + await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, 'job-1') + ).toMatchObject({ sshRequested: false }); + }); + + it('returns null when the job is not found', async () => { + const query = jest.fn().mockReturnValue({ + toPromise: async () => ({ + data: undefined, + error: new GraphqlError({ + graphQLErrors: [ + { message: 'Entity not found', extensions: { errorCode: 'ENTITY_NOT_FOUND' } }, + ], + networkError: undefined, + response: undefined, + }), + }), + }); + const graphqlClient = { query } as unknown as ExpoGraphqlClient; + expect( + await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, 'missing') + ).toBeNull(); + }); +}); From f8bc98851a8223922ac067fcd94569506b8a5097 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Fri, 24 Jul 2026 21:30:13 -0700 Subject: [PATCH 02/11] Poll for an open SSH session before sleeping. Avoids a needless wait when the first connect-info fetch is already ready. --- packages/eas-cli/src/commands/workflow/ssh.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eas-cli/src/commands/workflow/ssh.ts b/packages/eas-cli/src/commands/workflow/ssh.ts index dd60c886c4..1c1d368922 100644 --- a/packages/eas-cli/src/commands/workflow/ssh.ts +++ b/packages/eas-cli/src/commands/workflow/ssh.ts @@ -224,7 +224,6 @@ async function waitForSessionToOpenAsync( const deadline = Date.now() + SESSION_OPEN_TIMEOUT_MS; try { while (Date.now() < deadline) { - await sleepAsync(SESSION_OPEN_POLL_INTERVAL_MS); const connectInfo = await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync( graphqlClient, workflowJobId @@ -238,6 +237,7 @@ async function waitForSessionToOpenAsync( spinner.succeed('The ssh session is ready.'); return connectInfo?.session?.connectionConfig ?? null; } + await sleepAsync(SESSION_OPEN_POLL_INTERVAL_MS); } spinner.fail( 'Timed out waiting for the ssh session to open. The worker may still be starting up; try again in a moment.' From 8123f2d7dfc8c90809e5c2454a30c6a4f7d209a7 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Fri, 24 Jul 2026 21:30:51 -0700 Subject: [PATCH 03/11] Update SSH wait test for poll-before-sleep. The wait loop now polls first, so keep one pending poll before ready. --- packages/eas-cli/src/commands/workflow/__tests__/ssh.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/eas-cli/src/commands/workflow/__tests__/ssh.test.ts b/packages/eas-cli/src/commands/workflow/__tests__/ssh.test.ts index 015cbf290d..dc22faf5e7 100644 --- a/packages/eas-cli/src/commands/workflow/__tests__/ssh.test.ts +++ b/packages/eas-cli/src/commands/workflow/__tests__/ssh.test.ts @@ -301,7 +301,8 @@ describe(WorkflowSsh, () => { it('waits for a pending session to open, then connects', async () => { mockConnectInfo - .mockResolvedValueOnce(pendingInfo as never) + .mockResolvedValueOnce(pendingInfo as never) // initial status check + .mockResolvedValueOnce(pendingInfo as never) // first wait poll .mockResolvedValue(readyInfo as never); await createCommand(['job-1']).runAsync(); expect(mockSleep).toHaveBeenCalled(); From 4438ab97330d79c2056761d58f94e88bb8f9f9e7 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Fri, 24 Jul 2026 21:41:06 -0700 Subject: [PATCH 04/11] Cover WorkflowJobSshQuery error and turtleBuild session paths. --- .../__tests__/WorkflowJobSshQuery-test.ts | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/packages/eas-cli/src/graphql/queries/__tests__/WorkflowJobSshQuery-test.ts b/packages/eas-cli/src/graphql/queries/__tests__/WorkflowJobSshQuery-test.ts index 597e4f44f0..e99d082f1e 100644 --- a/packages/eas-cli/src/graphql/queries/__tests__/WorkflowJobSshQuery-test.ts +++ b/packages/eas-cli/src/graphql/queries/__tests__/WorkflowJobSshQuery-test.ts @@ -79,4 +79,80 @@ describe(WorkflowJobSshQuery.connectInfoForWorkflowJobAsync.name, () => { await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, 'missing') ).toBeNull(); }); + + it('returns null for not-found errors matched by message or code', async () => { + for (const graphQLErrors of [ + [{ message: 'Workflow job not found', extensions: {} }], + [{ message: 'missing', extensions: { code: 'ENTITY_NOT_FOUND' } }], + ]) { + const query = jest.fn().mockReturnValue({ + toPromise: async () => ({ + data: undefined, + error: new GraphqlError({ + graphQLErrors, + networkError: undefined, + response: undefined, + }), + }), + }); + const graphqlClient = { query } as unknown as ExpoGraphqlClient; + expect( + await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, 'missing') + ).toBeNull(); + } + }); + + it('uses the turtleBuild session when there is no turtleJobRun', async () => { + const { graphqlClient } = makeClient({ + id: 'job-1', + status: WorkflowJobStatus.Success, + type: WorkflowJobType.Build, + workflowRun: { sshSettings: { idleTimeoutSeconds: 60 } }, + turtleJobRun: null, + turtleBuild: { + sshSession: { + id: 'ts-build', + connectionConfig: { host: 'relay.expo.dev', secret: 'TOK', reconnecting: true }, + }, + }, + }); + + expect( + await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, 'job-1') + ).toEqual({ + sshRequested: true, + jobCompleted: true, + session: { + id: 'ts-build', + connectionConfig: { host: 'relay.expo.dev', secret: 'TOK', reconnecting: true }, + }, + }); + }); + + it('rethrows GraphQL errors that are not not-found', async () => { + const error = new GraphqlError({ + graphQLErrors: [{ message: 'boom', extensions: { errorCode: 'INTERNAL' } }], + networkError: undefined, + response: undefined, + }); + const query = jest.fn().mockReturnValue({ + toPromise: async () => ({ data: undefined, error }), + }); + const graphqlClient = { query } as unknown as ExpoGraphqlClient; + await expect( + WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, 'job-1') + ).rejects.toBe(error); + }); + + it('rethrows unexpected non-GraphQL errors', async () => { + const query = jest.fn().mockReturnValue({ + toPromise: async () => { + throw new Error('network down'); + }, + }); + const graphqlClient = { query } as unknown as ExpoGraphqlClient; + await expect( + WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, 'job-1') + ).rejects.toThrow('network down'); + }); }); From e50c85743f591756a849cb446ad6f97599fa04e4 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Mon, 3 Aug 2026 13:22:56 -0700 Subject: [PATCH 05/11] [eas-cli] Regen GraphQL types from staging (TurtleSshSession) --- packages/eas-cli/graphql.schema.json | 59 ----------------------- packages/eas-cli/src/graphql/generated.ts | 13 ----- 2 files changed, 72 deletions(-) diff --git a/packages/eas-cli/graphql.schema.json b/packages/eas-cli/graphql.schema.json index 9d65b0ddd9..dfaad56c31 100644 --- a/packages/eas-cli/graphql.schema.json +++ b/packages/eas-cli/graphql.schema.json @@ -96614,18 +96614,6 @@ "defaultValue": null, "isDeprecated": false, "deprecationReason": null - }, - { - "name": "ssh", - "description": null, - "type": { - "kind": "INPUT_OBJECT", - "name": "WorkflowRunSshInput", - "ofType": null - }, - "defaultValue": null, - "isDeprecated": false, - "deprecationReason": null } ], "interfaces": null, @@ -96828,18 +96816,6 @@ "isDeprecated": false, "deprecationReason": null }, - { - "name": "ssh", - "description": null, - "type": { - "kind": "INPUT_OBJECT", - "name": "WorkflowRunSshInput", - "ofType": null - }, - "defaultValue": null, - "isDeprecated": false, - "deprecationReason": null - }, { "name": "workflowRevisionId", "description": null, @@ -96885,18 +96861,6 @@ "isDeprecated": false, "deprecationReason": null }, - { - "name": "ssh", - "description": null, - "type": { - "kind": "INPUT_OBJECT", - "name": "WorkflowRunSshInput", - "ofType": null - }, - "defaultValue": null, - "isDeprecated": false, - "deprecationReason": null - }, { "name": "workflowRunId", "description": null, @@ -96976,29 +96940,6 @@ "enumValues": null, "possibleTypes": null }, - { - "kind": "INPUT_OBJECT", - "name": "WorkflowRunSshInput", - "description": "Enables ssh on the run's VM jobs. Presence turns ssh on; idleTimeoutSeconds is optional,\ndefaults server-side, and is validated against a supported range.", - "fields": null, - "inputFields": [ - { - "name": "idleTimeoutSeconds", - "description": null, - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - }, - "defaultValue": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, { "kind": "OBJECT", "name": "WorkflowRunSshSettings", diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index 20d380276a..26ad7cc63b 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -13506,7 +13506,6 @@ export type WorkflowRun = ActivityTimelineProjectActivity & { retriedWorkflowRun?: Maybe; retries: Array; sourceExpiresAt?: Maybe; - /** SSH settings for this run's VM jobs when SSH was requested; otherwise null. */ sshSettings?: Maybe; status: WorkflowRunStatus; triggerEventType: WorkflowRunTriggerEventType; @@ -13553,7 +13552,6 @@ export type WorkflowRunGitBranchFilterInput = { export type WorkflowRunInput = { inputs?: InputMaybe; projectSource: WorkflowProjectSourceInput; - ssh?: InputMaybe; }; export type WorkflowRunMutation = { @@ -13588,14 +13586,12 @@ export type WorkflowRunMutationCreateWorkflowRunArgs = { export type WorkflowRunMutationCreateWorkflowRunFromGitRefArgs = { gitRef: Scalars['String']['input']; inputs?: InputMaybe; - ssh?: InputMaybe; workflowRevisionId: Scalars['ID']['input']; }; export type WorkflowRunMutationRetryWorkflowRunArgs = { fromFailedJobs?: InputMaybe; - ssh?: InputMaybe; workflowRunId: Scalars['ID']['input']; }; @@ -13609,14 +13605,6 @@ export type WorkflowRunQueryByIdArgs = { workflowRunId: Scalars['ID']['input']; }; -/** - * Enables ssh on the run's VM jobs. Presence turns ssh on; idleTimeoutSeconds is optional, - * defaults server-side, and is validated against a supported range. - */ -export type WorkflowRunSshInput = { - idleTimeoutSeconds?: InputMaybe; -}; - export type WorkflowRunSshSettings = { __typename?: 'WorkflowRunSshSettings'; idleTimeoutSeconds: Scalars['Int']['output']; @@ -14905,7 +14893,6 @@ export type CreateWorkflowRunFromGitRefMutationVariables = Exact<{ workflowRevisionId: Scalars['ID']['input']; gitRef: Scalars['String']['input']; inputs?: InputMaybe; - ssh?: InputMaybe; }>; From dd232ebb235162890070ea223e39b398e4523934 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sat, 15 Aug 2026 01:40:50 -0700 Subject: [PATCH 06/11] [eas-cli] address workflow:ssh review feedback --- .../commands/workflow/__tests__/ssh.test.ts | 36 ++++++++++++- packages/eas-cli/src/commands/workflow/ssh.ts | 51 +++++++++++-------- 2 files changed, 66 insertions(+), 21 deletions(-) diff --git a/packages/eas-cli/src/commands/workflow/__tests__/ssh.test.ts b/packages/eas-cli/src/commands/workflow/__tests__/ssh.test.ts index dc22faf5e7..067bfa237b 100644 --- a/packages/eas-cli/src/commands/workflow/__tests__/ssh.test.ts +++ b/packages/eas-cli/src/commands/workflow/__tests__/ssh.test.ts @@ -11,6 +11,8 @@ import WorkflowSsh, { parseSshArgv, resolveSshConnectStatus, splitConnectionHost, + sshHostAliasForResource, + terminalSshStatusMessage, } from '../ssh'; jest.mock('@expo/spawn-async', () => ({ __esModule: true, default: jest.fn() })); @@ -175,6 +177,33 @@ describe(splitConnectionHost, () => { }); }); +describe(terminalSshStatusMessage, () => { + it('has no message while the session can still open', () => { + expect(terminalSshStatusMessage('pending', 'job-1')).toBeNull(); + expect(terminalSshStatusMessage('ready', 'job-1')).toBeNull(); + }); + + it('distinguishes not-enabled from ended', () => { + expect(terminalSshStatusMessage('not-enabled', 'job-1')).toMatch(/SSH was not enabled/); + expect(terminalSshStatusMessage('ended', 'job-1')).toMatch(/has ended/); + expect(terminalSshStatusMessage('unknown', 'job-1')).toMatch(/No workflow job found/); + }); +}); + +describe(sshHostAliasForResource, () => { + it('suffixes the alias with the start of the resource id', () => { + expect(sshHostAliasForResource('job-1')).toBe('eas-workflow-ssh-job1'); + }); + + it('drops characters that are not safe in an ssh config host alias', () => { + expect(sshHostAliasForResource('a b\nHost evil')).toBe('eas-workflow-ssh-abHostev'); + }); + + it('falls back to the bare alias when nothing usable remains', () => { + expect(sshHostAliasForResource('---')).toBe('eas-workflow-ssh'); + }); +}); + describe(WorkflowSsh, () => { const graphqlClient = {} as never; const mockConnectInfo = jest.mocked(WorkflowJobSshQuery.connectInfoForWorkflowJobAsync); @@ -291,9 +320,14 @@ describe(WorkflowSsh, () => { expect.stringContaining('HostName relay.expo.dev'), { mode: 0o600 } ); + expect(mockWriteFile).toHaveBeenCalledWith( + '/tmp/eas-ssh-1/config', + expect.stringContaining('Host eas-workflow-ssh-job1'), + { mode: 0o600 } + ); expect(mockSpawn).toHaveBeenCalledWith( 'ssh', - ['-F', '/tmp/eas-ssh-1/config', 'eas-workflow-ssh', 'ls', '-la'], + ['-F', '/tmp/eas-ssh-1/config', 'eas-workflow-ssh-job1', 'ls', '-la'], { stdio: 'inherit' } ); expect(mockRm).toHaveBeenCalledWith('/tmp/eas-ssh-1', { recursive: true, force: true }); diff --git a/packages/eas-cli/src/commands/workflow/ssh.ts b/packages/eas-cli/src/commands/workflow/ssh.ts index 1c1d368922..4726ba2b81 100644 --- a/packages/eas-cli/src/commands/workflow/ssh.ts +++ b/packages/eas-cli/src/commands/workflow/ssh.ts @@ -56,6 +56,28 @@ export function resolveSshConnectStatus( return jobCompleted ? 'ended' : 'pending'; } +export function terminalSshStatusMessage( + status: SshConnectStatus, + resourceId: string +): string | null { + switch (status) { + case 'unknown': + return `No workflow job found for "${resourceId}". Pass a workflow job id from a run started with \`eas workflow:run --ssh\`.`; + case 'not-enabled': + return `SSH was not enabled for "${resourceId}". Start the run with \`eas workflow:run --ssh\` to enable it.`; + case 'ended': + return 'This ssh session has ended.'; + case 'pending': + case 'ready': + return null; + } +} + +export function sshHostAliasForResource(resourceId: string): string { + const suffix = resourceId.replace(/[^A-Za-z0-9]/g, '').slice(0, 8); + return suffix ? `eas-workflow-ssh-${suffix}` : 'eas-workflow-ssh'; +} + export function parseSshArgv(rawArgv: readonly string[]): { showConnect: boolean; resourceId: string | undefined; @@ -116,22 +138,9 @@ export default class WorkflowSsh extends EasCommand { resourceId ); const status = resolveSshConnectStatus(connectInfo); - if (status === 'unknown') { - Log.error( - `No workflow job found for "${resourceId}". Pass a workflow job id from a run started with \`eas workflow:run --ssh\`.` - ); - process.exitCode = 1; - return; - } - if (status === 'not-enabled') { - Log.error( - `SSH was not enabled for "${resourceId}". Start the run with \`eas workflow:run --ssh\` to enable it.` - ); - process.exitCode = 1; - return; - } - if (status === 'ended') { - Log.error('This ssh session has ended.'); + const statusMessage = terminalSshStatusMessage(status, resourceId); + if (statusMessage) { + Log.error(statusMessage); process.exitCode = 1; return; } @@ -176,10 +185,11 @@ export default class WorkflowSsh extends EasCommand { const configDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-ssh-')); try { const configPath = path.join(configDir, 'config'); + const hostAlias = sshHostAliasForResource(resourceId); await fs.promises.writeFile( configPath, [ - 'Host eas-workflow-ssh', + `Host ${hostAlias}`, ` HostName ${host}`, ...(port !== undefined ? [` Port ${port}`] : []), ` User ${secret}`, @@ -191,7 +201,7 @@ export default class WorkflowSsh extends EasCommand { ); this.isRunningSubprocess = true; - await spawnAsync('ssh', ['-F', configPath, 'eas-workflow-ssh', ...command], { + await spawnAsync('ssh', ['-F', configPath, hostAlias, ...command], { stdio: 'inherit', }); } finally { @@ -229,8 +239,9 @@ async function waitForSessionToOpenAsync( workflowJobId ); const status = resolveSshConnectStatus(connectInfo); - if (status === 'unknown' || status === 'not-enabled' || status === 'ended') { - spinner.fail('The ssh session ended before it opened.'); + const statusMessage = terminalSshStatusMessage(status, workflowJobId); + if (statusMessage) { + spinner.fail(statusMessage); return null; } if (status === 'ready') { From 3474bfacf34ce01f6bcdc28647357458bd7ecf08 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sat, 15 Aug 2026 01:58:32 -0700 Subject: [PATCH 07/11] [eas-cli] drop the GET_BUILD check from sshRequested --- .../eas-cli/src/graphql/queries/WorkflowJobSshQuery.ts | 9 ++------- .../queries/__tests__/WorkflowJobSshQuery-test.ts | 9 +++------ 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/packages/eas-cli/src/graphql/queries/WorkflowJobSshQuery.ts b/packages/eas-cli/src/graphql/queries/WorkflowJobSshQuery.ts index 0c6eb181d5..c82443cf2d 100644 --- a/packages/eas-cli/src/graphql/queries/WorkflowJobSshQuery.ts +++ b/packages/eas-cli/src/graphql/queries/WorkflowJobSshQuery.ts @@ -2,7 +2,7 @@ import gql from 'graphql-tag'; import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; import { GraphqlError, withErrorHandlingAsync } from '../client'; -import { WorkflowJobStatus, WorkflowJobType } from '../generated'; +import { WorkflowJobStatus } from '../generated'; const FINAL_WORKFLOW_JOB_STATUSES = new Set([ WorkflowJobStatus.Success, @@ -33,7 +33,6 @@ type WorkflowJobSshPollQuery = { byId: { id: string; status: WorkflowJobStatus; - type: WorkflowJobType; workflowRun: { id: string; sshSettings: { idleTimeoutSeconds: number } | null; @@ -53,10 +52,7 @@ function toConnectInfo( ): WorkflowJobSshConnectInfo { const hasTurtleTarget = job.turtleJobRun != null || job.turtleBuild != null; return { - sshRequested: - job.type !== WorkflowJobType.GetBuild && - hasTurtleTarget && - job.workflowRun.sshSettings != null, + sshRequested: hasTurtleTarget && job.workflowRun.sshSettings != null, jobCompleted: FINAL_WORKFLOW_JOB_STATUSES.has(job.status), session: job.turtleJobRun?.sshSession ?? job.turtleBuild?.sshSession ?? null, }; @@ -92,7 +88,6 @@ export const WorkflowJobSshQuery = { byId(workflowJobId: $workflowJobId) { id status - type workflowRun { id sshSettings { diff --git a/packages/eas-cli/src/graphql/queries/__tests__/WorkflowJobSshQuery-test.ts b/packages/eas-cli/src/graphql/queries/__tests__/WorkflowJobSshQuery-test.ts index e99d082f1e..e35a07ee9f 100644 --- a/packages/eas-cli/src/graphql/queries/__tests__/WorkflowJobSshQuery-test.ts +++ b/packages/eas-cli/src/graphql/queries/__tests__/WorkflowJobSshQuery-test.ts @@ -1,6 +1,6 @@ import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; import { GraphqlError } from '../../client'; -import { WorkflowJobStatus, WorkflowJobType } from '../../generated'; +import { WorkflowJobStatus } from '../../generated'; import { WorkflowJobSshQuery } from '../WorkflowJobSshQuery'; describe(WorkflowJobSshQuery.connectInfoForWorkflowJobAsync.name, () => { @@ -18,7 +18,6 @@ describe(WorkflowJobSshQuery.connectInfoForWorkflowJobAsync.name, () => { const { graphqlClient, query } = makeClient({ id: 'job-1', status: WorkflowJobStatus.InProgress, - type: WorkflowJobType.Custom, workflowRun: { sshSettings: { idleTimeoutSeconds: 0 } }, turtleJobRun: { sshSession: { @@ -46,12 +45,11 @@ describe(WorkflowJobSshQuery.connectInfoForWorkflowJobAsync.name, () => { ); }); - it('treats GET_BUILD as not ssh-requested even when the run has sshSettings', async () => { + it('is not ssh-requested when the run has no sshSettings', async () => { const { graphqlClient } = makeClient({ id: 'job-1', status: WorkflowJobStatus.InProgress, - type: WorkflowJobType.GetBuild, - workflowRun: { sshSettings: { idleTimeoutSeconds: 0 } }, + workflowRun: { sshSettings: null }, turtleJobRun: null, turtleBuild: { sshSession: null }, }); @@ -106,7 +104,6 @@ describe(WorkflowJobSshQuery.connectInfoForWorkflowJobAsync.name, () => { const { graphqlClient } = makeClient({ id: 'job-1', status: WorkflowJobStatus.Success, - type: WorkflowJobType.Build, workflowRun: { sshSettings: { idleTimeoutSeconds: 60 } }, turtleJobRun: null, turtleBuild: { From ff21942f9a3e7ab0b45a571d2c3f77b6a7b6c9a9 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Wed, 26 Aug 2026 21:40:48 -0700 Subject: [PATCH 08/11] [eas-cli] accept a job run id in workflow:ssh --- .../commands/workflow/__tests__/ssh.test.ts | 2 +- packages/eas-cli/src/commands/workflow/ssh.ts | 4 +- .../graphql/queries/WorkflowJobSshQuery.ts | 79 ++++++++++++++++++- 3 files changed, 81 insertions(+), 4 deletions(-) diff --git a/packages/eas-cli/src/commands/workflow/__tests__/ssh.test.ts b/packages/eas-cli/src/commands/workflow/__tests__/ssh.test.ts index 067bfa237b..ccc1c1c183 100644 --- a/packages/eas-cli/src/commands/workflow/__tests__/ssh.test.ts +++ b/packages/eas-cli/src/commands/workflow/__tests__/ssh.test.ts @@ -206,7 +206,7 @@ describe(sshHostAliasForResource, () => { describe(WorkflowSsh, () => { const graphqlClient = {} as never; - const mockConnectInfo = jest.mocked(WorkflowJobSshQuery.connectInfoForWorkflowJobAsync); + const mockConnectInfo = jest.mocked(WorkflowJobSshQuery.connectInfoForResourceIdAsync); const mockSpawn = jest.mocked(spawnAsync); const mockSleep = jest.mocked(sleepAsync); const mockMkdtemp = jest.mocked(fs.promises.mkdtemp); diff --git a/packages/eas-cli/src/commands/workflow/ssh.ts b/packages/eas-cli/src/commands/workflow/ssh.ts index 4726ba2b81..710ebe8b67 100644 --- a/packages/eas-cli/src/commands/workflow/ssh.ts +++ b/packages/eas-cli/src/commands/workflow/ssh.ts @@ -133,7 +133,7 @@ export default class WorkflowSsh extends EasCommand { loggedIn: { graphqlClient }, } = await this.getContextAsync(WorkflowSsh, { nonInteractive: true }); - const connectInfo = await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync( + const connectInfo = await WorkflowJobSshQuery.connectInfoForResourceIdAsync( graphqlClient, resourceId ); @@ -234,7 +234,7 @@ async function waitForSessionToOpenAsync( const deadline = Date.now() + SESSION_OPEN_TIMEOUT_MS; try { while (Date.now() < deadline) { - const connectInfo = await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync( + const connectInfo = await WorkflowJobSshQuery.connectInfoForResourceIdAsync( graphqlClient, workflowJobId ); diff --git a/packages/eas-cli/src/graphql/queries/WorkflowJobSshQuery.ts b/packages/eas-cli/src/graphql/queries/WorkflowJobSshQuery.ts index c82443cf2d..dd3e69b396 100644 --- a/packages/eas-cli/src/graphql/queries/WorkflowJobSshQuery.ts +++ b/packages/eas-cli/src/graphql/queries/WorkflowJobSshQuery.ts @@ -2,7 +2,7 @@ import gql from 'graphql-tag'; import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; import { GraphqlError, withErrorHandlingAsync } from '../client'; -import { WorkflowJobStatus } from '../generated'; +import { JobRunStatus, WorkflowJobStatus } from '../generated'; const FINAL_WORKFLOW_JOB_STATUSES = new Set([ WorkflowJobStatus.Success, @@ -11,6 +11,12 @@ const FINAL_WORKFLOW_JOB_STATUSES = new Set([ WorkflowJobStatus.Skipped, ]); +const FINAL_JOB_RUN_STATUSES = new Set([ + JobRunStatus.Errored, + JobRunStatus.Finished, + JobRunStatus.Canceled, +]); + export type WorkflowJobSshConnectionConfig = { host: string; secret: string; @@ -47,6 +53,20 @@ type WorkflowJobSshPollQueryVariables = { workflowJobId: string; }; +type JobRunSshPollQuery = { + jobRun: { + byId: { + id: string; + status: JobRunStatus; + sshSession: WorkflowJobSshSession | null; + }; + }; +}; + +type JobRunSshPollQueryVariables = { + jobRunId: string; +}; + function toConnectInfo( job: WorkflowJobSshPollQuery['workflowJobs']['byId'] ): WorkflowJobSshConnectInfo { @@ -133,4 +153,61 @@ export const WorkflowJobSshQuery = { } return toConnectInfo(data.workflowJobs.byId); }, + + async connectInfoForJobRunAsync( + graphqlClient: ExpoGraphqlClient, + jobRunId: string + ): Promise { + let data: JobRunSshPollQuery; + try { + data = await withErrorHandlingAsync( + graphqlClient + .query( + gql` + query JobRunSshPoll($jobRunId: ID!) { + jobRun { + byId(jobRunId: $jobRunId) { + id + status + sshSession { + id + connectionConfig { + host + secret + reconnecting + } + } + } + } + } + `, + { jobRunId }, + { requestPolicy: 'network-only' } + ) + .toPromise() + ); + } catch (error) { + if (isNotFoundError(error)) { + return null; + } + throw error; + } + const jobRun = data.jobRun.byId; + const jobCompleted = FINAL_JOB_RUN_STATUSES.has(jobRun.status); + return { + sshRequested: jobRun.sshSession != null || jobCompleted, + jobCompleted, + session: jobRun.sshSession, + }; + }, + + async connectInfoForResourceIdAsync( + graphqlClient: ExpoGraphqlClient, + resourceId: string + ): Promise { + return ( + (await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, resourceId)) ?? + (await WorkflowJobSshQuery.connectInfoForJobRunAsync(graphqlClient, resourceId)) + ); + }, }; From 94b7aa87ada20cae5702a5bb78e09bca469205e5 Mon Sep 17 00:00:00 2001 From: Tomasz Mazur <47872060+AHGIJMKLKKZNPJKQR@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:03:58 +0200 Subject: [PATCH 09/11] Use URL to parse the host --- .../commands/workflow/__tests__/ssh.test.ts | 49 +------------------ packages/eas-cli/src/commands/workflow/ssh.ts | 37 ++++++-------- 2 files changed, 16 insertions(+), 70 deletions(-) diff --git a/packages/eas-cli/src/commands/workflow/__tests__/ssh.test.ts b/packages/eas-cli/src/commands/workflow/__tests__/ssh.test.ts index ccc1c1c183..2659c299c8 100644 --- a/packages/eas-cli/src/commands/workflow/__tests__/ssh.test.ts +++ b/packages/eas-cli/src/commands/workflow/__tests__/ssh.test.ts @@ -6,8 +6,6 @@ import { WorkflowJobSshQuery } from '../../../graphql/queries/WorkflowJobSshQuer import Log from '../../../log'; import { sleepAsync } from '../../../utils/promise'; import WorkflowSsh, { - CONNECTION_HOST_REGEX, - CONNECTION_SECRET_REGEX, parseSshArgv, resolveSshConnectStatus, splitConnectionHost, @@ -132,46 +130,14 @@ describe(resolveSshConnectStatus, () => { }); }); -describe('workflow:ssh connection validation', () => { - describe('CONNECTION_SECRET_REGEX', () => { - it.each(['TOKENabc123', 'sessionId:dGhpcytpcy9iYXNlNjQ9', 'a.b_c~d-e'])( - 'accepts the upterm token %s', - token => { - expect(CONNECTION_SECRET_REGEX.test(token)).toBe(true); - } - ); - - it.each(['tok en', 'tok\nUser attacker', 'tok@host', 'tok"x', "tok'x", 'tok`x', ''])( - 'rejects %j so it cannot inject an ssh config directive', - token => { - expect(CONNECTION_SECRET_REGEX.test(token)).toBe(false); - } - ); - }); - - describe('CONNECTION_HOST_REGEX', () => { - it('accepts a plain hostname', () => { - expect(CONNECTION_HOST_REGEX.test('uptermd.upterm.dev')).toBe(true); - }); - - it.each(['host name', 'host\nHostName evil', 'host@x', 'host/x', ''])('rejects %j', host => { - expect(CONNECTION_HOST_REGEX.test(host)).toBe(false); - }); - - it('accepts a hostname with a port', () => { - expect(CONNECTION_HOST_REGEX.test('relay.expo.dev:8022')).toBe(true); - }); - }); -}); - describe(splitConnectionHost, () => { it('returns the host with no port for a plain hostname', () => { - expect(splitConnectionHost('relay.expo.dev')).toEqual({ host: 'relay.expo.dev' }); + expect(splitConnectionHost('relay.expo.dev')).toEqual({ hostname: 'relay.expo.dev' }); }); it('splits a host:port into host and numeric port', () => { expect(splitConnectionHost('relay.expo.dev:8022')).toEqual({ - host: 'relay.expo.dev', + hostname: 'relay.expo.dev', port: 8022, }); }); @@ -379,17 +345,6 @@ describe(WorkflowSsh, () => { await expect(createCommand(['job-1']).runAsync()).rejects.toThrow('connection host'); }); - it('throws on an unexpected connection token', async () => { - mockConnectInfo.mockResolvedValue({ - sshRequested: true, - jobCompleted: false, - session: { - connectionConfig: { host: 'relay.expo.dev', secret: 'bad token', reconnecting: false }, - }, - } as never); - await expect(createCommand(['job-1']).runAsync()).rejects.toThrow('connection token'); - }); - it('propagates the ssh exit code from the catch handler', async () => { const command = createCommand(['job-1']); // @ts-expect-error isRunningSubprocess is private diff --git a/packages/eas-cli/src/commands/workflow/ssh.ts b/packages/eas-cli/src/commands/workflow/ssh.ts index 710ebe8b67..5c50bbb942 100644 --- a/packages/eas-cli/src/commands/workflow/ssh.ts +++ b/packages/eas-cli/src/commands/workflow/ssh.ts @@ -15,15 +15,17 @@ import Log from '../../log'; import { ora } from '../../ora'; import { sleepAsync } from '../../utils/promise'; -export const CONNECTION_HOST_REGEX = /^[A-Za-z0-9.-]+(?::\d+)?$/; -export const CONNECTION_SECRET_REGEX = /^[A-Za-z0-9._~:/+=-]+$/; - -export function splitConnectionHost(connectionHost: string): { host: string; port?: number } { - const match = connectionHost.match(/^(.+):(\d+)$/); - if (!match) { - return { host: connectionHost }; +export function splitConnectionHost(connectionHost: string): { hostname: string; port?: number } { + try { + const { hostname, port: stringPort } = new URL(`ssh://${connectionHost}`); + const port = stringPort === '' ? undefined : Number(stringPort); + return { hostname, port }; + } catch (err) { + throw new Error( + 'Unexpected connection host reported for this ssh session. Update eas-cli and try again, or contact support if it persists.', + { cause: err } + ); } - return { host: match[1], port: Number(match[2]) }; } const SSH_INSECURE_OPTS = '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'; @@ -154,22 +156,11 @@ export default class WorkflowSsh extends EasCommand { } const { host: connectionHost, secret } = connectionConfig; - if (!CONNECTION_HOST_REGEX.test(connectionHost)) { - throw new Error( - 'Unexpected connection host reported for this ssh session. Update eas-cli and try again, or contact support if it persists.' - ); - } - if (!CONNECTION_SECRET_REGEX.test(secret)) { - throw new Error( - 'Unexpected connection token reported for this ssh session. Update eas-cli and try again, or contact support if it persists.' - ); - } - - const { host, port } = splitConnectionHost(connectionHost); + const { hostname, port } = splitConnectionHost(connectionHost); const portOption = port !== undefined ? ` -p ${port}` : ''; if (showConnect) { - Log.log(`ssh ${SSH_INSECURE_OPTS}${portOption} ${secret}@${host}`); + Log.log(`ssh ${SSH_INSECURE_OPTS}${portOption} ${secret}@${hostname}`); Log.newLine(); Log.log( 'If your network blocks the direct SSH connection, reach the session through the WebSocket relay with upterm (https://upterm.dev):' @@ -177,7 +168,7 @@ export default class WorkflowSsh extends EasCommand { // WSS terminates on the relay hostname (default 443). Do not paste an SSH // :port into the wss:// URL — that port is only for the ssh destination. Log.log( - ` ssh -o ProxyCommand="upterm proxy wss://${secret}@${host}" ${SSH_INSECURE_OPTS}${portOption} ${secret}@${host}` + ` ssh -o ProxyCommand="upterm proxy wss://${secret}@${hostname}" ${SSH_INSECURE_OPTS}${portOption} ${secret}@${hostname}` ); return; } @@ -190,7 +181,7 @@ export default class WorkflowSsh extends EasCommand { configPath, [ `Host ${hostAlias}`, - ` HostName ${host}`, + ` HostName ${hostname}`, ...(port !== undefined ? [` Port ${port}`] : []), ` User ${secret}`, ' StrictHostKeyChecking no', From 30d4bd9a079ea7a49d8bff8c4056bc442136d05d Mon Sep 17 00:00:00 2001 From: Tomasz Mazur <47872060+AHGIJMKLKKZNPJKQR@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:32:06 +0200 Subject: [PATCH 10/11] Regenerate GraphQL --- packages/eas-cli/src/graphql/generated.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index 26ad7cc63b..268979eca4 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -15676,7 +15676,14 @@ export type WorkflowJobSshPollQueryVariables = Exact<{ }>; -export type WorkflowJobSshPollQuery = { __typename?: 'RootQuery', workflowJobs: { __typename?: 'WorkflowJobQuery', byId: { __typename?: 'WorkflowJob', id: string, status: WorkflowJobStatus, type: WorkflowJobType, workflowRun: { __typename?: 'WorkflowRun', id: string, sshSettings?: { __typename?: 'WorkflowRunSshSettings', idleTimeoutSeconds: number } | null }, turtleJobRun?: { __typename?: 'JobRun', id: string, sshSession?: { __typename?: 'TurtleSshSession', id: string, connectionConfig: { __typename?: 'TurtleSshConnectionConfig', host: string, secret: string, reconnecting: boolean } } | null } | null, turtleBuild?: { __typename?: 'Build', id: string, sshSession?: { __typename?: 'TurtleSshSession', id: string, connectionConfig: { __typename?: 'TurtleSshConnectionConfig', host: string, secret: string, reconnecting: boolean } } | null } | null } } }; +export type WorkflowJobSshPollQuery = { __typename?: 'RootQuery', workflowJobs: { __typename?: 'WorkflowJobQuery', byId: { __typename?: 'WorkflowJob', id: string, status: WorkflowJobStatus, workflowRun: { __typename?: 'WorkflowRun', id: string, sshSettings?: { __typename?: 'WorkflowRunSshSettings', idleTimeoutSeconds: number } | null }, turtleJobRun?: { __typename?: 'JobRun', id: string, sshSession?: { __typename?: 'TurtleSshSession', id: string, connectionConfig: { __typename?: 'TurtleSshConnectionConfig', host: string, secret: string, reconnecting: boolean } } | null } | null, turtleBuild?: { __typename?: 'Build', id: string, sshSession?: { __typename?: 'TurtleSshSession', id: string, connectionConfig: { __typename?: 'TurtleSshConnectionConfig', host: string, secret: string, reconnecting: boolean } } | null } | null } } }; + +export type JobRunSshPollQueryVariables = Exact<{ + jobRunId: Scalars['ID']['input']; +}>; + + +export type JobRunSshPollQuery = { __typename?: 'RootQuery', jobRun: { __typename?: 'JobRunQuery', byId: { __typename?: 'JobRun', id: string, status: JobRunStatus, sshSession?: { __typename?: 'TurtleSshSession', id: string, connectionConfig: { __typename?: 'TurtleSshConnectionConfig', host: string, secret: string, reconnecting: boolean } } | null } } }; export type ExpoGoSupportedSdkVersionsQueryVariables = Exact<{ [key: string]: never; }>; From a200594352aa0b67c57eb1fddeb2ef8a44f2ae21 Mon Sep 17 00:00:00 2001 From: Tomasz Mazur <47872060+AHGIJMKLKKZNPJKQR@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:03:52 +0200 Subject: [PATCH 11/11] Move changelog entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c1964629d..08eaaaec13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ This is the log of notable changes to EAS CLI and related packages. - [eas-cli] Update workflow run logs in real time, instead of every 10 seconds. ([#4228](https://github.com/expo/eas-cli/pull/4228) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR)) - [build-tools] Add `expo-device-hub` web previews to Android web-preview-only, Agent Device, Argent, and Appium remote sessions. ([#4304](https://github.com/expo/eas-cli/pull/4304) by [@krystofwoldrich](https://github.com/krystofwoldrich)) +- [eas-cli] Add `eas workflow:ssh [command...]` to open an ssh session on the worker running a workflow job. ([#4032](https://github.com/expo/eas-cli/pull/4032) by [@gwdp](https://github.com/gwdp)) ### 🐛 Bug fixes @@ -65,7 +66,6 @@ This is the log of notable changes to EAS CLI and related packages. - [eas-cli] Add experimental `--resource-class` flag to `eas simulator`. ([#4268](https://github.com/expo/eas-cli/pull/4268) by [@gwdp](https://github.com/gwdp)) - [build-tools] Add worker-side SSH session helpers (upterm relay + session create/report/close). ([#4030](https://github.com/expo/eas-cli/pull/4030) by [@gwdp](https://github.com/gwdp)) - [worker] Wire an SSH session into the build lifecycle behind the workflow ssh flag. ([#4031](https://github.com/expo/eas-cli/pull/4031) by [@gwdp](https://github.com/gwdp)) -- [eas-cli] Add `eas workflow:ssh [command...]` to open an ssh session on the worker running a workflow job. ([#4032](https://github.com/expo/eas-cli/pull/4032) by [@gwdp](https://github.com/gwdp)) ### 🐛 Bug fixes