Files
orca/src/cli/execution-host-flag.test.ts
T
4940654793 fix(cli): --host and --project accept the names host list and project list print (#30438)
* Accept host aliases and infer the host for a single-setup project

`orca worktree create --project scalp-it` failed with "Project is not set
up on the selected host" even though the project has exactly one ready
setup, and `--host trader-local` was rejected as an invalid value even
though `orca host list` prints that name. Three separate causes:

- `--project` was matched against the stored `projectId` only, which is a
  provider-scoped string (`github:owner/repo`). The display name shown by
  `orca project list` never matched, so the lookup found nothing and the
  failure was reported as a missing host.
- With no `--host`, the host fallback existed but was unreachable, because
  the project lookup above had already filtered to zero candidates.
- `parseExecutionHostId` accepts only `local`, `ssh:<id>` and
  `runtime:<id>`, so a bare alias could not parse at all. `ssh:<label>`
  already resolved labels; only the unprefixed spelling was missing.

Now:

- `--project` takes the project id or the display name, resolving through
  `project.list` and falling back to the setup's own display name for
  hosts that predate that method. Two projects sharing a name are refused
  rather than guessed.
- `--host` is only required when the project is set up on more than one
  host. A single setup is used directly; an ambiguous one fails with every
  host and the flag to select it.
- `--host <alias>` resolves against both axes: paired servers by name
  before routing, SSH targets by label once a client exists. An alias that
  names a machine on both is refused with the prefixed flag for each.

Errors now echo the flag the caller typed instead of an `ssh:` spelling
they did not use, and carry the hosts to retry with in `nextSteps`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Refuse a host alias that collides with duplicated SSH labels

assertAliasNamesOneHost asked findSshTargetByName whether the alias also
named an SSH target, but that function returns undefined for a label two
targets share — deliberately, so an ambiguous label never resolves. Read
as a boolean it says "no SSH target by that name", so an alias naming one
paired server and two same-labelled SSH targets looked conflict-free and
resolved to the server.

The opposite direction was already covered: a unique SSH target against
two servers sharing a name fails through assertEnvironmentNameUnambiguous.
Fall back to ambiguousSshTargets so both axes refuse, and list every
colliding target in nextSteps rather than only the first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Only fall back to setup display names on a server without project.list

findProjectsByName caught every error from project.list and returned an
empty list, so a network failure, a permission error, or a genuine server
fault was indistinguishable from "no project by that name" — and the
caller was then told `Project is not set up on the selected host` for a
project that exists and is set up.

Narrow the catch to method_not_found, the only code that means the weaker
fallback is warranted, matching callProjectHostSetup and listSshTargets in
the same layer. The function now returns null for that version gap and an
empty array for a real miss, so the two cases stay distinguishable at the
call site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Refuse a legacy display-name match that spans two projects

The pre-project.list fallback matches ProjectHostSetup.displayName, which
is the repo's folder name rather than the project's name, so two projects
holding repos with the same folder name both match. selectSetupOnHost
would then have taken the first candidate — targeting a project the caller
never named.

Reject when the matched setups span more than one projectId and name each
one, the same rule the project.list path already applies to duplicate
display names. A single project with several setups is unaffected; the
host selection below still decides between them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Spell the --host placeholder as <host> in the project setup usage lines

The worktree create usage was updated to <host> when aliases became
valid, but the four project setup-* lines kept <host-id> in both the
command specs and the root help text they mirror. Those commands accept
an alias too, so the narrower placeholder hid it from anyone reading help.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Document host names and the optional --host in the CLI docs

The published CLI reference described `--host` as taking only `local`,
`ssh:<target-id>` or `runtime:<environment-id>`, and the automations page
showed `--host <hostId>` as required alongside `--project`. Both now
understate what the commands accept, which is how the original report
started: `orca host list` prints a name, the docs never said you could
type it, so the generated id looked mandatory.

Spell out that `--host` takes the bare name from `host list` on either
axis, that a name resolves only when unique and is otherwise refused with
the prefixed flag for each candidate, and that `--host` is unnecessary
when the project has a single setup. Also note `--project` accepts the
display name from `orca project list`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Name the runtime host id as the paired-server tie-breaker in the docs

The host-selection page told readers to fall back to ids when a name is
ambiguous but only ever showed `--environment <server-name>` for paired
servers — and that selector is exactly the one that cannot separate two
servers sharing a name. Spell out the pair the resolver actually emits in
its nextSteps: `--host ssh:<target-id>` and `--host runtime:<environment-id>`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Spell the automations create usage as --project <id-or-name> [--host <host>]

`automations create` resolves --project and --host through the same
alias-capable resolver as `worktree create`, and its own notes already say
--project takes a display name and --host takes a bare host name. The usage
line still read `--project <id> [--host <id>]`, the last spot left on the old
spelling (flagged by CodeRabbit after the main merge).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Give the fake runtime client cast in the host alias tests its SAFETY note

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): accept every name host list and project list print, everywhere --project/--host is taken

