mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
feat(acp): chat agents can declare a managed runtime that a start runs but never installs (#28460)
* feat(acp): ACP agents can declare a managed runtime that a start runs but never installs A start resolves PATH and the agent's install folders first, then the newest whole release under Orca's data folder (its completion receipt and member sizes checked); with none it refuses with runtimeNotInstalled instead of downloading. * fix(runtime): tell an unreadable runtime release from a broken one, and point a not-installed chat at the chat itself A transient read error (EIO, EMFILE) no longer counts a working release as incomplete; only definite absence or a mismatch does. The not-installed refusal no longer sends a remote chat's user to this computer's Settings. * fix(acp): a start runs Orca's installed release before a copy on PATH, and a copy elsewhere only beside every file it needs
This commit is contained in:
@@ -15,6 +15,7 @@ import { isStableCliVersionFrom, isStableCliVersionOnLine } from '../agent-cli-v
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import type { AgentSessionModelOption } from '../../shared/agent-session-wire'
|
||||
import type { InitializeResponse } from './generated/acp-protocol.generated'
|
||||
import type { ManagedRuntime } from '../node-runtime/managed-runtime-release'
|
||||
import { readGrokModelCatalog } from './acp-dialects/grok-model-catalog'
|
||||
import {
|
||||
OPENCODE_MODEL_LISTING_ARGS,
|
||||
@@ -88,6 +89,9 @@ export type AcpLaunchSpec = {
|
||||
supportsVersion?(version: string): boolean
|
||||
/** Where the agent installs its own binary, searched after PATH. */
|
||||
installDirectories(input: { env: Readonly<Record<string, string>>; homePath: string }): string[]
|
||||
/** A pinned copy Orca installs under its data folder when the person asks, searched last; a
|
||||
* start never installs it. A spec with one names no `supportsVersion`. */
|
||||
managedRuntime?: ManagedRuntime
|
||||
/** Images go to the agent when it also advertises them; off sends text prompts only. */
|
||||
imagePrompts?: true
|
||||
/** The agent compacts its conversation when sent `/compact` as a prompt; off hides `/compact`. */
|
||||
|
||||
@@ -69,6 +69,7 @@ function scriptedGrok(initializeMeta: Record<string, unknown>): {
|
||||
closed: () => closes,
|
||||
deps: {
|
||||
resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/user' }),
|
||||
dataDirectory: '/orca-data',
|
||||
resolveLaunchEnv: () => ({ XAI_API_KEY: 'from-settings' }),
|
||||
resolveCommand: () => '/opt/grok/bin/grok',
|
||||
homePath: '/home/user',
|
||||
@@ -155,6 +156,7 @@ describe('ACP model catalog probes', () => {
|
||||
const connect = vi.fn()
|
||||
const probe = createAcpModelCatalogProbe(OPENCODE, {
|
||||
resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/user' }),
|
||||
dataDirectory: '/orca-data',
|
||||
resolveCommand: () => '/opt/opencode/bin/opencode',
|
||||
probeVersion: async () => true,
|
||||
homePath: '/home/user',
|
||||
@@ -180,6 +182,7 @@ describe('ACP model catalog probes', () => {
|
||||
const runListing = vi.fn()
|
||||
const probe = createAcpModelCatalogProbe(OPENCODE, {
|
||||
resolveEnvironment: async () => ({ PATH: '/usr/bin' }),
|
||||
dataDirectory: '/orca-data',
|
||||
resolveCommand: () => '/opt/opencode/bin/opencode',
|
||||
probeVersion: async () => false,
|
||||
runListing
|
||||
|
||||
@@ -27,7 +27,7 @@ import type { AcpStructuredConnection } from './acp-structured-connection'
|
||||
import { ACP_HANDLE_TRANSPORT, acpAgentName } from './acp-structured-agent-definitions'
|
||||
import { AcpStructuredLane, acpLaneChildWorkDelivery } from './acp-structured-lane'
|
||||
import { NO_ACP_CHILD_STOPS, probeAcpChildStops } from './acp-structured-child-stop'
|
||||
import type { AcpStructuredLaunch } from './acp-structured-launch-resolution'
|
||||
import { prepareAcpStructuredLaunch } from './acp-structured-launch-preparation'
|
||||
import { AcpStructuredOptions, restoreAcpSessionOptions } from './acp-structured-options'
|
||||
import { AcpStructuredPrompts } from './acp-structured-prompts'
|
||||
import {
|
||||
@@ -77,12 +77,7 @@ export async function acquireAcpStructuredSession(input: {
|
||||
}
|
||||
}
|
||||
closedBeforeSpawn()
|
||||
const launch: AcpStructuredLaunch = await deps
|
||||
.resolveLaunch({ identity: acquire.identity, spawnToken: acquire.spawnToken })
|
||||
.catch((error: unknown) => {
|
||||
throw new AgentSessionPreSpawnError(error)
|
||||
})
|
||||
closedBeforeSpawn()
|
||||
const launch = await prepareAcpStructuredLaunch(deps, acquire, closedBeforeSpawn)
|
||||
let session: AcpStructuredSession | null = null
|
||||
// A slot rather than a `let`: closures read it, and control-flow narrowing cannot see them write.
|
||||
const slot: { lane: AcpStructuredLane | null; reattaching: boolean } = {
|
||||
|
||||
@@ -62,6 +62,7 @@ afterEach(async () => {
|
||||
function grokProbe() {
|
||||
return createAcpModelCatalogProbe(GROK, {
|
||||
resolveEnvironment: async () => ({ PATH: '/usr/bin' }),
|
||||
dataDirectory: '/orca-data',
|
||||
resolveCommand: () => '/opt/grok/bin/grok',
|
||||
homePath: '/home/user',
|
||||
connect: () => {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// What a start checks on this host before it spawns the agent. A runtime Orca manages is installed
|
||||
// only when the person asks, so a start that finds no copy refuses rather than downloading one.
|
||||
|
||||
import {
|
||||
AgentSessionPreSpawnError,
|
||||
type StructuredAgentSessionAcquireInput
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import type { AcpStructuredLaunch } from './acp-structured-launch-resolution'
|
||||
import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps'
|
||||
|
||||
/** The launch a start spawns; `closedBeforeSpawn` throws once the start was abandoned. */
|
||||
export async function prepareAcpStructuredLaunch(
|
||||
deps: Pick<AcpStructuredSessionAdapterDeps, 'spec' | 'resolveLaunch'>,
|
||||
acquire: Pick<StructuredAgentSessionAcquireInput, 'identity' | 'spawnToken'>,
|
||||
closedBeforeSpawn: () => void
|
||||
): Promise<AcpStructuredLaunch> {
|
||||
const { spec } = deps
|
||||
const launch = await deps
|
||||
.resolveLaunch({ identity: acquire.identity, spawnToken: acquire.spawnToken })
|
||||
.catch((error: unknown) => {
|
||||
throw new AgentSessionPreSpawnError(error)
|
||||
})
|
||||
closedBeforeSpawn()
|
||||
// A missed lookup returns the bare name; a selected Command setting resolves or refuses.
|
||||
if (spec.managedRuntime && launch.command === spec.command) {
|
||||
throw new AgentSessionPreSpawnError(
|
||||
new Error(`${spec.managedRuntime.label} is not installed on this host`),
|
||||
{ reason: 'runtimeNotInstalled' }
|
||||
)
|
||||
}
|
||||
return launch
|
||||
}
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
type StructuredAgentCommandSettings
|
||||
} from '../native-chat/structured-agent-command-resolution'
|
||||
import { probeAgentCliVersion } from '../agent-cli-version-probe'
|
||||
import {
|
||||
copyBesidePinnedMembers,
|
||||
resolveInstalledRuntimeRelease
|
||||
} from '../node-runtime/managed-runtime-release'
|
||||
import { hasExplicitTuiLaunchCommand } from '../../shared/tui-agent-launch-command-override'
|
||||
import { readAgentJournalTurn } from '../../shared/agent-session-turn-record'
|
||||
import type { JournalLoad } from '../native-chat/agent-session-journal/journal-open'
|
||||
import {
|
||||
@@ -73,6 +78,8 @@ export type AcpStructuredLaunchPartDeps = {
|
||||
/** The environment the child process inherits at spawn; this process's own by default. */
|
||||
inheritedEnv?: NodeJS.ProcessEnv
|
||||
probeVersion?: typeof probeAgentCliVersion
|
||||
/** Orca's data folder on this host, which holds the spec's managed runtime. */
|
||||
dataDirectory: string
|
||||
} & Pick<AcpLaunchVisualsDeps, 'logger'>
|
||||
|
||||
/** What a launch's binary and environment are read from; a catalog probe reads the same. */
|
||||
@@ -87,11 +94,16 @@ export type AcpLaunchInvocationDeps = {
|
||||
refreshEnvironmentAfterMiss?: (askedAt: number) => Promise<void>
|
||||
/** Where the program starts, for a PATH entry relative to it; Orca's own when absent. */
|
||||
resolveWorkingDirectory?: () => Promise<string> | string
|
||||
} & Pick<AcpStructuredLaunchPartDeps, 'resolveCommand' | 'homePath' | 'inheritedEnv'>
|
||||
} & Pick<
|
||||
AcpStructuredLaunchPartDeps,
|
||||
'resolveCommand' | 'homePath' | 'inheritedEnv' | 'dataDirectory'
|
||||
>
|
||||
|
||||
/** The binary a launch with `env` spawns: the user's Command setting when set, else the stock
|
||||
* binary from PATH, then the agent's own install directories. A create's version check resolves
|
||||
* through here too, so it asks about the same file. */
|
||||
* binary from PATH, then the agent's own install directories. A spec with a managed runtime runs
|
||||
* its installed release before those, and a copy from them only beside every pinned member; the
|
||||
* bare name when none qualifies. A create's version check resolves through here too, so it asks
|
||||
* about the same file. A null `dataDirectory` skips the installed release. */
|
||||
export function resolveAcpLaunchCommand(
|
||||
spec: AcpLaunchSpec,
|
||||
env: Readonly<Record<string, string>>,
|
||||
@@ -99,21 +111,36 @@ export function resolveAcpLaunchCommand(
|
||||
commandSettings?: StructuredAgentCommandSettings
|
||||
resolveCommand?: typeof resolveCliCommand
|
||||
homePath?: string
|
||||
} = {}
|
||||
dataDirectory: string | null
|
||||
}
|
||||
): string {
|
||||
const homePath = env.HOME ?? env.USERPROFILE ?? options.homePath ?? homedir()
|
||||
const pathEnv = [env.PATH ?? env.Path, ...spec.installDirectories({ env, homePath })]
|
||||
.filter((entry): entry is string => Boolean(entry))
|
||||
.join(delimiter)
|
||||
return resolveStructuredAgentCommand(
|
||||
spec.agent,
|
||||
options.commandSettings ?? {},
|
||||
{ pathEnv, homePath },
|
||||
{
|
||||
command: spec.command,
|
||||
...(options.resolveCommand ? { resolve: options.resolveCommand } : {})
|
||||
}
|
||||
)
|
||||
const settings = options.commandSettings ?? {}
|
||||
const resolve = (searchPath: string) =>
|
||||
resolveStructuredAgentCommand(
|
||||
spec.agent,
|
||||
settings,
|
||||
{ pathEnv: searchPath, homePath },
|
||||
{
|
||||
command: spec.command,
|
||||
...(options.resolveCommand ? { resolve: options.resolveCommand } : {})
|
||||
}
|
||||
)
|
||||
const { managedRuntime } = spec
|
||||
if (!managedRuntime || hasExplicitTuiLaunchCommand(settings, spec.agent)) {
|
||||
return resolve(pathEnv)
|
||||
}
|
||||
const pin = managedRuntime.pin()
|
||||
const root = options.dataDirectory && managedRuntime.root(options.dataDirectory)
|
||||
const release = root ? resolveInstalledRuntimeRelease(root, pin).release : null
|
||||
if (release) {
|
||||
return resolve(release.folder)
|
||||
}
|
||||
const found = resolve(pathEnv)
|
||||
return (found !== spec.command && copyBesidePinnedMembers(found, pin)) || spec.command
|
||||
}
|
||||
|
||||
/** Whether `command` is a release the spec runs a structured chat on (true when it names none),
|
||||
@@ -202,7 +229,8 @@ export function acpStructuredLaunchPart(
|
||||
resolveWorkingDirectory: basis.launchDirectory,
|
||||
...(deps.resolveCommand ? { resolveCommand: deps.resolveCommand } : {}),
|
||||
...(deps.homePath ? { homePath: deps.homePath } : {}),
|
||||
...(deps.inheritedEnv ? { inheritedEnv: deps.inheritedEnv } : {})
|
||||
...(deps.inheritedEnv ? { inheritedEnv: deps.inheritedEnv } : {}),
|
||||
dataDirectory: deps.dataDirectory
|
||||
}
|
||||
)
|
||||
const cwd = await basis.launchDirectory()
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { mkdir, mkdtemp, realpath, rm, truncate, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
|
||||
import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
|
||||
import { RUNTIME_RELEASE_RECEIPT } from '../node-runtime/managed-runtime-release'
|
||||
import { createAcpStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support'
|
||||
import { GROK, openAcpAdapterRig } from './acp-structured-adapter.test-support'
|
||||
|
||||
afterEach(() => closeProviderTimelineRigs())
|
||||
|
||||
it('starts the installed release first, else a copy beside every pinned member', async () => {
|
||||
const dataDirectory = await mkdtemp(join(tmpdir(), 'orca-acp-managed-'))
|
||||
const homePath = join(dataDirectory, 'home')
|
||||
const command = `orca-managed-${basename(dataDirectory)}`
|
||||
const root = join(dataDirectory, 'agent-runtimes', 'grok')
|
||||
const pin = {
|
||||
version: '1.0',
|
||||
url: 'https://example.test/grok-1.0.zip',
|
||||
archiveSha256: 'a'.repeat(64),
|
||||
archiveBytes: 10,
|
||||
members: [
|
||||
{ path: command, sha256: 'b'.repeat(64), bytes: 6 },
|
||||
{ path: 'harness', sha256: 'c'.repeat(64), bytes: 7 }
|
||||
]
|
||||
}
|
||||
const spec = {
|
||||
...GROK,
|
||||
command,
|
||||
managedRuntime: { label: 'Grok', root: () => root, pin: () => pin }
|
||||
}
|
||||
const spawn = async () => {
|
||||
const resolveLaunch = createAcpStructuredLaunchResolver(spec, {
|
||||
store: {
|
||||
getRecord: () => ({
|
||||
...agentSessionRecordFixture(),
|
||||
provider: 'grok',
|
||||
providerHandleChain: [],
|
||||
accountHome: { variable: 'GROK_HOME', path: join(dataDirectory, '.grok') }
|
||||
}),
|
||||
pinLaunchDirectory: vi.fn()
|
||||
},
|
||||
readJournal: () => null,
|
||||
resolveEnvironment: async () => ({ PATH: '', HOME: homePath }),
|
||||
dataDirectory
|
||||
})
|
||||
const rig = await openAcpAdapterRig({ spec, deps: { resolveLaunch } })
|
||||
await rig.acquire()
|
||||
return rig.child().launch.command
|
||||
}
|
||||
await expect(spawn()).rejects.toMatchObject({ reason: 'runtimeNotInstalled' })
|
||||
|
||||
// A server without its companion beside it is no copy.
|
||||
const localBin = join(homePath, '.local', 'bin')
|
||||
await mkdir(localBin, { recursive: true })
|
||||
await writeFile(join(localBin, command), '', { mode: 0o755 })
|
||||
await expect(spawn()).rejects.toMatchObject({ reason: 'runtimeNotInstalled' })
|
||||
|
||||
const release = join(root, '1.0')
|
||||
await mkdir(release, { recursive: true })
|
||||
await writeFile(join(release, command), 'server', { mode: 0o755 })
|
||||
await writeFile(join(release, 'harness'), 'harness', { mode: 0o755 })
|
||||
await writeFile(
|
||||
join(release, RUNTIME_RELEASE_RECEIPT),
|
||||
JSON.stringify({
|
||||
releaseId: pin.archiveSha256,
|
||||
version: '1.0',
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
members: pin.members.map(({ path, bytes }) => ({ path, bytes }))
|
||||
})
|
||||
)
|
||||
await writeFile(join(localBin, 'harness'), '', { mode: 0o755 })
|
||||
expect(await spawn()).toBe(join(release, command))
|
||||
|
||||
await truncate(join(release, command), 2)
|
||||
expect(await spawn()).toBe(await realpath(join(localBin, command)))
|
||||
await rm(dataDirectory, { recursive: true, force: true })
|
||||
})
|
||||
@@ -135,6 +135,7 @@ export type AgentSessionPreSpawnReason = Extract<
|
||||
| 'claudeAccountFolderMissing'
|
||||
| 'claudeAccountSetupFailed'
|
||||
| 'agentCommandNotRunnable'
|
||||
| 'runtimeNotInstalled'
|
||||
>
|
||||
|
||||
/** Acquisition failed with first-hand proof that no provider process existed. */
|
||||
|
||||
@@ -36,7 +36,8 @@ const TYPED_START_REFUSALS = [
|
||||
'historyInOtherAccount',
|
||||
'claudeAccountFolderMissing',
|
||||
'claudeAccountSetupFailed',
|
||||
'agentCommandNotRunnable'
|
||||
'agentCommandNotRunnable',
|
||||
'runtimeNotInstalled'
|
||||
] as const satisfies readonly (AgentSessionFailureKind &
|
||||
AgentSessionRefusalReason<'agent_session_operation_invalid'>)[]
|
||||
|
||||
|
||||
@@ -410,7 +410,8 @@ describe('adopting an OpenCode CLI conversation', () => {
|
||||
const launch = await resolveAcpLaunchInvocation(spec, unmanaged, {
|
||||
resolveEnvironment: async () => baseEnvironment,
|
||||
resolveLaunchEnv: () => launchEnv,
|
||||
resolveCommand: () => '/test/opencode'
|
||||
resolveCommand: () => '/test/opencode',
|
||||
dataDirectory: root
|
||||
})
|
||||
|
||||
expect(adoption.transcriptPath).toBe(configuredDb)
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// The installed releases of a runtime Orca downloads for an agent: `<root>/<version>/` holding the
|
||||
// pinned members and a receipt written before the folder was renamed into place. Read-only, and
|
||||
// cheap enough for every launch: a full hash is only ever taken by an install.
|
||||
|
||||
import { lstatSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence'
|
||||
|
||||
export type ManagedRuntimePin = {
|
||||
version: string
|
||||
url: string
|
||||
archiveSha256: string
|
||||
/** The decoded archive's size. */
|
||||
archiveBytes: number
|
||||
/** Archive-relative path, sha256 and size of every executable the release must hold. */
|
||||
members: readonly { path: string; sha256: string; bytes: number }[]
|
||||
}
|
||||
|
||||
/** A runtime Orca installs only when the person asks; a launch reads what is installed. */
|
||||
export type ManagedRuntime = {
|
||||
/** Shown to the person, e.g. "Antigravity". */
|
||||
label: string
|
||||
/** The folder that holds only this runtime's releases. */
|
||||
root(dataDirectory: string): string
|
||||
/** This host's pinned release; null where none is published. */
|
||||
pin(): ManagedRuntimePin | null
|
||||
}
|
||||
|
||||
export const RUNTIME_RELEASE_RECEIPT = '.install-complete.json'
|
||||
const RECEIPT_MAX_BYTES = 8 * 1024
|
||||
const VERSION_ORDER = new Intl.Collator(undefined, { numeric: true })
|
||||
|
||||
const runtimeReleaseReceiptSchema = z.object({
|
||||
releaseId: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
version: z.string().min(1),
|
||||
platform: z.string(),
|
||||
arch: z.string(),
|
||||
members: z.array(z.object({ path: z.string().min(1), bytes: z.number().int().positive() })).min(1)
|
||||
})
|
||||
|
||||
export type InstalledRuntimeRelease = {
|
||||
folder: string
|
||||
version: string
|
||||
/** The pinned release itself, rather than an older one kept runnable. */
|
||||
current: boolean
|
||||
}
|
||||
|
||||
/** What a release folder read proved: whole, definitively not (absent, mismatched or partial), or
|
||||
* unknown because the read itself failed. */
|
||||
type ReleaseRead = { releaseId: string } | 'broken' | 'unreadable'
|
||||
|
||||
export type InstalledRuntimeReleases = {
|
||||
/** The release a launch runs: the pinned one when installed whole, else the newest other whole
|
||||
* one; null when none is. Staging, links and anything incomplete never count. */
|
||||
release: InstalledRuntimeRelease | null
|
||||
/** Release folders that are definitively not whole, e.g. a member deleted or truncated. */
|
||||
broken: string[]
|
||||
/** Some folder could not be read, so whether it is whole is unknown. */
|
||||
unreadable: boolean
|
||||
}
|
||||
|
||||
export function resolveInstalledRuntimeRelease(
|
||||
root: string,
|
||||
pin: ManagedRuntimePin | null
|
||||
): InstalledRuntimeReleases {
|
||||
let names: string[]
|
||||
try {
|
||||
names = readdirSync(root)
|
||||
} catch (error) {
|
||||
return { release: null, broken: [], unreadable: !isDefinitiveAbsence(error) }
|
||||
}
|
||||
const reads = names
|
||||
.filter((name) => !name.startsWith('.'))
|
||||
.map((name) => ({ name, read: readRelease(join(root, name), name) }))
|
||||
const releases = reads.flatMap(({ name, read }) =>
|
||||
typeof read === 'object' ? [{ name, releaseId: read.releaseId }] : []
|
||||
)
|
||||
const pinned = releases.find(
|
||||
(release) => release.name === pin?.version && release.releaseId === pin.archiveSha256
|
||||
)
|
||||
const chosen = pinned ?? releases.sort((a, b) => VERSION_ORDER.compare(b.name, a.name))[0]
|
||||
return {
|
||||
release: chosen
|
||||
? { folder: join(root, chosen.name), version: chosen.name, current: chosen === pinned }
|
||||
: null,
|
||||
broken: reads.filter(({ read }) => read === 'broken').map(({ name }) => name),
|
||||
unreadable: reads.some(({ read }) => read === 'unreadable')
|
||||
}
|
||||
}
|
||||
|
||||
/** `command`'s real path when every pinned member sits beside it, as the server expects its
|
||||
* companions; null otherwise, so a lone server file found elsewhere is never started. */
|
||||
export function copyBesidePinnedMembers(
|
||||
command: string,
|
||||
pin: ManagedRuntimePin | null
|
||||
): string | null {
|
||||
try {
|
||||
const real = realpathSync(command)
|
||||
const whole = (pin?.members ?? []).every((member) => {
|
||||
const file = statSync(join(dirname(real), basename(member.path)))
|
||||
return file.isFile() && (process.platform === 'win32' || (file.mode & 0o111) !== 0)
|
||||
})
|
||||
return whole ? real : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function readRelease(folder: string, version: string): ReleaseRead {
|
||||
try {
|
||||
const receiptPath = join(folder, RUNTIME_RELEASE_RECEIPT)
|
||||
const stat = lstatSync(receiptPath)
|
||||
if (!lstatSync(folder).isDirectory() || !stat.isFile() || stat.size > RECEIPT_MAX_BYTES) {
|
||||
return 'broken'
|
||||
}
|
||||
const receipt = runtimeReleaseReceiptSchema.safeParse(
|
||||
parseJson(readFileSync(receiptPath, 'utf8'))
|
||||
)
|
||||
const whole =
|
||||
receipt.success &&
|
||||
receipt.data.version === version &&
|
||||
receipt.data.platform === process.platform &&
|
||||
receipt.data.arch === process.arch &&
|
||||
receipt.data.members.every((member) => {
|
||||
const segments = member.path.split('/')
|
||||
if (segments.some((segment) => !segment || segment === '.' || segment === '..')) {
|
||||
return false
|
||||
}
|
||||
const file = lstatSync(join(folder, ...segments))
|
||||
return (
|
||||
file.isFile() &&
|
||||
file.size === member.bytes &&
|
||||
(process.platform === 'win32' || (file.mode & 0o111) !== 0)
|
||||
)
|
||||
})
|
||||
return whole ? { releaseId: receipt.data.releaseId } : 'broken'
|
||||
} catch (error) {
|
||||
return isDefinitiveAbsence(error) ? 'broken' : 'unreadable'
|
||||
}
|
||||
}
|
||||
|
||||
function parseJson(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -110,12 +110,14 @@ export function createCodexStructuredLaunchResolver(
|
||||
/** Unsealed unless a token is passed: the env an agent part produced, before the seal. */
|
||||
export function createAcpStructuredLaunchResolver(
|
||||
spec: AcpLaunchSpec,
|
||||
deps: LaunchTestSources & AcpStructuredLaunchPartDeps
|
||||
deps: LaunchTestSources &
|
||||
Omit<AcpStructuredLaunchPartDeps, 'dataDirectory'> &
|
||||
Partial<Pick<AcpStructuredLaunchPartDeps, 'dataDirectory'>>
|
||||
) {
|
||||
return withTestSpawnToken(
|
||||
acpStructuredAgentDefinition(spec),
|
||||
launchSources(deps),
|
||||
acpStructuredLaunchPart(spec, deps),
|
||||
acpStructuredLaunchPart(spec, { dataDirectory: '/orca-data', ...deps }),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ export function acpModelCatalogDiscovery(
|
||||
probe: createAcpModelCatalogProbe(spec, {
|
||||
resolveEnvironment: environment.resolveBaseEnvironment,
|
||||
refreshEnvironmentAfterMiss: environment.refreshAfterMissingProgram,
|
||||
dataDirectory: deps.stateDirectory,
|
||||
...(deps.resolveAgentLaunchEnv ? { resolveLaunchEnv: deps.resolveAgentLaunchEnv } : {}),
|
||||
...(deps.resolveAgentCommandSettings
|
||||
? { resolveCommandSettings: deps.resolveAgentCommandSettings }
|
||||
|
||||
@@ -42,7 +42,11 @@ function acpMetadata(spec: AcpLaunchSpec): StructuredAgentRuntimeMetadata {
|
||||
launchCheck: {
|
||||
resolveLaunch: (shellEnv, commandSettings) => {
|
||||
const env = { ...shellEnv, ...spec.env }
|
||||
return { command: resolveAcpLaunchCommand(spec, env, { commandSettings }), env }
|
||||
const command = resolveAcpLaunchCommand(spec, env, {
|
||||
commandSettings,
|
||||
dataDirectory: null
|
||||
})
|
||||
return { command, env }
|
||||
},
|
||||
supportsVersion: (launch) => acpLaunchVersionSupported(spec, launch)
|
||||
}
|
||||
|
||||
@@ -117,6 +117,7 @@ function createAcpAdapter(
|
||||
const part = acpStructuredLaunchPart(spec, {
|
||||
readJournal,
|
||||
...(deps.resolveAgentFullAccess ? { resolveFullAccess: deps.resolveAgentFullAccess } : {}),
|
||||
dataDirectory: deps.stateDirectory,
|
||||
logger: deps.logger
|
||||
})
|
||||
return new AcpStructuredSessionAdapter({
|
||||
|
||||
@@ -135,6 +135,12 @@ const PIECES: Record<AgentSessionFailureCopyId, (values: AgentSessionFailureCopy
|
||||
COPY.agentCommandNotRunnable,
|
||||
values
|
||||
),
|
||||
runtimeNotInstalled: (values) =>
|
||||
translate(
|
||||
'components.native-chat.failureWords.runtimeNotInstalled',
|
||||
COPY.runtimeNotInstalled,
|
||||
values
|
||||
),
|
||||
chooseClaudeAccount: () =>
|
||||
translate(
|
||||
'components.native-chat.failureWords.chooseClaudeAccount',
|
||||
|
||||
+1
@@ -2849,6 +2849,7 @@
|
||||
"failureWords": {
|
||||
"accountSwitchInProgress": "A Claude account switch is in progress. Try again after it finishes.",
|
||||
"agentCommandNotRunnable": "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.",
|
||||
"runtimeNotInstalled": "{{agent}} isn't installed on the computer running this chat. Install it from the chat in a current version of Orca.",
|
||||
"agentCommandNotSignedIn": "Sign in to {{agent}} with `{{loginCommand}}` on the computer running this chat.",
|
||||
"agentNotSignedIn": "Sign in to {{agent}}.",
|
||||
"answerUnconfirmed": "Your answer was recorded but the agent did not confirm it.",
|
||||
|
||||
@@ -18194,6 +18194,7 @@
|
||||
"claudeAccountFolderMissing": "The selected Claude account's folder is missing. Sign in to it again or choose another account.",
|
||||
"claudeAccountSetupFailed": "The selected Claude account could not be set up. Try again or choose another account.",
|
||||
"agentCommandNotRunnable": "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.",
|
||||
"runtimeNotInstalled": "{{agent}} isn't installed on the computer running this chat. Install it from the chat in a current version of Orca.",
|
||||
"chooseClaudeAccount": "Choose or add one in Claude Accounts settings.",
|
||||
"chooseClaudeAccountThenRunCommand": "Choose or add one in Claude Accounts settings, then run /{{command}} again.",
|
||||
"chooseClaudeAccountThenSend": "Choose or add one in Claude Accounts settings, then send your message again.",
|
||||
|
||||
@@ -17974,6 +17974,7 @@
|
||||
"claudeAccountFolderMissing": "Falta la carpeta de la cuenta de Claude seleccionada. Vuelva a iniciar sesión en ella o elija otra cuenta.",
|
||||
"claudeAccountSetupFailed": "No se pudo configurar la cuenta de Claude seleccionada. Vuelva a intentarlo o elija otra cuenta.",
|
||||
"agentCommandNotRunnable": "{{agent}}: el Comando en Ajustes → Agentes debe ser la ruta o el nombre de un programa que Orca pueda encontrar, sin argumentos ni variables. Cámbielo o restablézcalo con Reiniciar.",
|
||||
"runtimeNotInstalled": "{{agent}} no está instalado en el equipo que ejecuta este chat. Instálelo desde el chat en una versión actual de Orca.",
|
||||
"chooseClaudeAccount": "Elija o agregue una en la configuración de Cuentas de Claude.",
|
||||
"chooseClaudeAccountThenRunCommand": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a ejecutar /{{command}}.",
|
||||
"chooseClaudeAccountThenSend": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a enviar su mensaje.",
|
||||
|
||||
@@ -18078,6 +18078,7 @@
|
||||
"claudeAccountFolderMissing": "Le dossier du compte Claude sélectionné est introuvable. Reconnectez-vous à ce compte ou choisissez-en un autre.",
|
||||
"claudeAccountSetupFailed": "Le compte Claude sélectionné n'a pas pu être configuré. Réessayez ou choisissez un autre compte.",
|
||||
"agentCommandNotRunnable": "{{agent}} : la Commande dans Paramètres → Agents doit être le chemin ou le nom d'un programme qu'Orca peut trouver, sans arguments ni variables. Modifiez-la ou réinitialisez-la.",
|
||||
"runtimeNotInstalled": "{{agent}} n'est pas installé sur l'ordinateur qui exécute ce chat. Installez-le depuis le chat dans une version récente d'Orca.",
|
||||
"chooseClaudeAccount": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude.",
|
||||
"chooseClaudeAccountThenRunCommand": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis relancez /{{command}}.",
|
||||
"chooseClaudeAccountThenSend": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis renvoyez votre message.",
|
||||
|
||||
@@ -18009,6 +18009,7 @@
|
||||
"claudeAccountFolderMissing": "選択した Claude アカウントのフォルダーが見つかりません。もう一度サインインするか、別のアカウントを選択してください。",
|
||||
"claudeAccountSetupFailed": "選択した Claude アカウントを設定できませんでした。もう一度試すか、別のアカウントを選択してください。",
|
||||
"agentCommandNotRunnable": "[設定] → [Agent] にある {{agent}} の [コマンド] には、Orca が見つけられるプログラムのパスまたは名前を、引数や変数なしで指定する必要があります。変更するか、[リセット] してください。",
|
||||
"runtimeNotInstalled": "{{agent}} はこのチャットを実行しているコンピューターにインストールされていません。最新バージョンの Orca でチャットからインストールしてください。",
|
||||
"chooseClaudeAccount": "Claude アカウントの設定で選択または追加してください。",
|
||||
"chooseClaudeAccountThenRunCommand": "Claude アカウントの設定で選択または追加してから、/{{command}} をもう一度実行してください。",
|
||||
"chooseClaudeAccountThenSend": "Claude アカウントの設定で選択または追加してから、メッセージをもう一度送信してください。",
|
||||
|
||||
@@ -18009,6 +18009,7 @@
|
||||
"claudeAccountFolderMissing": "선택한 Claude 계정의 폴더가 없습니다. 다시 로그인하거나 다른 계정을 선택하세요.",
|
||||
"claudeAccountSetupFailed": "선택한 Claude 계정을 설정할 수 없습니다. 다시 시도하거나 다른 계정을 선택하세요.",
|
||||
"agentCommandNotRunnable": "{{agent}}의 설정 → 에이전트 → 명령은 Orca가 찾을 수 있는 프로그램 경로나 이름이어야 하며, 인수나 변수를 포함할 수 없습니다. 변경하거나 재설정하세요.",
|
||||
"runtimeNotInstalled": "{{agent}}이(가) 이 채팅을 실행하는 컴퓨터에 설치되어 있지 않습니다. 최신 버전의 Orca에서 채팅을 통해 설치하세요.",
|
||||
"chooseClaudeAccount": "Claude 계정 설정에서 선택하거나 추가하세요.",
|
||||
"chooseClaudeAccountThenRunCommand": "Claude 계정 설정에서 선택하거나 추가한 다음 /{{command}}을(를) 다시 실행하세요.",
|
||||
"chooseClaudeAccountThenSend": "Claude 계정 설정에서 선택하거나 추가한 다음 메시지를 다시 보내세요.",
|
||||
|
||||
@@ -17974,6 +17974,7 @@
|
||||
"claudeAccountFolderMissing": "所选 Claude 账户的文件夹不存在。请重新登录该账户或选择其他账户。",
|
||||
"claudeAccountSetupFailed": "无法设置所选 Claude 账户。请重试或选择其他账户。",
|
||||
"agentCommandNotRunnable": "{{agent}} 在设置 → 智能体中的命令必须是 Orca 能找到的程序路径或名称,且不能包含参数或变量。请修改或重置。",
|
||||
"runtimeNotInstalled": "运行此聊天的计算机上未安装 {{agent}}。请在最新版本的 Orca 中从聊天里安装。",
|
||||
"chooseClaudeAccount": "请在 Claude 账户设置中选择或添加一个。",
|
||||
"chooseClaudeAccountThenRunCommand": "请在 Claude 账户设置中选择或添加一个,然后重新运行 /{{command}}。",
|
||||
"chooseClaudeAccountThenSend": "请在 Claude 账户设置中选择或添加一个,然后重新发送您的消息。",
|
||||
|
||||
@@ -71,6 +71,8 @@ export const AGENT_SESSION_FAILURE_COPY = {
|
||||
claudeAccountSetupFailed: CLAUDE_PROFILE_SETUP_FAILED_MESSAGE,
|
||||
agentCommandNotRunnable:
|
||||
"{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.",
|
||||
runtimeNotInstalled:
|
||||
"{{agent}} isn't installed on the computer running this chat. Install it from the chat in a current version of Orca.",
|
||||
chooseClaudeAccount: 'Choose or add one in Claude Accounts settings.',
|
||||
chooseClaudeAccountThenRunCommand:
|
||||
'Choose or add one in Claude Accounts settings, then run /{{command}} again.',
|
||||
|
||||
@@ -208,6 +208,8 @@ const FAILURE_SENTENCES = {
|
||||
claudeAccountSetupFailed: (_context, _fact, _surface, say) => say('claudeAccountSetupFailed'),
|
||||
agentCommandNotRunnable: (context, _fact, _surface, say) =>
|
||||
say('agentCommandNotRunnable', agent(say, context)),
|
||||
runtimeNotInstalled: (context, _fact, _surface, say) =>
|
||||
say('runtimeNotInstalled', agent(say, context)),
|
||||
managedAccountUnsupported: (context, _fact, _surface, say) =>
|
||||
joinSentences([
|
||||
say('managedAccountUnsupported'),
|
||||
|
||||
@@ -37,6 +37,7 @@ export const AGENT_SESSION_FAILURE_KINDS = [
|
||||
'claudeAccountFolderMissing',
|
||||
'claudeAccountSetupFailed',
|
||||
'agentCommandNotRunnable',
|
||||
'runtimeNotInstalled',
|
||||
'providerExited',
|
||||
'restartFailed',
|
||||
'providerRejected',
|
||||
|
||||
@@ -84,6 +84,8 @@ export const AGENT_SESSION_REFUSAL_REASONS = {
|
||||
'claudeAccountSetupFailed',
|
||||
/** Settings → Agents → Command names no program this host can run. */
|
||||
'agentCommandNotRunnable',
|
||||
/** The runtime Orca installs for the agent is not installed on this host. */
|
||||
'runtimeNotInstalled',
|
||||
/** The agent started, then Orca could not open the chat's conversation for it. */
|
||||
'attachFailed'
|
||||
],
|
||||
|
||||
@@ -118,6 +118,7 @@ const REASON_WORDS = {
|
||||
claudeAccountFolderMissing: { fact: 'claudeAccountFolderMissing', action: 'actFirst' },
|
||||
claudeAccountSetupFailed: { fact: 'claudeAccountSetupFailed', action: 'retry' },
|
||||
agentCommandNotRunnable: { fact: 'agentCommandNotRunnable', action: 'actFirst' },
|
||||
runtimeNotInstalled: { fact: 'runtimeNotInstalled', action: 'actFirst' },
|
||||
attachFailed: codeWords('retry')
|
||||
},
|
||||
agent_session_ownership_unknown: {
|
||||
|
||||
@@ -96,6 +96,7 @@ const KIND_CATEGORY = {
|
||||
claudeAccountFolderMissing: 'startFailed',
|
||||
claudeAccountSetupFailed: 'startFailed',
|
||||
agentCommandNotRunnable: 'startFailed',
|
||||
runtimeNotInstalled: 'startFailed',
|
||||
restartFailed: 'startFailed',
|
||||
providerRejected: 'content',
|
||||
attachmentInvalid: 'content',
|
||||
@@ -127,6 +128,7 @@ const KIND_VERDICT = {
|
||||
claudeAccountFolderMissing: 'failure',
|
||||
claudeAccountSetupFailed: 'failure',
|
||||
agentCommandNotRunnable: 'failure',
|
||||
runtimeNotInstalled: 'failure',
|
||||
restartFailed: 'failure',
|
||||
providerRejected: 'failure',
|
||||
attachmentInvalid: 'failure',
|
||||
|
||||
Reference in New Issue
Block a user