mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 17:17:38 +00:00
* 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>
348 lines
14 KiB
TypeScript
348 lines
14 KiB
TypeScript
import {
|
|
LOCAL_EXECUTION_HOST_ID,
|
|
normalizeExecutionHostId,
|
|
parseExecutionHostId,
|
|
toSshExecutionHostId,
|
|
type ParsedExecutionHost
|
|
} from '../shared/execution-host'
|
|
import {
|
|
ambiguousEnvironments,
|
|
ambiguousSshTargets,
|
|
crossKindNextSteps,
|
|
findEnvironmentByName,
|
|
findSshTargetByName,
|
|
fetchSshTargetSummaries,
|
|
LOCAL_HOST_LIST_NAME,
|
|
normalizeTypedName,
|
|
resolveSshHostTargetId,
|
|
stripWrappingQuotes,
|
|
type EnvironmentSummary,
|
|
type SshTargetSummary
|
|
} from './host-selector-alternatives'
|
|
import type { RuntimeClient } from './runtime-client'
|
|
import { RuntimeClientError } from './runtime/types'
|
|
|
|
export type HostFlagRoutingSelection = {
|
|
// Why: SSH targets live in the running Orca host, not on disk, so enumerating them needs a
|
|
// client. Injected as a thunk so the lookup only happens on the error path we are explaining.
|
|
listSshTargets: () => Promise<SshTargetSummary[]>
|
|
// Why: unlike the suggestion listing, this throws when the targets cannot be read. A bare name
|
|
// may only route to a paired server once no SSH target is known to share it.
|
|
verifySshTargets: () => Promise<SshTargetSummary[]>
|
|
pairingCode: string | null
|
|
// Why: an ambient ORCA_ENVIRONMENT counts as a selection too, so carry the label to
|
|
// name the real source in the conflict message.
|
|
environmentSelector: { value: string; label: string } | null
|
|
}
|
|
|
|
// Why: `orca host list` prints the alias a caller actually knows (`trader-local`), so that is what
|
|
// they type — but which machine it names needs the host listings, which only the async resolvers
|
|
// below can read. Carry the alias unresolved rather than rejecting the obvious spelling.
|
|
export type HostFlagAlias = { kind: 'alias'; alias: string }
|
|
export type HostFlagSelection = ParsedExecutionHost | HostFlagAlias
|
|
|
|
function isLocalHostListName(alias: string): boolean {
|
|
return normalizeTypedName(alias) === LOCAL_HOST_LIST_NAME
|
|
}
|
|
|
|
export function parseHostFlag(flags: Map<string, string | boolean>): HostFlagSelection | undefined {
|
|
if (!flags.has('host')) {
|
|
return undefined
|
|
}
|
|
const raw = flags.get('host')
|
|
if (typeof raw !== 'string' || raw.length === 0) {
|
|
throw new RuntimeClientError('invalid_argument', 'Missing value for --host')
|
|
}
|
|
const value = stripWrappingQuotes(raw.trim())
|
|
const parsed = parseExecutionHostId(value)
|
|
if (parsed) {
|
|
return parsed
|
|
}
|
|
const alias = value.trim()
|
|
// A value that claims a kind but does not parse is malformed, not an alias: `ssh:` names no
|
|
// target, and a `|` would rebind the id downstream. Only an unprefixed name can be a label.
|
|
if (!alias || alias.startsWith('ssh:') || alias.startsWith('runtime:')) {
|
|
throw new RuntimeClientError(
|
|
'invalid_argument',
|
|
`Invalid --host value: ${raw}. Expected local, ssh:<target-id>, runtime:<environment-id>, or a host name from \`orca host list\`.`
|
|
)
|
|
}
|
|
return { kind: 'alias', alias }
|
|
}
|
|
|
|
// Why: `runtime:<environment-id>` ids are minted by this machine's pairing store, so they
|
|
// only name a machine relative to it. Filtering or mutating the locally connected runtime
|
|
// with one answers for the wrong host, and an id that matches nothing is indistinguishable
|
|
// from a real host with no rows — so resolve the id here and send the command to it.
|
|
export async function resolveHostFlagEnvironmentId(
|
|
flags: Map<string, string | boolean>,
|
|
selection: HostFlagRoutingSelection
|
|
): Promise<string | null> {
|
|
const host = parseHostFlag(flags)
|
|
if (host?.kind !== 'runtime' && host?.kind !== 'alias') {
|
|
return null
|
|
}
|
|
const [{ listEnvironments, resolveEnvironment }, { getDefaultUserDataPath }] = await Promise.all([
|
|
import('./runtime/environments.js'),
|
|
import('./runtime-client.js')
|
|
])
|
|
const userDataPath = getDefaultUserDataPath()
|
|
const environments = listEnvironments(userDataPath).map((candidate) => ({
|
|
id: candidate.id,
|
|
name: candidate.name
|
|
}))
|
|
const wanted = host.kind === 'runtime' ? host.environmentId : host.alias
|
|
const flagSpelling = host.kind === 'runtime' ? `--host ${host.id}` : `--host ${host.alias}`
|
|
// Why: with --environment or --pairing-code the command already runs on a chosen server, so a
|
|
// bare name is this machine or an SSH target that server reaches, never a second server.
|
|
if (host.kind === 'alias' && (selection.environmentSelector || selection.pairingCode)) {
|
|
return null
|
|
}
|
|
// Why: --environment has always taken a name or an id, and the name is what people and agents
|
|
// actually know. Requiring the raw uuid here made the obvious spelling fail; accept either and
|
|
// canonicalize to the id so stored host ids still compare correctly downstream.
|
|
const environment = findEnvironmentByName(environments, wanted)
|
|
if (!environment) {
|
|
assertEnvironmentNameUnambiguous(environments, wanted, flagSpelling)
|
|
// A bare alias has axes left to try: this machine, and SSH targets on the connected runtime,
|
|
// which resolveHostFlagTarget answers once a client exists. Missing here is not a failure.
|
|
if (host.kind === 'alias') {
|
|
return null
|
|
}
|
|
// Why: `runtime:<id>` is a host id that also appears in stored rows, so it resolves by id
|
|
// only — unlike --environment, which also accepts a name. Say so, and hand back the ids an
|
|
// agent can retry with instead of making it scrape the sentence.
|
|
const sshTargets = await selection.listSshTargets()
|
|
throw new RuntimeClientError(
|
|
'invalid_argument',
|
|
`Unknown Orca server in ${flagSpelling}: no paired Orca server is named or has id ${wanted}.`,
|
|
{
|
|
knownEnvironments: environments,
|
|
knownSshTargets: sshTargets,
|
|
nextSteps: [
|
|
...crossKindNextSteps(wanted, { environments, sshTargets }, 'environment'),
|
|
'Run `orca environment list` to see paired Orca servers.',
|
|
'Use --host local to target this machine.'
|
|
]
|
|
}
|
|
)
|
|
}
|
|
if (host.kind === 'alias') {
|
|
if (isLocalHostListName(host.alias)) {
|
|
throw new RuntimeClientError(
|
|
'invalid_argument',
|
|
`Ambiguous --host ${host.alias}: it names both this machine and a paired Orca server.`,
|
|
{
|
|
knownEnvironments: [environment],
|
|
nextSteps: [
|
|
'Use --host local for this machine.',
|
|
`Use --host runtime:${environment.id} for the paired Orca server named ${environment.name}.`
|
|
]
|
|
}
|
|
)
|
|
}
|
|
await assertAliasNamesOneHost(host.alias, environment, selection)
|
|
}
|
|
if (selection.pairingCode) {
|
|
throw new RuntimeClientError(
|
|
'invalid_argument',
|
|
`${flagSpelling} already selects a paired Orca server; use either --host runtime:<id> or --pairing-code, not both.`
|
|
)
|
|
}
|
|
if (selection.environmentSelector) {
|
|
const selected = resolveEnvironment(userDataPath, selection.environmentSelector.value)
|
|
if (selected.id !== environment.id) {
|
|
throw new RuntimeClientError(
|
|
'invalid_argument',
|
|
`${flagSpelling} and ${selection.environmentSelector.label} ${selection.environmentSelector.value} name different Orca servers.`
|
|
)
|
|
}
|
|
}
|
|
return environment.id
|
|
}
|
|
|
|
// Why: a paired server and an SSH target are different machines, and one alias can name both.
|
|
// Picking either would send the command to a machine the caller did not choose — the failure the
|
|
// prefixed spellings exist to prevent — so hand back both flags and let them say which.
|
|
async function assertAliasNamesOneHost(
|
|
alias: string,
|
|
environment: EnvironmentSummary,
|
|
selection: HostFlagRoutingSelection
|
|
): Promise<void> {
|
|
let sshTargets: SshTargetSummary[]
|
|
try {
|
|
sshTargets = await selection.verifySshTargets()
|
|
} catch {
|
|
throw new RuntimeClientError(
|
|
'invalid_argument',
|
|
`Cannot verify --host ${alias}: it names a paired Orca server, but this machine's SSH targets could not be read to rule out one with the same name.`,
|
|
{
|
|
knownEnvironments: [environment],
|
|
nextSteps: [
|
|
`Use --host runtime:${environment.id} for the paired Orca server named ${environment.name}.`
|
|
]
|
|
}
|
|
)
|
|
}
|
|
// Why: findSshTargetByName collapses a duplicated label to undefined, which reads here as "no
|
|
// SSH target by that name" — so two colliding targets would have looked like no conflict at all
|
|
// and resolved to the server. Duplicates are more ambiguity, not less.
|
|
const matched = findSshTargetByName(sshTargets, alias)
|
|
const conflicts = matched ? [matched] : ambiguousSshTargets(sshTargets, alias)
|
|
if (conflicts.length === 0) {
|
|
return
|
|
}
|
|
const sshCount =
|
|
conflicts.length === 1 ? 'an SSH target' : `${conflicts.length} SSH targets sharing that label`
|
|
throw new RuntimeClientError(
|
|
'invalid_argument',
|
|
`Ambiguous --host ${alias}: it names both a paired Orca server and ${sshCount} on this machine.`,
|
|
{
|
|
knownEnvironments: [environment],
|
|
knownSshTargets: conflicts,
|
|
nextSteps: [
|
|
`Use --host runtime:${environment.id} for the paired Orca server named ${environment.name}.`,
|
|
...conflicts.map(
|
|
(target) => `Use --host ssh:${target.id} for the SSH target labeled ${target.label}.`
|
|
)
|
|
]
|
|
}
|
|
)
|
|
}
|
|
|
|
// Why: a runtime stamps its own setups `local` when they were made on the box and
|
|
// `runtime:<id>` when a paired client made them. Once --host routed the command to that
|
|
// runtime both spellings mean the same machine, so a host filter has to accept both.
|
|
export function hostFilterMatchesHostId(
|
|
filter: ParsedExecutionHost,
|
|
candidateHostId: string | null | undefined
|
|
): boolean {
|
|
const candidate = normalizeExecutionHostId(candidateHostId)
|
|
if (candidate === filter.id) {
|
|
return true
|
|
}
|
|
return filter.kind === 'runtime' && candidate === LOCAL_EXECUTION_HOST_ID
|
|
}
|
|
|
|
// Why: `ssh:` reaches a machine the connected runtime owns, so it can only be checked against
|
|
// that runtime — unlike `runtime:`, which is resolved from this machine's pairing store before a
|
|
// client exists. Callers that act on a --host value run this so an unknown target fails loudly
|
|
// instead of quietly filtering to nothing.
|
|
export async function resolveHostFlagTarget(
|
|
flags: Map<string, string | boolean>,
|
|
client: RuntimeClient
|
|
): Promise<ParsedExecutionHost | undefined> {
|
|
const host = parseHostFlag(flags)
|
|
if (host?.kind !== 'ssh' && host?.kind !== 'alias') {
|
|
return host
|
|
}
|
|
const [{ listEnvironments }, { getDefaultUserDataPath }] = await Promise.all([
|
|
import('./runtime/environments.js'),
|
|
import('./runtime-client.js')
|
|
])
|
|
const environments = listEnvironments(getDefaultUserDataPath()).map((candidate) => ({
|
|
id: candidate.id,
|
|
name: candidate.name
|
|
}))
|
|
if (host.kind === 'alias' && isLocalHostListName(host.alias)) {
|
|
return resolveLocalHostListName(host.alias, client)
|
|
}
|
|
// A bare alias reaches here only after resolveHostFlagEnvironmentId found no paired server by
|
|
// that name, so SSH is the one axis left and an unknown name is a real failure.
|
|
const wanted = host.kind === 'ssh' ? host.targetId : host.alias
|
|
const flagSpelling = host.kind === 'ssh' ? `--host ${host.id}` : `--host ${host.alias}`
|
|
const targetId = await resolveSshHostTargetId(
|
|
client,
|
|
wanted,
|
|
environments,
|
|
flagSpelling,
|
|
host.kind === 'alias'
|
|
)
|
|
const sshHostId = toSshExecutionHostId(targetId)
|
|
return parseExecutionHostId(sshHostId) ?? { kind: 'ssh', id: sshHostId, targetId }
|
|
}
|
|
|
|
async function resolveLocalHostListName(
|
|
alias: string,
|
|
client: RuntimeClient
|
|
): Promise<ParsedExecutionHost> {
|
|
// Strict listing: an unreadable target list cannot rule out a clash.
|
|
const { targets: sshTargets } = await fetchSshTargetSummaries<SshTargetSummary>(client)
|
|
const clashing = sshTargets.filter(
|
|
(target) => normalizeTypedName(target.label) === LOCAL_HOST_LIST_NAME
|
|
)
|
|
if (clashing.length > 0) {
|
|
throw new RuntimeClientError(
|
|
'invalid_argument',
|
|
`Ambiguous --host ${alias}: it names both this machine and an SSH target.`,
|
|
{
|
|
knownSshTargets: clashing,
|
|
nextSteps: [
|
|
'Use --host local for this machine.',
|
|
...clashing.map((target) => `Use --host ssh:${target.id} for ${target.label}.`)
|
|
]
|
|
}
|
|
)
|
|
}
|
|
return { kind: 'local', id: LOCAL_EXECUTION_HOST_ID }
|
|
}
|
|
|
|
// Why: the store refuses an ambiguous environment name rather than guessing which server was
|
|
// meant. Resolving one here would put the guess back, in the flag whose whole purpose is to stop
|
|
// a command reaching a machine the caller did not choose.
|
|
function assertEnvironmentNameUnambiguous(
|
|
environments: readonly { id: string; name: string }[],
|
|
name: string,
|
|
flag: string
|
|
): void {
|
|
const ambiguous = ambiguousEnvironments(environments, name)
|
|
if (ambiguous.length === 0) {
|
|
return
|
|
}
|
|
throw new RuntimeClientError(
|
|
'invalid_argument',
|
|
`Ambiguous Orca server in ${flag}: ${ambiguous.length} paired servers are named ${name}. Use the environment id.`,
|
|
{
|
|
knownEnvironments: ambiguous,
|
|
nextSteps: ambiguous.map(
|
|
(candidate) => `Use --host runtime:${candidate.id} for the server named ${candidate.name}.`
|
|
)
|
|
}
|
|
)
|
|
}
|
|
|
|
// Why: the inverse of the --host case. `--environment openclaw` failed with a bare "Unknown
|
|
// environment", when openclaw is very often an SSH target — a different axis, not a typo. The
|
|
// store's own error cannot carry the hint (translateStoreError forwards code and message only,
|
|
// dropping data), so resolve the selector here where the payload survives.
|
|
export async function assertEnvironmentSelectorResolvable(
|
|
selector: string,
|
|
listSshTargetsForSuggestion: () => Promise<SshTargetSummary[]>
|
|
): Promise<void> {
|
|
const [{ listEnvironments }, { getDefaultUserDataPath }] = await Promise.all([
|
|
import('./runtime/environments.js'),
|
|
import('./runtime-client.js')
|
|
])
|
|
const environments = listEnvironments(getDefaultUserDataPath()).map((candidate) => ({
|
|
id: candidate.id,
|
|
name: candidate.name
|
|
}))
|
|
if (findEnvironmentByName(environments, selector)) {
|
|
return
|
|
}
|
|
assertEnvironmentNameUnambiguous(environments, selector, `--environment ${selector}`)
|
|
const sshTargets = await listSshTargetsForSuggestion()
|
|
throw new RuntimeClientError(
|
|
'invalid_argument',
|
|
`Unknown Orca server in --environment ${selector}: no paired Orca server is named or has id ${selector}.`,
|
|
{
|
|
knownEnvironments: environments,
|
|
knownSshTargets: sshTargets,
|
|
nextSteps: [
|
|
...crossKindNextSteps(selector, { environments, sshTargets }, 'environment'),
|
|
'Run `orca host list` to see every machine you can target and the flag for each.'
|
|
]
|
|
}
|
|
)
|
|
}
|