Extends the alias work to the commands it did not reach and the names it
missed:

- `project setups --project <display name>` filtered by projectId only.
- `project setup-existing-folder|setup-clone|setup-create --project <name>`
  now send the project id that name resolves to (pass-through for ids and
  for servers without project.list).
- `--host "this machine"` (the local row's name) resolves to local, and is
  refused if an SSH target or paired server shares it.
- One pair of wrapping quotes is ignored, since cmd.exe passes single
  quotes through literally.
- With --environment/--pairing-code, a bare --host name is a machine the
  routed server reaches, not a different locally paired server.
- An unknown project is reported as a project miss, not a host miss.

Co-authored-by: Younghwan Chae <chyohw97@gmail.com>

* fix(cli): keep exact project ids over names, and refuse an unverifiable host name

An exact --project id whose only setup is not ready no longer falls through
to a project whose display name equals that id. A bare --host name that
matches a paired server now routes there only once this machine's SSH
targets were actually read, and under --environment/--pairing-code a bare
name always means a machine the routed server reaches.

Co-authored-by: Younghwan Chae <chyohw97@gmail.com>

---------

Co-authored-by: younghwan <chyohw97@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-18 15:22:37 -07:00

493 lines
18 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
const { listEnvironmentsMock, resolveEnvironmentMock, getDefaultUserDataPathMock } = vi.hoisted(
() => ({
listEnvironmentsMock: vi.fn(),
resolveEnvironmentMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data')
})
)
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: vi.fn(),
listEnvironments: listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: resolveEnvironmentMock
}))
vi.mock('./runtime-client', () => ({
getDefaultUserDataPath: getDefaultUserDataPathMock
}))
import {
assertEnvironmentSelectorResolvable,
hostFilterMatchesHostId,
parseHostFlag,
resolveHostFlagEnvironmentId,
resolveHostFlagTarget
} from './execution-host-flag'
import { parseExecutionHostId } from '../shared/execution-host'
import type { RuntimeClient } from './runtime-client'
const listSshTargetsMock = vi.fn(async () => [] as { id: string; label: string }[])
const NO_SELECTION = {
listSshTargets: listSshTargetsMock,
verifySshTargets: listSshTargetsMock,
pairingCode: null,
environmentSelector: null
}
function flags(entries: Record<string, string | boolean>): Map<string, string | boolean> {
return new Map(Object.entries(entries))
}
function environment(id: string, name = id) {
return { id, name }
}
describe('parseHostFlag', () => {
it('returns undefined when --host is absent', () => {
expect(parseHostFlag(flags({}))).toBeUndefined()
})
it('rejects a --host flag with no value', () => {
expect(() => parseHostFlag(flags({ host: true }))).toThrow('Missing value for --host')
})
// A value that claims a kind but does not parse is malformed, not a name someone could have
// meant — only an unprefixed value can be a host alias.
it.each(['runtime:', 'ssh:', 'ssh:a|b', ' '])('rejects the unparseable host id %s', (value) => {
expect(() => parseHostFlag(flags({ host: value }))).toThrow(`Invalid --host value: ${value}`)
})
it('parses the three supported host kinds', () => {
expect(parseHostFlag(flags({ host: 'local' }))?.kind).toBe('local')
expect(parseHostFlag(flags({ host: 'ssh:box-1' }))?.kind).toBe('ssh')
expect(parseHostFlag(flags({ host: 'runtime:env-1' }))).toEqual({
kind: 'runtime',
id: 'runtime:env-1',
environmentId: 'env-1'
})
})
// Why: `orca host list` prints the alias, so the alias is what gets typed. Rejecting it sent
// people hunting for a machine-generated id the listing showed in a different column.
it('carries an unprefixed host name through as an alias', () => {
expect(parseHostFlag(flags({ host: 'trader-local' }))).toEqual({
kind: 'alias',
alias: 'trader-local'
})
expect(parseHostFlag(flags({ host: ' trader-local ' }))).toEqual({
kind: 'alias',
alias: 'trader-local'
})
})
})
describe('resolveHostFlagEnvironmentId', () => {
it('keeps the current connection for local and ssh hosts', async () => {
await expect(
resolveHostFlagEnvironmentId(flags({ host: 'local' }), NO_SELECTION)
).resolves.toBe(null)
await expect(
resolveHostFlagEnvironmentId(flags({ host: 'ssh:box-1' }), NO_SELECTION)
).resolves.toBe(null)
expect(listEnvironmentsMock).not.toHaveBeenCalled()
})
it('routes a paired runtime host to that environment', async () => {
listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')])
await expect(
resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-1' }), NO_SELECTION)
).resolves.toBe('env-1')
})
it('rejects a runtime host id that no paired environment owns', async () => {
listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')])
await expect(
resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-missing' }), NO_SELECTION)
).rejects.toThrow('no paired Orca server is named or has id env-missing')
})
// Why: the name is what a person or agent actually knows; requiring the raw uuid made the
// obvious spelling fail against a server that is sitting right there.
it('accepts the environment name as well as its id', async () => {
listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')])
await expect(
resolveHostFlagEnvironmentId(flags({ host: 'runtime:gpu' }), NO_SELECTION)
).resolves.toBe('env-1')
})
it('decodes percent-encoded environment ids', async () => {
listEnvironmentsMock.mockReturnValue([environment('env one')])
await expect(
resolveHostFlagEnvironmentId(flags({ host: 'runtime:env%20one' }), NO_SELECTION)
).resolves.toBe('env one')
})
it('refuses a runtime host alongside --pairing-code', async () => {
listEnvironmentsMock.mockReturnValue([environment('env-1')])
await expect(
resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-1' }), {
listSshTargets: listSshTargetsMock,
verifySshTargets: listSshTargetsMock,
pairingCode: 'orca://pair?x',
environmentSelector: null
})
).rejects.toThrow('not both')
})
it('refuses a runtime host that disagrees with --environment', async () => {
listEnvironmentsMock.mockReturnValue([environment('env-1')])
resolveEnvironmentMock.mockReturnValue(environment('env-2'))
await expect(
resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-1' }), {
listSshTargets: listSshTargetsMock,
verifySshTargets: listSshTargetsMock,
pairingCode: null,
environmentSelector: { value: 'other', label: '--environment' }
})
).rejects.toThrow('name different Orca servers')
})
it('names the ambient variable when ORCA_ENVIRONMENT is the conflicting selector', async () => {
listEnvironmentsMock.mockReturnValue([environment('env-1')])
resolveEnvironmentMock.mockReturnValue(environment('env-2'))
await expect(
resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-1' }), {
listSshTargets: listSshTargetsMock,
verifySshTargets: listSshTargetsMock,
pairingCode: null,
environmentSelector: { value: 'staging', label: 'ORCA_ENVIRONMENT' }
})
).rejects.toThrow('ORCA_ENVIRONMENT staging name different Orca servers')
})
it('hands an agent the known environment ids to retry with', async () => {
listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')])
await expect(
resolveHostFlagEnvironmentId(flags({ host: 'runtime:missing' }), NO_SELECTION)
).rejects.toMatchObject({
code: 'invalid_argument',
data: { knownEnvironments: [{ id: 'env-1', name: 'gpu' }] }
})
})
it('accepts --environment naming the same server as the runtime host', async () => {
listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')])
resolveEnvironmentMock.mockReturnValue(environment('env-1', 'gpu'))
await expect(
resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-1' }), {
listSshTargets: listSshTargetsMock,
verifySshTargets: listSshTargetsMock,
pairingCode: null,
environmentSelector: { value: 'gpu', label: '--environment' }
})
).resolves.toBe('env-1')
})
})
// Why: the reported failure. `orca host list` shows `trader-local`, so `--host trader-local` is
// what gets typed, and only the machine-generated `ssh:ssh-1783599077486-fyasux` was accepted.
describe('unprefixed --host aliases', () => {
function clientWithTargets(targets: { id: string; label: string }[]): RuntimeClient {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SSH target resolution reads only client.call.
return {
call: vi.fn(async () => ({ result: { targets } }))
} as unknown as RuntimeClient
}
it('routes an alias that names a paired server to that environment', async () => {
listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')])
listSshTargetsMock.mockResolvedValue([])
await expect(resolveHostFlagEnvironmentId(flags({ host: 'gpu' }), NO_SELECTION)).resolves.toBe(
'env-1'
)
})
// The SSH axis lives on the connected runtime, so a miss here is not yet a failure.
it('leaves an alias that names no paired server to the SSH axis', async () => {
listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')])
await expect(
resolveHostFlagEnvironmentId(flags({ host: 'trader-local' }), NO_SELECTION)
).resolves.toBe(null)
})
it('resolves an alias to the SSH target that carries that label', async () => {
listEnvironmentsMock.mockReturnValue([])
const client = clientWithTargets([{ id: 'ssh-1783599077486-fyasux', label: 'trader-local' }])
await expect(resolveHostFlagTarget(flags({ host: 'trader-local' }), client)).resolves.toEqual({
kind: 'ssh',
id: 'ssh:ssh-1783599077486-fyasux',
targetId: 'ssh-1783599077486-fyasux'
})
})
it('names the alias the caller typed rather than an ssh: spelling they did not use', async () => {
listEnvironmentsMock.mockReturnValue([])
await expect(
resolveHostFlagTarget(flags({ host: 'trader-local' }), clientWithTargets([]))
).rejects.toThrow('Unknown host in --host trader-local')
})
// Why: a paired server and an SSH target are different machines. Picking either would run the
// command somewhere the caller never named.
it('refuses an alias that names a machine on both axes', async () => {
listEnvironmentsMock.mockReturnValue([environment('env-1', 'awin')])
listSshTargetsMock.mockResolvedValue([{ id: 'ssh-1-a', label: 'awin' }])
await expect(
resolveHostFlagEnvironmentId(flags({ host: 'awin' }), NO_SELECTION)
).rejects.toMatchObject({
code: 'invalid_argument',
data: {
nextSteps: expect.arrayContaining([
expect.stringContaining('--host runtime:env-1'),
expect.stringContaining('--host ssh:ssh-1-a')
])
}
})
})
// Why: findSshTargetByName returns undefined for a duplicated label, which reads the same as
// "no SSH target by that name" — so this case used to look conflict-free and resolve to the
// paired server, the one direction that still guessed.
it('refuses an alias naming a server and two SSH targets sharing a label', async () => {
listEnvironmentsMock.mockReturnValue([environment('env-1', 'awin')])
listSshTargetsMock.mockResolvedValue([
{ id: 'ssh-1-a', label: 'awin' },
{ id: 'ssh-2-b', label: 'awin' }
])
await expect(
resolveHostFlagEnvironmentId(flags({ host: 'awin' }), NO_SELECTION)
).rejects.toMatchObject({
code: 'invalid_argument',
data: {
nextSteps: expect.arrayContaining([
expect.stringContaining('--host runtime:env-1'),
expect.stringContaining('--host ssh:ssh-1-a'),
expect.stringContaining('--host ssh:ssh-2-b')
])
}
})
})
it('refuses an alias two paired servers answer to', async () => {
listEnvironmentsMock.mockReturnValue([
environment('env-1', 'awin'),
environment('env-2', 'awin')
])
await expect(
resolveHostFlagEnvironmentId(flags({ host: 'awin' }), NO_SELECTION)
).rejects.toThrow('Ambiguous Orca server in --host awin')
})
it('refuses to route an alias to a paired server when SSH targets cannot be read', async () => {
listEnvironmentsMock.mockReturnValue([environment('env-1', 'awin')])
await expect(
resolveHostFlagEnvironmentId(flags({ host: 'awin' }), {
...NO_SELECTION,
verifySshTargets: async () => {
throw new Error('runtime unreachable')
}
})
).rejects.toMatchObject({
message: expect.stringContaining('Cannot verify --host awin'),
data: { nextSteps: [expect.stringContaining('--host runtime:env-1')] }
})
})
// Why: `orca host list` prints `this machine` as the local row's name, with a space.
it('resolves the local row name to --host local', async () => {
listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')])
await expect(
resolveHostFlagEnvironmentId(flags({ host: 'This Machine' }), NO_SELECTION)
).resolves.toBe(null)
await expect(
resolveHostFlagTarget(flags({ host: 'this machine' }), clientWithTargets([]))
).resolves.toEqual({ kind: 'local', id: 'local' })
})
it('refuses the local row name when an SSH target carries it too', async () => {
listEnvironmentsMock.mockReturnValue([])
await expect(
resolveHostFlagTarget(
flags({ host: 'this machine' }),
clientWithTargets([{ id: 'ssh-1-a', label: 'this machine' }])
)
).rejects.toMatchObject({
message: expect.stringContaining('Ambiguous --host this machine'),
data: {
nextSteps: expect.arrayContaining([
'Use --host local for this machine.',
expect.stringContaining('--host ssh:ssh-1-a')
])
}
})
})
it('refuses the local row name when a paired server carries it too', async () => {
listEnvironmentsMock.mockReturnValue([environment('env-1', 'this machine')])
await expect(
resolveHostFlagEnvironmentId(flags({ host: 'this machine' }), NO_SELECTION)
).rejects.toThrow('Ambiguous --host this machine')
})
it('resolves an SSH target label that contains spaces', async () => {
listEnvironmentsMock.mockReturnValue([])
const client = clientWithTargets([{ id: 'ssh-9', label: 'Build Box 2' }])
await expect(resolveHostFlagTarget(flags({ host: 'build box 2' }), client)).resolves.toEqual({
kind: 'ssh',
id: 'ssh:ssh-9',
targetId: 'ssh-9'
})
})
// Why: cmd.exe does not strip single quotes, so `--host 'gpu'` arrives with them.
it('ignores one pair of wrapping quotes around a host name', async () => {
listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')])
listSshTargetsMock.mockResolvedValue([])
await expect(
resolveHostFlagEnvironmentId(flags({ host: "'gpu'" }), NO_SELECTION)
).resolves.toBe('env-1')
expect(parseHostFlag(flags({ host: '"local"' }))).toEqual({ kind: 'local', id: 'local' })
})
// Why: with --environment the command runs on that server, so a bare name is a machine it
// reaches; a locally paired server sharing the name, even the routed one, is not a host there.
it('leaves an alias to the routed server SSH targets when --environment picks the server', async () => {
listEnvironmentsMock.mockReturnValue([
environment('env-1', 'openclaw'),
environment('env-2', 'omarchy')
])
await expect(
resolveHostFlagEnvironmentId(flags({ host: 'omarchy' }), {
...NO_SELECTION,
environmentSelector: { value: 'openclaw', label: '--environment' }
})
).resolves.toBe(null)
await expect(
resolveHostFlagEnvironmentId(flags({ host: 'openclaw' }), {
...NO_SELECTION,
environmentSelector: { value: 'openclaw', label: '--environment' }
})
).resolves.toBe(null)
})
})
describe('hostFilterMatchesHostId', () => {
const runtimeHost = parseExecutionHostId('runtime:env-1')!
const localHost = parseExecutionHostId('local')!
const sshHost = parseExecutionHostId('ssh:box-1')!
it('matches the identical host id', () => {
expect(hostFilterMatchesHostId(runtimeHost, 'runtime:env-1')).toBe(true)
expect(hostFilterMatchesHostId(localHost, 'local')).toBe(true)
expect(hostFilterMatchesHostId(sshHost, 'ssh:box-1')).toBe(true)
})
it('treats the routed runtime own local rows as that runtime', () => {
expect(hostFilterMatchesHostId(runtimeHost, 'local')).toBe(true)
})
it('does not widen local or ssh filters', () => {
expect(hostFilterMatchesHostId(localHost, 'runtime:env-1')).toBe(false)
expect(hostFilterMatchesHostId(sshHost, 'local')).toBe(false)
})
it('does not match a different runtime host', () => {
expect(hostFilterMatchesHostId(runtimeHost, 'runtime:env-2')).toBe(false)
expect(hostFilterMatchesHostId(runtimeHost, null)).toBe(false)
})
})
describe('ambiguous environment names', () => {
it('refuses --host runtime:<name> when two servers share that name', async () => {
listEnvironmentsMock.mockReturnValue([
environment('env-1', 'awin'),
environment('env-2', 'awin')
])
await expect(
resolveHostFlagEnvironmentId(flags({ host: 'runtime:awin' }), NO_SELECTION)
).rejects.toThrow('2 paired servers are named awin')
})
it('refuses --environment <name> when two servers share that name', async () => {
listEnvironmentsMock.mockReturnValue([
environment('env-1', 'awin'),
environment('env-2', 'awin')
])
await expect(assertEnvironmentSelectorResolvable('awin', listSshTargetsMock)).rejects.toThrow(
'2 paired servers are named awin'
)
})
it('still routes an exact id past a colliding name', async () => {
listEnvironmentsMock.mockReturnValue([
environment('env-1', 'awin'),
environment('env-2', 'awin')
])
await expect(
resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-2' }), NO_SELECTION)
).resolves.toBe('env-2')
})
})
describe('assertEnvironmentSelectorResolvable', () => {
it('accepts a paired server by name or id', async () => {
listEnvironmentsMock.mockReturnValue([environment('env-1', 'awin')])
listSshTargetsMock.mockClear()
await expect(
assertEnvironmentSelectorResolvable('awin', listSshTargetsMock)
).resolves.toBeUndefined()
await expect(
assertEnvironmentSelectorResolvable('env-1', listSshTargetsMock)
).resolves.toBeUndefined()
expect(listSshTargetsMock).not.toHaveBeenCalled()
})
// Why: the inverse of the --host case, and the one the report actually hit — a name that is
// an SSH target dead-ended with a bare "unknown environment".
it('names the SSH target when the selector is one', async () => {
listEnvironmentsMock.mockReturnValue([environment('env-1', 'awin')])
listSshTargetsMock.mockResolvedValue([{ id: 'ssh-1-a', label: 'openclaw' }])
await expect(
assertEnvironmentSelectorResolvable('openclaw', listSshTargetsMock)
).rejects.toMatchObject({
code: 'invalid_argument',
data: {
nextSteps: expect.arrayContaining([expect.stringContaining('--host ssh:ssh-1-a')])
}
})
})
it('still points at host list when the name is on neither axis', async () => {
listEnvironmentsMock.mockReturnValue([])
listSshTargetsMock.mockResolvedValue([])
await expect(
assertEnvironmentSelectorResolvable('nowhere', listSshTargetsMock)
).rejects.toThrow('no paired Orca server is named or has id nowhere')
})
})