mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 16:03:45 +00:00
* fix(cli): resolve host names across both kinds, and stop ssh: answering empty `--host ssh:<id>` was never validated. An unknown target filtered to nothing and returned ok:true with an empty list — the same silent wrong-machine answer that unknown `runtime:` ids gave before they were rejected. And because SSH target ids are machine-generated (`ssh-<timestamp>-<random>`) while the name anyone actually knows is the label, this fired on the ordinary spelling rather than a rare typo: every human-typed SSH name missed. The two kinds of remote machine are also reached on different axes. A paired Orca server is a connection (`--environment <name>`); an SSH target is a machine the connected host reaches (`--host ssh:<id>`). A caller only knows "the machine called X", so naming X on the wrong axis was the common failure and produced either an empty answer or a dead-end "unknown environment". Now: `ssh:` resolves labels as well as ids and rejects an unknown target with the known ones listed; `runtime:` accepts the environment name as well as its id, matching --environment, and canonicalizes to the id so stored host ids still compare; and when a name misses on one axis but exists on the other, the error says which and gives the exact flag. Candidates ride along in error.data so an agent can recover without parsing prose. `orca host list` is the discovery surface that was missing entirely — nothing in the CLI listed SSH targets, so a caller told to use one had nowhere to look. It prints this machine, the SSH targets registered on the connected host, and the paired servers, each with the selector to use. * fix(cli): give --environment the same cross-kind hint, and validate the ssh host on setup-create Two gaps a follow-up survey found in the first pass. `--environment openclaw` still dead-ended with a bare "Unknown environment" while an SSH target by that name sat right there — the inverse of the case just fixed, and the direction the report actually hit. The store's own error cannot carry the hint: translateStoreError forwards code and message and drops data. So the selector is resolved before the client is built, where the payload survives. Only the explicit flag is asserted eagerly; an ambient ORCA_ENVIRONMENT stays lazy, because failing local-only commands over stale background config would be a regression. `project setup-create` records independent metadata and, unlike the other setup paths, is not covered by the runtime's ssh rejection — so an unknown target persisted a row pointing at a machine that does not exist. It now resolves the host. `local` and `runtime:` still pass through untouched: this is also the provisioning path, where a runtime host legitimately may not exist yet when its metadata is written. `setup-existing-folder` and `setup-clone` deliberately keep the unresolved id. The runtime rejects every ssh host for those operations regardless of whether it exists, so resolving first would answer "no such target" and imply the command would have worked with the right id. * fix(cli): refuse an ambiguous host name instead of resolving the first match Name lookup took the first match while the environment store itself refuses an ambiguous name rather than guessing. That put the guess back, in the selector whose entire purpose is to stop a command reaching a machine the caller did not choose — and it applied to both spellings: two SSH targets sharing a label, and two paired servers sharing a name. Both now resolve to nothing and report every candidate with its id, so the caller picks. An exact id still resolves past a colliding name, since an id is never ambiguous. Also pins the property that makes accepting a name safe at all: `runtime:<id>` is a persisted token that lands in ProjectHostSetup.hostId and is embedded in generated setup ids, so the name is canonicalized to the id before anything downstream sees it. A test now asserts a name never reaches the wire. * fix(cli): fall back to the older ssh listing so an old host is not read as having no targets Hosts predating ssh.listTargetSummaries still answer ssh.listTargets, and both are served by the same summariser. Swallowing the method_not_found made such a host indistinguishable from one with no SSH targets registered, which would reject a target id that is valid there — a new-client/old-host regression on a path that previously passed the id through unvalidated.
211 lines
8.2 KiB
JavaScript
211 lines
8.2 KiB
JavaScript
#!/usr/bin/env node
|
|
import {
|
|
findCommandSpec,
|
|
isCommandGroup,
|
|
normalizeCommandPositionals,
|
|
parseArgs,
|
|
resolveHelpPath,
|
|
specPaths,
|
|
validateCommandAndFlags
|
|
} from './args'
|
|
import { dispatch } from './dispatch'
|
|
import {
|
|
assertEnvironmentSelectorResolvable,
|
|
resolveHostFlagEnvironmentId
|
|
} from './execution-host-flag'
|
|
import { listSshTargets } from './host-selector-alternatives'
|
|
import { reportCliError } from './format'
|
|
import { printHelp } from './help'
|
|
import type { RuntimeClient } from './runtime-client'
|
|
import { COMMAND_SPECS } from './specs'
|
|
|
|
export { COMMAND_SPECS } from './specs'
|
|
export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selectors'
|
|
|
|
const COMMAND_PATHS = COMMAND_SPECS.flatMap((spec) => specPaths(spec))
|
|
|
|
function shouldIgnoreRemoteSelection(commandPath: string[]): boolean {
|
|
return (
|
|
commandPath[0] === 'account' ||
|
|
commandPath[0] === 'artifacts' ||
|
|
commandPath[0] === 'environment' ||
|
|
commandPath[0] === 'serve' ||
|
|
commandPath[0] === 'agent' ||
|
|
commandPath[0] === 'vm' ||
|
|
commandPath[0] === 'agent-context'
|
|
)
|
|
}
|
|
|
|
// Why: the RuntimeClient graph is 153 of the CLI's 199 eager modules (zod via
|
|
// shared/pairing, ws + tweetnacl via websocket-transport). Loading it here
|
|
// rather than at module scope means --help, `help <cmd>`, and command/flag
|
|
// errors — which all return before this call — never pay for it. Awaited
|
|
// before dispatch so `ctx.client` stays a synchronous getter.
|
|
async function loadRuntimeClientClass(): Promise<typeof RuntimeClient> {
|
|
return (await import('./runtime-client.js')).RuntimeClient
|
|
}
|
|
|
|
// Why: the SSH relay bridge executes this CLI on the Orca host while the
|
|
// caller's shell cwd lives on the remote machine (which cannot be chdir'd
|
|
// into). ORCA_CLI_CWD carries that remote cwd so cwd-based selectors like
|
|
// `--worktree active` resolve against the caller's directory.
|
|
function resolveInvocationCwd(): string {
|
|
const override = process.env.ORCA_CLI_CWD
|
|
return typeof override === 'string' && override.length > 0 ? override : process.cwd()
|
|
}
|
|
|
|
export async function main(
|
|
argv = process.argv.slice(2),
|
|
cwd = resolveInvocationCwd()
|
|
): Promise<void> {
|
|
if (argv[0] === 'agent-teams-tmux') {
|
|
await runAgentTeamsTmuxShim(argv.slice(1))
|
|
return
|
|
}
|
|
if (argv[0] === 'claude-teams') {
|
|
await runClaudeTeams(argv.slice(1), cwd)
|
|
return
|
|
}
|
|
const parsed = normalizeCommandPositionals(COMMAND_SPECS, parseArgs(argv, COMMAND_PATHS))
|
|
const helpPath = resolveHelpPath(parsed)
|
|
if (helpPath !== null) {
|
|
printHelp(COMMAND_SPECS, helpPath)
|
|
if (
|
|
helpPath.length > 0 &&
|
|
!findCommandSpec(COMMAND_SPECS, helpPath) &&
|
|
!isCommandGroup(helpPath)
|
|
) {
|
|
process.exitCode = 1
|
|
}
|
|
return
|
|
}
|
|
if (parsed.commandPath.length === 0) {
|
|
printHelp(COMMAND_SPECS, [])
|
|
return
|
|
}
|
|
const json = parsed.flags.has('json')
|
|
|
|
try {
|
|
// Why: CLI syntax and flag errors should be reported before any runtime
|
|
// lookup so users do not get misleading "Orca is not running" failures for
|
|
// simple command typos or unsupported flags.
|
|
validateCommandAndFlags(COMMAND_SPECS, parsed)
|
|
const RuntimeClientClass = await loadRuntimeClientClass()
|
|
const ignoreRemoteSelection = shouldIgnoreRemoteSelection(parsed.commandPath)
|
|
const pairingCode = ignoreRemoteSelection ? null : parsed.flags.get('pairing-code')
|
|
const environmentSelector = ignoreRemoteSelection ? null : parsed.flags.get('environment')
|
|
// Why: only the explicit flag is asserted eagerly. An ambient ORCA_ENVIRONMENT is background
|
|
// config, and failing local-only commands because of a stale one would be a regression; the
|
|
// explicit flag means the caller named that machine, so a bad name should fail immediately
|
|
// with the cross-kind hint rather than a bare store error at first use.
|
|
const listSshTargetsForSuggestion = async (): Promise<{ id: string; label: string }[]> =>
|
|
listSshTargets(new RuntimeClientClass(undefined, undefined, null, null))
|
|
if (typeof environmentSelector === 'string') {
|
|
await assertEnvironmentSelectorResolvable(environmentSelector, listSshTargetsForSuggestion)
|
|
}
|
|
// Why: --host runtime:<id> names a paired server, not a filter over this
|
|
// runtime's rows, so it has to pick the connection before the client exists.
|
|
// An ambient ORCA_ENVIRONMENT is checked for disagreement too — silently
|
|
// retargeting a mutation to another server is the bug this flag already had.
|
|
// An ambient pairing code cannot be resolved to an id to compare, so the
|
|
// explicit flag simply wins there.
|
|
const hostEnvironmentId = ignoreRemoteSelection
|
|
? null
|
|
: await resolveHostFlagEnvironmentId(parsed.flags, {
|
|
// Why: only consulted when the name missed, and against this machine's own runtime —
|
|
// SSH targets are registered there, not in the paired server we failed to find.
|
|
listSshTargets: listSshTargetsForSuggestion,
|
|
pairingCode: typeof pairingCode === 'string' ? pairingCode : null,
|
|
environmentSelector:
|
|
typeof environmentSelector === 'string'
|
|
? { value: environmentSelector, label: '--environment' }
|
|
: process.env.ORCA_ENVIRONMENT
|
|
? { value: process.env.ORCA_ENVIRONMENT, label: 'ORCA_ENVIRONMENT' }
|
|
: null
|
|
})
|
|
// Why: --host runtime:<name> is canonicalized to the environment's id so downstream host-id
|
|
// comparisons against stored rows still match; rewrite the flag once, here, rather than
|
|
// resolving the name again at every consumer.
|
|
if (hostEnvironmentId !== null) {
|
|
parsed.flags.set('host', `runtime:${hostEnvironmentId}`)
|
|
}
|
|
// Why: pass `null` (not `undefined`) when remote selection is suppressed
|
|
// so the RuntimeClient default parameter does not re-activate the
|
|
// ORCA_PAIRING_CODE / ORCA_ENVIRONMENT env-var fallback for commands
|
|
// that must run locally (environment / serve).
|
|
const suppressed = ignoreRemoteSelection ? null : undefined
|
|
// An explicit --host runtime:<id> outranks an ambient pairing code or environment.
|
|
const remotePairingCode =
|
|
hostEnvironmentId !== null ? null : typeof pairingCode === 'string' ? pairingCode : suppressed
|
|
const remoteEnvironment =
|
|
hostEnvironmentId ??
|
|
(typeof environmentSelector === 'string' ? environmentSelector : suppressed)
|
|
let client: RuntimeClient | undefined
|
|
await dispatch(parsed.commandPath, {
|
|
flags: parsed.flags,
|
|
// Why: local-only handlers must not resolve runtime metadata just to dispatch.
|
|
get client() {
|
|
client ??= new RuntimeClientClass(
|
|
undefined,
|
|
undefined,
|
|
remotePairingCode,
|
|
remoteEnvironment
|
|
)
|
|
return client
|
|
},
|
|
cwd,
|
|
json
|
|
})
|
|
} catch (error) {
|
|
reportCliError(error, json, { commandPath: parsed.commandPath })
|
|
process.exitCode = 1
|
|
}
|
|
}
|
|
|
|
async function runClaudeTeams(argv: string[], cwd: string): Promise<void> {
|
|
try {
|
|
// Why: everything after `orca claude-teams` belongs to Claude Code, not
|
|
// Orca's own flag parser, so new Claude flags work without Orca changes.
|
|
const client = new (await loadRuntimeClientClass())(undefined, undefined, null, null)
|
|
await dispatch(['claude-teams'], {
|
|
flags: new Map(),
|
|
client,
|
|
cwd,
|
|
json: false,
|
|
rawArgs: argv
|
|
})
|
|
} catch (error) {
|
|
reportCliError(error, false, { commandPath: ['claude-teams'] })
|
|
process.exitCode = 1
|
|
}
|
|
}
|
|
|
|
async function runAgentTeamsTmuxShim(argv: string[]): Promise<void> {
|
|
try {
|
|
const client = new (await loadRuntimeClientClass())(undefined, 10_000)
|
|
const response = await client.call<{
|
|
tmux: { stdout: string; stderr: string; exitCode: number }
|
|
}>(
|
|
'agentTeams.tmuxCompat',
|
|
{
|
|
teamId: process.env.ORCA_AGENT_TEAMS_TEAM_ID,
|
|
token: process.env.ORCA_AGENT_TEAMS_TOKEN,
|
|
envPane: process.env.TMUX_PANE,
|
|
cwd: process.cwd(),
|
|
argv
|
|
},
|
|
{ timeoutMs: 10_000 }
|
|
)
|
|
process.stdout.write(response.result.tmux.stdout)
|
|
process.stderr.write(response.result.tmux.stderr)
|
|
process.exitCode = response.result.tmux.exitCode
|
|
} catch (error) {
|
|
reportCliError(error, false, { commandPath: ['agent-teams-tmux'] })
|
|
process.exitCode = 1
|
|
}
|
|
}
|
|
|
|
if (require.main === module) {
|
|
void main()
|
|
}
|