mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 00:03:20 +00:00
Merge remote-tracking branch 'origin/main' into OrcaWin/windows-worktree-agent-resume
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
const { createHash } = require('node:crypto')
|
||||
const { readFileSync, renameSync, rmSync, writeFileSync } = require('node:fs')
|
||||
const { join, resolve } = require('node:path')
|
||||
|
||||
const EXPECTED_NODE_PTY_VERSION = '1.1.0'
|
||||
const ORIGINAL_SOURCE_SHA256 = '0d010879bb6680a0253d44363183d53e631f42972594eb6dcb1fb842c8c85e52'
|
||||
const PATCHED_SOURCE_SHA256 = '84df20cfe711a88d2bef35078615c58a6ce14f39348a4aef40e852b854dcd857'
|
||||
const ORIGINAL_BODY = 'var consoleProcessList = getConsoleProcessList(shellPid);'
|
||||
const PATCHED_BODY = `var consoleProcessList;
|
||||
try {
|
||||
consoleProcessList = getConsoleProcessList(shellPid);
|
||||
}
|
||||
catch (_a) {
|
||||
// Why: AttachConsole can fail without a Win32 console; use node-pty's timeout fallback immediately.
|
||||
consoleProcessList = [shellPid];
|
||||
}`
|
||||
|
||||
function inspectNodePtyConsoleListAgent(relayDir = process.cwd()) {
|
||||
const nodePtyDir = resolve(relayDir, 'node_modules', 'node-pty')
|
||||
const packageJsonPath = join(nodePtyDir, 'package.json')
|
||||
const agentPath = join(nodePtyDir, 'lib', 'conpty_console_list_agent.js')
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
|
||||
if (packageJson.version !== EXPECTED_NODE_PTY_VERSION) {
|
||||
throw new Error(
|
||||
`Refusing to patch node-pty ${packageJson.version}; expected ${EXPECTED_NODE_PTY_VERSION}`
|
||||
)
|
||||
}
|
||||
const source = readFileSync(agentPath, 'utf8')
|
||||
return { agentPath, source }
|
||||
}
|
||||
|
||||
function assertPatchedNodePtyConsoleListAgent(relayDir = process.cwd()) {
|
||||
const inspected = inspectNodePtyConsoleListAgent(relayDir)
|
||||
if (sourceSha256(inspected.source) !== PATCHED_SOURCE_SHA256) {
|
||||
throw new Error('node-pty ConPTY console-list fallback is not installed')
|
||||
}
|
||||
}
|
||||
|
||||
function patchNodePtyConsoleListAgent(relayDir = process.cwd()) {
|
||||
const inspected = inspectNodePtyConsoleListAgent(relayDir)
|
||||
const sourceHash = sourceSha256(inspected.source)
|
||||
if (sourceHash === PATCHED_SOURCE_SHA256) {
|
||||
return
|
||||
}
|
||||
if (sourceHash !== ORIGINAL_SOURCE_SHA256) {
|
||||
throw new Error('Refusing to patch unexpected node-pty console-list agent source')
|
||||
}
|
||||
const patchedSource = inspected.source.replace(ORIGINAL_BODY, PATCHED_BODY)
|
||||
const temporaryPath = `${inspected.agentPath}.orca-patch-${process.pid}`
|
||||
// Why: a terminated remote install must leave either known source version recoverable on reconnect.
|
||||
try {
|
||||
writeFileSync(temporaryPath, patchedSource)
|
||||
renameSync(temporaryPath, inspected.agentPath)
|
||||
} finally {
|
||||
rmSync(temporaryPath, { force: true })
|
||||
}
|
||||
assertPatchedNodePtyConsoleListAgent(relayDir)
|
||||
}
|
||||
|
||||
function sourceSha256(source) {
|
||||
return createHash('sha256').update(source).digest('hex')
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
patchNodePtyConsoleListAgent()
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assertPatchedNodePtyConsoleListAgent,
|
||||
patchNodePtyConsoleListAgent
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-07-19",
|
||||
"updatedAt": "2026-07-20",
|
||||
"policy": {
|
||||
"maturityLevels": [
|
||||
"experimental",
|
||||
@@ -1224,7 +1224,8 @@
|
||||
"PTY sizing",
|
||||
"split layout",
|
||||
"restore",
|
||||
"hidden-to-visible transitions"
|
||||
"hidden-to-visible transitions",
|
||||
"window wake"
|
||||
],
|
||||
"platforms": [
|
||||
"macos",
|
||||
@@ -1241,7 +1242,7 @@
|
||||
"macos"
|
||||
],
|
||||
"coveredProviders": [],
|
||||
"coverageNotes": "Local macOS evidence on main@1282f5c2d, including #7192's runtime-mirror geometry authority slice. Shell-visible size, SSH/remote geometry, Windows ConPTY readback, and resume-time reassertion coverage arrive with the pending stack and #7006.",
|
||||
"coverageNotes": "Local macOS evidence on main@1282f5c2d, including #7192's runtime-mirror geometry authority slice. Deterministic provider-contract coverage now includes settled window-wake reassertion and SSH relay applied-size readback. Live shell-visible SSH/remote geometry and Windows ConPTY readback remain non-blocking gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/6644",
|
||||
"https://github.com/stablyai/orca/pull/6649",
|
||||
@@ -1252,15 +1253,19 @@
|
||||
"https://github.com/stablyai/orca/pull/7192"
|
||||
],
|
||||
"invariant": "A visible desktop-owned terminal cannot trust 0x0, stale requested size, or renderer-only size; xterm, fit/proposed size, applied PTY size, shell-visible size, and the runtime mirror's parse dimensions must converge or enter explicit degraded state, and mirror resize reflow must stay ordered with queued output writes.",
|
||||
"oracle": "The current executable slice uses deterministic frame schedulers and fake providers to force 0x0 first fit, delayed layout settle, dropped resize/readback drift, and hidden-to-visible activation, then asserts the renderer forwards a usable size, pty:getSize reports applied rather than merely requested size where available, and visibility resume reasserts real drift without hot listSessions. Shell-visible stty/echo-wrap convergence remains a live-gate follow-up.",
|
||||
"oracle": "The current executable slice uses deterministic frame schedulers and fake providers to force 0x0 first fit, delayed layout settle, dropped resize/readback drift, hidden-to-visible activation, and window wake. It asserts the renderer forwards a usable size, pty:getSize reports applied rather than merely requested size where available, visibility resume reasserts real drift without hot listSessions, one settled wake produces one geometry-only readback, and SSH relay readback is authoritative with bounded fallback. Shell-visible stty/echo-wrap convergence remains a live-gate follow-up.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts"
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/main/ipc/pty.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/runtime/orca-runtime.test.ts src/relay/pty-handler.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts",
|
||||
"src/main/ipc/pty.test.ts",
|
||||
"src/main/runtime/orca-runtime.test.ts"
|
||||
"src/main/providers/ssh-pty-provider.test.ts",
|
||||
"src/main/runtime/orca-runtime.test.ts",
|
||||
"src/relay/pty-handler.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
@@ -1271,6 +1276,13 @@
|
||||
"applied-size readback divergence is re-forwarded before handoff"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts",
|
||||
"assertions": [
|
||||
"only one applied-size read stays in flight while the grid changes",
|
||||
"stale in-flight targets never resize the PTY back to an older grid"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts",
|
||||
"assertions": [
|
||||
@@ -1279,11 +1291,26 @@
|
||||
"hidden 0x0 background spawn is not forced to phantom desktop size"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts",
|
||||
"assertions": [
|
||||
"window focus reasserts PTY size after fitting",
|
||||
"the immediate and settled wake fits produce exactly one geometry-only reassertion"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/pty.test.ts",
|
||||
"assertions": [
|
||||
"accepted desktop resizes fan out to the runtime after provider resize",
|
||||
"rejected desktop resizes do not fan out to the runtime"
|
||||
"rejected desktop resizes do not fan out to the runtime",
|
||||
"provider-owned null remains unverified instead of falling back to requested size"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/providers/ssh-pty-provider.test.ts",
|
||||
"assertions": [
|
||||
"SSH applied-size readback uses the relay with a one-second timeout",
|
||||
"old-relay method-not-found is cached while transient failures remain retryable"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -1292,17 +1319,24 @@
|
||||
"the headless mirror resizes after an accepted desktop PTY resize (#7192)",
|
||||
"headless mirror resizes are ordered behind queued PTY writes (#7192)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/relay/pty-handler.test.ts",
|
||||
"assertions": [
|
||||
"relay readback reports the grid actually applied by node-pty",
|
||||
"missing relay PTYs return an unverified null size"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-03",
|
||||
"date": "2026-07-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/main/ipc/pty.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/runtime/orca-runtime.test.ts src/relay/pty-handler.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 8.4,
|
||||
"summary": "4 test file(s) passed, 777 tests passed on main@1282f5c2d in a clean checkout."
|
||||
"durationSeconds": 18.54,
|
||||
"summary": "8 test files passed, 1,272 tests passed on the PR branch."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
@@ -1319,16 +1353,15 @@
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Must include resize/throughput budget so convergence loops do not add frame or SIGWINCH churn."
|
||||
"evidence": "The wake hook count test permits two visual fits but exactly one applied-size read per visible pane and no broad visibility/agent lifecycle call. SSH readback has a one-second timeout, caches stable method-not-found, and retries only transient failures. No polling, listSessions scan, subprocess, or unbounded retry was added."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Build deterministic fault hooks before adding broad E2E coverage.",
|
||||
"Keep stress variants non-blocking until runtime and flake history are known."
|
||||
],
|
||||
"knownGaps": [
|
||||
"No live Electron geometry command yet beyond the local live PTY resize slice.",
|
||||
"The runtime mirror geometry slice is unregistered: #7192's red tests (orca-runtime.test.ts headless-mirror resize and ordering, pty.test.ts desktop resize fan-out) exist on fresh main but not at this branch's merge-base, so they can only be registered during the rebase.",
|
||||
"Does not yet prove shell-visible size, provider-applied size, and xterm size converge across SSH/remote paths.",
|
||||
"Local and Docker-backed SSH Electron wake specs exist but remain manual/non-blocking pending runtime and flake history.",
|
||||
"The deterministic gate proves the SSH provider/relay readback contract, not live shell-visible convergence across every remote path.",
|
||||
"Does not yet prove Windows ConPTY geometry/readback.",
|
||||
"Current command uses deterministic fake providers for the main/renderer contracts, not a real remote PTY."
|
||||
],
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
import { build } from 'esbuild'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const __dirname = import.meta.dirname
|
||||
@@ -26,6 +26,13 @@ const MANAGED_HOOK_RUNTIME_ENTRY = join(
|
||||
'managed-hook-runtime.ts'
|
||||
)
|
||||
const JSONC_PARSER_ESM_ENTRY = join(ROOT, 'node_modules', 'jsonc-parser', 'lib', 'esm', 'main.js')
|
||||
const NODE_PTY_CONSOLE_LIST_PATCH_FILENAME = 'node-pty-1.1.0-console-list-agent-patch.cjs'
|
||||
const NODE_PTY_CONSOLE_LIST_PATCH_SOURCE = join(
|
||||
ROOT,
|
||||
'config',
|
||||
'relay-assets',
|
||||
NODE_PTY_CONSOLE_LIST_PATCH_FILENAME
|
||||
)
|
||||
|
||||
const PLATFORMS = [
|
||||
'linux-x64',
|
||||
@@ -59,6 +66,13 @@ for (const platform of PLATFORMS) {
|
||||
}
|
||||
})
|
||||
|
||||
if (platform.startsWith('win32-')) {
|
||||
copyFileSync(
|
||||
NODE_PTY_CONSOLE_LIST_PATCH_SOURCE,
|
||||
join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)
|
||||
)
|
||||
}
|
||||
|
||||
await build({
|
||||
entryPoints: [WATCHER_ENTRY],
|
||||
bundle: true,
|
||||
@@ -101,9 +115,12 @@ for (const platform of PLATFORMS) {
|
||||
.update(relayContent)
|
||||
.update(watcherContent)
|
||||
.update(managedHookRuntimeContent)
|
||||
.digest('hex')
|
||||
.slice(0, 12)
|
||||
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${hash}`)
|
||||
// Why: changing the remote node-pty patch must select a fresh immutable Windows relay directory.
|
||||
if (platform.startsWith('win32-')) {
|
||||
hash.update(readFileSync(join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)))
|
||||
}
|
||||
const contentHash = hash.digest('hex').slice(0, 12)
|
||||
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${contentHash}`)
|
||||
|
||||
console.log(`Built relay for ${platform} → ${outDir}/relay.js`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const {
|
||||
assertPatchedNodePtyConsoleListAgent,
|
||||
patchNodePtyConsoleListAgent
|
||||
} = require('../relay-assets/node-pty-1.1.0-console-list-agent-patch.cjs')
|
||||
const projectDir = resolve(import.meta.dirname, '..', '..')
|
||||
const cleanupDirs = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of cleanupDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('Windows SSH relay node-pty console-list patch', () => {
|
||||
it('installs and verifies the fallback idempotently', () => {
|
||||
const fixture = writeNodePtyFixture('1.1.0', publishedAgentSource())
|
||||
|
||||
patchNodePtyConsoleListAgent(fixture.root)
|
||||
const once = readFileSync(fixture.agentPath, 'utf8')
|
||||
expect(once).toContain('consoleProcessList = [shellPid];')
|
||||
expect(existsSync(`${fixture.agentPath}.orca-patch-${process.pid}`)).toBe(false)
|
||||
expect(() => assertPatchedNodePtyConsoleListAgent(fixture.root)).not.toThrow()
|
||||
|
||||
patchNodePtyConsoleListAgent(fixture.root)
|
||||
expect(readFileSync(fixture.agentPath, 'utf8')).toBe(once)
|
||||
})
|
||||
|
||||
it('refuses a different package version or unexpected agent source', () => {
|
||||
const wrongVersion = writeNodePtyFixture('1.2.0-beta.11', publishedAgentSource())
|
||||
expect(() => patchNodePtyConsoleListAgent(wrongVersion.root)).toThrow('expected 1.1.0')
|
||||
|
||||
const drifted = writeNodePtyFixture('1.1.0', `${publishedAgentSource()}\n// drift`)
|
||||
expect(() => patchNodePtyConsoleListAgent(drifted.root)).toThrow('unexpected node-pty')
|
||||
|
||||
const tamperedPatch = writeNodePtyFixture('1.1.0', publishedAgentSource())
|
||||
patchNodePtyConsoleListAgent(tamperedPatch.root)
|
||||
writeFileSync(tamperedPatch.agentPath, `${readFileSync(tamperedPatch.agentPath)}\n// drift`)
|
||||
expect(() => assertPatchedNodePtyConsoleListAgent(tamperedPatch.root)).toThrow('not installed')
|
||||
})
|
||||
})
|
||||
|
||||
function writeNodePtyFixture(version, agentSource) {
|
||||
const root = mkdtempSync(join(projectDir, '.node-pty-console-list-patch-test-'))
|
||||
cleanupDirs.push(root)
|
||||
const nodePtyDir = join(root, 'node_modules', 'node-pty')
|
||||
const libDir = join(nodePtyDir, 'lib')
|
||||
const agentPath = join(libDir, 'conpty_console_list_agent.js')
|
||||
mkdirSync(libDir, { recursive: true })
|
||||
writeFileSync(join(nodePtyDir, 'package.json'), JSON.stringify({ version }))
|
||||
writeFileSync(agentPath, agentSource)
|
||||
return { root, agentPath }
|
||||
}
|
||||
|
||||
function publishedAgentSource() {
|
||||
return [
|
||||
'"use strict";',
|
||||
'/**',
|
||||
' * Copyright (c) 2019, Microsoft Corporation (MIT License).',
|
||||
' *',
|
||||
' * This module fetches the console process list for a particular PID. It must be',
|
||||
' * called from a different process (child_process.fork) as there can only be a',
|
||||
' * single console attached to a process.',
|
||||
' */',
|
||||
'Object.defineProperty(exports, "__esModule", { value: true });',
|
||||
'var utils_1 = require("./utils");',
|
||||
"var getConsoleProcessList = utils_1.loadNativeModule('conpty_console_list').module.getConsoleProcessList;",
|
||||
'var shellPid = parseInt(process.argv[2], 10);',
|
||||
'var consoleProcessList = getConsoleProcessList(shellPid);',
|
||||
'process.send({ consoleProcessList: consoleProcessList });',
|
||||
'process.exit(0);',
|
||||
'//# sourceMappingURL=conpty_console_list_agent.js.map'
|
||||
].join('\n')
|
||||
}
|
||||
@@ -217,6 +217,23 @@ describe('Electron runtime package contract', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('packages and verifies the Windows SSH node-pty console-list fallback', () => {
|
||||
const relayBuild = readFileSync(join(projectDir, 'config/scripts/build-relay.mjs'), 'utf8')
|
||||
const relayDeploy = readFileSync(join(projectDir, 'src/main/ssh/ssh-relay-deploy.ts'), 'utf8')
|
||||
const patchAsset = readFileSync(
|
||||
join(projectDir, 'config/relay-assets/node-pty-1.1.0-console-list-agent-patch.cjs'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(relayBuild).toContain('copyFileSync(')
|
||||
expect(relayBuild).toContain('hash.update(readFileSync')
|
||||
expect(relayBuild).toContain('node-pty-1.1.0-console-list-agent-patch.cjs')
|
||||
expect(relayDeploy).toContain('assertPatchedNodePtyConsoleListAgent')
|
||||
expect(relayDeploy.match(/\$\{windowsNodePtyPatchCommand\(nodePath\)\}/g)).toHaveLength(2)
|
||||
expect(patchAsset).toContain('consoleProcessList = [shellPid];')
|
||||
expect(patchAsset).toContain('packageJson.version !== EXPECTED_NODE_PTY_VERSION')
|
||||
})
|
||||
|
||||
it('pins the Windows release builder to the VS 2022 runner image', () => {
|
||||
const releaseWorkflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8')
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const rawExtraArgs = process.argv.slice(2)
|
||||
const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
|
||||
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const env = {
|
||||
...process.env,
|
||||
ORCA_E2E_WEB_CLIENT: '1'
|
||||
}
|
||||
|
||||
// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
|
||||
const spawnOptions = {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
shell: process.platform === 'win32'
|
||||
}
|
||||
|
||||
const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)
|
||||
|
||||
if (runtime.status !== 0) {
|
||||
process.exit(runtime.status ?? 1)
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
pnpm,
|
||||
[
|
||||
'exec',
|
||||
'playwright',
|
||||
'test',
|
||||
'tests/e2e/multi-client-navigation-isolation.spec.ts',
|
||||
'--config',
|
||||
'tests/playwright.config.ts',
|
||||
'--project',
|
||||
'electron-headless',
|
||||
'--workers=1',
|
||||
...extraArgs
|
||||
],
|
||||
spawnOptions
|
||||
)
|
||||
|
||||
process.exit(result.status ?? 1)
|
||||
@@ -0,0 +1,435 @@
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { copyFileSync, cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { createConnection } from 'node:net'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '..', '..')
|
||||
const relayBuildDir = join(projectDir, 'out', 'relay', 'win32-x64')
|
||||
const SENTINEL = Buffer.from('ORCA-RELAY v0.1.0 READY\n')
|
||||
const HEADER_LENGTH = 13
|
||||
const REGRESSION_TIMEOUT_MS = 15_000
|
||||
const NODE_PTY_PATCH_FILENAME = 'node-pty-1.1.0-console-list-agent-patch.cjs'
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
console.log('SKIP: Windows ConPTY regression only runs on Windows.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
const nodePath = resolve(options.node ?? process.execPath)
|
||||
const nodePtyDir = resolve(options.nodePty ?? join(projectDir, 'node_modules', 'node-pty'))
|
||||
const expectFailure = options.expect === 'attach-console-failure'
|
||||
|
||||
for (const required of [
|
||||
nodePath,
|
||||
nodePtyDir,
|
||||
join(relayBuildDir, 'relay.js'),
|
||||
join(relayBuildDir, '.version')
|
||||
]) {
|
||||
if (!existsSync(required)) {
|
||||
throw new Error(`Required regression input is missing: ${required}`)
|
||||
}
|
||||
}
|
||||
|
||||
const runDir = mkdtempSync(join(projectDir, '.issue-9586-relay-repro-'))
|
||||
const relayPath = join(runDir, 'relay.js')
|
||||
const stdoutLog = join(runDir, 'relay.log')
|
||||
const stderrLog = join(runDir, 'relay.err.log')
|
||||
const socketPath = `\\\\.\\pipe\\orca-issue-9586-${process.pid}-${Date.now()}`
|
||||
let relayPid
|
||||
|
||||
try {
|
||||
prepareRelayTree(runDir, nodePtyDir)
|
||||
if (!options.skipRelayPatch) {
|
||||
applyPackagedNodePtyPatch(nodePath, runDir)
|
||||
}
|
||||
relayPid = launchRelayWithoutConsole({
|
||||
nodePath,
|
||||
relayPath,
|
||||
runDir,
|
||||
socketPath,
|
||||
stdoutLog,
|
||||
stderrLog
|
||||
})
|
||||
await waitForPipe(socketPath, 5_000)
|
||||
const observation = await exerciseRelayClient({
|
||||
nodePath,
|
||||
relayPath,
|
||||
runDir,
|
||||
socketPath,
|
||||
shell: options.shell
|
||||
})
|
||||
await waitForExit(relayPid, 8_000)
|
||||
|
||||
const relayStdout = readIfPresent(stdoutLog)
|
||||
const relayStderr = readIfPresent(stderrLog)
|
||||
const attachConsoleFailed = relayStderr.includes('Error: AttachConsole failed')
|
||||
const installedNodePtyDir = join(runDir, 'node_modules', 'node-pty')
|
||||
const agentPath = join(installedNodePtyDir, 'lib', 'conpty_console_list_agent.js')
|
||||
const nativeBindingPath = join(
|
||||
installedNodePtyDir,
|
||||
'prebuilds',
|
||||
`${process.platform}-${process.arch}`,
|
||||
'conpty.node'
|
||||
)
|
||||
const summary = {
|
||||
node: observation.nodeVersion,
|
||||
nodePty: JSON.parse(readFileSync(join(nodePtyDir, 'package.json'), 'utf8')).version,
|
||||
shell: options.shell,
|
||||
relayPid,
|
||||
handshake: observation.handshake,
|
||||
ptySpawn: observation.ptySpawn,
|
||||
ptyShutdown: observation.ptyShutdown,
|
||||
relayAliveAfterPtyShutdown: observation.relayAliveAfterPtyShutdown,
|
||||
bridgeExit: observation.bridgeExit,
|
||||
relayPatchApplied: !options.skipRelayPatch,
|
||||
agentSha256: fileSha256(agentPath),
|
||||
nativeBindingPresent: existsSync(nativeBindingPath),
|
||||
attachConsoleFailed,
|
||||
relayExitedAfterClientDisconnect: !isProcessAlive(relayPid)
|
||||
}
|
||||
console.log(JSON.stringify(summary, null, 2))
|
||||
|
||||
if (expectFailure !== attachConsoleFailed) {
|
||||
throw new Error(
|
||||
expectFailure
|
||||
? `Expected the real console-list agent to fail AttachConsole. stderr:\n${relayStderr}`
|
||||
: `The real console-list agent failed AttachConsole. stderr:\n${relayStderr}`
|
||||
)
|
||||
}
|
||||
if (
|
||||
!observation.handshake ||
|
||||
!observation.ptySpawn ||
|
||||
!observation.ptyShutdown ||
|
||||
!observation.relayAliveAfterPtyShutdown
|
||||
) {
|
||||
throw new Error(
|
||||
`Relay lifecycle did not complete. stdout:\n${relayStdout}\nstderr:\n${relayStderr}`
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (relayPid && isProcessAlive(relayPid)) {
|
||||
stopExactProcess(relayPid, relayPath)
|
||||
}
|
||||
rmSync(runDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 })
|
||||
}
|
||||
|
||||
function parseArgs(args) {
|
||||
const parsed = { expect: 'clean', shell: 'cmd.exe', skipRelayPatch: false }
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
const flag = args[index]
|
||||
if (flag === '--skip-relay-patch') {
|
||||
parsed.skipRelayPatch = true
|
||||
continue
|
||||
}
|
||||
const value = args[index + 1]
|
||||
if (!value || !['--node', '--node-pty', '--expect', '--shell'].includes(flag)) {
|
||||
throw new Error(
|
||||
'Usage: node windows-ssh-attach-console-repro.mjs [--node PATH] [--node-pty DIR] [--shell PATH] [--skip-relay-patch] [--expect clean|attach-console-failure]'
|
||||
)
|
||||
}
|
||||
if (flag === '--node') {
|
||||
parsed.node = value
|
||||
}
|
||||
if (flag === '--node-pty') {
|
||||
parsed.nodePty = value
|
||||
}
|
||||
if (flag === '--expect') {
|
||||
parsed.expect = value
|
||||
}
|
||||
if (flag === '--shell') {
|
||||
parsed.shell = value
|
||||
}
|
||||
index++
|
||||
}
|
||||
if (!['clean', 'attach-console-failure'].includes(parsed.expect)) {
|
||||
throw new Error(`Unsupported expectation: ${parsed.expect}`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function prepareRelayTree(runDir, nodePtyDir) {
|
||||
for (const filename of [
|
||||
'relay.js',
|
||||
'relay-watcher.js',
|
||||
'managed-hook-runtime.js',
|
||||
NODE_PTY_PATCH_FILENAME,
|
||||
'.version'
|
||||
]) {
|
||||
copyFileSync(join(relayBuildDir, filename), join(runDir, filename))
|
||||
}
|
||||
cpSync(nodePtyDir, join(runDir, 'node_modules', 'node-pty'), { recursive: true })
|
||||
}
|
||||
|
||||
function applyPackagedNodePtyPatch(nodePath, runDir) {
|
||||
const result = spawnSync(nodePath, [join(runDir, NODE_PTY_PATCH_FILENAME)], {
|
||||
cwd: runDir,
|
||||
encoding: 'utf8',
|
||||
windowsHide: true
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Packaged node-pty patch failed: ${result.stderr || result.stdout}`)
|
||||
}
|
||||
}
|
||||
|
||||
function launchRelayWithoutConsole({
|
||||
nodePath,
|
||||
relayPath,
|
||||
runDir,
|
||||
socketPath,
|
||||
stdoutLog,
|
||||
stderrLog
|
||||
}) {
|
||||
const relayArgs = [
|
||||
quoteWindowsArg(nodePath),
|
||||
quoteWindowsArg(relayPath),
|
||||
'--detached',
|
||||
'--grace-time',
|
||||
'5',
|
||||
'--sock-path',
|
||||
quoteWindowsArg(socketPath),
|
||||
'--endpoint-dir',
|
||||
quoteWindowsArg(join(runDir, 'endpoint')),
|
||||
'--log-file',
|
||||
quoteWindowsArg(stdoutLog),
|
||||
`1>${quoteWindowsArg(stdoutLog)}`,
|
||||
`2>${quoteWindowsArg(stderrLog)}`
|
||||
].join(' ')
|
||||
const commandLine = `cmd.exe /d /s /c "${relayArgs}"`
|
||||
const script = [
|
||||
`$result = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ${powerShellLiteral(commandLine)}; CurrentDirectory = ${powerShellLiteral(runDir)} }`,
|
||||
`if ($result.ReturnValue -ne 0) { throw "Win32_Process.Create failed with $($result.ReturnValue)" }`,
|
||||
'$result.ProcessId'
|
||||
].join('; ')
|
||||
const launched = runPowerShell(script)
|
||||
const pid = Number(launched.stdout.trim())
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
throw new Error(`Could not parse detached relay pid from: ${launched.stdout}`)
|
||||
}
|
||||
return pid
|
||||
}
|
||||
|
||||
function exerciseRelayClient({ nodePath, relayPath, runDir, socketPath, shell }) {
|
||||
return new Promise((resolvePromise, rejectPromise) => {
|
||||
const bridge = spawn(nodePath, [relayPath, '--connect', '--sock-path', socketPath], {
|
||||
cwd: runDir,
|
||||
windowsHide: true,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
const observation = {
|
||||
nodeVersion: readNodeVersion(nodePath),
|
||||
handshake: false,
|
||||
ptySpawn: false,
|
||||
ptyShutdown: false,
|
||||
relayAliveAfterPtyShutdown: false,
|
||||
bridgeExit: null
|
||||
}
|
||||
let stderr = ''
|
||||
let buffer = Buffer.alloc(0)
|
||||
let sentinelRead = false
|
||||
let outgoingSequence = 1
|
||||
let settled = false
|
||||
const timeout = setTimeout(
|
||||
() => finish(new Error(`Timed out waiting for relay client lifecycle. stderr:\n${stderr}`)),
|
||||
REGRESSION_TIMEOUT_MS
|
||||
)
|
||||
|
||||
bridge.stderr.on('data', (data) => {
|
||||
stderr += data.toString()
|
||||
})
|
||||
bridge.on('error', finish)
|
||||
bridge.on('close', (code, signal) => {
|
||||
observation.bridgeExit = { code, signal }
|
||||
if (!settled && observation.relayAliveAfterPtyShutdown) {
|
||||
finish()
|
||||
} else if (!settled) {
|
||||
finish(new Error(`Relay bridge closed before lifecycle completed. stderr:\n${stderr}`))
|
||||
}
|
||||
})
|
||||
bridge.stdout.on('data', (data) => {
|
||||
try {
|
||||
buffer = Buffer.concat([buffer, data])
|
||||
if (!sentinelRead) {
|
||||
const sentinelIndex = buffer.indexOf(SENTINEL)
|
||||
if (sentinelIndex === -1) {
|
||||
return
|
||||
}
|
||||
buffer = buffer.subarray(sentinelIndex + SENTINEL.length)
|
||||
sentinelRead = true
|
||||
observation.handshake = true
|
||||
request(1, 'pty.spawn', {
|
||||
shellOverride: shell,
|
||||
cwd: projectDir,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
env: {}
|
||||
})
|
||||
}
|
||||
for (const message of drainMessages()) {
|
||||
if (message.id === 1) {
|
||||
throwResponseError(message)
|
||||
observation.ptySpawn = true
|
||||
request(2, 'pty.shutdown', { id: message.result.id, immediate: true })
|
||||
} else if (message.id === 2) {
|
||||
throwResponseError(message)
|
||||
observation.ptyShutdown = true
|
||||
request(3, 'relay.status', {})
|
||||
} else if (message.id === 3) {
|
||||
throwResponseError(message)
|
||||
observation.relayAliveAfterPtyShutdown = message.result.ptys.active === 0
|
||||
bridge.stdin.end()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
finish(error)
|
||||
}
|
||||
})
|
||||
|
||||
function request(id, method, params) {
|
||||
bridge.stdin.write(encodeRequestFrame({ id, method, params }, outgoingSequence++))
|
||||
}
|
||||
|
||||
function drainMessages() {
|
||||
const messages = []
|
||||
while (buffer.length >= HEADER_LENGTH) {
|
||||
const type = buffer[0]
|
||||
const payloadLength = buffer.readUInt32BE(9)
|
||||
if (buffer.length < HEADER_LENGTH + payloadLength) {
|
||||
break
|
||||
}
|
||||
const payload = buffer.subarray(HEADER_LENGTH, HEADER_LENGTH + payloadLength)
|
||||
buffer = buffer.subarray(HEADER_LENGTH + payloadLength)
|
||||
if (type === 1) {
|
||||
messages.push(JSON.parse(payload.toString('utf8')))
|
||||
}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
function finish(error) {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
if (error) {
|
||||
bridge.kill()
|
||||
rejectPromise(error)
|
||||
} else {
|
||||
resolvePromise(observation)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function waitForPipe(socketPath, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
return new Promise((resolvePromise, rejectPromise) => {
|
||||
const attempt = () => {
|
||||
const socket = createConnection(socketPath)
|
||||
let settled = false
|
||||
const retry = () => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
socket.destroy()
|
||||
if (Date.now() >= deadline) {
|
||||
rejectPromise(new Error(`Detached relay did not listen on ${socketPath}`))
|
||||
} else {
|
||||
setTimeout(attempt, 50)
|
||||
}
|
||||
}
|
||||
socket.once('connect', () => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
socket.destroy()
|
||||
resolvePromise()
|
||||
})
|
||||
socket.once('error', retry)
|
||||
socket.setTimeout(250, retry)
|
||||
}
|
||||
attempt()
|
||||
})
|
||||
}
|
||||
|
||||
function encodeRequestFrame({ id, method, params }, sequence) {
|
||||
const payload = Buffer.from(JSON.stringify({ jsonrpc: '2.0', id, method, params }))
|
||||
const header = Buffer.alloc(HEADER_LENGTH)
|
||||
header[0] = 1
|
||||
header.writeUInt32BE(sequence, 1)
|
||||
header.writeUInt32BE(0, 5)
|
||||
header.writeUInt32BE(payload.length, 9)
|
||||
return Buffer.concat([header, payload])
|
||||
}
|
||||
|
||||
function throwResponseError(message) {
|
||||
if (message.error) {
|
||||
throw new Error(`Relay RPC failed: ${message.error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function readNodeVersion(nodePath) {
|
||||
return spawnSync(nodePath, ['--version'], { encoding: 'utf8' }).stdout.trim()
|
||||
}
|
||||
|
||||
function waitForExit(pid, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
return new Promise((resolvePromise) => {
|
||||
const poll = () => {
|
||||
if (!isProcessAlive(pid) || Date.now() >= deadline) {
|
||||
resolvePromise()
|
||||
return
|
||||
}
|
||||
setTimeout(poll, 50)
|
||||
}
|
||||
poll()
|
||||
})
|
||||
}
|
||||
|
||||
function isProcessAlive(pid) {
|
||||
const result = runPowerShell(
|
||||
`if (Get-Process -Id ${pid} -ErrorAction SilentlyContinue) { 'ALIVE' }`
|
||||
)
|
||||
return result.stdout.includes('ALIVE')
|
||||
}
|
||||
|
||||
function stopExactProcess(pid, relayPath) {
|
||||
const script = [
|
||||
`$process = Get-CimInstance Win32_Process -Filter ${powerShellLiteral(`ProcessId = ${pid}`)}`,
|
||||
`if ($process -and $process.CommandLine -like ${powerShellLiteral(`*${relayPath}*`)}) { Stop-Process -Id ${pid} -Force }`
|
||||
].join('; ')
|
||||
runPowerShell(script)
|
||||
}
|
||||
|
||||
function runPowerShell(script) {
|
||||
const encoded = Buffer.from(script, 'utf16le').toString('base64')
|
||||
const result = spawnSync(
|
||||
'powershell.exe',
|
||||
['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encoded],
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`PowerShell failed (${result.status}): ${result.stderr || result.stdout}`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function quoteWindowsArg(value) {
|
||||
return `"${value.replaceAll('"', '\\"')}"`
|
||||
}
|
||||
|
||||
function powerShellLiteral(value) {
|
||||
return `'${value.replaceAll("'", "''")}'`
|
||||
}
|
||||
|
||||
function readIfPresent(path) {
|
||||
return existsSync(path) ? readFileSync(path, 'utf8') : ''
|
||||
}
|
||||
|
||||
function fileSha256(path) {
|
||||
return createHash('sha256').update(readFileSync(path)).digest('hex')
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
"../src/main/antigravity/hook-service.ts",
|
||||
"../src/main/claude/hook-settings.ts",
|
||||
"../src/main/claude/hook-service.ts",
|
||||
"../src/main/claude/statusline-script.ts",
|
||||
"../src/main/codex/codex-app-server-capability-cache.ts",
|
||||
"../src/main/codex/codex-app-server-capability-signal.ts",
|
||||
"../src/main/codex/codex-app-server-client.ts",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 6.7m">
|
||||
<title>downloads: 6.7m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 7.0m">
|
||||
<title>downloads: 7.0m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">6.7m</text>
|
||||
<text x="93.5" y="14">6.7m</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">7.0m</text>
|
||||
<text x="93.5" y="14">7.0m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 943 B After Width: | Height: | Size: 943 B |
@@ -15,7 +15,7 @@ Install the AppImage runtime dependency and Xvfb:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y curl libfuse2 xvfb
|
||||
sudo apt-get install -y curl file libfuse2 xvfb
|
||||
```
|
||||
|
||||
Download and make the AppImage executable:
|
||||
@@ -56,11 +56,14 @@ The command prints the runtime endpoint and pairing URL. Stop it with `Ctrl+C`.
|
||||
## Systemd Service
|
||||
|
||||
Create a dedicated service user and install directory. Run the service as this
|
||||
user instead of root so the AppImage can keep Chromium's sandbox enabled.
|
||||
user instead of root so the AppImage can keep Chromium's sandbox enabled. Keep
|
||||
the install directory root-owned: the service needs to read and execute the
|
||||
AppImage, but must not be able to replace it or the rollback artifacts.
|
||||
|
||||
```bash
|
||||
sudo useradd --system --create-home --shell /usr/sbin/nologin orca
|
||||
sudo chown -R orca:orca /opt/orca
|
||||
sudo chown root:root /opt/orca /opt/orca/orca-linux.AppImage
|
||||
sudo chmod 755 /opt/orca /opt/orca/orca-linux.AppImage
|
||||
```
|
||||
|
||||
For most hosts, one `orca serve` service is enough because Orca starts Xvfb on
|
||||
@@ -165,6 +168,436 @@ If you later install the desktop CLI from Orca settings, use that CLI for normal
|
||||
shell workflows. Keep the AppImage path in systemd so service restarts do not
|
||||
depend on an interactive shell profile.
|
||||
|
||||
## Upgrade
|
||||
|
||||
`orca serve` never updates itself. In headless mode Orca wires up no auto-updater
|
||||
at all — the built-in updater only runs in the desktop GUI, and no paired mobile
|
||||
or web client can trigger it remotely. Upgrading is always a deliberate step:
|
||||
replace the AppImage and restart the service.
|
||||
|
||||
Two facts make this safe and predictable:
|
||||
|
||||
- **State lives in the service user's home, not next to the binary.** Persisted
|
||||
data is under `/home/orca/.config/` (Orca uses both an `orca` and an `Orca`
|
||||
directory there), fully independent of `/opt/orca/orca-linux.AppImage`.
|
||||
Replacing the binary never touches projects, worktree metadata, terminal
|
||||
history, orchestration state, or paired-device keys — so mobile and web
|
||||
clients reconnect after an upgrade without re-pairing.
|
||||
- **New builds migrate old state on load.** Orca loads older `orca-data.json`
|
||||
state into the current schema and writes it back in the current shape, so a
|
||||
forward upgrade needs no manual data step.
|
||||
|
||||
Rolling back is the case that needs care — see [Roll back](#roll-back).
|
||||
|
||||
### Record the version you deploy
|
||||
|
||||
Orca has no headless version command: there is no `--version` flag or `version`
|
||||
subcommand, and `orca serve` prints only its endpoint. Choose a release tag
|
||||
explicitly instead of following the `latest` URL, and record it next to the
|
||||
binary so upgrades are auditable. The steps below keep that record in
|
||||
`/opt/orca/VERSION`.
|
||||
|
||||
### Upgrade steps
|
||||
|
||||
Never download straight onto `/opt/orca/orca-linux.AppImage`. The AppImage is
|
||||
FUSE-mounted, so overwriting it in place while the service runs can crash or
|
||||
corrupt the live process — and even with the service stopped, a failed or partial
|
||||
download would clobber the working binary. Instead download to a temporary name
|
||||
on the same filesystem, verify it, then swap it in with an atomic rename.
|
||||
|
||||
Check capacity before starting:
|
||||
|
||||
```bash
|
||||
sudo chown root:root /opt/orca
|
||||
sudo chmod 755 /opt/orca
|
||||
sudo test ! -L /opt/orca/orca-linux.AppImage
|
||||
sudo chown root:root /opt/orca/orca-linux.AppImage
|
||||
sudo chmod 755 /opt/orca/orca-linux.AppImage
|
||||
# Clear predictable staging names left by an older attempt after locking the directory
|
||||
sudo rm -f /opt/orca/orca-linux.AppImage.new /opt/orca/VERSION.new \
|
||||
/opt/orca/orca-linux.AppImage.recovering /opt/orca/VERSION.recovering
|
||||
sudo du -sh /home/orca/.config
|
||||
df -h /opt/orca /home/orca
|
||||
```
|
||||
|
||||
`/opt/orca` needs room for the compressed Orca profile archive, the staged
|
||||
build, and the rollback binary. A rollback extracts the old profile and preserves
|
||||
the post-upgrade Orca profile directories, so `/home` needs room for both copies.
|
||||
|
||||
Run the following block as one Bash script so its fail-fast and recovery traps
|
||||
remain active for the whole operation:
|
||||
|
||||
```bash
|
||||
set -euo pipefail
|
||||
|
||||
# Replace this example with the release tag you intend to deploy
|
||||
ORCA_VERSION=v1.4.147
|
||||
|
||||
# Select the release asset on the server where Orca runs
|
||||
case "$(uname -m)" in
|
||||
x86_64)
|
||||
ORCA_ASSET=orca-linux.AppImage
|
||||
ORCA_FILE_MACHINE=x86-64
|
||||
;;
|
||||
aarch64 | arm64)
|
||||
ORCA_ASSET=orca-linux-arm64.AppImage
|
||||
ORCA_FILE_MACHINE='ARM aarch64'
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported architecture: $(uname -m)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
ORCA_ROLLBACK_NEW=
|
||||
ORCA_ROLLBACK=
|
||||
ORCA_SERVICE_STOPPED=0
|
||||
ORCA_BINARY_PROMOTED=0
|
||||
recover_failed_upgrade() {
|
||||
exit_status=$?
|
||||
trap - EXIT
|
||||
set +e
|
||||
if ((exit_status != 0)); then
|
||||
sudo rm -f /opt/orca/orca-linux.AppImage.new /opt/orca/VERSION.new \
|
||||
/opt/orca/orca-linux.AppImage.recovering /opt/orca/VERSION.recovering
|
||||
fi
|
||||
if ((exit_status != 0)) && [[ -n "$ORCA_ROLLBACK_NEW" ]] && \
|
||||
sudo test -d "$ORCA_ROLLBACK_NEW"; then
|
||||
sudo rm -rf -- "$ORCA_ROLLBACK_NEW"
|
||||
fi
|
||||
if ((exit_status != 0 && ORCA_SERVICE_STOPPED)); then
|
||||
recovery_ok=1
|
||||
if ((ORCA_BINARY_PROMOTED)); then
|
||||
if ! sudo cp -a "$ORCA_ROLLBACK/orca-linux.AppImage" \
|
||||
/opt/orca/orca-linux.AppImage.recovering || \
|
||||
! sudo mv -f /opt/orca/orca-linux.AppImage.recovering \
|
||||
/opt/orca/orca-linux.AppImage; then
|
||||
recovery_ok=0
|
||||
fi
|
||||
if sudo test -f "$ORCA_ROLLBACK/VERSION"; then
|
||||
if ! sudo cp -a "$ORCA_ROLLBACK/VERSION" /opt/orca/VERSION.recovering || \
|
||||
! sudo mv -f /opt/orca/VERSION.recovering /opt/orca/VERSION; then
|
||||
recovery_ok=0
|
||||
fi
|
||||
elif ! sudo rm -f /opt/orca/VERSION; then
|
||||
recovery_ok=0
|
||||
fi
|
||||
fi
|
||||
sudo rm -f /opt/orca/orca-linux.AppImage.recovering \
|
||||
/opt/orca/VERSION.recovering
|
||||
if ((recovery_ok)); then
|
||||
sudo systemctl start orca-serve.service || true
|
||||
else
|
||||
echo 'Upgrade recovery failed; service remains stopped' >&2
|
||||
fi
|
||||
fi
|
||||
exit "$exit_status"
|
||||
}
|
||||
trap recover_failed_upgrade EXIT
|
||||
|
||||
# 1. Stage and verify the new build while the server stays online
|
||||
sudo curl -fL --retry 3 "https://github.com/stablyai/orca/releases/download/${ORCA_VERSION}/${ORCA_ASSET}" \
|
||||
-o /opt/orca/orca-linux.AppImage.new
|
||||
sudo chown root:root /opt/orca/orca-linux.AppImage.new
|
||||
sudo chmod 755 /opt/orca/orca-linux.AppImage.new
|
||||
|
||||
# Both checks must match; either grep stops this fail-fast block otherwise
|
||||
ORCA_FILE_INFO=$(LC_ALL=C file /opt/orca/orca-linux.AppImage.new)
|
||||
grep 'ELF .* executable' <<<"$ORCA_FILE_INFO"
|
||||
grep -F "$ORCA_FILE_MACHINE" <<<"$ORCA_FILE_INFO"
|
||||
|
||||
# 2. Assemble the prior binary and version in a root-only rollback bundle
|
||||
ORCA_ROLLBACK_BASE=/opt/orca/orca-rollback-$(date +%F-%H%M%S-%N)
|
||||
ORCA_ROLLBACK_NEW=${ORCA_ROLLBACK_BASE}.new
|
||||
ORCA_ROLLBACK=${ORCA_ROLLBACK_BASE}.ready
|
||||
sudo install -d -m 700 "$ORCA_ROLLBACK_NEW"
|
||||
sudo cp -a /opt/orca/orca-linux.AppImage "$ORCA_ROLLBACK_NEW/orca-linux.AppImage"
|
||||
if sudo test -f /opt/orca/VERSION; then
|
||||
sudo cp -a /opt/orca/VERSION "$ORCA_ROLLBACK_NEW/VERSION"
|
||||
fi
|
||||
|
||||
# Stage the new version record before the stop window
|
||||
printf '%s\n' "$ORCA_VERSION" | sudo tee /opt/orca/VERSION.new >/dev/null
|
||||
sudo chown root:root /opt/orca/VERSION.new
|
||||
sudo chmod 644 /opt/orca/VERSION.new
|
||||
|
||||
# 3. Stop the server so the profile backup is consistent
|
||||
ORCA_SERVICE_STOPPED=1
|
||||
sudo systemctl stop orca-serve.service
|
||||
|
||||
# Add only Orca-owned profile directories, then publish the complete bundle
|
||||
ORCA_PROFILE_DIRS=()
|
||||
for profile_dir in orca Orca; do
|
||||
if sudo test -L "/home/orca/.config/$profile_dir"; then
|
||||
echo "Refusing symlinked Orca profile: /home/orca/.config/$profile_dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
if sudo test -d "/home/orca/.config/$profile_dir"; then
|
||||
if [[ "$profile_dir" == Orca ]] && \
|
||||
sudo test /home/orca/.config/orca -ef /home/orca/.config/Orca; then
|
||||
continue
|
||||
fi
|
||||
ORCA_PROFILE_DIRS+=("$profile_dir")
|
||||
fi
|
||||
done
|
||||
if ((${#ORCA_PROFILE_DIRS[@]} == 0)); then
|
||||
echo 'No Orca profile directory found under /home/orca/.config' >&2
|
||||
exit 1
|
||||
fi
|
||||
sudo tar czf "$ORCA_ROLLBACK_NEW/profile.tgz" \
|
||||
-C /home/orca/.config "${ORCA_PROFILE_DIRS[@]}"
|
||||
sudo chmod 600 "$ORCA_ROLLBACK_NEW/profile.tgz"
|
||||
sudo mv "$ORCA_ROLLBACK_NEW" "$ORCA_ROLLBACK"
|
||||
|
||||
# 4. Atomically replace the binary and version record, then start
|
||||
ORCA_BINARY_PROMOTED=1
|
||||
sudo mv -f /opt/orca/orca-linux.AppImage.new /opt/orca/orca-linux.AppImage
|
||||
sudo mv -f /opt/orca/VERSION.new /opt/orca/VERSION
|
||||
sudo systemctl start orca-serve.service
|
||||
ORCA_SERVICE_STOPPED=0
|
||||
trap - EXIT
|
||||
```
|
||||
|
||||
The profile archive created in step 3 captures both Orca profile directory names
|
||||
when present without rewinding unrelated tools under `/home/orca/.config`. The
|
||||
`.ready` suffix is published only after the prior binary, version record, and
|
||||
profile archive are complete. If you run the managed Xvfb unit, only
|
||||
`orca-serve.service` needs restarting — leave `orca-xvfb.service` running.
|
||||
|
||||
### Verify
|
||||
|
||||
```bash
|
||||
sudo journalctl -u orca-serve.service -f
|
||||
```
|
||||
|
||||
A healthy start prints `Orca server ready: ws://0.0.0.0:6768` (with your port).
|
||||
Confirm a client reconnects before you discard the backup. The timestamped
|
||||
rollback bundles are not pruned automatically. After the new version satisfies
|
||||
your retention policy, select and inspect the newest complete bundle before
|
||||
removing it:
|
||||
|
||||
```bash
|
||||
shopt -s nullglob
|
||||
ORCA_ROLLBACK_SETS=(/opt/orca/orca-rollback-*.ready)
|
||||
((${#ORCA_ROLLBACK_SETS[@]} > 0))
|
||||
ORCA_ROLLBACK=${ORCA_ROLLBACK_SETS[${#ORCA_ROLLBACK_SETS[@]} - 1]}
|
||||
printf 'Removing rollback bundle: %s\n' "$ORCA_ROLLBACK"
|
||||
sudo test -d "$ORCA_ROLLBACK"
|
||||
sudo rm -rf -- "$ORCA_ROLLBACK"
|
||||
```
|
||||
|
||||
Each `.ready` directory is a self-contained rollback generation; never combine
|
||||
files from different bundles.
|
||||
|
||||
### Roll back
|
||||
|
||||
A rollback is **not** binary-only safe. Once a newer build has started, it can
|
||||
rewrite `orca-data.json` in the current schema. If an older build then writes
|
||||
that file, it can discard fields it does not recognize. The rolling
|
||||
`orca-data.json.bak.*` files are corruption-recovery snapshots, not a dedicated
|
||||
pre-upgrade copy, and normal writes can rotate them away. To roll back cleanly,
|
||||
restore the backup from step 3 **and** swap the binary back. Run this block as one
|
||||
Bash script:
|
||||
|
||||
```bash
|
||||
set -euo pipefail
|
||||
|
||||
# Select and validate one complete generation before taking the service offline
|
||||
shopt -s nullglob
|
||||
ORCA_ROLLBACK_SETS=(/opt/orca/orca-rollback-*.ready)
|
||||
((${#ORCA_ROLLBACK_SETS[@]} > 0))
|
||||
ORCA_ROLLBACK=${ORCA_ROLLBACK_SETS[${#ORCA_ROLLBACK_SETS[@]} - 1]}
|
||||
sudo test -f "$ORCA_ROLLBACK/orca-linux.AppImage"
|
||||
sudo tar tzf "$ORCA_ROLLBACK/profile.tgz" >/dev/null
|
||||
|
||||
# Extract and validate the old profile while the current server stays online
|
||||
sudo test ! -L /home
|
||||
ORCA_HOME_OWNER=$(sudo stat -c %u /home)
|
||||
ORCA_HOME_MODE=$(sudo stat -c %a /home)
|
||||
if [[ "$ORCA_HOME_OWNER" != 0 ]] || ((8#$ORCA_HOME_MODE & 0022)) || \
|
||||
sudo -u orca test -w /home; then
|
||||
echo 'Refusing rollback because /home is not root-controlled' >&2
|
||||
exit 1
|
||||
fi
|
||||
ORCA_RESTORE=$(sudo mktemp -d /home/.orca-restore.XXXXXX)
|
||||
ORCA_SERVICE_STOPPED=0
|
||||
ORCA_MOVED_CURRENT_DIRS=()
|
||||
ORCA_INSTALLED_RESTORE_DIRS=()
|
||||
ORCA_CURRENT_BINARY_MOVED=0
|
||||
ORCA_CURRENT_VERSION_MOVED=0
|
||||
ORCA_VERSION_REPLACEMENT_STARTED=0
|
||||
ORCA_POST_UPGRADE=
|
||||
ORCA_ROLLBACK_BINARY_STAGED=
|
||||
ORCA_ROLLBACK_VERSION_STAGED=
|
||||
ORCA_ROLLBACK_HAS_VERSION=0
|
||||
restart_after_rollback_error() {
|
||||
exit_status=$?
|
||||
trap - EXIT
|
||||
set +e
|
||||
if ((exit_status != 0 && ORCA_SERVICE_STOPPED)); then
|
||||
recovery_ok=1
|
||||
if ((${#ORCA_INSTALLED_RESTORE_DIRS[@]})); then
|
||||
for profile_dir in "${ORCA_INSTALLED_RESTORE_DIRS[@]}"; do
|
||||
if sudo test -d "/home/orca/.config/$profile_dir"; then
|
||||
if ! sudo mv "/home/orca/.config/$profile_dir" \
|
||||
"$ORCA_RESTORE/$profile_dir.failed"; then
|
||||
recovery_ok=0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if ((${#ORCA_MOVED_CURRENT_DIRS[@]})); then
|
||||
for profile_dir in "${ORCA_MOVED_CURRENT_DIRS[@]}"; do
|
||||
if sudo test -d "$ORCA_POST_UPGRADE/$profile_dir"; then
|
||||
if ! sudo mv "$ORCA_POST_UPGRADE/$profile_dir" /home/orca/.config/; then
|
||||
recovery_ok=0
|
||||
fi
|
||||
elif ! sudo test -d "/home/orca/.config/$profile_dir"; then
|
||||
recovery_ok=0
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [[ -n "$ORCA_POST_UPGRADE" ]]; then
|
||||
sudo rmdir "$ORCA_POST_UPGRADE" 2>/dev/null || true
|
||||
fi
|
||||
if ((ORCA_CURRENT_BINARY_MOVED)); then
|
||||
if sudo test -f "$ORCA_CURRENT_BINARY"; then
|
||||
if ! sudo mv -f "$ORCA_CURRENT_BINARY" /opt/orca/orca-linux.AppImage; then
|
||||
recovery_ok=0
|
||||
fi
|
||||
elif ! sudo test -f /opt/orca/orca-linux.AppImage; then
|
||||
recovery_ok=0
|
||||
fi
|
||||
fi
|
||||
if ((ORCA_CURRENT_VERSION_MOVED)); then
|
||||
if sudo test -f "$ORCA_CURRENT_VERSION"; then
|
||||
if ! sudo mv -f "$ORCA_CURRENT_VERSION" /opt/orca/VERSION; then
|
||||
recovery_ok=0
|
||||
fi
|
||||
elif ! sudo test -f /opt/orca/VERSION; then
|
||||
recovery_ok=0
|
||||
fi
|
||||
elif ((ORCA_VERSION_REPLACEMENT_STARTED)); then
|
||||
if ! sudo rm -f /opt/orca/VERSION; then
|
||||
recovery_ok=0
|
||||
fi
|
||||
fi
|
||||
if ((recovery_ok)); then
|
||||
sudo systemctl start orca-serve.service || true
|
||||
else
|
||||
echo 'Rollback recovery failed; service remains stopped' >&2
|
||||
fi
|
||||
fi
|
||||
if [[ -n "$ORCA_ROLLBACK_BINARY_STAGED" ]]; then
|
||||
sudo rm -f -- "$ORCA_ROLLBACK_BINARY_STAGED"
|
||||
fi
|
||||
if [[ -n "$ORCA_ROLLBACK_VERSION_STAGED" ]]; then
|
||||
sudo rm -f -- "$ORCA_ROLLBACK_VERSION_STAGED"
|
||||
fi
|
||||
sudo rm -rf -- "$ORCA_RESTORE"
|
||||
exit "$exit_status"
|
||||
}
|
||||
trap restart_after_rollback_error EXIT
|
||||
|
||||
if [[ "$(sudo stat -c %d "$ORCA_RESTORE")" != \
|
||||
"$(sudo stat -c %d /home/orca/.config)" ]]; then
|
||||
echo 'Refusing rollback because staging and the Orca profile are on different filesystems' >&2
|
||||
exit 1
|
||||
fi
|
||||
sudo tar xzf "$ORCA_ROLLBACK/profile.tgz" -C "$ORCA_RESTORE"
|
||||
ORCA_RESTORE_DIRS=()
|
||||
for profile_dir in orca Orca; do
|
||||
if sudo test -L "$ORCA_RESTORE/$profile_dir"; then
|
||||
echo "Rollback bundle contains a symlinked profile: $profile_dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
if sudo test -d "$ORCA_RESTORE/$profile_dir"; then
|
||||
if [[ "$profile_dir" == Orca ]] && \
|
||||
sudo test "$ORCA_RESTORE/orca" -ef "$ORCA_RESTORE/Orca"; then
|
||||
continue
|
||||
fi
|
||||
ORCA_RESTORE_DIRS+=("$profile_dir")
|
||||
fi
|
||||
done
|
||||
if ((${#ORCA_RESTORE_DIRS[@]} == 0)); then
|
||||
echo "Rollback bundle has no Orca profile directories: $ORCA_ROLLBACK" >&2
|
||||
exit 1
|
||||
fi
|
||||
for profile_dir in "${ORCA_RESTORE_DIRS[@]}"; do
|
||||
sudo chown -R orca:orca "$ORCA_RESTORE/$profile_dir"
|
||||
done
|
||||
|
||||
ORCA_ROLLBACK_STAMP=$(date +%F-%H%M%S-%N)
|
||||
ORCA_ROLLBACK_BINARY_STAGED=/opt/orca/orca-linux.AppImage.rollback-staged-$ORCA_ROLLBACK_STAMP
|
||||
sudo cp -a "$ORCA_ROLLBACK/orca-linux.AppImage" "$ORCA_ROLLBACK_BINARY_STAGED"
|
||||
if sudo test -f "$ORCA_ROLLBACK/VERSION"; then
|
||||
ORCA_ROLLBACK_HAS_VERSION=1
|
||||
ORCA_ROLLBACK_VERSION_STAGED=/opt/orca/VERSION.rollback-staged-$ORCA_ROLLBACK_STAMP
|
||||
sudo cp -a "$ORCA_ROLLBACK/VERSION" "$ORCA_ROLLBACK_VERSION_STAGED"
|
||||
fi
|
||||
|
||||
ORCA_SERVICE_STOPPED=1
|
||||
sudo systemctl stop orca-serve.service
|
||||
|
||||
# Preserve and replace only Orca-owned profile directories
|
||||
ORCA_CURRENT_DIRS=()
|
||||
for profile_dir in orca Orca; do
|
||||
if sudo test -L "/home/orca/.config/$profile_dir"; then
|
||||
echo "Refusing symlinked Orca profile: /home/orca/.config/$profile_dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
if sudo test -d "/home/orca/.config/$profile_dir"; then
|
||||
if [[ "$profile_dir" == Orca ]] && \
|
||||
sudo test /home/orca/.config/orca -ef /home/orca/.config/Orca; then
|
||||
continue
|
||||
fi
|
||||
ORCA_CURRENT_DIRS+=("$profile_dir")
|
||||
fi
|
||||
done
|
||||
ORCA_POST_UPGRADE=/home/orca/.config/orca-rollback-$ORCA_ROLLBACK_STAMP
|
||||
sudo install -d -o orca -g orca -m 700 "$ORCA_POST_UPGRADE"
|
||||
if ((${#ORCA_CURRENT_DIRS[@]})); then
|
||||
for profile_dir in "${ORCA_CURRENT_DIRS[@]}"; do
|
||||
ORCA_MOVED_CURRENT_DIRS+=("$profile_dir")
|
||||
sudo mv "/home/orca/.config/$profile_dir" "$ORCA_POST_UPGRADE/"
|
||||
done
|
||||
fi
|
||||
for profile_dir in "${ORCA_RESTORE_DIRS[@]}"; do
|
||||
ORCA_INSTALLED_RESTORE_DIRS+=("$profile_dir")
|
||||
sudo mv "$ORCA_RESTORE/$profile_dir" /home/orca/.config/
|
||||
done
|
||||
|
||||
ORCA_CURRENT_BINARY=/opt/orca/orca-linux.AppImage.rollback-current-$ORCA_ROLLBACK_STAMP
|
||||
ORCA_CURRENT_BINARY_MOVED=1
|
||||
sudo mv /opt/orca/orca-linux.AppImage "$ORCA_CURRENT_BINARY"
|
||||
sudo mv -f "$ORCA_ROLLBACK_BINARY_STAGED" /opt/orca/orca-linux.AppImage
|
||||
|
||||
ORCA_CURRENT_VERSION=/opt/orca/VERSION.rollback-current-$ORCA_ROLLBACK_STAMP
|
||||
if sudo test -f /opt/orca/VERSION; then
|
||||
ORCA_CURRENT_VERSION_MOVED=1
|
||||
sudo mv /opt/orca/VERSION "$ORCA_CURRENT_VERSION"
|
||||
fi
|
||||
ORCA_VERSION_REPLACEMENT_STARTED=1
|
||||
if ((ORCA_ROLLBACK_HAS_VERSION)); then
|
||||
sudo mv -f "$ORCA_ROLLBACK_VERSION_STAGED" /opt/orca/VERSION
|
||||
else
|
||||
sudo rm -f /opt/orca/VERSION
|
||||
fi
|
||||
sudo systemctl start orca-serve.service
|
||||
ORCA_SERVICE_STOPPED=0
|
||||
sudo rm -rf -- "$ORCA_RESTORE"
|
||||
trap - EXIT
|
||||
```
|
||||
|
||||
Restoring the backup is required, not optional: swapping only the binary leaves
|
||||
the newer `orca-data.json` in place, where an older build can discard state it
|
||||
does not understand. Keep the pre-upgrade backup until the new version is proven
|
||||
on your host. The `orca-rollback-*` directory inside `.config` is also retained
|
||||
deliberately. The post-upgrade binary and version record are retained in
|
||||
`/opt/orca` with the same `rollback-current-<timestamp>` suffix. Inspect these
|
||||
artifacts and remove them according to your retention policy after the rollback
|
||||
is resolved.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- `dlopen(): error loading libfuse.so.2`: install `libfuse2`.
|
||||
@@ -178,6 +611,9 @@ depend on an interactive shell profile.
|
||||
`orca` user and that `/opt/orca` is readable by that user.
|
||||
- Clients cannot connect: make sure `--pairing-address` is an address reachable
|
||||
from the client, and make sure firewalls allow the selected `--port`.
|
||||
- Service crash-loops right after an upgrade: use [Roll back](#roll-back) with
|
||||
the pre-upgrade `.ready` bundle. Do not rerun the upgrade first; doing so would
|
||||
make the crashing version the next rollback binary.
|
||||
- Diagnosing other missing libraries: extract the AppImage without launching it
|
||||
with `./orca-linux.AppImage --appimage-extract`, then run
|
||||
`ldd squashfs-root/orca` to list any shared libraries the host is missing.
|
||||
|
||||
+36
-14
@@ -2,7 +2,26 @@
|
||||
|
||||
Date: 2026-07-19
|
||||
|
||||
Status: Final decision; implementation pending
|
||||
Status: Implemented with the 2026-07-20 ownership amendment below
|
||||
|
||||
## 2026-07-20 Ownership Amendment
|
||||
|
||||
Fresh paired-runtime evidence showed that the display/controller platform is not a safe proxy for
|
||||
the PTY backend. A macOS client can attach to a native ConPTY owned by a paired Windows runtime, so
|
||||
renderer-side local/SSH/remote heuristics can transfer OSC 10/11 authority to the wrong responder.
|
||||
|
||||
The PTY-owning process now classifies the backend from its own platform and the shell that actually
|
||||
won spawn: `windows-conpty`, `windows-wsl`, or `posix-pty`. Native ConPTY consumes complete OSC 10/11
|
||||
queries before model, replay, or view delivery. During the bounded startup window it replies once
|
||||
when validated theme colors are available; after the deadline, or without colors, it consumes the
|
||||
query without replying. A consuming-view handshake does not transfer this authority. Split query
|
||||
candidates remain bounded and private across authority-close, expiry, and snapshot barriers; a
|
||||
candidate that proves malformed is released unchanged.
|
||||
|
||||
WSL and POSIX PTYs continue to transfer authority to the normal visible/hidden responder after the
|
||||
startup window. The same owner-side rule applies to local, daemon, SSH-relay, and paired-runtime
|
||||
PTYs. This amendment supersedes contrary transfer/fallback language below; the echo projection is
|
||||
selected only by the authoritative owner backend, never by renderer or connection metadata.
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -31,13 +50,13 @@ the failure requires an additional agent, timing, input-mode, or replay conditio
|
||||
|
||||
## Root Cause
|
||||
|
||||
The startup OSC responder currently runs in Electron main only after runtime ingestion
|
||||
([`pty.ts`](../../../src/main/ipc/pty.ts)). `LocalPtyProvider` calls the runtime before its public
|
||||
data listeners ([`local-pty-provider.ts`](../../../src/main/providers/local-pty-provider.ts)), while
|
||||
daemon `Session` advances sequence state, writes its emulator, persists pending output, and fans out
|
||||
data before Electron main receives it ([`session.ts`](../../../src/main/daemon/session.ts)). A
|
||||
renderer-only filter can therefore hide the symptom without removing it from the authoritative
|
||||
model, daemon history, snapshots, or remote delivery.
|
||||
OSC responders downstream of the PTY owner do not know whether the bytes came from native ConPTY,
|
||||
WSL, or a POSIX PTY. `LocalPtyProvider` calls the runtime before its public data listeners
|
||||
([`local-pty-provider.ts`](../../../src/main/providers/local-pty-provider.ts)), while daemon `Session`
|
||||
advances sequence state, writes its emulator, persists pending output, and fans out data before
|
||||
Electron main receives it ([`session.ts`](../../../src/main/daemon/session.ts)). A renderer-only
|
||||
filter can therefore misclassify paired runtimes and hide the symptom without removing it from the
|
||||
authoritative model, daemon history, snapshots, or remote delivery.
|
||||
|
||||
The corrupted echo is timing-dependent but its ordering failure is deterministic: any sanitizer
|
||||
downstream of an authoritative consumer is too late. The independent `[I` symptom has not yet met
|
||||
@@ -228,14 +247,15 @@ When registered for an agent spawn, it:
|
||||
hidden model nor a delivered renderer can answer it a second time;
|
||||
6. begins echo recognition as soon as each individual reply is written.
|
||||
|
||||
If attributes or a provider are unavailable, the query is not consumed. It passes through unchanged
|
||||
to normal query authority. A reattach never registers startup response state, matching current
|
||||
behavior.
|
||||
If attributes or a provider are unavailable, native ConPTY consumes the query without replying;
|
||||
WSL and POSIX PTYs pass it through unchanged to normal query authority. A reattach never registers
|
||||
startup response state, matching current behavior, but native ConPTY ownership still prevents a
|
||||
downstream reply.
|
||||
|
||||
### Exact authority transfer
|
||||
|
||||
Startup query interception opens only for a fresh session whose atomic creation installed the
|
||||
transaction before buffered output release. It closes at the first of:
|
||||
Startup query response authority opens only for a fresh session whose atomic creation installed the
|
||||
transaction before buffered output release. For WSL and POSIX PTYs it closes at the first of:
|
||||
|
||||
- both OSC 10 and OSC 11 slots have been answered;
|
||||
- the startup deadline expires;
|
||||
@@ -243,7 +263,9 @@ transaction before buffered output release. It closes at the first of:
|
||||
hidden-runtime ownership mark is established;
|
||||
- the spawn fails, is cancelled, reattaches, or exits.
|
||||
|
||||
Closing query interception does not discard already-written reply candidates. Echo recognition has
|
||||
Native ConPTY does not transfer OSC 10/11 authority at those boundaries: the deadline stops source
|
||||
replies, while complete queries remain consumed for the life of the PTY. Closing response authority
|
||||
does not discard already-written reply candidates. Echo recognition has
|
||||
its own bounded lifetime and may finish or drain after normal authority takes over. A close and a
|
||||
provider callback are ordered by the source owner's per-PTY ingress queue. Transport attachment to
|
||||
a daemon/relay client is not a consuming-view signal. Main sends the close over a versioned control
|
||||
|
||||
@@ -239,6 +239,19 @@ export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
worker: {
|
||||
format: 'es'
|
||||
},
|
||||
build: {
|
||||
// Why: the pop-out dashboard is a second top-level window with its own
|
||||
// React root. It gets its own HTML entry so it can boot independently of
|
||||
// the main window while reusing the same preload/window.api. `index` must
|
||||
// stay listed — overriding input otherwise drops electron-vite's default
|
||||
// renderer entry.
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve('src/renderer/index.html'),
|
||||
popout: resolve('src/renderer/popout.html')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -26,7 +26,8 @@ import {
|
||||
Filter,
|
||||
Check,
|
||||
UserCircle,
|
||||
PanelLeftClose
|
||||
PanelLeftClose,
|
||||
SquareTerminal
|
||||
} from 'lucide-react-native'
|
||||
import type { RpcClient } from '../../../src/transport/rpc-client'
|
||||
import { loadHosts, updateLastConnected } from '../../../src/transport/host-store'
|
||||
@@ -57,6 +58,7 @@ import type { RepoIcon } from '../../../../src/shared/repo-icon'
|
||||
import { PickerModal } from '../../../src/components/PickerModal'
|
||||
import { ActionSheetContent } from '../../../src/components/ActionSheetModal'
|
||||
import { buildWorktreeNavigationActions } from '../../../src/agent-history/worktree-navigation-actions'
|
||||
import { floatingWorkspaceSessionPath } from '../../../src/session/floating-workspace'
|
||||
import { ConfirmModal } from '../../../src/components/ConfirmModal'
|
||||
import { BottomDrawer } from '../../../src/components/BottomDrawer'
|
||||
import { ProtocolBlockScreen } from '../../../src/components/ProtocolBlockScreen'
|
||||
@@ -68,7 +70,7 @@ import { setCachedRepos } from '../../../src/cache/repo-cache'
|
||||
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
|
||||
import { useResponsiveLayout } from '../../../src/layout/responsive-layout'
|
||||
import { leaveHostRoute } from '../../../src/host-route-exit'
|
||||
import { evaluateCompat, type CompatVerdict } from '../../../src/transport/protocol-compat'
|
||||
import { useHostStatusGates } from '../../../src/transport/host-status-gates'
|
||||
import { loadPinnedIds, savePinnedIds } from '../../../src/storage/preferences'
|
||||
import {
|
||||
createInitialHostRouteActionState,
|
||||
@@ -97,7 +99,7 @@ import {
|
||||
WORKSPACE_GROUP_OPTIONS as GROUP_OPTIONS,
|
||||
WORKSPACE_SORT_OPTIONS as SORT_OPTIONS
|
||||
} from '../../../src/worktree/workspace-list-picker-options'
|
||||
import type { DesktopStatus, RepoSummary } from '../../../src/worktree/host-worktree-rpc-types'
|
||||
import type { RepoSummary } from '../../../src/worktree/host-worktree-rpc-types'
|
||||
import type { WorkspaceStatusDefinition } from '../../../../src/shared/types'
|
||||
import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from '../../../src/worktree/mobile-workspace-statuses'
|
||||
|
||||
@@ -155,7 +157,6 @@ export function HostScreen({
|
||||
const [repoIconsByName, setRepoIconsByName] = useState<Map<string, RepoIcon>>(new Map())
|
||||
const [hostName, setHostName] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [compatVerdict, setCompatVerdict] = useState<CompatVerdict>({ kind: 'ok' })
|
||||
const [lastKnownWorktrees, setLastKnownWorktrees] = useState<Worktree[]>(initialCache ?? [])
|
||||
const [search, setSearch] = useState('')
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
@@ -175,7 +176,11 @@ export function HostScreen({
|
||||
const [showGroupPicker, setShowGroupPicker] = useState(false)
|
||||
const [showFilterModal, setShowFilterModal] = useState(false)
|
||||
const [actionTarget, setActionTarget] = useState<Worktree | null>(null)
|
||||
const [hostCapabilities, setHostCapabilities] = useState<string[]>([])
|
||||
const { hostCapabilities, floatingWorkspaceEnabled, compatVerdict } = useHostStatusGates({
|
||||
hostId,
|
||||
client,
|
||||
connState
|
||||
})
|
||||
const [confirmDelete, setConfirmDelete] = useState<Worktree | null>(null)
|
||||
const [confirmRemoveHost, setConfirmRemoveHost] = useState(false)
|
||||
const [routeActionState, setRouteActionState] = useState(() =>
|
||||
@@ -315,7 +320,6 @@ export function HostScreen({
|
||||
useEffect(() => {
|
||||
setHostName('')
|
||||
setError('')
|
||||
setCompatVerdict({ kind: 'ok' })
|
||||
setRepoColorsByName(new Map())
|
||||
setRepoIconsByName(new Map())
|
||||
repoMetadataFetchedAtRef.current = 0
|
||||
@@ -483,52 +487,6 @@ export function HostScreen({
|
||||
[client, connState, hostId]
|
||||
)
|
||||
|
||||
// Why: re-evaluate protocol compat on connect; today's constants are wide-open so this never blocks yet.
|
||||
useEffect(() => {
|
||||
if (connState !== 'connected' || !client) {
|
||||
// Why: drop capabilities while disconnected/switching so a capability-gated action can't linger for a new host.
|
||||
setHostCapabilities([])
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
const requestClient = client
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await requestClient.sendRequest('status.get')
|
||||
if (cancelled || clientRef.current !== requestClient) {
|
||||
return
|
||||
}
|
||||
if (!response.ok) {
|
||||
setHostCapabilities([])
|
||||
return
|
||||
}
|
||||
const status = (response as RpcSuccess).result as DesktopStatus & {
|
||||
capabilities?: string[]
|
||||
}
|
||||
setHostCapabilities(status.capabilities ?? [])
|
||||
const verdict = evaluateCompat({
|
||||
desktopProtocolVersion: status.protocolVersion,
|
||||
desktopMinCompatibleMobileVersion: status.minCompatibleMobileVersion
|
||||
})
|
||||
setCompatVerdict(verdict)
|
||||
if (verdict.kind === 'blocked') {
|
||||
// Why: support breadcrumb to confirm a block fired vs a render bug; no PII, just version ints.
|
||||
console.warn('[protocol-compat] blocked', {
|
||||
reason: verdict.reason,
|
||||
desktopVersion: verdict.desktopVersion,
|
||||
requiredMobileVersion: verdict.requiredMobileVersion,
|
||||
requiredDesktopVersion: verdict.requiredDesktopVersion
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Why: sendRequest can throw on transport tear-down; treat as transient, keep the prior verdict.
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [connState, client])
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
// Why: focus nudges reconnect and probes a possibly half-open socket; empty deps fire per focus, not per state flip (which defeats backoff).
|
||||
@@ -674,8 +632,7 @@ export function HostScreen({
|
||||
router.push(target)
|
||||
return
|
||||
}
|
||||
const targetPath = target.split('?')[0] ?? target
|
||||
if (pathname === targetPath) {
|
||||
if (pathname === (target.split('?')[0] ?? target)) {
|
||||
return
|
||||
}
|
||||
if (pathname === `/h/${hostId}`) {
|
||||
@@ -691,11 +648,11 @@ export function HostScreen({
|
||||
(item: Worktree) => {
|
||||
setOptimisticActiveWorktreeId(item.worktreeId)
|
||||
if (client && connState === 'connected') {
|
||||
// Why: notifyClients:false hydrates host tabs without pulling desktop into this worktree.
|
||||
void client
|
||||
.sendRequest('worktree.activate', {
|
||||
worktree: `id:${item.worktreeId}`,
|
||||
notifyClients: false
|
||||
notifyClients: false,
|
||||
navigation: 'caller'
|
||||
})
|
||||
.catch(() => null)
|
||||
}
|
||||
@@ -705,6 +662,12 @@ export function HostScreen({
|
||||
[client, connState, hostId, navigateFromHostList]
|
||||
)
|
||||
|
||||
const openFloatingWorkspace = useCallback(() => {
|
||||
// Why: no worktree.activate here — the floating sentinel has no worktree
|
||||
// record; session.tabs.list hydrates its host-owned tabs on open.
|
||||
navigateFromHostList(floatingWorkspaceSessionPath(hostId))
|
||||
}, [hostId, navigateFromHostList])
|
||||
|
||||
const handleSortChange = useCallback(
|
||||
(value: MobileSortMode) => {
|
||||
persistViewSettings({ sortMode: value })
|
||||
@@ -870,6 +833,24 @@ export function HostScreen({
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
{!embedded && floatingWorkspaceEnabled ? (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.floatingWorkspaceHeaderButton,
|
||||
connState !== 'connected' && styles.toolbarIconDisabled
|
||||
]}
|
||||
onPress={openFloatingWorkspace}
|
||||
disabled={connState !== 'connected'}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Floating Workspace"
|
||||
hitSlop={8}
|
||||
>
|
||||
<SquareTerminal
|
||||
size={18}
|
||||
color={connState === 'connected' ? colors.textPrimary : colors.textMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{embedded && onHideSidebar ? (
|
||||
<Pressable
|
||||
style={styles.sidebarCollapseButton}
|
||||
@@ -976,6 +957,24 @@ export function HostScreen({
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
{floatingWorkspaceEnabled ? (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.embeddedToolbarIconButton,
|
||||
connState !== 'connected' && styles.toolbarIconDisabled
|
||||
]}
|
||||
onPress={openFloatingWorkspace}
|
||||
disabled={connState !== 'connected'}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Floating Workspace"
|
||||
>
|
||||
<SquareTerminal
|
||||
size={18}
|
||||
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
<Pressable
|
||||
style={[
|
||||
styles.embeddedToolbarIconButton,
|
||||
@@ -1554,12 +1553,12 @@ const styles = StyleSheet.create({
|
||||
toolbarSpacer: {
|
||||
flex: 1
|
||||
},
|
||||
toolbarIconButton: {
|
||||
floatingWorkspaceHeaderButton: {
|
||||
width: 32,
|
||||
height: 28,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button
|
||||
marginLeft: spacing.xs
|
||||
},
|
||||
embeddedToolbarIconButton: {
|
||||
flex: 1,
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
panelRouteDescriptor
|
||||
} from '../../../../src/session/session-panel-host'
|
||||
import { useMobilePrBranchContext } from '../../../../src/session/use-mobile-pr-branch-context'
|
||||
import { isFloatingWorkspaceWorktreeId } from '../../../../src/session/floating-workspace'
|
||||
import { SessionDockColumn } from '../../../../src/session/SessionDockColumn'
|
||||
import { MobileSessionHeaderIconButton } from '../../../../src/session/MobileSessionHeaderIconButton'
|
||||
import { MobileSessionHeaderMoreActionsSheet } from '../../../../src/session/MobileSessionHeaderMoreActionsSheet'
|
||||
@@ -171,11 +172,8 @@ import {
|
||||
getMobileSessionTabTitle,
|
||||
resolveMobileTerminalTabAgentId
|
||||
} from '../../../../src/session/mobile-terminal-tab-agent'
|
||||
import {
|
||||
buildMobileNewTabAgentOptions,
|
||||
type MobileNewTabAgentOption,
|
||||
type MobileNewTabAgentSettings
|
||||
} from '../../../../src/session/mobile-new-tab-agent-options'
|
||||
import type { MobileNewTabAgentOption } from '../../../../src/session/mobile-new-tab-agent-options'
|
||||
import { loadMobileNewTabAgentOptions } from '../../../../src/session/mobile-new-tab-agent-loader'
|
||||
import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment'
|
||||
import { useMobileAttachmentInputLeaseGate } from '../../../../src/session/use-mobile-attachment-input-lease-gate'
|
||||
import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste'
|
||||
@@ -211,10 +209,7 @@ import { getMobileTerminalActionSheetActions } from '../../../../src/session/mob
|
||||
import * as nativeChatTerminalStream from '../../../../src/session/mobile-native-chat-terminal-stream'
|
||||
import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-mobile-native-chat-terminal-stream'
|
||||
import { subscribeMobileTerminalSafely } from '../../../../src/session/mobile-terminal-stream-subscribe'
|
||||
import {
|
||||
activateMobileSessionTab,
|
||||
focusMobileTerminal
|
||||
} from '../../../../src/session/mobile-session-tab-activation'
|
||||
import { activateMobileSessionTab } from '../../../../src/session/mobile-session-tab-activation'
|
||||
import { MobileTerminalDiagnostics } from '../../../../src/session/mobile-terminal-diagnostics'
|
||||
import {
|
||||
getRepoIdFromMobileWorktreeId,
|
||||
@@ -816,6 +811,8 @@ export default function SessionScreen() {
|
||||
warning?: string
|
||||
}>()
|
||||
const isFolderWorkspaceRoute = worktreeId.startsWith('folder:') // Synthetic ids have no repo scope.
|
||||
// Why: the floating sentinel has no repo/worktree, so repo-backed surfaces hide.
|
||||
const isFloatingWorkspaceRoute = isFloatingWorkspaceWorktreeId(worktreeId)
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
// Why: shared client per host owned by RpcClientProvider (docs/mobile-shared-client-per-host.md).
|
||||
@@ -833,11 +830,13 @@ export default function SessionScreen() {
|
||||
const { isWideLayout } = useResponsiveLayout()
|
||||
const [activePanel, setActivePanel] = useState<ActivePanel>(null)
|
||||
const [sessionContentRowWidth, setSessionContentRowWidth] = useState(0)
|
||||
const canDockPanel = canDockSessionPanel({
|
||||
isWideLayout,
|
||||
availableWidth: sessionContentRowWidth,
|
||||
dockWidth: HOST_DOCK_MIN_WIDTH
|
||||
})
|
||||
const canDockPanel =
|
||||
!isFloatingWorkspaceRoute &&
|
||||
canDockSessionPanel({
|
||||
isWideLayout,
|
||||
availableWidth: sessionContentRowWidth,
|
||||
dockWidth: HOST_DOCK_MIN_WIDTH
|
||||
})
|
||||
// Why: if rotation/split-screen makes the docked row too narrow, clear activePanel so it doesn't survive into overlay/push mode.
|
||||
useEffect(() => {
|
||||
if (!canDockPanel && activePanel !== null) {
|
||||
@@ -847,7 +846,9 @@ export default function SessionScreen() {
|
||||
// GitHub remote probe gates the PR dock icon so non-GitHub providers can't open the hosted-review surface; skip the unused identity RPCs.
|
||||
const { isGithubRepo: prIsGithubRepo, repoLoaded: prRepoContextLoaded } =
|
||||
useMobilePrBranchContext({
|
||||
client,
|
||||
// Why: a null client parks the hook in its not-ready state — the floating
|
||||
// sentinel has no repo to probe.
|
||||
client: isFloatingWorkspaceRoute ? null : client,
|
||||
connState,
|
||||
worktreeId,
|
||||
includeBranchIdentity: false
|
||||
@@ -1933,7 +1934,7 @@ export default function SessionScreen() {
|
||||
)
|
||||
|
||||
const loadDiffComments = useCallback(async (): Promise<void> => {
|
||||
if (!client || connState !== 'connected' || !worktreeId) {
|
||||
if (!client || connState !== 'connected' || !worktreeId || isFloatingWorkspaceRoute) {
|
||||
setDiffComments([])
|
||||
return
|
||||
}
|
||||
@@ -1947,7 +1948,7 @@ export default function SessionScreen() {
|
||||
worktree?: { diffComments?: unknown }
|
||||
}
|
||||
setDiffComments(normalizeMobileDiffComments(result.worktree?.diffComments, worktreeId))
|
||||
}, [client, connState, worktreeId])
|
||||
}, [client, connState, worktreeId, isFloatingWorkspaceRoute])
|
||||
|
||||
const persistDiffComments = useCallback(
|
||||
async (comments: readonly DiffComment[]): Promise<void> => {
|
||||
@@ -2621,12 +2622,13 @@ export default function SessionScreen() {
|
||||
showToast('Open Orca on the host to wake sleeping agents.', 3000)
|
||||
}
|
||||
}
|
||||
if (client && created !== '1') {
|
||||
if (client && created !== '1' && !isFloatingWorkspaceRoute) {
|
||||
// Why: hydrate host-owned tabs without pulling other paired clients (esp. desktop) into this worktree.
|
||||
void client
|
||||
.sendRequest('worktree.activate', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
notifyClients: false
|
||||
notifyClients: false,
|
||||
navigation: 'caller'
|
||||
})
|
||||
.then((response) => reportActivationOutcome(response.ok ? response : null))
|
||||
.catch(() => null)
|
||||
@@ -2644,7 +2646,7 @@ export default function SessionScreen() {
|
||||
}
|
||||
addTimer(() => void fetchTerminals({ allowEmptyLoaded: false }), 750)
|
||||
addTimer(() => void fetchTerminals({ allowEmptyLoaded: true }), 1500)
|
||||
if (client && created === '1') {
|
||||
if (client && created === '1' && !isFloatingWorkspaceRoute) {
|
||||
addTimer(() => {
|
||||
if (activeHandleRef.current) {
|
||||
return
|
||||
@@ -2653,7 +2655,8 @@ export default function SessionScreen() {
|
||||
const activationResponse = await client
|
||||
.sendRequest('worktree.activate', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
notifyClients: false
|
||||
notifyClients: false,
|
||||
navigation: 'caller'
|
||||
})
|
||||
.catch(() => null)
|
||||
reportActivationOutcome(activationResponse?.ok ? activationResponse : null)
|
||||
@@ -2672,7 +2675,16 @@ export default function SessionScreen() {
|
||||
clearTimeout(t)
|
||||
}
|
||||
}
|
||||
}, [client, connState, created, fetchSessionTabs, fetchTerminals, showToast, worktreeId])
|
||||
}, [
|
||||
client,
|
||||
connState,
|
||||
created,
|
||||
fetchSessionTabs,
|
||||
fetchTerminals,
|
||||
isFloatingWorkspaceRoute,
|
||||
showToast,
|
||||
worktreeId
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || connState !== 'connected') {
|
||||
@@ -2767,7 +2779,7 @@ export default function SessionScreen() {
|
||||
}, [])
|
||||
)
|
||||
|
||||
// Why: unsubscribe restores old dims (clears phone-fit banner); resubscribe phone-fits the new one; terminal.focus makes desktop follow.
|
||||
// Why: unsubscribe restores old dims (clears phone-fit banner); resubscribe phone-fits the new one.
|
||||
const switchTab = useCallback(
|
||||
(handle: string) => {
|
||||
triggerSelection()
|
||||
@@ -2795,13 +2807,12 @@ export default function SessionScreen() {
|
||||
}
|
||||
subscribeToTerminal(handle)
|
||||
if (client) {
|
||||
void focusMobileTerminal(client, handle).catch(() => {})
|
||||
if (matchingTab) {
|
||||
// Why: persist selection for headless hosts; snapshot gate stops this phone-local ack from impersonating desktop focus.
|
||||
void activateMobileSessionTab(client, {
|
||||
worktree: `id:${worktreeId}`,
|
||||
tabId: matchingTab.id,
|
||||
notifyClients: false
|
||||
notifyClients: false,
|
||||
navigation: 'caller'
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
@@ -2840,7 +2851,8 @@ export default function SessionScreen() {
|
||||
void activateMobileSessionTab(client, {
|
||||
worktree: `id:${worktreeId}`,
|
||||
tabId: tab.id,
|
||||
notifyClients: false
|
||||
notifyClients: false,
|
||||
navigation: 'caller'
|
||||
}).catch(() => {})
|
||||
}
|
||||
return
|
||||
@@ -2863,7 +2875,8 @@ export default function SessionScreen() {
|
||||
void activateMobileSessionTab(client, {
|
||||
worktree: `id:${worktreeId}`,
|
||||
tabId: tab.id,
|
||||
notifyClients: false
|
||||
notifyClients: false,
|
||||
navigation: 'caller'
|
||||
}).catch(() => {})
|
||||
}
|
||||
if (tab.type === 'browser') {
|
||||
@@ -3231,13 +3244,15 @@ export default function SessionScreen() {
|
||||
if (handle !== activeHandleRef.current) {
|
||||
return
|
||||
}
|
||||
if (terminalLinkOpenMode === 'phone-browser') {
|
||||
// Why: browser.tabCreate resolves a real worktree, which the floating
|
||||
// sentinel doesn't have — open taps in the phone browser instead.
|
||||
if (terminalLinkOpenMode === 'phone-browser' || isFloatingWorkspaceRoute) {
|
||||
void Linking.openURL(url).catch(() => {})
|
||||
return
|
||||
}
|
||||
void handleCreateBrowserRef.current?.(url)
|
||||
},
|
||||
[terminalLinkOpenMode]
|
||||
[terminalLinkOpenMode, isFloatingWorkspaceRoute]
|
||||
)
|
||||
|
||||
const toggleLiveInput = useCallback(() => {
|
||||
@@ -3575,7 +3590,8 @@ export default function SessionScreen() {
|
||||
}, [])
|
||||
|
||||
const getActiveWorktreeConnectionId = useCallback(async (): Promise<string | null> => {
|
||||
if (!client) {
|
||||
// Why: the floating workspace always runs on the paired host itself, never an SSH repo target.
|
||||
if (!client || isFloatingWorkspaceRoute) {
|
||||
return null
|
||||
}
|
||||
const repoId = getRepoIdFromMobileWorktreeId(worktreeId)
|
||||
@@ -3586,7 +3602,7 @@ export default function SessionScreen() {
|
||||
const repos =
|
||||
((repoResponse as RpcSuccess).result as { repos?: RuntimeRepoSummary[] }).repos ?? []
|
||||
return repos.find((repo) => repo.id === repoId)?.connectionId?.trim() || null
|
||||
}, [client, worktreeId])
|
||||
}, [client, isFloatingWorkspaceRoute, worktreeId])
|
||||
|
||||
const refreshCanPaste = useCallback(() => {
|
||||
void Promise.all([
|
||||
@@ -3683,43 +3699,14 @@ export default function SessionScreen() {
|
||||
setCreateTabAgentOptions([])
|
||||
|
||||
void (async () => {
|
||||
const [settingsResponse, repoResponse] = await Promise.all([
|
||||
client.sendRequest('settings.get'),
|
||||
client.sendRequest('repo.list')
|
||||
])
|
||||
if (!settingsResponse.ok) {
|
||||
throw new Error((settingsResponse as RpcFailure).error.message)
|
||||
}
|
||||
const settings = (
|
||||
(settingsResponse as RpcSuccess).result as {
|
||||
settings?: MobileNewTabAgentSettings
|
||||
}
|
||||
).settings
|
||||
if (!repoResponse.ok) {
|
||||
throw new Error((repoResponse as RpcFailure).error.message)
|
||||
}
|
||||
const repoId = getRepoIdFromMobileWorktreeId(worktreeId)
|
||||
if (!repoId) {
|
||||
throw new Error('worktree_repo_missing')
|
||||
}
|
||||
const repos =
|
||||
((repoResponse as RpcSuccess).result as { repos?: RuntimeRepoSummary[] }).repos ?? []
|
||||
const repo = repos.find((candidate) => candidate.id === repoId)
|
||||
if (!repo) {
|
||||
throw new Error('worktree_repo_not_found')
|
||||
}
|
||||
const connectionId = repo.connectionId?.trim() || null
|
||||
const detectedResponse = connectionId
|
||||
? await client.sendRequest('preflight.detectRemoteAgents', { connectionId })
|
||||
: await client.sendRequest('preflight.detectAgents')
|
||||
if (!detectedResponse.ok) {
|
||||
throw new Error((detectedResponse as RpcFailure).error.message)
|
||||
}
|
||||
const options = await loadMobileNewTabAgentOptions({
|
||||
client,
|
||||
worktreeId
|
||||
})
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
const detectedAgentIds = (detectedResponse as RpcSuccess).result as unknown[]
|
||||
setCreateTabAgentOptions(buildMobileNewTabAgentOptions(settings, detectedAgentIds))
|
||||
setCreateTabAgentOptions(options)
|
||||
setCreateTabAgentLoadState('loaded')
|
||||
})().catch(() => {
|
||||
if (!stale) {
|
||||
@@ -3763,7 +3750,10 @@ export default function SessionScreen() {
|
||||
? { startupCommandDelivery: options.startupCommandDelivery }
|
||||
: {}),
|
||||
...(options?.agentPrompt ? { agentPrompt: options.agentPrompt } : {}),
|
||||
...(agent ? { agent } : {})
|
||||
...(agent ? { agent } : {}),
|
||||
activate: false,
|
||||
select: true,
|
||||
navigation: 'caller'
|
||||
})
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as TerminalCreateResult
|
||||
@@ -4158,7 +4148,8 @@ export default function SessionScreen() {
|
||||
worktree: `id:${worktreeId}`,
|
||||
tabId: activePendingTerminalTab.id,
|
||||
leafId: activePendingTerminalTab.leafId,
|
||||
notifyClients: false
|
||||
notifyClients: false,
|
||||
navigation: 'caller'
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
@@ -4380,9 +4371,9 @@ export default function SessionScreen() {
|
||||
router.push(`/h/${hostId}/agent-history/${encodeURIComponent(worktreeId)}?${params.toString()}`)
|
||||
}
|
||||
const showAgentSessionHistoryAction =
|
||||
!isFolderWorkspaceRoute && agentSessionHistorySupported === true
|
||||
!isFolderWorkspaceRoute && !isFloatingWorkspaceRoute && agentSessionHistorySupported === true
|
||||
const showChecksAction = shouldShowSessionHeaderChecksAction({
|
||||
isFolderWorkspaceRoute,
|
||||
isFolderWorkspaceRoute: isFolderWorkspaceRoute || isFloatingWorkspaceRoute,
|
||||
repoContextLoaded: prRepoContextLoaded,
|
||||
hostedChecksSupported: prIsGithubRepo
|
||||
})
|
||||
@@ -4423,13 +4414,15 @@ export default function SessionScreen() {
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<MobileSessionHeaderIconButton
|
||||
active={activePanel === 'files'}
|
||||
accessibilityLabel="Open file explorer"
|
||||
icon={Folder}
|
||||
onPress={() => handlePanelTap('files')}
|
||||
/>
|
||||
{!isFolderWorkspaceRoute && (
|
||||
{!isFloatingWorkspaceRoute && (
|
||||
<MobileSessionHeaderIconButton
|
||||
active={activePanel === 'files'}
|
||||
accessibilityLabel="Open file explorer"
|
||||
icon={Folder}
|
||||
onPress={() => handlePanelTap('files')}
|
||||
/>
|
||||
)}
|
||||
{!isFolderWorkspaceRoute && !isFloatingWorkspaceRoute && (
|
||||
<MobileSessionHeaderIconButton
|
||||
active={activePanel === 'sourceControl'}
|
||||
accessibilityLabel="Open source control"
|
||||
@@ -5075,7 +5068,11 @@ export default function SessionScreen() {
|
||||
visible={showQuickCommands && quickCommandsSupported === true}
|
||||
onClose={() => setShowQuickCommands(false)}
|
||||
client={client}
|
||||
repoId={isFolderWorkspaceRoute ? null : getRepoIdFromMobileWorktreeId(worktreeId) || null}
|
||||
repoId={
|
||||
isFolderWorkspaceRoute || isFloatingWorkspaceRoute
|
||||
? null
|
||||
: getRepoIdFromMobileWorktreeId(worktreeId) || null
|
||||
}
|
||||
repoName={worktreeName || null}
|
||||
onLaunch={launchQuickCommand}
|
||||
/>
|
||||
@@ -5093,26 +5090,33 @@ export default function SessionScreen() {
|
||||
void handleCreateTerminal()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Browser',
|
||||
icon: Globe,
|
||||
onPress: () => {
|
||||
setShowCreateTabDrawer(false)
|
||||
if (browserScreencastSupported !== true) {
|
||||
showToast('Desktop update required for mobile browser streaming', 1600)
|
||||
return
|
||||
}
|
||||
setShowCreateBrowserModal(true)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Markdown Note',
|
||||
icon: FileText,
|
||||
onPress: () => {
|
||||
setShowCreateTabDrawer(false)
|
||||
void handleCreateMarkdownNote()
|
||||
}
|
||||
}
|
||||
// Why: browser/markdown creation resolve a real worktree on the host
|
||||
// (browser.tabCreate, files.createFile); the floating sentinel is
|
||||
// terminal-only over RPC, so those options hide there.
|
||||
...(isFloatingWorkspaceRoute
|
||||
? []
|
||||
: [
|
||||
{
|
||||
label: 'Browser',
|
||||
icon: Globe,
|
||||
onPress: () => {
|
||||
setShowCreateTabDrawer(false)
|
||||
if (browserScreencastSupported !== true) {
|
||||
showToast('Desktop update required for mobile browser streaming', 1600)
|
||||
return
|
||||
}
|
||||
setShowCreateBrowserModal(true)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Markdown Note',
|
||||
icon: FileText,
|
||||
onPress: () => {
|
||||
setShowCreateTabDrawer(false)
|
||||
void handleCreateMarkdownNote()
|
||||
}
|
||||
}
|
||||
])
|
||||
]}
|
||||
onClose={() => setShowCreateTabDrawer(false)}
|
||||
/>
|
||||
|
||||
+130
-110
@@ -77,6 +77,15 @@ import {
|
||||
findRepoForGitHubProjectRepository,
|
||||
type GitHubRepoSlugCacheEntry
|
||||
} from '../../../src/tasks/github-project-repo-match'
|
||||
import {
|
||||
parseGitHubProjectInput as parseProjectInput,
|
||||
type GitHubProjectOwnerType,
|
||||
type GitHubProjectPartialFailure,
|
||||
type GitHubProjectRef,
|
||||
type GitHubProjectSettings,
|
||||
type GitHubProjectSummary,
|
||||
type GitHubProjectViewSummary
|
||||
} from '../../../src/tasks/github-project-reference'
|
||||
import {
|
||||
extractGitHubIssueSourceFallback,
|
||||
extractGitHubIssueSourceError,
|
||||
@@ -130,11 +139,16 @@ import {
|
||||
} from '../../../src/tasks/mobile-task-copy-feedback-timer'
|
||||
import type {
|
||||
BaseRefSearchResult,
|
||||
GitHubOwnerRepo,
|
||||
PersistedTrustedOrcaHooks,
|
||||
SparsePreset,
|
||||
TuiAgent
|
||||
} from '../../../../src/shared/types'
|
||||
import type { SshConnectionState } from '../../../../src/shared/ssh-types'
|
||||
import {
|
||||
githubProjectHost,
|
||||
githubProjectIdentityKey as githubProjectKey
|
||||
} from '../../../../src/shared/github-project-identity'
|
||||
|
||||
type RepoSummary = {
|
||||
id: string
|
||||
@@ -148,11 +162,6 @@ type RepoSummary = {
|
||||
|
||||
type IssueSourcePreference = 'upstream' | 'origin' | 'auto'
|
||||
|
||||
type GitHubOwnerRepo = {
|
||||
owner: string
|
||||
repo: string
|
||||
}
|
||||
|
||||
type GitHubWorkItem = {
|
||||
id: string
|
||||
type: 'issue' | 'pr'
|
||||
@@ -444,34 +453,6 @@ type LinearStatusResponse = {
|
||||
activeWorkspaceId?: string | null
|
||||
}
|
||||
|
||||
type GitHubProjectOwnerType = 'organization' | 'user'
|
||||
type GitHubProjectRef = {
|
||||
owner: string
|
||||
ownerType: GitHubProjectOwnerType
|
||||
number: number
|
||||
}
|
||||
type GitHubProjectSettings = {
|
||||
pinned: GitHubProjectRef[]
|
||||
recent: Array<GitHubProjectRef & { lastOpenedAt: string }>
|
||||
lastViewByProject: Record<string, { viewId: string }>
|
||||
activeProject: GitHubProjectRef | null
|
||||
}
|
||||
type GitHubProjectSummary = GitHubProjectRef & {
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
source: string
|
||||
}
|
||||
type GitHubProjectPartialFailure = {
|
||||
owner: string
|
||||
message: string
|
||||
}
|
||||
type GitHubProjectViewSummary = {
|
||||
id: string
|
||||
number: number
|
||||
name: string
|
||||
layout: 'TABLE_LAYOUT' | 'BOARD_LAYOUT' | 'ROADMAP_LAYOUT'
|
||||
}
|
||||
type GitHubIssueType = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -951,48 +932,6 @@ function githubKindFromQuery(query: string, fallbackPreset: GitHubPreset): GitHu
|
||||
: 'issues'
|
||||
}
|
||||
|
||||
function githubProjectKey(project: GitHubProjectRef): string {
|
||||
return `${project.ownerType}:${project.owner}:${project.number}`
|
||||
}
|
||||
|
||||
function parseProjectInput(
|
||||
input: string
|
||||
): { owner: string; number: number; viewNumber?: number } | null {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
const short = /^([A-Za-z0-9][A-Za-z0-9-]*)\/(\d+)$/.exec(trimmed)
|
||||
if (short) {
|
||||
return { owner: short[1]!, number: Number(short[2]) }
|
||||
}
|
||||
try {
|
||||
const url = new URL(trimmed)
|
||||
if (url.hostname !== 'github.com') {
|
||||
return null
|
||||
}
|
||||
const parts = url.pathname.split('/').filter(Boolean)
|
||||
if ((parts[0] === 'orgs' || parts[0] === 'users') && parts[2] === 'projects' && parts[3]) {
|
||||
const number = Number(parts[3])
|
||||
if (!Number.isInteger(number) || number < 1) {
|
||||
return null
|
||||
}
|
||||
const viewNumber =
|
||||
parts[4] === 'views' && parts[5] && Number.isInteger(Number(parts[5]))
|
||||
? Number(parts[5])
|
||||
: undefined
|
||||
return {
|
||||
owner: parts[1]!,
|
||||
number,
|
||||
...(viewNumber && viewNumber > 0 ? { viewNumber } : {})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function projectRowType(row: GitHubProjectRow): 'issue' | 'pr' | null {
|
||||
if (row.itemType === 'ISSUE') {
|
||||
return 'issue'
|
||||
@@ -1014,6 +953,11 @@ function splitRepositorySlug(slug: string | null): { owner: string; repo: string
|
||||
return owner && repo ? { owner, repo } : null
|
||||
}
|
||||
|
||||
function projectRowGitHubRepository(row: GitHubProjectRow, host: string): GitHubOwnerRepo | null {
|
||||
const slug = splitRepositorySlug(row.content.repository)
|
||||
return slug ? { ...slug, host } : null
|
||||
}
|
||||
|
||||
const GITHUB_PROJECT_OPTION_COLORS: Record<string, string> = {
|
||||
GRAY: '#8b949e',
|
||||
RED: '#f85149',
|
||||
@@ -2468,6 +2412,10 @@ export default function MobileTasksScreen() {
|
||||
tasksSupportState.kind === 'unsupported' &&
|
||||
tasksSupportState.client === client
|
||||
const taskUiReady = tasksSupported && taskStateHydrated
|
||||
const activeGitHubProject = githubProjectSettings.activeProject
|
||||
const activeGitHubProjectHost = githubProjectHost(
|
||||
githubProjectTable?.project.host ?? activeGitHubProject?.host
|
||||
)
|
||||
const hostedRepos = useMemo(() => repos.filter(isHostedTaskRepo), [repos])
|
||||
const workspaceRepos = useMemo(() => repos.filter((repo) => repo.kind !== 'folder'), [repos])
|
||||
const reposById = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos])
|
||||
@@ -2483,9 +2431,10 @@ export default function MobileTasksScreen() {
|
||||
findRepoForGitHubProjectRepository(
|
||||
row.content.repository,
|
||||
hostedRepos,
|
||||
githubRepoSlugCache
|
||||
githubRepoSlugCache,
|
||||
activeGitHubProjectHost
|
||||
) as RepoSummary | null,
|
||||
[githubRepoSlugCache, hostedRepos]
|
||||
[activeGitHubProjectHost, githubRepoSlugCache, hostedRepos]
|
||||
)
|
||||
const githubProjectRepoSlugReady = useMemo(
|
||||
() =>
|
||||
@@ -2501,10 +2450,11 @@ export default function MobileTasksScreen() {
|
||||
? (filterGitHubProjectRowsForRepos(
|
||||
githubProjectTable.rows,
|
||||
hostedRepos,
|
||||
githubRepoSlugCache
|
||||
githubRepoSlugCache,
|
||||
activeGitHubProjectHost
|
||||
) as GitHubProjectRow[])
|
||||
: [],
|
||||
[githubProjectTable, githubRepoSlugCache, hostedRepos]
|
||||
[activeGitHubProjectHost, githubProjectTable, githubRepoSlugCache, hostedRepos]
|
||||
)
|
||||
const visibleGitHubProjectGroups = useMemo<ProjectGroup[]>(() => {
|
||||
if (!githubProjectTable) {
|
||||
@@ -2599,10 +2549,10 @@ export default function MobileTasksScreen() {
|
||||
return {
|
||||
repoId: repo.id,
|
||||
path: repo.path,
|
||||
slug: result ? `${result.owner}/${result.repo}` : null
|
||||
repository: result
|
||||
}
|
||||
} catch {
|
||||
return { repoId: repo.id, path: repo.path, slug: null }
|
||||
return { repoId: repo.id, path: repo.path, repository: null }
|
||||
}
|
||||
}).then((entries) => {
|
||||
if (cancelled) {
|
||||
@@ -2611,7 +2561,7 @@ export default function MobileTasksScreen() {
|
||||
setGithubRepoSlugCache((current) => {
|
||||
const next = { ...current }
|
||||
for (const entry of entries) {
|
||||
next[entry.repoId] = { path: entry.path, slug: entry.slug }
|
||||
next[entry.repoId] = { path: entry.path, repository: entry.repository }
|
||||
}
|
||||
return next
|
||||
})
|
||||
@@ -2630,7 +2580,6 @@ export default function MobileTasksScreen() {
|
||||
taskStateHydrated,
|
||||
tasksSupported
|
||||
])
|
||||
const activeGitHubProject = githubProjectSettings.activeProject
|
||||
const activeGitHubProjectKey = activeGitHubProject ? githubProjectKey(activeGitHubProject) : null
|
||||
const activeGitHubProjectViewId = activeGitHubProjectKey
|
||||
? githubProjectSettings.lastViewByProject[activeGitHubProjectKey]?.viewId
|
||||
@@ -3738,7 +3687,9 @@ export default function MobileTasksScreen() {
|
||||
}
|
||||
setGithubProjectError('')
|
||||
setGithubProjectPartialFailures([])
|
||||
const response = await client.sendRequest('github.project.listAccessible', {})
|
||||
const response = await client.sendRequest('github.project.listAccessible', {
|
||||
host: 'github.com'
|
||||
})
|
||||
if (!isSuccess(response)) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
@@ -3763,6 +3714,7 @@ export default function MobileTasksScreen() {
|
||||
}
|
||||
const response = await client.sendRequest('github.project.listViews', {
|
||||
owner: project.owner,
|
||||
host: githubProjectHost(project.host),
|
||||
ownerType: project.ownerType,
|
||||
projectNumber: project.number
|
||||
})
|
||||
@@ -3800,6 +3752,7 @@ export default function MobileTasksScreen() {
|
||||
'github.project.viewTable',
|
||||
{
|
||||
owner: activeGitHubProject.owner,
|
||||
host: activeGitHubProjectHost,
|
||||
ownerType: activeGitHubProject.ownerType,
|
||||
projectNumber: activeGitHubProject.number,
|
||||
viewId: activeGitHubProjectViewId,
|
||||
@@ -3838,7 +3791,14 @@ export default function MobileTasksScreen() {
|
||||
setGithubProjectLoading(false)
|
||||
}
|
||||
},
|
||||
[activeGitHubProject, activeGitHubProjectViewId, client, connState, tasksSupported]
|
||||
[
|
||||
activeGitHubProject,
|
||||
activeGitHubProjectHost,
|
||||
activeGitHubProjectViewId,
|
||||
client,
|
||||
connState,
|
||||
tasksSupported
|
||||
]
|
||||
)
|
||||
|
||||
const commitGitHubProjectView = useCallback(
|
||||
@@ -3932,7 +3892,8 @@ export default function MobileTasksScreen() {
|
||||
return
|
||||
}
|
||||
const input = githubProjectPasteInput.trim()
|
||||
if (!parseProjectInput(input)) {
|
||||
const parsed = parseProjectInput(input)
|
||||
if (!parsed) {
|
||||
setGithubProjectPasteError('Expected a project URL or owner/number.')
|
||||
return
|
||||
}
|
||||
@@ -3940,7 +3901,10 @@ export default function MobileTasksScreen() {
|
||||
setGithubProjectPasteError('')
|
||||
setGithubProjectError('')
|
||||
try {
|
||||
const response = await client.sendRequest('github.project.resolveRef', { input })
|
||||
const response = await client.sendRequest('github.project.resolveRef', {
|
||||
input,
|
||||
host: githubProjectHost(parsed.host)
|
||||
})
|
||||
if (!isSuccess(response)) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
@@ -3951,6 +3915,7 @@ export default function MobileTasksScreen() {
|
||||
ownerType: GitHubProjectOwnerType
|
||||
number: number
|
||||
title: string
|
||||
host?: string
|
||||
viewNumber?: number
|
||||
}
|
||||
| { ok: false; error: { message: string } }
|
||||
@@ -3964,7 +3929,8 @@ export default function MobileTasksScreen() {
|
||||
{
|
||||
owner: result.owner,
|
||||
ownerType: result.ownerType,
|
||||
number: result.number
|
||||
number: result.number,
|
||||
host: githubProjectHost(result.host ?? parsed.host)
|
||||
},
|
||||
{ viewNumber: result.viewNumber }
|
||||
)
|
||||
@@ -4536,6 +4502,7 @@ export default function MobileTasksScreen() {
|
||||
{
|
||||
owner: slug.owner,
|
||||
repo: slug.repo,
|
||||
host: activeGitHubProjectHost,
|
||||
number: projectRowItem.content.number,
|
||||
type
|
||||
},
|
||||
@@ -4610,7 +4577,14 @@ export default function MobileTasksScreen() {
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [client, githubProjectTable, projectRowDetailRefreshSeq, projectRowItem, tasksSupported])
|
||||
}, [
|
||||
activeGitHubProjectHost,
|
||||
client,
|
||||
githubProjectTable,
|
||||
projectRowDetailRefreshSeq,
|
||||
projectRowItem,
|
||||
tasksSupported
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const slug = splitRepositorySlug(projectMetadataRepository)
|
||||
@@ -4628,7 +4602,7 @@ export default function MobileTasksScreen() {
|
||||
void client
|
||||
.sendRequest(
|
||||
'github.project.listLabelsBySlug',
|
||||
{ owner: slug.owner, repo: slug.repo },
|
||||
{ owner: slug.owner, repo: slug.repo, host: activeGitHubProjectHost },
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
.then((response) => {
|
||||
@@ -4660,7 +4634,7 @@ export default function MobileTasksScreen() {
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [client, projectMetadataRepository, tasksSupported])
|
||||
}, [activeGitHubProjectHost, client, projectMetadataRepository, tasksSupported])
|
||||
|
||||
useEffect(() => {
|
||||
const slug = splitRepositorySlug(projectMetadataRepository)
|
||||
@@ -4681,6 +4655,7 @@ export default function MobileTasksScreen() {
|
||||
{
|
||||
owner: slug.owner,
|
||||
repo: slug.repo,
|
||||
host: activeGitHubProjectHost,
|
||||
...(projectMetadataSeedLogins ? { seedLogins: projectMetadataSeedLogins.split(',') } : {})
|
||||
},
|
||||
{ timeoutMs: 30_000 }
|
||||
@@ -4716,7 +4691,13 @@ export default function MobileTasksScreen() {
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [client, projectMetadataRepository, projectMetadataSeedLogins, tasksSupported])
|
||||
}, [
|
||||
activeGitHubProjectHost,
|
||||
client,
|
||||
projectMetadataRepository,
|
||||
projectMetadataSeedLogins,
|
||||
tasksSupported
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const slug = splitRepositorySlug(projectIssueTypeRepository)
|
||||
@@ -4734,7 +4715,7 @@ export default function MobileTasksScreen() {
|
||||
void client
|
||||
.sendRequest(
|
||||
'github.project.listIssueTypesBySlug',
|
||||
{ owner: slug.owner, repo: slug.repo },
|
||||
{ owner: slug.owner, repo: slug.repo, host: activeGitHubProjectHost },
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
.then((response) => {
|
||||
@@ -4768,7 +4749,7 @@ export default function MobileTasksScreen() {
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [client, projectIssueTypeRepository, tasksSupported])
|
||||
}, [activeGitHubProjectHost, client, projectIssueTypeRepository, tasksSupported])
|
||||
|
||||
const getWorkspaceTargetRepo = useCallback(
|
||||
(item: ActionableTaskItem, repoIdOverride?: string): RepoSummary | null => {
|
||||
@@ -5729,6 +5710,7 @@ export default function MobileTasksScreen() {
|
||||
{
|
||||
owner: slug.owner,
|
||||
repo: slug.repo,
|
||||
host: activeGitHubProjectHost,
|
||||
number: row.content.number,
|
||||
updates
|
||||
},
|
||||
@@ -5790,7 +5772,7 @@ export default function MobileTasksScreen() {
|
||||
setProjectMutating(false)
|
||||
}
|
||||
},
|
||||
[client, projectMutating]
|
||||
[activeGitHubProjectHost, client, projectMutating]
|
||||
)
|
||||
|
||||
const addProjectRowComment = useCallback(
|
||||
@@ -5810,6 +5792,7 @@ export default function MobileTasksScreen() {
|
||||
{
|
||||
owner: slug.owner,
|
||||
repo: slug.repo,
|
||||
host: activeGitHubProjectHost,
|
||||
number: row.content.number,
|
||||
body
|
||||
},
|
||||
@@ -5838,7 +5821,7 @@ export default function MobileTasksScreen() {
|
||||
setProjectMutating(false)
|
||||
}
|
||||
},
|
||||
[client, projectCommentDraft, projectMutating]
|
||||
[activeGitHubProjectHost, client, projectCommentDraft, projectMutating]
|
||||
)
|
||||
|
||||
const updateProjectRowComment = useCallback(
|
||||
@@ -5861,6 +5844,7 @@ export default function MobileTasksScreen() {
|
||||
{
|
||||
owner: slug.owner,
|
||||
repo: slug.repo,
|
||||
host: activeGitHubProjectHost,
|
||||
commentId,
|
||||
body
|
||||
},
|
||||
@@ -5898,7 +5882,7 @@ export default function MobileTasksScreen() {
|
||||
setProjectMutating(false)
|
||||
}
|
||||
},
|
||||
[client, projectEditingCommentDraft, projectMutating]
|
||||
[activeGitHubProjectHost, client, projectEditingCommentDraft, projectMutating]
|
||||
)
|
||||
|
||||
const deleteProjectRowComment = useCallback(
|
||||
@@ -5920,6 +5904,7 @@ export default function MobileTasksScreen() {
|
||||
{
|
||||
owner: slug.owner,
|
||||
repo: slug.repo,
|
||||
host: activeGitHubProjectHost,
|
||||
commentId
|
||||
},
|
||||
{ timeoutMs: 30_000 }
|
||||
@@ -5956,7 +5941,7 @@ export default function MobileTasksScreen() {
|
||||
setProjectMutating(false)
|
||||
}
|
||||
},
|
||||
[client, projectEditingCommentId, projectMutating]
|
||||
[activeGitHubProjectHost, client, projectEditingCommentId, projectMutating]
|
||||
)
|
||||
|
||||
const toggleProjectGitHubReviewThread = useCallback(
|
||||
@@ -5979,6 +5964,7 @@ export default function MobileTasksScreen() {
|
||||
'github.resolveReviewThread',
|
||||
{
|
||||
repo: `id:${repo.id}`,
|
||||
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
|
||||
threadId: comment.threadId,
|
||||
resolve
|
||||
},
|
||||
@@ -6010,7 +5996,7 @@ export default function MobileTasksScreen() {
|
||||
setProjectMutating(false)
|
||||
}
|
||||
},
|
||||
[client, findProjectRowRepo, projectMutating]
|
||||
[activeGitHubProjectHost, client, findProjectRowRepo, projectMutating]
|
||||
)
|
||||
|
||||
const replyToProjectGitHubComment = useCallback(
|
||||
@@ -6038,6 +6024,7 @@ export default function MobileTasksScreen() {
|
||||
{
|
||||
repo: `id:${repo.id}`,
|
||||
prNumber: row.content.number,
|
||||
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
|
||||
commentId: comment.id,
|
||||
body,
|
||||
threadId: comment.threadId,
|
||||
@@ -6051,6 +6038,7 @@ export default function MobileTasksScreen() {
|
||||
{
|
||||
repo: `id:${repo.id}`,
|
||||
number: row.content.number,
|
||||
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
|
||||
body: `@${commentAuthor(comment)} ${body}`,
|
||||
type: projectRowType(row) ?? 'issue'
|
||||
},
|
||||
@@ -6092,7 +6080,7 @@ export default function MobileTasksScreen() {
|
||||
setProjectMutating(false)
|
||||
}
|
||||
},
|
||||
[client, findProjectRowRepo, itemReplyDrafts, projectMutating]
|
||||
[activeGitHubProjectHost, client, findProjectRowRepo, itemReplyDrafts, projectMutating]
|
||||
)
|
||||
|
||||
const mutateProjectRowMetadata = useCallback(
|
||||
@@ -6120,6 +6108,7 @@ export default function MobileTasksScreen() {
|
||||
{
|
||||
owner: slug.owner,
|
||||
repo: slug.repo,
|
||||
host: activeGitHubProjectHost,
|
||||
number: row.content.number,
|
||||
updates
|
||||
},
|
||||
@@ -6204,7 +6193,7 @@ export default function MobileTasksScreen() {
|
||||
setProjectMutating(false)
|
||||
}
|
||||
},
|
||||
[client, projectMutating]
|
||||
[activeGitHubProjectHost, client, projectMutating]
|
||||
)
|
||||
|
||||
const mutateProjectRowField = useCallback(
|
||||
@@ -6223,11 +6212,13 @@ export default function MobileTasksScreen() {
|
||||
value === null
|
||||
? {
|
||||
projectId: githubProjectTable.project.id,
|
||||
host: activeGitHubProjectHost,
|
||||
itemId: row.id,
|
||||
fieldId: field.id
|
||||
}
|
||||
: {
|
||||
projectId: githubProjectTable.project.id,
|
||||
host: activeGitHubProjectHost,
|
||||
itemId: row.id,
|
||||
fieldId: field.id,
|
||||
value
|
||||
@@ -6274,7 +6265,7 @@ export default function MobileTasksScreen() {
|
||||
setProjectMutating(false)
|
||||
}
|
||||
},
|
||||
[client, githubProjectTable, projectMutating]
|
||||
[activeGitHubProjectHost, client, githubProjectTable, projectMutating]
|
||||
)
|
||||
|
||||
const mutateProjectRowIssueType = useCallback(
|
||||
@@ -6294,6 +6285,7 @@ export default function MobileTasksScreen() {
|
||||
{
|
||||
owner: slug.owner,
|
||||
repo: slug.repo,
|
||||
host: activeGitHubProjectHost,
|
||||
number: row.content.number,
|
||||
issueTypeId: issueType?.id ?? null
|
||||
},
|
||||
@@ -6329,7 +6321,7 @@ export default function MobileTasksScreen() {
|
||||
setProjectMutating(false)
|
||||
}
|
||||
},
|
||||
[client, projectMutating]
|
||||
[activeGitHubProjectHost, client, projectMutating]
|
||||
)
|
||||
|
||||
const requestProjectGitHubReviewers = useCallback(
|
||||
@@ -6350,6 +6342,7 @@ export default function MobileTasksScreen() {
|
||||
{
|
||||
repo: `id:${repo.id}`,
|
||||
prNumber: row.content.number,
|
||||
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
|
||||
reviewers
|
||||
},
|
||||
{ timeoutMs: 30_000 }
|
||||
@@ -6397,7 +6390,14 @@ export default function MobileTasksScreen() {
|
||||
setProjectMutating(false)
|
||||
}
|
||||
},
|
||||
[client, findProjectRowRepo, projectMutating, projectReviewersDraft, projectRowDetail]
|
||||
[
|
||||
activeGitHubProjectHost,
|
||||
client,
|
||||
findProjectRowRepo,
|
||||
projectMutating,
|
||||
projectReviewersDraft,
|
||||
projectRowDetail
|
||||
]
|
||||
)
|
||||
|
||||
const refreshProjectGitHubChecks = useCallback(
|
||||
@@ -6420,6 +6420,7 @@ export default function MobileTasksScreen() {
|
||||
{
|
||||
repo: `id:${repo.id}`,
|
||||
prNumber: row.content.number,
|
||||
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
|
||||
headSha: projectRowDetail?.provider === 'github' ? projectRowDetail.headSha : undefined,
|
||||
noCache: true
|
||||
},
|
||||
@@ -6441,7 +6442,7 @@ export default function MobileTasksScreen() {
|
||||
setProjectMutating(false)
|
||||
}
|
||||
},
|
||||
[client, findProjectRowRepo, projectMutating, projectRowDetail]
|
||||
[activeGitHubProjectHost, client, findProjectRowRepo, projectMutating, projectRowDetail]
|
||||
)
|
||||
|
||||
const rerunProjectGitHubChecks = useCallback(
|
||||
@@ -6464,6 +6465,7 @@ export default function MobileTasksScreen() {
|
||||
{
|
||||
repo: `id:${repo.id}`,
|
||||
prNumber: row.content.number,
|
||||
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
|
||||
headSha: projectRowDetail?.provider === 'github' ? projectRowDetail.headSha : undefined,
|
||||
failedOnly
|
||||
},
|
||||
@@ -6483,7 +6485,7 @@ export default function MobileTasksScreen() {
|
||||
setProjectMutating(false)
|
||||
}
|
||||
},
|
||||
[client, findProjectRowRepo, projectMutating, projectRowDetail]
|
||||
[activeGitHubProjectHost, client, findProjectRowRepo, projectMutating, projectRowDetail]
|
||||
)
|
||||
|
||||
const toggleProjectGitHubFileViewed = useCallback(
|
||||
@@ -6504,6 +6506,7 @@ export default function MobileTasksScreen() {
|
||||
'github.setPRFileViewed',
|
||||
{
|
||||
repo: `id:${repo.id}`,
|
||||
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
|
||||
pullRequestId: projectRowDetail.pullRequestId,
|
||||
path: file.path,
|
||||
viewed
|
||||
@@ -6536,7 +6539,7 @@ export default function MobileTasksScreen() {
|
||||
setProjectMutating(false)
|
||||
}
|
||||
},
|
||||
[client, findProjectRowRepo, projectMutating, projectRowDetail]
|
||||
[activeGitHubProjectHost, client, findProjectRowRepo, projectMutating, projectRowDetail]
|
||||
)
|
||||
|
||||
const toggleProjectGitHubFileExpansion = useCallback(
|
||||
@@ -6570,6 +6573,7 @@ export default function MobileTasksScreen() {
|
||||
{
|
||||
repo: `id:${repo.id}`,
|
||||
prNumber: row.content.number,
|
||||
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
|
||||
path: file.path,
|
||||
oldPath: file.oldPath,
|
||||
status: file.status ?? 'modified',
|
||||
@@ -6593,7 +6597,14 @@ export default function MobileTasksScreen() {
|
||||
setPrFileLoadingPath(null)
|
||||
}
|
||||
},
|
||||
[client, expandedPrFilePath, findProjectRowRepo, prFileContents, projectRowDetail]
|
||||
[
|
||||
activeGitHubProjectHost,
|
||||
client,
|
||||
expandedPrFilePath,
|
||||
findProjectRowRepo,
|
||||
prFileContents,
|
||||
projectRowDetail
|
||||
]
|
||||
)
|
||||
|
||||
const addProjectGitHubFileReviewComment = useCallback(
|
||||
@@ -6625,6 +6636,7 @@ export default function MobileTasksScreen() {
|
||||
{
|
||||
repo: `id:${repo.id}`,
|
||||
prNumber: row.content.number,
|
||||
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
|
||||
commitId: projectRowDetail.headSha,
|
||||
path: file.path,
|
||||
line,
|
||||
@@ -6669,7 +6681,14 @@ export default function MobileTasksScreen() {
|
||||
setProjectMutating(false)
|
||||
}
|
||||
},
|
||||
[client, findProjectRowRepo, prFileCommentDrafts, projectMutating, projectRowDetail]
|
||||
[
|
||||
activeGitHubProjectHost,
|
||||
client,
|
||||
findProjectRowRepo,
|
||||
prFileCommentDrafts,
|
||||
projectMutating,
|
||||
projectRowDetail
|
||||
]
|
||||
)
|
||||
|
||||
const mergeProjectGitHubPullRequest = useCallback(
|
||||
@@ -6695,6 +6714,7 @@ export default function MobileTasksScreen() {
|
||||
{
|
||||
repo: `id:${repo.id}`,
|
||||
prNumber: row.content.number,
|
||||
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
|
||||
method
|
||||
},
|
||||
{ timeoutMs: 60_000 }
|
||||
@@ -6731,7 +6751,7 @@ export default function MobileTasksScreen() {
|
||||
setProjectMutating(false)
|
||||
}
|
||||
},
|
||||
[client, findProjectRowRepo, projectMutating]
|
||||
[activeGitHubProjectHost, client, findProjectRowRepo, projectMutating]
|
||||
)
|
||||
|
||||
const toggleGitHubStatus = useCallback(
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Redirect, useLocalSearchParams } from 'expo-router'
|
||||
import {
|
||||
legacyNotificationOptInDestination,
|
||||
type LegacyNotificationOptInParams
|
||||
} from '../src/onboarding/legacy-notification-opt-in-destination'
|
||||
|
||||
export default function NotificationOptInRedirect() {
|
||||
const params = useLocalSearchParams<LegacyNotificationOptInParams>()
|
||||
|
||||
// Why: restored stacks may contain this retired route; replacement keeps Back from reopening it.
|
||||
return <Redirect href={legacyNotificationOptInDestination(params)} />
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type { RpcClient } from '../transport/rpc-client'
|
||||
import { getWorktreeLabel } from '../session/worktree-label'
|
||||
import {
|
||||
buildMobileAiVaultResumeLaunch,
|
||||
prepareMobileAiVaultSessionResume,
|
||||
createMobileAiVaultResumeMutationRegistry,
|
||||
readMobileRuntimeHostPlatform,
|
||||
readMobileRuntimeTerminalWindowsShell,
|
||||
@@ -197,8 +198,9 @@ export function MobileAgentSessionHistoryPanel({
|
||||
return
|
||||
}
|
||||
|
||||
const preparedSession = await prepareMobileAiVaultSessionResume(client, session)
|
||||
const launch = buildMobileAiVaultResumeLaunch({
|
||||
session,
|
||||
session: preparedSession,
|
||||
hostPlatform: platform,
|
||||
hostTerminalWindowsShell,
|
||||
settings
|
||||
|
||||
@@ -10,7 +10,7 @@ import { PRCommentComposer } from './PRCommentComposer'
|
||||
import { formatPrCommentRelativeTime } from './pr-comment-time'
|
||||
import { prCommentsStyles as styles } from './pr-comments-styles'
|
||||
|
||||
export type PRCommentRepoSlug = { owner: string; repo: string }
|
||||
export type PRCommentRepoSlug = { owner: string; repo: string; host?: string }
|
||||
|
||||
// Action handlers are passed from the comment actions hook (stable callbacks), so
|
||||
// adding them keeps the memo'd card from re-rendering on unrelated timeline changes.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export type LegacyNotificationOptInParams = {
|
||||
hostId?: string | string[]
|
||||
}
|
||||
|
||||
export type LegacyNotificationOptInDestination = {
|
||||
pathname: '/mobile-onboarding'
|
||||
params: { steps: 'notifications'; hostId?: string }
|
||||
}
|
||||
|
||||
/** Maps restored navigation and old deep links onto the canonical notifications step. */
|
||||
export function legacyNotificationOptInDestination(
|
||||
params: LegacyNotificationOptInParams
|
||||
): LegacyNotificationOptInDestination {
|
||||
const hostId = Array.isArray(params.hostId) ? params.hostId[0] : params.hostId
|
||||
return {
|
||||
pathname: '/mobile-onboarding',
|
||||
params: { steps: 'notifications', ...(hostId ? { hostId } : {}) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import NotificationOptInRedirect from '../../app/notification-opt-in'
|
||||
import { legacyNotificationOptInDestination } from './legacy-notification-opt-in-destination'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
params: {} as Record<string, string | string[] | undefined>,
|
||||
ensureNotificationPermissions: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('expo-router', () => ({
|
||||
Redirect: 'Redirect',
|
||||
useLocalSearchParams: () => mocks.params
|
||||
}))
|
||||
|
||||
vi.mock('../notifications/mobile-notifications', () => ({
|
||||
ensureNotificationPermissions: mocks.ensureNotificationPermissions
|
||||
}))
|
||||
|
||||
describe('legacy notification opt-in destination', () => {
|
||||
it('selects only notifications and preserves a scalar hostId', () => {
|
||||
expect(legacyNotificationOptInDestination({ hostId: 'paired-host' })).toEqual({
|
||||
pathname: '/mobile-onboarding',
|
||||
params: { steps: 'notifications', hostId: 'paired-host' }
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
[['first-host', 'second-host'], 'first-host'],
|
||||
[[], undefined],
|
||||
[undefined, undefined]
|
||||
] as const)('normalizes hostId %j to %s', (hostId, expectedHostId) => {
|
||||
expect(legacyNotificationOptInDestination({ hostId })).toEqual({
|
||||
pathname: '/mobile-onboarding',
|
||||
params: {
|
||||
steps: 'notifications',
|
||||
...(expectedHostId ? { hostId: expectedHostId } : {})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores unrelated legacy parameters', () => {
|
||||
const legacyParams = {
|
||||
hostId: 'paired-host',
|
||||
steps: 'session-view',
|
||||
returnTo: '/settings'
|
||||
}
|
||||
|
||||
expect(legacyNotificationOptInDestination(legacyParams)).toEqual({
|
||||
pathname: '/mobile-onboarding',
|
||||
params: { steps: 'notifications', hostId: 'paired-host' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('/notification-opt-in compatibility route', () => {
|
||||
it('is present and synchronously returns a replace-style redirect to the canonical route', () => {
|
||||
mocks.params = {
|
||||
hostId: ['paired-host', 'ignored-host'],
|
||||
steps: 'session-view',
|
||||
unrelated: 'ignored'
|
||||
}
|
||||
|
||||
expect(NotificationOptInRedirect()).toMatchObject({
|
||||
type: 'Redirect',
|
||||
props: {
|
||||
href: {
|
||||
pathname: '/mobile-onboarding',
|
||||
params: { steps: 'notifications', hostId: 'paired-host' }
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(mocks.ensureNotificationPermissions).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -198,7 +198,10 @@ describe('resumeAiVaultSessionInTerminal', () => {
|
||||
agentEnv: { ANTHROPIC_BASE_URL: 'http://localhost:3000' }
|
||||
},
|
||||
launchAgent: 'claude',
|
||||
clientMutationId: 'resume-1'
|
||||
clientMutationId: 'resume-1',
|
||||
activate: false,
|
||||
select: true,
|
||||
navigation: 'caller'
|
||||
})
|
||||
).resolves.toMatchObject({ id: 'tab-1', terminal: 'pty-1' })
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(
|
||||
@@ -214,7 +217,10 @@ describe('resumeAiVaultSessionInTerminal', () => {
|
||||
agentEnv: { ANTHROPIC_BASE_URL: 'http://localhost:3000' }
|
||||
},
|
||||
launchAgent: 'claude',
|
||||
clientMutationId: 'resume-1'
|
||||
clientMutationId: 'resume-1',
|
||||
activate: false,
|
||||
select: true,
|
||||
navigation: 'caller'
|
||||
},
|
||||
// Why: a socket drop mid-resume must reject within the request timeout
|
||||
// instead of parking on the reconnect waiter with the spinner pinned.
|
||||
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
buildAiVaultResumeShellCommand,
|
||||
realHomeCodexResumeEnvDeletion
|
||||
} from '../../../src/shared/ai-vault-types'
|
||||
import {
|
||||
isAiVaultPrepareSessionResumeUnavailableError,
|
||||
isLegacySharedCodexHome
|
||||
} from '../../../src/shared/ai-vault-resume-preparation'
|
||||
import { isResumableTuiAgent } from '../../../src/shared/agent-session-resume'
|
||||
import type { SleepingAgentLaunchConfig } from '../../../src/shared/agent-session-resume'
|
||||
import { buildAgentResumeStartupPlan } from '../../../src/shared/tui-agent-startup'
|
||||
@@ -151,6 +155,39 @@ function normalizeMobileAiVaultResumeCommandOverrides(
|
||||
// on the reconnect waiter for the full reconnect budget, pinning the spinner.
|
||||
export const RESUME_RPC_TIMEOUT_MS = 30_000
|
||||
|
||||
export async function prepareMobileAiVaultSessionResume(
|
||||
client: Pick<RpcClient, 'sendRequest'>,
|
||||
session: AiVaultSession
|
||||
): Promise<AiVaultSession> {
|
||||
if (session.agent !== 'codex' || !isLegacySharedCodexHome(session.codexHome)) {
|
||||
return session
|
||||
}
|
||||
const response = await client.sendRequest(
|
||||
'aiVault.prepareSessionResume',
|
||||
{
|
||||
agent: session.agent,
|
||||
filePath: session.filePath,
|
||||
codexHome: session.codexHome,
|
||||
executionHostId: session.executionHostId
|
||||
},
|
||||
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
|
||||
)
|
||||
if (!response.ok) {
|
||||
if (isAiVaultPrepareSessionResumeUnavailableError(response.error)) {
|
||||
// Why: older hosts cannot prepare, but their shared home still supports the legacy resume path.
|
||||
return session
|
||||
}
|
||||
throw new Error(
|
||||
response.error?.message || 'Could not prepare this legacy Codex session. Retry resume.'
|
||||
)
|
||||
}
|
||||
const result = response.result as { useRealCodexHome?: unknown } | null
|
||||
if (result?.useRealCodexHome !== true) {
|
||||
return session
|
||||
}
|
||||
return { ...session, codexHome: null }
|
||||
}
|
||||
|
||||
export async function resumeAiVaultSessionInTerminal(
|
||||
client: Pick<RpcClient, 'sendRequest'>,
|
||||
worktreeId: string,
|
||||
@@ -164,7 +201,10 @@ export async function resumeAiVaultSessionInTerminal(
|
||||
...(launch.envToDelete ? { envToDelete: launch.envToDelete } : {}),
|
||||
...(launch.launchConfig ? { launchConfig: launch.launchConfig } : {}),
|
||||
...(launch.launchAgent ? { launchAgent: launch.launchAgent } : {}),
|
||||
...(launch.clientMutationId ? { clientMutationId: launch.clientMutationId } : {})
|
||||
...(launch.clientMutationId ? { clientMutationId: launch.clientMutationId } : {}),
|
||||
activate: false,
|
||||
select: true,
|
||||
navigation: 'caller'
|
||||
},
|
||||
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
|
||||
)
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AiVaultSession } from '../../../src/shared/ai-vault-types'
|
||||
import {
|
||||
buildMobileAiVaultResumeLaunch,
|
||||
prepareMobileAiVaultSessionResume,
|
||||
resumeAiVaultSessionInTerminal,
|
||||
RESUME_RPC_TIMEOUT_MS
|
||||
} from './ai-vault-resume-launch'
|
||||
|
||||
const LEGACY_CODEX_HOME = '/Users/ada/Library/Application Support/orca/codex-runtime-home/home'
|
||||
|
||||
function legacySession(overrides: Partial<AiVaultSession> = {}): AiVaultSession {
|
||||
return {
|
||||
id: 'codex:legacy-1',
|
||||
executionHostId: 'local',
|
||||
agent: 'codex',
|
||||
sessionId: 'legacy-1',
|
||||
title: 'Resume me',
|
||||
cwd: '/Users/ada/repo',
|
||||
branch: 'main',
|
||||
model: null,
|
||||
filePath: `${LEGACY_CODEX_HOME}/sessions/2026/07/20/rollout-a.jsonl`,
|
||||
codexHome: LEGACY_CODEX_HOME,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
modifiedAt: '2026-07-20T00:00:00.000Z',
|
||||
messageCount: 2,
|
||||
totalTokens: 10,
|
||||
previewMessages: [],
|
||||
queuedMessageCount: 0,
|
||||
subagentTranscriptCount: 0,
|
||||
resumeCommand: '',
|
||||
subagent: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('prepareMobileAiVaultSessionResume', () => {
|
||||
it.each([
|
||||
{
|
||||
code: 'forbidden',
|
||||
message: "Method 'aiVault.prepareSessionResume' is not available to mobile clients"
|
||||
},
|
||||
{
|
||||
code: 'method_not_found',
|
||||
message: 'Unknown method: aiVault.prepareSessionResume'
|
||||
}
|
||||
])('uses the legacy command and environment on old-host $code', async (error) => {
|
||||
const legacy = legacySession()
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: false, error })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1', title: 'Terminal' } }
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true, result: { send: { accepted: true } } })
|
||||
|
||||
const prepared = await prepareMobileAiVaultSessionResume({ sendRequest }, legacy)
|
||||
const launch = buildMobileAiVaultResumeLaunch({ session: prepared, hostPlatform: 'darwin' })
|
||||
await resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', launch)
|
||||
|
||||
expect(prepared).toBe(legacy)
|
||||
expect(sendRequest.mock.calls[1]?.[1]).not.toHaveProperty('envToDelete')
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: 'pty-1',
|
||||
text: `cd '/Users/ada/repo' && CODEX_HOME='${LEGACY_CODEX_HOME}' codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'legacy-1'`,
|
||||
enter: true
|
||||
},
|
||||
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the real Codex home when a supported desktop requests it', async () => {
|
||||
const legacy = legacySession()
|
||||
const sendRequest = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
result: { useRealCodexHome: true }
|
||||
})
|
||||
|
||||
const prepared = await prepareMobileAiVaultSessionResume({ sendRequest }, legacy)
|
||||
const launch = buildMobileAiVaultResumeLaunch({ session: prepared, hostPlatform: 'darwin' })
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'aiVault.prepareSessionResume',
|
||||
{
|
||||
agent: 'codex',
|
||||
filePath: legacy.filePath,
|
||||
codexHome: LEGACY_CODEX_HOME,
|
||||
executionHostId: 'local'
|
||||
},
|
||||
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
|
||||
)
|
||||
expect(prepared).toEqual({ ...legacy, codexHome: null })
|
||||
expect(launch.command).toBe(
|
||||
"cd '/Users/ada/repo' && codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'legacy-1'"
|
||||
)
|
||||
expect(launch.envToDelete).toEqual(['CODEX_HOME', 'ORCA_CODEX_HOME'])
|
||||
})
|
||||
|
||||
it('preserves the legacy resume path when a supported desktop declines real-home use', async () => {
|
||||
const legacy = legacySession()
|
||||
const sendRequest = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
result: { useRealCodexHome: false }
|
||||
})
|
||||
|
||||
const prepared = await prepareMobileAiVaultSessionResume({ sendRequest }, legacy)
|
||||
const launch = buildMobileAiVaultResumeLaunch({ session: prepared, hostPlatform: 'darwin' })
|
||||
|
||||
expect(prepared).toBe(legacy)
|
||||
expect(launch.command).toContain(`CODEX_HOME='${LEGACY_CODEX_HOME}'`)
|
||||
expect(launch.envToDelete).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ agent: 'claude' as const, codexHome: null },
|
||||
{ agent: 'codex' as const, codexHome: '/Users/ada/.config/codex' },
|
||||
{
|
||||
agent: 'codex' as const,
|
||||
codexHome: '/Users/ada/Library/Application Support/orca/codex-accounts/a/home'
|
||||
},
|
||||
{ agent: 'codex' as const, codexHome: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex' }
|
||||
])('does not prepare non-legacy session $agent at $codexHome', async (overrides) => {
|
||||
const current = legacySession(overrides)
|
||||
const sendRequest = vi.fn()
|
||||
|
||||
await expect(prepareMobileAiVaultSessionResume({ sendRequest }, current)).resolves.toBe(current)
|
||||
expect(sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
code: 'internal_error',
|
||||
message: 'Retry resume after checking session folder permissions.'
|
||||
},
|
||||
{
|
||||
code: 'forbidden',
|
||||
message: 'Session resume is forbidden'
|
||||
}
|
||||
])('keeps genuine server error visible: $code', async (error) => {
|
||||
const sendRequest = vi.fn().mockResolvedValue({ ok: false, error })
|
||||
|
||||
await expect(
|
||||
prepareMobileAiVaultSessionResume({ sendRequest }, legacySession())
|
||||
).rejects.toThrow(error.message)
|
||||
})
|
||||
|
||||
it.each(['Request timed out: aiVault.prepareSessionResume', 'Connection interrupted'])(
|
||||
'keeps transport failure visible: %s',
|
||||
async (message) => {
|
||||
const failure = new Error(message)
|
||||
const sendRequest = vi.fn().mockRejectedValue(failure)
|
||||
|
||||
await expect(
|
||||
prepareMobileAiVaultSessionResume({ sendRequest }, legacySession())
|
||||
).rejects.toBe(failure)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
FLOATING_WORKSPACE_WORKTREE_ID,
|
||||
floatingWorkspaceSessionPath,
|
||||
isFloatingWorkspaceWorktreeId
|
||||
} from './floating-workspace'
|
||||
|
||||
describe('floating workspace routing', () => {
|
||||
it('matches only the desktop sentinel id', () => {
|
||||
expect(isFloatingWorkspaceWorktreeId('global-floating-terminal')).toBe(true)
|
||||
expect(isFloatingWorkspaceWorktreeId('repo-1::/worktree')).toBe(false)
|
||||
expect(isFloatingWorkspaceWorktreeId('folder:group-1')).toBe(false)
|
||||
expect(isFloatingWorkspaceWorktreeId(undefined)).toBe(false)
|
||||
expect(isFloatingWorkspaceWorktreeId(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('builds the session route with an explicit title seed', () => {
|
||||
expect(floatingWorkspaceSessionPath('host-1')).toBe(
|
||||
`/h/host-1/session/${FLOATING_WORKSPACE_WORKTREE_ID}?name=Floating%20Workspace`
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
// Mirrors FLOATING_TERMINAL_WORKTREE_ID in src/shared/constants.ts — the desktop
|
||||
// Floating Workspace's synthetic id (no backing repo/worktree; always local runtime).
|
||||
export const FLOATING_WORKSPACE_WORKTREE_ID = 'global-floating-terminal'
|
||||
|
||||
export const FLOATING_WORKSPACE_TITLE = 'Floating Workspace'
|
||||
|
||||
export function isFloatingWorkspaceWorktreeId(worktreeId: string | null | undefined): boolean {
|
||||
return worktreeId === FLOATING_WORKSPACE_WORKTREE_ID
|
||||
}
|
||||
|
||||
// Route target for the host-header entry; the ?name param seeds the session
|
||||
// screen title before tabs load.
|
||||
export function floatingWorkspaceSessionPath(hostId: string | undefined): string {
|
||||
return `/h/${hostId}/session/${FLOATING_WORKSPACE_WORKTREE_ID}?name=${encodeURIComponent(FLOATING_WORKSPACE_TITLE)}`
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import {
|
||||
fetchAddIssueComment,
|
||||
fetchAddPRReviewCommentReply,
|
||||
fetchDeleteIssueComment,
|
||||
fetchMergePR,
|
||||
fetchResolveReviewThread,
|
||||
@@ -29,6 +31,7 @@ function clientRejecting(message: string) {
|
||||
}
|
||||
|
||||
const WORKTREE_ID = 'repo-42::/path/to/wt'
|
||||
const ENTERPRISE_PR_REPO = { owner: 'o', repo: 'r', host: 'github.acme.test' }
|
||||
|
||||
describe('fetchResolveReviewThread / fetchUpdatePRTitle — bare-boolean host result', () => {
|
||||
it('treats an explicit true as success', async () => {
|
||||
@@ -102,12 +105,51 @@ describe('mutation transport rejection normalization', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Enterprise PR repo forwarding', () => {
|
||||
it('forwards the host through title, root-comment, and review-reply RPCs', async () => {
|
||||
const titleClient = clientReturning(okResponse(true))
|
||||
await fetchUpdatePRTitle(titleClient, WORKTREE_ID, {
|
||||
prNumber: 7,
|
||||
title: 'New title',
|
||||
prRepo: ENTERPRISE_PR_REPO
|
||||
})
|
||||
expect(titleClient.sendRequest).toHaveBeenCalledWith(
|
||||
'github.updatePRTitle',
|
||||
expect.objectContaining({ prRepo: ENTERPRISE_PR_REPO })
|
||||
)
|
||||
|
||||
const rootClient = clientReturning(okResponse({ ok: true }))
|
||||
await fetchAddIssueComment(rootClient, WORKTREE_ID, {
|
||||
prNumber: 7,
|
||||
body: 'Root comment',
|
||||
prRepo: ENTERPRISE_PR_REPO
|
||||
})
|
||||
expect(rootClient.sendRequest).toHaveBeenCalledWith(
|
||||
'github.addIssueComment',
|
||||
expect.objectContaining({ prRepo: ENTERPRISE_PR_REPO })
|
||||
)
|
||||
|
||||
const replyClient = clientReturning(okResponse({ ok: true }))
|
||||
await fetchAddPRReviewCommentReply(replyClient, WORKTREE_ID, {
|
||||
prNumber: 7,
|
||||
commentId: 42,
|
||||
body: 'Reply',
|
||||
prRepo: ENTERPRISE_PR_REPO
|
||||
})
|
||||
expect(replyClient.sendRequest).toHaveBeenCalledWith(
|
||||
'github.addPRReviewCommentReply',
|
||||
expect.objectContaining({ prRepo: ENTERPRISE_PR_REPO })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchUpdateIssueComment / fetchDeleteIssueComment — slug-addressed envelope', () => {
|
||||
it('sends owner/repo/commentId(+body) and reads the { ok } envelope', async () => {
|
||||
const editClient = clientReturning(okResponse({ ok: true }))
|
||||
const edit = await fetchUpdateIssueComment(editClient, {
|
||||
owner: 'o',
|
||||
repo: 'r',
|
||||
host: 'github.acme.test',
|
||||
commentId: 5,
|
||||
body: 'edited'
|
||||
})
|
||||
@@ -115,16 +157,23 @@ describe('fetchUpdateIssueComment / fetchDeleteIssueComment — slug-addressed e
|
||||
expect(editClient.sendRequest).toHaveBeenCalledWith('github.project.updateIssueCommentBySlug', {
|
||||
owner: 'o',
|
||||
repo: 'r',
|
||||
host: 'github.acme.test',
|
||||
commentId: 5,
|
||||
body: 'edited'
|
||||
})
|
||||
|
||||
const delClient = clientReturning(okResponse({ ok: true }))
|
||||
const del = await fetchDeleteIssueComment(delClient, { owner: 'o', repo: 'r', commentId: 5 })
|
||||
const del = await fetchDeleteIssueComment(delClient, {
|
||||
owner: 'o',
|
||||
repo: 'r',
|
||||
host: 'github.acme.test',
|
||||
commentId: 5
|
||||
})
|
||||
expect(del).toEqual({ ok: true })
|
||||
expect(delClient.sendRequest).toHaveBeenCalledWith('github.project.deleteIssueCommentBySlug', {
|
||||
owner: 'o',
|
||||
repo: 'r',
|
||||
host: 'github.acme.test',
|
||||
commentId: 5
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { GitHubPRMergeMethod } from '../../../src/shared/types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { buildGithubPrParams, type GitHubPrRepoSlug } from './github-pr-rpc'
|
||||
import { buildGithubPrParams, githubPrRepoSlugParam, type GitHubPrRepoSlug } from './github-pr-rpc'
|
||||
|
||||
// Mutation wrappers for the github.* PR surface, split out so github-pr-rpc.ts
|
||||
// stays under the max-lines budget. They mirror the read wrappers' shape but
|
||||
@@ -101,16 +101,10 @@ export async function fetchUpdatePRTitle(
|
||||
args: { prNumber: number; title: string; prRepo?: GitHubPrRepoSlug | null }
|
||||
): Promise<GitHubPrMutationOutcome> {
|
||||
const params: Record<string, unknown> = { prNumber: args.prNumber, title: args.title }
|
||||
// updatePRTitle accepts prRepo for fork PRs, but it is not in the centralized
|
||||
// METHODS_ACCEPTING_PR_REPO read allow-list — pass it explicitly so it reaches the
|
||||
// host schema (which declares it optional/nullable).
|
||||
if (args.prRepo) {
|
||||
params.prRepo = { owner: args.prRepo.owner, repo: args.prRepo.repo }
|
||||
}
|
||||
const response = await sendRaw(
|
||||
client,
|
||||
'github.updatePRTitle',
|
||||
buildGithubPrParams('github.updatePRTitle', worktreeId, params)
|
||||
buildGithubPrParams('github.updatePRTitle', worktreeId, params, { prRepo: args.prRepo })
|
||||
)
|
||||
if (!response.ok) {
|
||||
return { ok: false, error: response.error || 'Request failed: github.updatePRTitle' }
|
||||
@@ -147,48 +141,51 @@ export async function fetchSetPRAutoMerge(
|
||||
export async function fetchUpdatePRState(
|
||||
client: Pick<RpcClient, 'sendRequest'>,
|
||||
worktreeId: string,
|
||||
args: { prNumber: number; state: 'open' | 'closed' }
|
||||
args: { prNumber: number; state: 'open' | 'closed'; prRepo?: GitHubPrRepoSlug | null }
|
||||
): Promise<GitHubPrMutationOutcome> {
|
||||
// updatePRState does NOT accept prRepo (KTD3) — buildGithubPrParams omits it.
|
||||
return sendGithubPrMutation(
|
||||
client,
|
||||
'github.updatePRState',
|
||||
buildGithubPrParams('github.updatePRState', worktreeId, {
|
||||
prNumber: args.prNumber,
|
||||
updates: { state: args.state }
|
||||
})
|
||||
buildGithubPrParams(
|
||||
'github.updatePRState',
|
||||
worktreeId,
|
||||
{ prNumber: args.prNumber, updates: { state: args.state } },
|
||||
{ prRepo: args.prRepo }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchRequestPRReviewers(
|
||||
client: Pick<RpcClient, 'sendRequest'>,
|
||||
worktreeId: string,
|
||||
args: { prNumber: number; reviewers: string[] }
|
||||
args: { prNumber: number; reviewers: string[]; prRepo?: GitHubPrRepoSlug | null }
|
||||
): Promise<GitHubPrMutationOutcome> {
|
||||
// requestPRReviewers does NOT accept prRepo (KTD3).
|
||||
return sendGithubPrMutation(
|
||||
client,
|
||||
'github.requestPRReviewers',
|
||||
buildGithubPrParams('github.requestPRReviewers', worktreeId, {
|
||||
prNumber: args.prNumber,
|
||||
reviewers: args.reviewers
|
||||
})
|
||||
buildGithubPrParams(
|
||||
'github.requestPRReviewers',
|
||||
worktreeId,
|
||||
{ prNumber: args.prNumber, reviewers: args.reviewers },
|
||||
{ prRepo: args.prRepo }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchRemovePRReviewers(
|
||||
client: Pick<RpcClient, 'sendRequest'>,
|
||||
worktreeId: string,
|
||||
args: { prNumber: number; reviewers: string[] }
|
||||
args: { prNumber: number; reviewers: string[]; prRepo?: GitHubPrRepoSlug | null }
|
||||
): Promise<GitHubPrMutationOutcome> {
|
||||
// removePRReviewers does NOT accept prRepo (KTD3).
|
||||
return sendGithubPrMutation(
|
||||
client,
|
||||
'github.removePRReviewers',
|
||||
buildGithubPrParams('github.removePRReviewers', worktreeId, {
|
||||
prNumber: args.prNumber,
|
||||
reviewers: args.reviewers
|
||||
})
|
||||
buildGithubPrParams(
|
||||
'github.removePRReviewers',
|
||||
worktreeId,
|
||||
{ prNumber: args.prNumber, reviewers: args.reviewers },
|
||||
{ prRepo: args.prRepo }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -222,16 +219,12 @@ export async function fetchAddPRReviewCommentReply(
|
||||
if (typeof args.line === 'number') {
|
||||
params.line = args.line
|
||||
}
|
||||
// addPRReviewCommentReply accepts prRepo for fork PRs, but it is not in the
|
||||
// centralized METHODS_ACCEPTING_PR_REPO allow-list (read-focused) — pass it
|
||||
// explicitly so it reaches the host schema, which declares it optional.
|
||||
if (args.prRepo) {
|
||||
params.prRepo = { owner: args.prRepo.owner, repo: args.prRepo.repo }
|
||||
}
|
||||
return sendGithubPrMutation(
|
||||
client,
|
||||
'github.addPRReviewCommentReply',
|
||||
buildGithubPrParams('github.addPRReviewCommentReply', worktreeId, params)
|
||||
buildGithubPrParams('github.addPRReviewCommentReply', worktreeId, params, {
|
||||
prRepo: args.prRepo
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -246,13 +239,10 @@ export async function fetchAddIssueComment(
|
||||
body: args.body,
|
||||
type: 'pr'
|
||||
}
|
||||
if (args.prRepo) {
|
||||
params.prRepo = { owner: args.prRepo.owner, repo: args.prRepo.repo }
|
||||
}
|
||||
return sendGithubPrMutation(
|
||||
client,
|
||||
'github.addIssueComment',
|
||||
buildGithubPrParams('github.addIssueComment', worktreeId, params)
|
||||
buildGithubPrParams('github.addIssueComment', worktreeId, params, { prRepo: args.prRepo })
|
||||
)
|
||||
}
|
||||
|
||||
@@ -262,15 +252,17 @@ export async function fetchAddIssueComment(
|
||||
export async function fetchResolveReviewThread(
|
||||
client: Pick<RpcClient, 'sendRequest'>,
|
||||
worktreeId: string,
|
||||
args: { threadId: string; resolve: boolean }
|
||||
args: { threadId: string; resolve: boolean; prRepo?: GitHubPrRepoSlug | null }
|
||||
): Promise<GitHubPrMutationOutcome> {
|
||||
const response = await sendRaw(
|
||||
client,
|
||||
'github.resolveReviewThread',
|
||||
buildGithubPrParams('github.resolveReviewThread', worktreeId, {
|
||||
threadId: args.threadId,
|
||||
resolve: args.resolve
|
||||
})
|
||||
buildGithubPrParams(
|
||||
'github.resolveReviewThread',
|
||||
worktreeId,
|
||||
{ threadId: args.threadId, resolve: args.resolve },
|
||||
{ prRepo: args.prRepo }
|
||||
)
|
||||
)
|
||||
if (!response.ok) {
|
||||
return {
|
||||
@@ -292,11 +284,10 @@ export async function fetchResolveReviewThread(
|
||||
// GitHubProjectMutationResult `{ ok }` envelope sendGithubPrMutation reads.
|
||||
export async function fetchUpdateIssueComment(
|
||||
client: Pick<RpcClient, 'sendRequest'>,
|
||||
args: { owner: string; repo: string; commentId: number; body: string }
|
||||
args: { owner: string; repo: string; host?: string; commentId: number; body: string }
|
||||
): Promise<GitHubPrMutationOutcome> {
|
||||
return sendGithubPrMutation(client, 'github.project.updateIssueCommentBySlug', {
|
||||
owner: args.owner,
|
||||
repo: args.repo,
|
||||
...githubPrRepoSlugParam(args),
|
||||
commentId: args.commentId,
|
||||
body: args.body
|
||||
})
|
||||
@@ -305,11 +296,10 @@ export async function fetchUpdateIssueComment(
|
||||
// Delete a root conversation (issue) comment. Slug-addressed like the edit wrapper.
|
||||
export async function fetchDeleteIssueComment(
|
||||
client: Pick<RpcClient, 'sendRequest'>,
|
||||
args: { owner: string; repo: string; commentId: number }
|
||||
args: { owner: string; repo: string; host?: string; commentId: number }
|
||||
): Promise<GitHubPrMutationOutcome> {
|
||||
return sendGithubPrMutation(client, 'github.project.deleteIssueCommentBySlug', {
|
||||
owner: args.owner,
|
||||
repo: args.repo,
|
||||
...githubPrRepoSlugParam(args),
|
||||
commentId: args.commentId
|
||||
})
|
||||
}
|
||||
@@ -317,9 +307,13 @@ export async function fetchDeleteIssueComment(
|
||||
export async function fetchRerunPRChecks(
|
||||
client: Pick<RpcClient, 'sendRequest'>,
|
||||
worktreeId: string,
|
||||
args: { prNumber: number; headSha?: string | null; failedOnly?: boolean }
|
||||
args: {
|
||||
prNumber: number
|
||||
headSha?: string | null
|
||||
failedOnly?: boolean
|
||||
prRepo?: GitHubPrRepoSlug | null
|
||||
}
|
||||
): Promise<GitHubPrMutationOutcome> {
|
||||
// rerunPRChecks does NOT accept prRepo (KTD3); headSha is a plain param here.
|
||||
const params: Record<string, unknown> = { prNumber: args.prNumber }
|
||||
if (args.failedOnly !== undefined) {
|
||||
params.failedOnly = args.failedOnly
|
||||
@@ -330,6 +324,6 @@ export async function fetchRerunPRChecks(
|
||||
return sendGithubPrMutation(
|
||||
client,
|
||||
'github.rerunPRChecks',
|
||||
buildGithubPrParams('github.rerunPRChecks', worktreeId, params)
|
||||
buildGithubPrParams('github.rerunPRChecks', worktreeId, params, { prRepo: args.prRepo })
|
||||
)
|
||||
}
|
||||
|
||||
@@ -103,13 +103,17 @@ describe('readPRForBranch', () => {
|
||||
const parsed = readPRForBranch({
|
||||
number: 3,
|
||||
state: 'open',
|
||||
prRepo: { owner: 'forkOwner', repo: 'forkRepo' },
|
||||
prRepo: { owner: 'forkOwner', repo: 'forkRepo', host: 'github.acme.test' },
|
||||
mergeMethodSettings: {
|
||||
defaultMethod: 'squash',
|
||||
allowedMethods: { merge: false, squash: true, rebase: true }
|
||||
}
|
||||
})
|
||||
expect(parsed?.prRepo).toEqual({ owner: 'forkOwner', repo: 'forkRepo' })
|
||||
expect(parsed?.prRepo).toEqual({
|
||||
owner: 'forkOwner',
|
||||
repo: 'forkRepo',
|
||||
host: 'github.acme.test'
|
||||
})
|
||||
expect(parsed?.mergeMethodSettings).toEqual({
|
||||
defaultMethod: 'squash',
|
||||
allowedMethods: { merge: false, squash: true, rebase: true }
|
||||
@@ -266,7 +270,7 @@ describe('readAssignableUsers', () => {
|
||||
})
|
||||
|
||||
describe('buildGithubPrParams — method-aware prRepo / headSha', () => {
|
||||
const fork = { owner: 'forkOwner', repo: 'forkRepo' }
|
||||
const fork = { owner: 'forkOwner', repo: 'forkRepo', host: 'github.acme.test' }
|
||||
|
||||
it('reuses mobileRepoSelectorFromWorktreeId for the repo selector', () => {
|
||||
const params = buildGithubPrParams('github.prChecks', WORKTREE_ID, { prNumber: 1 })
|
||||
@@ -278,9 +282,20 @@ describe('buildGithubPrParams — method-aware prRepo / headSha', () => {
|
||||
for (const method of [
|
||||
'github.prChecks',
|
||||
'github.prCheckDetails',
|
||||
'github.rerunPRChecks',
|
||||
'github.resolveReviewThread',
|
||||
'github.setPRFileViewed',
|
||||
'github.updatePRState',
|
||||
'github.requestPRReviewers',
|
||||
'github.removePRReviewers',
|
||||
'github.mergePR',
|
||||
'github.setPRAutoMerge',
|
||||
'github.prComments'
|
||||
'github.updatePRTitle',
|
||||
'github.prComments',
|
||||
'github.prFileContents',
|
||||
'github.addPRReviewComment',
|
||||
'github.addIssueComment',
|
||||
'github.addPRReviewCommentReply'
|
||||
]) {
|
||||
const params = buildGithubPrParams(method, WORKTREE_ID, { prNumber: 1 }, { prRepo: fork })
|
||||
expect(params.prRepo).toEqual(fork)
|
||||
@@ -288,13 +303,7 @@ describe('buildGithubPrParams — method-aware prRepo / headSha', () => {
|
||||
})
|
||||
|
||||
it('omits prRepo for methods that reject it', () => {
|
||||
for (const method of [
|
||||
'github.updatePRState',
|
||||
'github.requestPRReviewers',
|
||||
'github.removePRReviewers',
|
||||
'github.listAssignableUsers',
|
||||
'github.rerunPRChecks'
|
||||
]) {
|
||||
for (const method of ['github.repoSlug', 'github.prForBranch', 'github.listAssignableUsers']) {
|
||||
const params = buildGithubPrParams(method, WORKTREE_ID, { prNumber: 1 }, { prRepo: fork })
|
||||
expect('prRepo' in params).toBe(false)
|
||||
}
|
||||
@@ -380,12 +389,15 @@ describe('fetch wrappers', () => {
|
||||
expect('headSha' in (params as object)).toBe(false)
|
||||
})
|
||||
|
||||
it('fetchGithubRepoSlug returns the slug for a github repo, null otherwise', async () => {
|
||||
it('fetchGithubRepoSlug preserves an Enterprise host, and returns null otherwise', async () => {
|
||||
const found = await fetchGithubRepoSlug(
|
||||
mockClient(okResponse({ owner: 'o', repo: 'r' })).client,
|
||||
mockClient(okResponse({ owner: 'o', repo: 'r', host: 'github.acme.test' })).client,
|
||||
WORKTREE_ID
|
||||
)
|
||||
expect(found).toEqual({ ok: true, result: { owner: 'o', repo: 'r' } })
|
||||
expect(found).toEqual({
|
||||
ok: true,
|
||||
result: { owner: 'o', repo: 'r', host: 'github.acme.test' }
|
||||
})
|
||||
const none = await fetchGithubRepoSlug(mockClient(okResponse(null)).client, WORKTREE_ID)
|
||||
expect(none).toEqual({ ok: true, result: null })
|
||||
})
|
||||
|
||||
@@ -31,20 +31,35 @@ export {
|
||||
|
||||
// Why: a fork PR's head lives in a different owner/repo; the host's SlugRepo
|
||||
// (`{ owner, repo }`) identifies it. Only a subset of github.* methods accept it.
|
||||
export type GitHubPrRepoSlug = { owner: string; repo: string }
|
||||
// Why: `host` must survive the RPC boundary or GHES actions on the host fall
|
||||
// back to a same-named github.com repo (src/shared/types.ts identity contract).
|
||||
export type GitHubPrRepoSlug = { owner: string; repo: string; host?: string }
|
||||
|
||||
export function githubPrRepoSlugParam(slug: GitHubPrRepoSlug): Record<string, string> {
|
||||
return { owner: slug.owner, repo: slug.repo, ...(slug.host ? { host: slug.host } : {}) }
|
||||
}
|
||||
|
||||
export type GitHubPrReadOutcome<T> = { ok: true; result: T } | { ok: false; error: string }
|
||||
|
||||
// Why: `prRepo` is method-asymmetric (KTD3). These are the only github.* methods
|
||||
// whose host schema (SlugRepo on PullRequest/PullRequestChecks/PullRequestCheckDetails)
|
||||
// accepts it; the rest reject the key. Centralizing the allow-list keeps a fork's
|
||||
// prRepo from leaking into a schema that would reject it.
|
||||
// Why: `prRepo` remains method-asymmetric. Keep the RPC schema allow-list here
|
||||
// so fork/GHES identity reaches every PR-scoped read or mutation that accepts it.
|
||||
const METHODS_ACCEPTING_PR_REPO = new Set<string>([
|
||||
'github.prChecks',
|
||||
'github.prCheckDetails',
|
||||
'github.rerunPRChecks',
|
||||
'github.resolveReviewThread',
|
||||
'github.setPRFileViewed',
|
||||
'github.updatePRState',
|
||||
'github.requestPRReviewers',
|
||||
'github.removePRReviewers',
|
||||
'github.mergePR',
|
||||
'github.setPRAutoMerge',
|
||||
'github.prComments'
|
||||
'github.updatePRTitle',
|
||||
'github.prComments',
|
||||
'github.prFileContents',
|
||||
'github.addPRReviewComment',
|
||||
'github.addIssueComment',
|
||||
'github.addPRReviewCommentReply'
|
||||
])
|
||||
|
||||
// Why: only github.prChecks declares a `headSha` param (PullRequestCheckDetails
|
||||
@@ -62,7 +77,7 @@ export function buildGithubPrParams(
|
||||
...params
|
||||
}
|
||||
if (options?.prRepo && METHODS_ACCEPTING_PR_REPO.has(method) && !('prRepo' in built)) {
|
||||
built.prRepo = { owner: options.prRepo.owner, repo: options.prRepo.repo }
|
||||
built.prRepo = githubPrRepoSlugParam(options.prRepo)
|
||||
}
|
||||
if (options?.headSha && METHODS_ACCEPTING_HEAD_SHA.has(method) && !('headSha' in built)) {
|
||||
built.headSha = options.headSha
|
||||
@@ -107,7 +122,10 @@ export async function fetchGithubRepoSlug(
|
||||
const record = value as Record<string, unknown>
|
||||
const owner = record.owner
|
||||
const repo = record.repo
|
||||
return typeof owner === 'string' && typeof repo === 'string' ? { owner, repo } : null
|
||||
const host = record.host
|
||||
return typeof owner === 'string' && typeof repo === 'string'
|
||||
? { owner, repo, ...(typeof host === 'string' && host ? { host } : {}) }
|
||||
: null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,14 @@ describe('readRepoIdentity', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves an Enterprise host', () => {
|
||||
expect(readRepoIdentity({ owner: 'octo', repo: 'orca', host: 'github.acme.test' })).toEqual({
|
||||
owner: 'octo',
|
||||
repo: 'orca',
|
||||
host: 'github.acme.test'
|
||||
})
|
||||
})
|
||||
|
||||
it('drops a non-record value', () => {
|
||||
expect(readRepoIdentity(null)).toBeUndefined()
|
||||
expect(readRepoIdentity('octo/orca')).toBeUndefined()
|
||||
|
||||
@@ -153,7 +153,10 @@ export function readRepoIdentity(value: unknown): GitHubRepositoryIdentity | und
|
||||
if (!owner || !repo) {
|
||||
return undefined
|
||||
}
|
||||
return { owner, repo }
|
||||
// Why: dropping `host` here would strip the GHES identity before every
|
||||
// subsequent PR RPC, forcing the host to re-derive it per call.
|
||||
const host = readString(value.host)
|
||||
return { owner, repo, ...(host ? { host } : {}) }
|
||||
}
|
||||
|
||||
function readMergeMethod(value: unknown): GitHubPRMergeMethod | undefined {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { FLOATING_WORKSPACE_WORKTREE_ID } from './floating-workspace'
|
||||
import { loadMobileNewTabAgentOptions } from './mobile-new-tab-agent-loader'
|
||||
|
||||
function createClient(
|
||||
handler: (method: string, params?: unknown) => Promise<unknown>
|
||||
): RpcClient & { sendRequest: ReturnType<typeof vi.fn> } {
|
||||
return {
|
||||
sendRequest: vi.fn(handler),
|
||||
subscribe: vi.fn(() => () => {})
|
||||
} as unknown as RpcClient & { sendRequest: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
|
||||
describe('mobile new-tab agent loading', () => {
|
||||
it('detects agents locally for the floating workspace without listing repos', async () => {
|
||||
const client = createClient(async (method) => {
|
||||
if (method === 'settings.get') {
|
||||
return {
|
||||
ok: true,
|
||||
result: { settings: { defaultTuiAgent: 'codex', disabledTuiAgents: [] } }
|
||||
}
|
||||
}
|
||||
if (method === 'preflight.detectAgents') {
|
||||
return { ok: true, result: ['claude', 'codex'] }
|
||||
}
|
||||
throw new Error(`unexpected request: ${method}`)
|
||||
})
|
||||
|
||||
await expect(
|
||||
loadMobileNewTabAgentOptions({
|
||||
client,
|
||||
worktreeId: FLOATING_WORKSPACE_WORKTREE_ID
|
||||
})
|
||||
).resolves.toEqual([
|
||||
{ agent: 'codex', label: 'Codex' },
|
||||
{ agent: 'claude', label: 'Claude' }
|
||||
])
|
||||
expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
|
||||
'preflight.detectAgents',
|
||||
'settings.get'
|
||||
])
|
||||
})
|
||||
|
||||
it('detects agents through the worktree repo connection for SSH sessions', async () => {
|
||||
const client = createClient(async (method, params) => {
|
||||
if (method === 'settings.get') {
|
||||
return { ok: true, result: { settings: {} } }
|
||||
}
|
||||
if (method === 'repo.list') {
|
||||
return { ok: true, result: { repos: [{ id: 'repo-1', connectionId: 'ssh-1' }] } }
|
||||
}
|
||||
if (method === 'preflight.detectRemoteAgents') {
|
||||
expect(params).toEqual({ connectionId: 'ssh-1' })
|
||||
return { ok: true, result: ['claude'] }
|
||||
}
|
||||
throw new Error(`unexpected request: ${method}`)
|
||||
})
|
||||
|
||||
await expect(
|
||||
loadMobileNewTabAgentOptions({
|
||||
client,
|
||||
worktreeId: 'repo-1::/remote/worktree'
|
||||
})
|
||||
).resolves.toEqual([{ agent: 'claude', label: 'Claude' }])
|
||||
expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
|
||||
'repo.list',
|
||||
'settings.get',
|
||||
'preflight.detectRemoteAgents'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcFailure, RpcSuccess } from '../transport/types'
|
||||
import { isFloatingWorkspaceWorktreeId } from './floating-workspace'
|
||||
import { getRepoIdFromMobileWorktreeId } from './mobile-session-route-helpers'
|
||||
import {
|
||||
buildMobileNewTabAgentOptions,
|
||||
type MobileNewTabAgentOption,
|
||||
type MobileNewTabAgentSettings
|
||||
} from './mobile-new-tab-agent-options'
|
||||
|
||||
type RuntimeRepoSummary = {
|
||||
id: string
|
||||
connectionId?: string | null
|
||||
}
|
||||
|
||||
export async function loadMobileNewTabAgentOptions(args: {
|
||||
client: RpcClient
|
||||
worktreeId: string
|
||||
}): Promise<MobileNewTabAgentOption[]> {
|
||||
const { client, worktreeId } = args
|
||||
// Why: the floating workspace runs on the paired host, so it has no repo connection to resolve.
|
||||
const detectedAgentsRequest = isFloatingWorkspaceWorktreeId(worktreeId)
|
||||
? client.sendRequest('preflight.detectAgents')
|
||||
: loadWorkspaceDetectedAgents(client, worktreeId)
|
||||
const [settingsResponse, detectedResponse] = await Promise.all([
|
||||
client.sendRequest('settings.get'),
|
||||
detectedAgentsRequest
|
||||
])
|
||||
if (!settingsResponse.ok) {
|
||||
throw new Error((settingsResponse as RpcFailure).error.message)
|
||||
}
|
||||
if (!detectedResponse.ok) {
|
||||
throw new Error((detectedResponse as RpcFailure).error.message)
|
||||
}
|
||||
const settings = (
|
||||
(settingsResponse as RpcSuccess).result as {
|
||||
settings?: MobileNewTabAgentSettings
|
||||
}
|
||||
).settings
|
||||
return buildMobileNewTabAgentOptions(
|
||||
settings,
|
||||
(detectedResponse as RpcSuccess).result as unknown[]
|
||||
)
|
||||
}
|
||||
|
||||
async function loadWorkspaceDetectedAgents(client: RpcClient, worktreeId: string) {
|
||||
const repoResponse = await client.sendRequest('repo.list')
|
||||
if (!repoResponse.ok) {
|
||||
throw new Error((repoResponse as RpcFailure).error.message)
|
||||
}
|
||||
const repoId = getRepoIdFromMobileWorktreeId(worktreeId)
|
||||
const repos =
|
||||
((repoResponse as RpcSuccess).result as { repos?: RuntimeRepoSummary[] }).repos ?? []
|
||||
const repo = repos.find((candidate) => candidate.id === repoId)
|
||||
if (!repo) {
|
||||
throw new Error('worktree_repo_not_found')
|
||||
}
|
||||
const connectionId = repo.connectionId?.trim() || null
|
||||
return connectionId
|
||||
? client.sendRequest('preflight.detectRemoteAgents', { connectionId })
|
||||
: client.sendRequest('preflight.detectAgents')
|
||||
}
|
||||
@@ -36,7 +36,10 @@ describe('mobile session startup', () => {
|
||||
)
|
||||
|
||||
expect(startupEffect).toContain("void client\n .sendRequest('worktree.activate'")
|
||||
expect(startupEffect).toContain("if (client && created !== '1' && !isFloatingWorkspaceRoute)")
|
||||
expect(startupEffect).toContain("if (client && created === '1' && !isFloatingWorkspaceRoute)")
|
||||
expect(startupEffect).toContain('notifyClients: false')
|
||||
expect(startupEffect).toContain("navigation: 'caller'")
|
||||
expect(startupEffect).not.toContain("await client\n .sendRequest('worktree.activate'")
|
||||
expect(startupEffect.indexOf("sendRequest('worktree.activate'")).toBeLessThan(
|
||||
startupEffect.indexOf('await fetchSessionTabs()')
|
||||
@@ -62,21 +65,23 @@ describe('mobile session startup', () => {
|
||||
expect(pendingActivationEffect).toContain('tabId: activePendingTerminalTab.id')
|
||||
expect(pendingActivationEffect).toContain('leafId: activePendingTerminalTab.leafId')
|
||||
expect(pendingActivationEffect).toContain('notifyClients: false')
|
||||
expect(pendingActivationEffect).toContain("navigation: 'caller'")
|
||||
expect(pendingActivationEffect).toContain(
|
||||
'applySessionTabs((response as RpcSuccess).result as SessionTabsResult)'
|
||||
)
|
||||
expect(pendingActivationEffect).toContain('scheduleDelayedAction(() => void fetchSessionTabs()')
|
||||
})
|
||||
|
||||
it('mirrors ready terminal taps while persisting them for headless hosts', () => {
|
||||
it('keeps ready terminal taps local while publishing caller selection', () => {
|
||||
const readyTerminalSwitch = sliceBetween(
|
||||
'const switchTab = useCallback(',
|
||||
'const switchSessionTab = useCallback('
|
||||
)
|
||||
|
||||
expect(readyTerminalSwitch).toContain('focusMobileTerminal(client, handle)')
|
||||
expect(readyTerminalSwitch).not.toContain('focusMobileTerminal(client, handle)')
|
||||
expect(readyTerminalSwitch).toContain('activateMobileSessionTab(client,')
|
||||
expect(readyTerminalSwitch).toContain('notifyClients: false')
|
||||
expect(readyTerminalSwitch).toContain("navigation: 'caller'")
|
||||
})
|
||||
|
||||
it('keeps background and pending session-tab activation local to the phone', () => {
|
||||
@@ -85,6 +90,7 @@ describe('mobile session startup', () => {
|
||||
expect(activationRequests).toHaveLength(4)
|
||||
for (const request of activationRequests) {
|
||||
expect(request.slice(0, request.indexOf('})'))).toContain('notifyClients: false')
|
||||
expect(request.slice(0, request.indexOf('})'))).toContain("navigation: 'caller'")
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -25,8 +25,14 @@ describe('mobile session tab activation', () => {
|
||||
}
|
||||
)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(1, 'terminal.focus', { terminal: 'terminal-1' })
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(2, 'terminal.focus', { terminal: 'terminal-1' })
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(1, 'terminal.focus', {
|
||||
terminal: 'terminal-1',
|
||||
navigation: 'host'
|
||||
})
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(2, 'terminal.focus', {
|
||||
terminal: 'terminal-1',
|
||||
navigation: 'host'
|
||||
})
|
||||
})
|
||||
|
||||
it('retries session-tab activation with the same target after cutover', async () => {
|
||||
@@ -38,7 +44,8 @@ describe('mobile session tab activation', () => {
|
||||
worktree: 'id:worktree-1',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'leaf-1',
|
||||
notifyClients: false as const
|
||||
notifyClients: false as const,
|
||||
navigation: 'caller' as const
|
||||
}
|
||||
|
||||
await expect(activateMobileSessionTab(clientWith(sendRequest), params)).resolves.toMatchObject({
|
||||
@@ -67,7 +74,8 @@ describe('mobile session tab activation', () => {
|
||||
activateMobileSessionTab(clientWith(sendRequest), {
|
||||
worktree: 'id:worktree-1',
|
||||
tabId: 'tab-1',
|
||||
notifyClients: false
|
||||
notifyClients: false,
|
||||
navigation: 'caller'
|
||||
})
|
||||
).rejects.toBeInstanceOf(LogicalClientCutoverError)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
|
||||
@@ -14,6 +14,7 @@ type MobileSessionTabActivationParams = {
|
||||
tabId: string
|
||||
leafId?: string
|
||||
notifyClients: false
|
||||
navigation: 'caller'
|
||||
}
|
||||
|
||||
async function retryIdempotentActivationAfterCutover(
|
||||
@@ -72,7 +73,7 @@ export function focusMobileTerminal(
|
||||
terminal: string
|
||||
): Promise<RpcResponse> {
|
||||
return retryIdempotentActivationAfterCutover(
|
||||
() => client.sendRequest('terminal.focus', { terminal }),
|
||||
() => client.sendRequest('terminal.focus', { terminal, navigation: 'host' }),
|
||||
'terminal.focus',
|
||||
terminal
|
||||
)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { GitHubPRMergeMethod } from '../../../src/shared/types'
|
||||
import type { GitHubPrMutationOutcome } from './github-pr-mutations'
|
||||
import type { GitHubPrRepoSlug } from './github-pr-rpc'
|
||||
|
||||
export type PrActionMutations = {
|
||||
mergePR: (args: {
|
||||
prNumber: number
|
||||
method?: GitHubPRMergeMethod
|
||||
prRepo?: GitHubPrRepoSlug | null
|
||||
}) => Promise<GitHubPrMutationOutcome>
|
||||
setPRAutoMerge: (args: {
|
||||
prNumber: number
|
||||
enabled: boolean
|
||||
method?: GitHubPRMergeMethod
|
||||
prRepo?: GitHubPrRepoSlug | null
|
||||
}) => Promise<GitHubPrMutationOutcome>
|
||||
updatePRState: (args: {
|
||||
prNumber: number
|
||||
state: 'open' | 'closed'
|
||||
prRepo?: GitHubPrRepoSlug | null
|
||||
}) => Promise<GitHubPrMutationOutcome>
|
||||
requestReviewers: (args: {
|
||||
prNumber: number
|
||||
reviewers: string[]
|
||||
prRepo?: GitHubPrRepoSlug | null
|
||||
}) => Promise<GitHubPrMutationOutcome>
|
||||
removeReviewers: (args: {
|
||||
prNumber: number
|
||||
reviewers: string[]
|
||||
prRepo?: GitHubPrRepoSlug | null
|
||||
}) => Promise<GitHubPrMutationOutcome>
|
||||
rerunChecks: (args: {
|
||||
prNumber: number
|
||||
headSha?: string | null
|
||||
failedOnly?: boolean
|
||||
prRepo?: GitHubPrRepoSlug | null
|
||||
}) => Promise<GitHubPrMutationOutcome>
|
||||
}
|
||||
@@ -198,4 +198,43 @@ describe('PrActionsEngine — PR identity changes', () => {
|
||||
await action
|
||||
expect(refetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears in-flight state when only the GitHub host changes', async () => {
|
||||
const slow = deferred<GitHubPrMutationOutcome>()
|
||||
const mutations: PrActionMutations = {
|
||||
mergePR: async () => ({ ok: true }),
|
||||
setPRAutoMerge: async () => slow.promise,
|
||||
updatePRState: async () => ({ ok: true }),
|
||||
requestReviewers: async () => ({ ok: true }),
|
||||
removeReviewers: async () => ({ ok: true }),
|
||||
rerunChecks: async () => ({ ok: true })
|
||||
}
|
||||
const refetch = vi.fn()
|
||||
const onChange = vi.fn()
|
||||
const baseConfig = {
|
||||
mutations,
|
||||
prNumber: 1,
|
||||
refetch,
|
||||
onChange
|
||||
}
|
||||
const engine = new PrActionsEngine({
|
||||
...baseConfig,
|
||||
prRepo: { owner: 'acme', repo: 'widgets' }
|
||||
})
|
||||
|
||||
const action = engine.setAutoMerge(true)
|
||||
expect(engine.resolveAutoMerge(false)).toBe(true)
|
||||
expect(engine.isBusy({ kind: 'autoMerge' })).toBe(true)
|
||||
|
||||
engine.updateConfig({
|
||||
...baseConfig,
|
||||
prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' }
|
||||
})
|
||||
expect(engine.resolveAutoMerge(false)).toBe(false)
|
||||
expect(engine.busy).toBeNull()
|
||||
|
||||
slow.resolve({ ok: true })
|
||||
await action
|
||||
expect(refetch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,45 +1,18 @@
|
||||
import type { GitHubPRMergeMethod, PRState } from '../../../src/shared/types'
|
||||
import { githubRepoIdentityKey } from '../../../src/shared/github-repository-identity-key'
|
||||
import { classifyPrSidebarFailure } from './mobile-pr-sidebar-state'
|
||||
import { createOptimisticField, type OptimisticField } from './optimistic-write-sequence'
|
||||
import type { GitHubPrMutationOutcome } from './github-pr-mutations'
|
||||
import type { GitHubPrRepoSlug } from './github-pr-rpc'
|
||||
import type { PrActionMutations } from './pr-action-mutation-contract'
|
||||
|
||||
export type { PrActionMutations } from './pr-action-mutation-contract'
|
||||
|
||||
// Pure (React-free) engine for the PR mutation actions: owns optimistic fields,
|
||||
// busy/error/blocked state, and the success/transient/permanent routing. The hook
|
||||
// is a thin adapter that subscribes to `onChange` and exposes these methods. Kept
|
||||
// React-free so the U6 action logic is unit-testable with injected fakes.
|
||||
|
||||
export type PrActionMutations = {
|
||||
mergePR: (args: {
|
||||
prNumber: number
|
||||
method?: GitHubPRMergeMethod
|
||||
prRepo?: GitHubPrRepoSlug | null
|
||||
}) => Promise<GitHubPrMutationOutcome>
|
||||
setPRAutoMerge: (args: {
|
||||
prNumber: number
|
||||
enabled: boolean
|
||||
method?: GitHubPRMergeMethod
|
||||
prRepo?: GitHubPrRepoSlug | null
|
||||
}) => Promise<GitHubPrMutationOutcome>
|
||||
updatePRState: (args: {
|
||||
prNumber: number
|
||||
state: 'open' | 'closed'
|
||||
}) => Promise<GitHubPrMutationOutcome>
|
||||
requestReviewers: (args: {
|
||||
prNumber: number
|
||||
reviewers: string[]
|
||||
}) => Promise<GitHubPrMutationOutcome>
|
||||
removeReviewers: (args: {
|
||||
prNumber: number
|
||||
reviewers: string[]
|
||||
}) => Promise<GitHubPrMutationOutcome>
|
||||
rerunChecks: (args: {
|
||||
prNumber: number
|
||||
headSha?: string | null
|
||||
failedOnly?: boolean
|
||||
}) => Promise<GitHubPrMutationOutcome>
|
||||
}
|
||||
|
||||
export type PrActionBusyKey =
|
||||
| { kind: 'merge' }
|
||||
| { kind: 'autoMerge' }
|
||||
@@ -68,7 +41,7 @@ export type PrActionsEngineConfig = {
|
||||
}
|
||||
|
||||
function prActionsIdentity(cfg: PrActionsEngineConfig): string {
|
||||
const repo = cfg.prRepo ? `${cfg.prRepo.owner}/${cfg.prRepo.repo}` : ''
|
||||
const repo = cfg.prRepo ? githubRepoIdentityKey(cfg.prRepo) : ''
|
||||
return `${cfg.prNumber}:${repo}`
|
||||
}
|
||||
|
||||
@@ -260,7 +233,8 @@ export class PrActionsEngine {
|
||||
try {
|
||||
const outcome = await cfg.mutations.updatePRState({
|
||||
prNumber: cfg.prNumber,
|
||||
state
|
||||
state,
|
||||
prRepo: cfg.prRepo
|
||||
})
|
||||
await this.settle(identity, outcome, {
|
||||
onSuccess: () => this.stateField.settleSuccess(seq),
|
||||
@@ -281,7 +255,8 @@ export class PrActionsEngine {
|
||||
try {
|
||||
const outcome = await cfg.mutations.requestReviewers({
|
||||
prNumber: cfg.prNumber,
|
||||
reviewers: [login]
|
||||
reviewers: [login],
|
||||
prRepo: cfg.prRepo
|
||||
})
|
||||
await this.settle(identity, outcome, {
|
||||
onSuccess: () => field.settleSuccess(seq),
|
||||
@@ -302,7 +277,8 @@ export class PrActionsEngine {
|
||||
try {
|
||||
const outcome = await cfg.mutations.removeReviewers({
|
||||
prNumber: cfg.prNumber,
|
||||
reviewers: [login]
|
||||
reviewers: [login],
|
||||
prRepo: cfg.prRepo
|
||||
})
|
||||
await this.settle(identity, outcome, {
|
||||
onSuccess: () => field.settleSuccess(seq),
|
||||
@@ -322,7 +298,8 @@ export class PrActionsEngine {
|
||||
const outcome = await cfg.mutations.rerunChecks({
|
||||
prNumber: cfg.prNumber,
|
||||
headSha: cfg.headSha,
|
||||
failedOnly: true
|
||||
failedOnly: true,
|
||||
prRepo: cfg.prRepo
|
||||
})
|
||||
await this.settle(identity, outcome, { onSuccess: () => {}, onRevert: () => {} })
|
||||
} finally {
|
||||
|
||||
@@ -24,7 +24,10 @@ describe('createTerminalAndSendPrompt', () => {
|
||||
await createTerminalAndSendPrompt(client, 'wt-1', 'do the thing')
|
||||
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'session.tabs.createTerminal', {
|
||||
worktree: 'id:wt-1'
|
||||
worktree: 'id:wt-1',
|
||||
activate: false,
|
||||
select: true,
|
||||
navigation: 'caller'
|
||||
})
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'terminal.send', {
|
||||
terminal: 'term-1',
|
||||
|
||||
@@ -16,7 +16,10 @@ export async function createTerminalAndSendPrompt(
|
||||
prompt: string
|
||||
): Promise<void> {
|
||||
const created = await client.sendRequest('session.tabs.createTerminal', {
|
||||
worktree: `id:${worktreeId}`
|
||||
worktree: `id:${worktreeId}`,
|
||||
activate: false,
|
||||
select: true,
|
||||
navigation: 'caller'
|
||||
})
|
||||
if (!created.ok) {
|
||||
throw new Error(created.error?.message || 'Failed to create terminal')
|
||||
|
||||
@@ -134,13 +134,19 @@ describe('canEditComment / canDeleteComment', () => {
|
||||
|
||||
describe('buildEditCommentParams / buildDeleteCommentParams', () => {
|
||||
it('build slug-addressed params', () => {
|
||||
const slug = { owner: 'o', repo: 'r' }
|
||||
const slug = { owner: 'o', repo: 'r', host: 'github.acme.test' }
|
||||
expect(buildEditCommentParams(slug, 42, 'new body')).toEqual({
|
||||
owner: 'o',
|
||||
repo: 'r',
|
||||
host: 'github.acme.test',
|
||||
commentId: 42,
|
||||
body: 'new body'
|
||||
})
|
||||
expect(buildDeleteCommentParams(slug, 42)).toEqual({ owner: 'o', repo: 'r', commentId: 42 })
|
||||
expect(buildDeleteCommentParams(slug, 42)).toEqual({
|
||||
owner: 'o',
|
||||
repo: 'r',
|
||||
host: 'github.acme.test',
|
||||
commentId: 42
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PRComment, PRState } from '../../../src/shared/types'
|
||||
import type { GitHubOwnerRepo, PRComment, PRState } from '../../../src/shared/types'
|
||||
|
||||
// Pure helpers for the interactive PR comment timeline (reply / resolve / add
|
||||
// root comment). Kept free of React/native imports so they unit-test under the
|
||||
@@ -87,35 +87,46 @@ export function isMutablePRConversationComment(
|
||||
// can hide the affordance rather than firing a doomed request.
|
||||
export function canEditComment(
|
||||
comment: Pick<PRComment, 'id' | 'threadId' | 'path' | 'url'>,
|
||||
prRepo: { owner: string; repo: string } | null | undefined
|
||||
prRepo: GitHubOwnerRepo | null | undefined
|
||||
): boolean {
|
||||
return Boolean(prRepo) && isMutablePRConversationComment(comment)
|
||||
}
|
||||
|
||||
export function canDeleteComment(
|
||||
comment: Pick<PRComment, 'id' | 'threadId' | 'path' | 'url'>,
|
||||
prRepo: { owner: string; repo: string } | null | undefined
|
||||
prRepo: GitHubOwnerRepo | null | undefined
|
||||
): boolean {
|
||||
return Boolean(prRepo) && isMutablePRConversationComment(comment)
|
||||
}
|
||||
|
||||
export type EditCommentParams = { owner: string; repo: string; commentId: number; body: string }
|
||||
export type EditCommentParams = GitHubOwnerRepo & { commentId: number; body: string }
|
||||
|
||||
export function buildEditCommentParams(
|
||||
prRepo: { owner: string; repo: string },
|
||||
prRepo: GitHubOwnerRepo,
|
||||
commentId: number,
|
||||
body: string
|
||||
): EditCommentParams {
|
||||
return { owner: prRepo.owner, repo: prRepo.repo, commentId, body }
|
||||
return {
|
||||
owner: prRepo.owner,
|
||||
repo: prRepo.repo,
|
||||
...(prRepo.host ? { host: prRepo.host } : {}),
|
||||
commentId,
|
||||
body
|
||||
}
|
||||
}
|
||||
|
||||
export type DeleteCommentParams = { owner: string; repo: string; commentId: number }
|
||||
export type DeleteCommentParams = GitHubOwnerRepo & { commentId: number }
|
||||
|
||||
export function buildDeleteCommentParams(
|
||||
prRepo: { owner: string; repo: string },
|
||||
prRepo: GitHubOwnerRepo,
|
||||
commentId: number
|
||||
): DeleteCommentParams {
|
||||
return { owner: prRepo.owner, repo: prRepo.repo, commentId }
|
||||
return {
|
||||
owner: prRepo.owner,
|
||||
repo: prRepo.repo,
|
||||
...(prRepo.host ? { host: prRepo.host } : {}),
|
||||
commentId
|
||||
}
|
||||
}
|
||||
|
||||
// The composer disables submit on empty/whitespace input (host rejects empty body).
|
||||
|
||||
@@ -166,4 +166,86 @@ describe('useLiveWorktreeName request volume', () => {
|
||||
|
||||
expect(unsubscribeStream).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('never calls worktree.show for the floating workspace sentinel', async () => {
|
||||
let name = ''
|
||||
function FloatingHarness(): null {
|
||||
name = useLiveWorktreeName({
|
||||
client,
|
||||
connState: 'connected',
|
||||
routeName: undefined,
|
||||
worktreeId: 'global-floating-terminal'
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(FloatingHarness))
|
||||
await Promise.resolve()
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
})
|
||||
|
||||
expect(sendRequest).not.toHaveBeenCalled()
|
||||
expect(subscribe).not.toHaveBeenCalled()
|
||||
expect(name).toBe('Floating Workspace')
|
||||
})
|
||||
|
||||
it('does not render stale names while switching through the floating route', async () => {
|
||||
const firstNameByWorktree = new Map<string, string>()
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
function RouteHarness(props: { routeName?: string; worktreeId: string }): null {
|
||||
const name = useLiveWorktreeName({
|
||||
client,
|
||||
connState: 'connected',
|
||||
routeName: props.routeName,
|
||||
worktreeId: props.worktreeId
|
||||
})
|
||||
if (!firstNameByWorktree.has(props.worktreeId)) {
|
||||
firstNameByWorktree.set(props.worktreeId, name)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(RouteHarness, {
|
||||
routeName: 'Previous workspace',
|
||||
worktreeId: 'repo-1::/worktree'
|
||||
})
|
||||
)
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
renderer?.update(
|
||||
createElement(RouteHarness, {
|
||||
worktreeId: 'global-floating-terminal'
|
||||
})
|
||||
)
|
||||
})
|
||||
await act(async () => {
|
||||
renderer?.update(
|
||||
createElement(RouteHarness, {
|
||||
routeName: 'Next workspace',
|
||||
worktreeId: 'repo-2::/worktree'
|
||||
})
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
act(() => renderer?.unmount())
|
||||
}
|
||||
|
||||
expect(firstNameByWorktree.get('global-floating-terminal')).toBe('Floating Workspace')
|
||||
expect(firstNameByWorktree.get('repo-2::/worktree')).toBe('Next workspace')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getRepoIdFromWorktreeId } from '../../../src/shared/worktree-id'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState, RpcSuccess } from '../transport/types'
|
||||
import { getLiveWorktreeDisplayName, type WorktreeDisplayNameSource } from './worktree-display-name'
|
||||
import { FLOATING_WORKSPACE_TITLE, isFloatingWorkspaceWorktreeId } from './floating-workspace'
|
||||
|
||||
const WORKTREE_NAME_FALLBACK_POLL_MS = 3000
|
||||
|
||||
@@ -16,15 +17,26 @@ type Params = {
|
||||
}
|
||||
|
||||
export function useLiveWorktreeName({ client, connState, routeName, worktreeId }: Params): string {
|
||||
const [worktreeName, setWorktreeName] = useState(() => routeName?.trim() ?? '')
|
||||
// Why: the floating sentinel has no worktree record, so worktree.show would
|
||||
// fail forever and keep the 3s fallback poll alive; its title is fixed.
|
||||
const isFloatingWorkspace = isFloatingWorkspaceWorktreeId(worktreeId)
|
||||
const routeNameHint = routeName?.trim() ?? ''
|
||||
const [worktreeName, setWorktreeName] = useState(() => ({
|
||||
worktreeId,
|
||||
name: routeNameHint
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
setWorktreeName(routeName?.trim() ?? '')
|
||||
}, [routeName, worktreeId])
|
||||
setWorktreeName((current) =>
|
||||
current.worktreeId === worktreeId && current.name === routeNameHint
|
||||
? current
|
||||
: { worktreeId, name: routeNameHint }
|
||||
)
|
||||
}, [routeNameHint, worktreeId])
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!client || connState !== 'connected') {
|
||||
if (isFloatingWorkspace || !client || connState !== 'connected') {
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
@@ -58,7 +70,11 @@ export function useLiveWorktreeName({ client, connState, routeName, worktreeId }
|
||||
? getLiveWorktreeDisplayName([result.worktree], worktreeId)
|
||||
: null
|
||||
if (liveName) {
|
||||
setWorktreeName((current) => (current === liveName ? current : liveName))
|
||||
setWorktreeName((current) =>
|
||||
current.worktreeId === worktreeId && current.name === liveName
|
||||
? current
|
||||
: { worktreeId, name: liveName }
|
||||
)
|
||||
}
|
||||
hasSuccessfulRefresh = true
|
||||
if (eventStreamReady) {
|
||||
@@ -128,8 +144,11 @@ export function useLiveWorktreeName({ client, connState, routeName, worktreeId }
|
||||
stopFallbackPoll()
|
||||
unsubscribe()
|
||||
}
|
||||
}, [client, connState, worktreeId])
|
||||
}, [client, connState, worktreeId, isFloatingWorkspace])
|
||||
)
|
||||
|
||||
return worktreeName
|
||||
if (isFloatingWorkspace) {
|
||||
return FLOATING_WORKSPACE_TITLE
|
||||
}
|
||||
return worktreeName.worktreeId === worktreeId ? worktreeName.name : routeNameHint
|
||||
}
|
||||
|
||||
@@ -99,7 +99,10 @@ export function useMobileDiffReviewSendActions(input: SendActionsInput) {
|
||||
throw new Error('Waiting for desktop...')
|
||||
}
|
||||
const response = await client.sendRequest('session.tabs.createTerminal', {
|
||||
worktree: `id:${worktreeId}`
|
||||
worktree: `id:${worktreeId}`,
|
||||
activate: false,
|
||||
select: true,
|
||||
navigation: 'caller'
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error?.message || 'Failed to create terminal')
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { FLOATING_WORKSPACE_WORKTREE_ID } from './floating-workspace'
|
||||
import { useMobileNativeChatReadability } from './use-mobile-native-chat-readability'
|
||||
|
||||
describe('useMobileNativeChatReadability', () => {
|
||||
@@ -18,15 +19,19 @@ describe('useMobileNativeChatReadability', () => {
|
||||
renderer = null
|
||||
})
|
||||
|
||||
async function mount(connectionId: string | null): Promise<void> {
|
||||
async function mount(
|
||||
connectionId: string | null,
|
||||
worktreeId = 'repo::/worktree'
|
||||
): Promise<ReturnType<typeof vi.fn>> {
|
||||
const sendRequest = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
result: { repos: [{ id: 'repo', connectionId }] }
|
||||
})
|
||||
const client = {
|
||||
sendRequest: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
result: { repos: [{ id: 'repo', connectionId }] }
|
||||
})
|
||||
sendRequest
|
||||
} as unknown as RpcClient
|
||||
function Harness(): null {
|
||||
readable = useMobileNativeChatReadability(client, 'repo::/worktree')
|
||||
readable = useMobileNativeChatReadability(client, worktreeId)
|
||||
return null
|
||||
}
|
||||
const original = console.error
|
||||
@@ -44,6 +49,7 @@ describe('useMobileNativeChatReadability', () => {
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
return sendRequest
|
||||
}
|
||||
|
||||
it('admits local and runtime-owned transcript hosts', async () => {
|
||||
@@ -61,6 +67,13 @@ describe('useMobileNativeChatReadability', () => {
|
||||
expect(readable).toBe(false)
|
||||
})
|
||||
|
||||
it('treats the host-local floating workspace as readable without listing repos', async () => {
|
||||
const sendRequest = await mount(null, FLOATING_WORKSPACE_WORKTREE_ID)
|
||||
|
||||
expect(readable).toBe(true)
|
||||
expect(sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed immediately while a reused route resolves its new worktree', async () => {
|
||||
let resolveNext: (response: unknown) => void = () => {}
|
||||
const client = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { isFloatingWorkspaceWorktreeId } from './floating-workspace'
|
||||
import { isMobileNativeChatTranscriptReadable } from './mobile-native-chat-eligibility'
|
||||
import { getRepoIdFromMobileWorktreeId } from './mobile-session-route-helpers'
|
||||
|
||||
@@ -10,12 +11,17 @@ export function useMobileNativeChatReadability(
|
||||
client: RpcClient | null,
|
||||
worktreeId: string
|
||||
): boolean {
|
||||
const isFloatingWorkspace = isFloatingWorkspaceWorktreeId(worktreeId)
|
||||
const [state, setState] = useState<ReadabilityState>({
|
||||
client: null,
|
||||
worktreeId: '',
|
||||
readable: false
|
||||
})
|
||||
useEffect(() => {
|
||||
// Why: the floating workspace always runs on the paired host and has no repo connection to resolve.
|
||||
if (isFloatingWorkspace) {
|
||||
return
|
||||
}
|
||||
let active = true
|
||||
if (!client) {
|
||||
setState({ client, worktreeId, readable: false })
|
||||
@@ -46,7 +52,10 @@ export function useMobileNativeChatReadability(
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [client, worktreeId])
|
||||
}, [client, isFloatingWorkspace, worktreeId])
|
||||
if (isFloatingWorkspace) {
|
||||
return true
|
||||
}
|
||||
// Why: route reuse renders before its new effect resolves; never expose the
|
||||
// previous repo's readability under a different client/worktree key.
|
||||
return state.client === client && state.worktreeId === worktreeId ? state.readable : false
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from './github-pr-mutations'
|
||||
|
||||
const WORKTREE_ID = 'repo-42::/path/to/wt'
|
||||
const ENTERPRISE_PR_REPO = { owner: 'fork', repo: 'proj', host: 'github.acme.test' }
|
||||
|
||||
function okStatus(): RpcResponse {
|
||||
return { id: 'x', ok: true, result: { ok: true }, _meta: { runtimeId: 'r' } }
|
||||
@@ -46,13 +47,17 @@ function fakeMutations(overrides: Partial<PrActionMutations> = {}): PrActionMuta
|
||||
}
|
||||
}
|
||||
|
||||
function makeEngine(mutations: PrActionMutations, refetch = vi.fn(async () => {})) {
|
||||
function makeEngine(
|
||||
mutations: PrActionMutations,
|
||||
refetch = vi.fn(async () => {}),
|
||||
prRepo: typeof ENTERPRISE_PR_REPO | null = null
|
||||
) {
|
||||
const onChange = vi.fn()
|
||||
const engine = new PrActionsEngine({
|
||||
mutations,
|
||||
prNumber: 7,
|
||||
headSha: 'abc123',
|
||||
prRepo: null,
|
||||
prRepo,
|
||||
refetch,
|
||||
onChange
|
||||
})
|
||||
@@ -67,7 +72,7 @@ describe('mutation wrappers — prRepo + param shapes', () => {
|
||||
await fetchMergePR(client, WORKTREE_ID, {
|
||||
prNumber: 7,
|
||||
method: 'squash',
|
||||
prRepo: { owner: 'fork', repo: 'proj' }
|
||||
prRepo: ENTERPRISE_PR_REPO
|
||||
})
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'github.mergePR',
|
||||
@@ -75,17 +80,25 @@ describe('mutation wrappers — prRepo + param shapes', () => {
|
||||
repo: 'id:repo-42',
|
||||
prNumber: 7,
|
||||
method: 'squash',
|
||||
prRepo: { owner: 'fork', repo: 'proj' }
|
||||
prRepo: ENTERPRISE_PR_REPO
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('updatePRState close carries NO prRepo and the state update', async () => {
|
||||
it('updatePRState carries the host-qualified PR repo and state update', async () => {
|
||||
const { client, sendRequest } = mockClient(okStatus())
|
||||
await fetchUpdatePRState(client, WORKTREE_ID, { prNumber: 7, state: 'closed' })
|
||||
await fetchUpdatePRState(client, WORKTREE_ID, {
|
||||
prNumber: 7,
|
||||
state: 'closed',
|
||||
prRepo: ENTERPRISE_PR_REPO
|
||||
})
|
||||
const [, params] = sendRequest.mock.calls[0]
|
||||
expect(params).toEqual({ repo: 'id:repo-42', prNumber: 7, updates: { state: 'closed' } })
|
||||
expect(params).not.toHaveProperty('prRepo')
|
||||
expect(params).toEqual({
|
||||
repo: 'id:repo-42',
|
||||
prNumber: 7,
|
||||
updates: { state: 'closed' },
|
||||
prRepo: ENTERPRISE_PR_REPO
|
||||
})
|
||||
})
|
||||
|
||||
it('updatePRState reopen passes state:open', async () => {
|
||||
@@ -95,27 +108,43 @@ describe('mutation wrappers — prRepo + param shapes', () => {
|
||||
expect((params.updates as { state: string }).state).toBe('open')
|
||||
})
|
||||
|
||||
it('request/remove reviewers carry NO prRepo even when a fork is in play', async () => {
|
||||
it('request/remove reviewers carry the host-qualified PR repo', async () => {
|
||||
const { client, sendRequest } = mockClient(okStatus())
|
||||
await fetchRequestPRReviewers(client, WORKTREE_ID, { prNumber: 7, reviewers: ['alice'] })
|
||||
await fetchRemovePRReviewers(client, WORKTREE_ID, { prNumber: 7, reviewers: ['bob'] })
|
||||
for (const [, params] of sendRequest.mock.calls) {
|
||||
expect(params).not.toHaveProperty('prRepo')
|
||||
}
|
||||
expect(sendRequest.mock.calls[0][1]).toMatchObject({ reviewers: ['alice'] })
|
||||
expect(sendRequest.mock.calls[1][1]).toMatchObject({ reviewers: ['bob'] })
|
||||
await fetchRequestPRReviewers(client, WORKTREE_ID, {
|
||||
prNumber: 7,
|
||||
reviewers: ['alice'],
|
||||
prRepo: ENTERPRISE_PR_REPO
|
||||
})
|
||||
await fetchRemovePRReviewers(client, WORKTREE_ID, {
|
||||
prNumber: 7,
|
||||
reviewers: ['bob'],
|
||||
prRepo: ENTERPRISE_PR_REPO
|
||||
})
|
||||
expect(sendRequest.mock.calls[0][1]).toMatchObject({
|
||||
reviewers: ['alice'],
|
||||
prRepo: ENTERPRISE_PR_REPO
|
||||
})
|
||||
expect(sendRequest.mock.calls[1][1]).toMatchObject({
|
||||
reviewers: ['bob'],
|
||||
prRepo: ENTERPRISE_PR_REPO
|
||||
})
|
||||
})
|
||||
|
||||
it('rerunPRChecks carries failedOnly + headSha and NO prRepo', async () => {
|
||||
it('rerunPRChecks carries failedOnly, headSha, and the host-qualified PR repo', async () => {
|
||||
const { client, sendRequest } = mockClient(okStatus())
|
||||
await fetchRerunPRChecks(client, WORKTREE_ID, {
|
||||
prNumber: 7,
|
||||
headSha: 'abc',
|
||||
failedOnly: true
|
||||
failedOnly: true,
|
||||
prRepo: ENTERPRISE_PR_REPO
|
||||
})
|
||||
const [, params] = sendRequest.mock.calls[0]
|
||||
expect(params).toMatchObject({ prNumber: 7, failedOnly: true, headSha: 'abc' })
|
||||
expect(params).not.toHaveProperty('prRepo')
|
||||
expect(params).toMatchObject({
|
||||
prNumber: 7,
|
||||
failedOnly: true,
|
||||
headSha: 'abc',
|
||||
prRepo: ENTERPRISE_PR_REPO
|
||||
})
|
||||
})
|
||||
|
||||
it('a host { ok:false, error } result surfaces as a failure outcome', async () => {
|
||||
@@ -155,14 +184,14 @@ describe('PrActionsEngine — auto-merge optimistic revert', () => {
|
||||
const mutations = fakeMutations({
|
||||
setPRAutoMerge: vi.fn(async () => ({ ok: false as const, error: 'connection lost' }))
|
||||
})
|
||||
const { engine } = makeEngine(mutations)
|
||||
const { engine } = makeEngine(mutations, undefined, ENTERPRISE_PR_REPO)
|
||||
// authoritative = false; user enables.
|
||||
await engine.setAutoMerge(true, 'squash')
|
||||
expect(mutations.setPRAutoMerge).toHaveBeenCalledWith({
|
||||
prNumber: 7,
|
||||
enabled: true,
|
||||
method: 'squash',
|
||||
prRepo: null
|
||||
prRepo: ENTERPRISE_PR_REPO
|
||||
})
|
||||
// Reverted to authoritative after transient failure.
|
||||
expect(engine.resolveAutoMerge(false)).toBe(false)
|
||||
@@ -182,20 +211,32 @@ describe('PrActionsEngine — auto-merge optimistic revert', () => {
|
||||
describe('PrActionsEngine — updateState close/reopen', () => {
|
||||
it('close fires state:closed and reopen fires state:open', async () => {
|
||||
const mutations = fakeMutations()
|
||||
const { engine } = makeEngine(mutations)
|
||||
const { engine } = makeEngine(mutations, undefined, ENTERPRISE_PR_REPO)
|
||||
await engine.updateState('closed')
|
||||
await engine.updateState('open')
|
||||
expect(mutations.updatePRState).toHaveBeenNthCalledWith(1, { prNumber: 7, state: 'closed' })
|
||||
expect(mutations.updatePRState).toHaveBeenNthCalledWith(2, { prNumber: 7, state: 'open' })
|
||||
expect(mutations.updatePRState).toHaveBeenNthCalledWith(1, {
|
||||
prNumber: 7,
|
||||
state: 'closed',
|
||||
prRepo: ENTERPRISE_PR_REPO
|
||||
})
|
||||
expect(mutations.updatePRState).toHaveBeenNthCalledWith(2, {
|
||||
prNumber: 7,
|
||||
state: 'open',
|
||||
prRepo: ENTERPRISE_PR_REPO
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('PrActionsEngine — reviewers', () => {
|
||||
it('requestReviewer adds via requestReviewers with a single login (no prRepo at engine level)', async () => {
|
||||
it('requestReviewer adds via requestReviewers with a host-qualified PR repo', async () => {
|
||||
const mutations = fakeMutations()
|
||||
const { engine } = makeEngine(mutations)
|
||||
const { engine } = makeEngine(mutations, undefined, ENTERPRISE_PR_REPO)
|
||||
await engine.requestReviewer('alice')
|
||||
expect(mutations.requestReviewers).toHaveBeenCalledWith({ prNumber: 7, reviewers: ['alice'] })
|
||||
expect(mutations.requestReviewers).toHaveBeenCalledWith({
|
||||
prNumber: 7,
|
||||
reviewers: ['alice'],
|
||||
prRepo: ENTERPRISE_PR_REPO
|
||||
})
|
||||
})
|
||||
|
||||
it('removeReviewer optimistic revert on transient failure', async () => {
|
||||
@@ -213,12 +254,13 @@ describe('PrActionsEngine — reviewers', () => {
|
||||
describe('PrActionsEngine — rerun checks', () => {
|
||||
it('fires failedOnly:true with headSha and refetches on success', async () => {
|
||||
const mutations = fakeMutations()
|
||||
const { engine, refetch } = makeEngine(mutations)
|
||||
const { engine, refetch } = makeEngine(mutations, undefined, ENTERPRISE_PR_REPO)
|
||||
await engine.rerunFailingChecks()
|
||||
expect(mutations.rerunChecks).toHaveBeenCalledWith({
|
||||
prNumber: 7,
|
||||
headSha: 'abc123',
|
||||
failedOnly: true
|
||||
failedOnly: true,
|
||||
prRepo: ENTERPRISE_PR_REPO
|
||||
})
|
||||
expect(refetch).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -30,7 +30,11 @@ export type PrCommentMutations = {
|
||||
line?: number
|
||||
prRepo?: GitHubPrRepoSlug | null
|
||||
}) => Promise<GitHubPrMutationOutcome>
|
||||
resolveThread: (args: { threadId: string; resolve: boolean }) => Promise<GitHubPrMutationOutcome>
|
||||
resolveThread: (args: {
|
||||
threadId: string
|
||||
resolve: boolean
|
||||
prRepo?: GitHubPrRepoSlug | null
|
||||
}) => Promise<GitHubPrMutationOutcome>
|
||||
addRootComment: (args: {
|
||||
prNumber: number
|
||||
body: string
|
||||
@@ -39,12 +43,14 @@ export type PrCommentMutations = {
|
||||
editComment: (args: {
|
||||
owner: string
|
||||
repo: string
|
||||
host?: string
|
||||
commentId: number
|
||||
body: string
|
||||
}) => Promise<GitHubPrMutationOutcome>
|
||||
deleteComment: (args: {
|
||||
owner: string
|
||||
repo: string
|
||||
host?: string
|
||||
commentId: number
|
||||
}) => Promise<GitHubPrMutationOutcome>
|
||||
}
|
||||
@@ -169,9 +175,9 @@ export function useMobilePrCommentActions(input: PrCommentActionsInput) {
|
||||
if (!mutations || !params) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
return run(resolveKey(params.threadId), () => mutations.resolveThread(params))
|
||||
return run(resolveKey(params.threadId), () => mutations.resolveThread({ ...params, prRepo }))
|
||||
},
|
||||
[mutations, run]
|
||||
[mutations, prRepo, run]
|
||||
)
|
||||
|
||||
const addRootComment = useCallback(
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(new URL('../../app/h/[hostId]/tasks.tsx', import.meta.url), 'utf8')
|
||||
|
||||
describe('mobile GitHub Project host routing boundary', () => {
|
||||
it('host-qualifies every Project RPC request', () => {
|
||||
const calls = [...source.matchAll(/['"](github\.project\.[^'"]+)['"]/g)]
|
||||
expect(calls.length).toBeGreaterThan(10)
|
||||
for (const call of calls) {
|
||||
const request = source.slice(call.index, call.index + 700)
|
||||
expect(request, `${call[1]} must carry a host`).toMatch(/\bhost\s*:/)
|
||||
}
|
||||
})
|
||||
|
||||
it('pins Project-row PR actions to the row repository identity', () => {
|
||||
const actions = source.slice(source.indexOf('const toggleProjectGitHubReviewThread'))
|
||||
for (const method of [
|
||||
'github.resolveReviewThread',
|
||||
'github.addPRReviewCommentReply',
|
||||
'github.addIssueComment',
|
||||
'github.requestPRReviewers',
|
||||
'github.prChecks',
|
||||
'github.rerunPRChecks',
|
||||
'github.setPRFileViewed',
|
||||
'github.prFileContents',
|
||||
'github.addPRReviewComment',
|
||||
'github.mergePR'
|
||||
]) {
|
||||
const offset = actions.indexOf(`'${method}'`)
|
||||
expect(offset, `${method} must remain wired in the Project action path`).toBeGreaterThan(-1)
|
||||
expect(actions.slice(offset, offset + 700), `${method} must carry prRepo`).toContain(
|
||||
'prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost)'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('pins discovery to github.com while pasted URLs supply their parsed host', () => {
|
||||
expect(source).toContain("'github.project.listAccessible', {\n host: 'github.com'")
|
||||
expect(source).toContain('host: githubProjectHost(parsed.host)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseGitHubProjectInput } from './github-project-reference'
|
||||
|
||||
describe('parseGitHubProjectInput', () => {
|
||||
it('keeps legacy owner/number input hostless', () => {
|
||||
expect(parseGitHubProjectInput('Acme/42')).toEqual({ owner: 'Acme', number: 42 })
|
||||
})
|
||||
|
||||
it('preserves GitHub Enterprise hosts and view numbers', () => {
|
||||
expect(
|
||||
parseGitHubProjectInput('https://GitHub.Acme-Corp.com:8443/orgs/Acme/projects/42/views/7')
|
||||
).toEqual({
|
||||
owner: 'Acme',
|
||||
number: 42,
|
||||
host: 'github.acme-corp.com:8443',
|
||||
viewNumber: 7
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts github.com user Project URLs', () => {
|
||||
expect(parseGitHubProjectInput('http://github.com/users/octocat/projects/3')).toEqual({
|
||||
owner: 'octocat',
|
||||
number: 3,
|
||||
host: 'github.com'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects credentials, invalid routes, and unsafe integers', () => {
|
||||
for (const input of [
|
||||
'https://user:token@github.acme.test/orgs/acme/projects/1',
|
||||
'https://github.acme.test/acme/projects/1',
|
||||
'https://github.acme.test/orgs/acme/projects/1/files',
|
||||
'https://github.acme.test/orgs/acme/projects/9007199254740992',
|
||||
'https://github.acme.test/orgs/acme/projects/1/views/0'
|
||||
]) {
|
||||
expect(parseGitHubProjectInput(input)).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { GitHubProjectIdentity } from '../../../src/shared/github-project-identity'
|
||||
|
||||
export type GitHubProjectOwnerType = GitHubProjectIdentity['ownerType']
|
||||
export type GitHubProjectRef = GitHubProjectIdentity
|
||||
export type GitHubProjectSettings = {
|
||||
pinned: GitHubProjectRef[]
|
||||
recent: Array<GitHubProjectRef & { lastOpenedAt: string }>
|
||||
lastViewByProject: Record<string, { viewId: string }>
|
||||
activeProject: GitHubProjectRef | null
|
||||
}
|
||||
export type GitHubProjectSummary = GitHubProjectRef & {
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
source: string
|
||||
}
|
||||
export type GitHubProjectPartialFailure = { owner: string; message: string }
|
||||
export type GitHubProjectViewSummary = {
|
||||
id: string
|
||||
number: number
|
||||
name: string
|
||||
layout: 'TABLE_LAYOUT' | 'BOARD_LAYOUT' | 'ROADMAP_LAYOUT'
|
||||
}
|
||||
|
||||
export type ParsedGitHubProjectInput = {
|
||||
owner: string
|
||||
number: number
|
||||
host?: string
|
||||
viewNumber?: number
|
||||
}
|
||||
|
||||
const OWNER_RE = /^[A-Za-z0-9][A-Za-z0-9-]*$/
|
||||
|
||||
function positiveInteger(value: string | undefined): number | null {
|
||||
if (!value || !/^\d+$/.test(value)) {
|
||||
return null
|
||||
}
|
||||
const parsed = Number(value)
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null
|
||||
}
|
||||
|
||||
export function parseGitHubProjectInput(input: string): ParsedGitHubProjectInput | null {
|
||||
const trimmed = input.trim()
|
||||
const short = /^([A-Za-z0-9][A-Za-z0-9-]*)\/(\d+)$/.exec(trimmed)
|
||||
if (short) {
|
||||
const number = positiveInteger(short[2])
|
||||
return number ? { owner: short[1]!, number } : null
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed)
|
||||
if (
|
||||
(url.protocol !== 'https:' && url.protocol !== 'http:') ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
!url.host
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const parts = url.pathname.split('/').filter(Boolean)
|
||||
const hasView = parts.length === 6 && parts[4] === 'views'
|
||||
if (
|
||||
(parts[0] !== 'orgs' && parts[0] !== 'users') ||
|
||||
!OWNER_RE.test(parts[1] ?? '') ||
|
||||
parts[2] !== 'projects' ||
|
||||
(parts.length !== 4 && !hasView)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const number = positiveInteger(parts[3])
|
||||
const viewNumber = hasView ? positiveInteger(parts[5]) : null
|
||||
if (!number || (hasView && !viewNumber)) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
owner: parts[1]!,
|
||||
number,
|
||||
host: url.host.toLowerCase(),
|
||||
...(viewNumber ? { viewNumber } : {})
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,10 @@ describe('GitHub project repo matching', () => {
|
||||
it('matches project rows by resolved repo slug before path/display heuristics', () => {
|
||||
expect(
|
||||
findRepoForGitHubProjectRepository('stablyai/orca', repos, {
|
||||
'repo-1': { path: '/Users/me/orca', slug: 'stablyai/orca' }
|
||||
'repo-1': {
|
||||
path: '/Users/me/orca',
|
||||
repository: { owner: 'stablyai', repo: 'orca' }
|
||||
}
|
||||
})
|
||||
).toBe(repos[0])
|
||||
})
|
||||
@@ -28,8 +31,14 @@ describe('GitHub project repo matching', () => {
|
||||
it('does not pick a repo when resolved slugs are ambiguous', () => {
|
||||
expect(
|
||||
findRepoForGitHubProjectRepository('stablyai/orca', repos, {
|
||||
'repo-1': { path: '/Users/me/orca', slug: 'stablyai/orca' },
|
||||
'repo-2': { path: '/Users/me/other', slug: 'stablyai/orca' }
|
||||
'repo-1': {
|
||||
path: '/Users/me/orca',
|
||||
repository: { owner: 'stablyai', repo: 'orca' }
|
||||
},
|
||||
'repo-2': {
|
||||
path: '/Users/me/other',
|
||||
repository: { owner: 'stablyai', repo: 'orca' }
|
||||
}
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
@@ -56,7 +65,10 @@ describe('GitHub project repo matching', () => {
|
||||
'stablyai/orca',
|
||||
[{ id: 'repo-1', path: '/Users/me/stablyai/orca', displayName: 'orca' }],
|
||||
{
|
||||
'repo-1': { path: '/Users/me/stablyai/orca', slug: 'fork/orca' }
|
||||
'repo-1': {
|
||||
path: '/Users/me/stablyai/orca',
|
||||
repository: { owner: 'fork', repo: 'orca' }
|
||||
}
|
||||
}
|
||||
)
|
||||
).toBeNull()
|
||||
@@ -71,8 +83,46 @@ describe('GitHub project repo matching', () => {
|
||||
|
||||
expect(
|
||||
filterGitHubProjectRowsForRepos(rows, repos, {
|
||||
'repo-1': { path: '/Users/me/orca', slug: 'stablyai/orca' }
|
||||
'repo-1': {
|
||||
path: '/Users/me/orca',
|
||||
repository: { owner: 'stablyai', repo: 'orca' }
|
||||
}
|
||||
}).map((row) => row.id)
|
||||
).toEqual(['row-1'])
|
||||
})
|
||||
|
||||
it('matches same-named repositories only on the active Project host', () => {
|
||||
expect(
|
||||
findRepoForGitHubProjectRepository(
|
||||
'stablyai/orca',
|
||||
repos,
|
||||
{
|
||||
'repo-1': {
|
||||
path: '/Users/me/orca',
|
||||
repository: { owner: 'stablyai', repo: 'orca', host: 'github.com' }
|
||||
},
|
||||
'repo-2': {
|
||||
path: '/Users/me/other',
|
||||
repository: {
|
||||
owner: 'stablyai',
|
||||
repo: 'orca',
|
||||
host: 'github.acme-corp.com'
|
||||
}
|
||||
}
|
||||
},
|
||||
'github.acme-corp.com'
|
||||
)
|
||||
).toBe(repos[1])
|
||||
})
|
||||
|
||||
it('does not use hostless path heuristics for Enterprise Project rows', () => {
|
||||
expect(
|
||||
findRepoForGitHubProjectRepository(
|
||||
'stablyai/orca',
|
||||
[{ id: 'repo-1', path: '/Users/me/stablyai/orca', displayName: 'orca' }],
|
||||
{},
|
||||
'github.acme-corp.com'
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
githubRepoIdentityKey,
|
||||
isDefaultGitHubHost
|
||||
} from '../../../src/shared/github-repository-identity-key'
|
||||
|
||||
export type GitHubProjectRepoMatch = {
|
||||
id: string
|
||||
path: string
|
||||
@@ -6,13 +11,13 @@ export type GitHubProjectRepoMatch = {
|
||||
|
||||
export type GitHubRepoSlugCacheEntry = {
|
||||
path: string
|
||||
slug: string | null
|
||||
repository: { owner: string; repo: string; host?: string } | null
|
||||
}
|
||||
|
||||
type CachedSlugState =
|
||||
| { status: 'missing' }
|
||||
| { status: 'stale' }
|
||||
| { status: 'resolved'; slug: string | null }
|
||||
| { status: 'resolved'; repository: GitHubRepoSlugCacheEntry['repository'] }
|
||||
|
||||
export function normalizeGitHubRepositorySlug(value: string | null | undefined): string | null {
|
||||
const trimmed = value?.trim()
|
||||
@@ -37,13 +42,14 @@ function cachedSlugStateForRepo(
|
||||
if (cached.path !== repo.path) {
|
||||
return { status: 'stale' }
|
||||
}
|
||||
return { status: 'resolved', slug: normalizeGitHubRepositorySlug(cached.slug) }
|
||||
return { status: 'resolved', repository: cached.repository }
|
||||
}
|
||||
|
||||
export function findRepoForGitHubProjectRepository(
|
||||
repository: string | null | undefined,
|
||||
repos: GitHubProjectRepoMatch[],
|
||||
slugsByRepoId: Record<string, GitHubRepoSlugCacheEntry | undefined> = {}
|
||||
slugsByRepoId: Record<string, GitHubRepoSlugCacheEntry | undefined> = {},
|
||||
projectHost?: string
|
||||
): GitHubProjectRepoMatch | null {
|
||||
const slug = normalizeGitHubRepositorySlug(repository)
|
||||
if (!slug) {
|
||||
@@ -53,9 +59,18 @@ export function findRepoForGitHubProjectRepository(
|
||||
const slugStates = new Map(
|
||||
repos.map((repo) => [repo.id, cachedSlugStateForRepo(repo, slugsByRepoId)])
|
||||
)
|
||||
const requestedIdentityKey = githubRepoIdentityKey({
|
||||
owner: slug.split('/')[0]!,
|
||||
repo: slug.split('/')[1]!,
|
||||
...(projectHost ? { host: projectHost } : {})
|
||||
})
|
||||
const slugMatches = repos.filter((repo) => {
|
||||
const state = slugStates.get(repo.id)
|
||||
return state?.status === 'resolved' && state.slug === slug
|
||||
return (
|
||||
state?.status === 'resolved' &&
|
||||
state.repository !== null &&
|
||||
githubRepoIdentityKey(state.repository) === requestedIdentityKey
|
||||
)
|
||||
})
|
||||
if (slugMatches.length === 1) {
|
||||
return slugMatches[0]!
|
||||
@@ -64,10 +79,16 @@ export function findRepoForGitHubProjectRepository(
|
||||
return null
|
||||
}
|
||||
|
||||
if (!isDefaultGitHubHost(projectHost)) {
|
||||
// Why: display names and local paths contain no host evidence, so using
|
||||
// them for GHES rows could bind an Enterprise item to a github.com repo.
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
repos.find((repo) => {
|
||||
const state = slugStates.get(repo.id)
|
||||
if (state?.status === 'resolved' && state.slug !== null) {
|
||||
if (state?.status === 'resolved' && state.repository !== null) {
|
||||
return false
|
||||
}
|
||||
const display = repo.displayName.trim().toLowerCase()
|
||||
@@ -82,9 +103,12 @@ export function filterGitHubProjectRowsForRepos<
|
||||
>(
|
||||
rows: readonly Row[],
|
||||
repos: GitHubProjectRepoMatch[],
|
||||
slugsByRepoId: Record<string, GitHubRepoSlugCacheEntry | undefined> = {}
|
||||
slugsByRepoId: Record<string, GitHubRepoSlugCacheEntry | undefined> = {},
|
||||
projectHost?: string
|
||||
): Row[] {
|
||||
return rows.filter((row) =>
|
||||
Boolean(findRepoForGitHubProjectRepository(row.content.repository, repos, slugsByRepoId))
|
||||
Boolean(
|
||||
findRepoForGitHubProjectRepository(row.content.repository, repos, slugsByRepoId, projectHost)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
deriveRepoSlug,
|
||||
findRepoMatchingSlug,
|
||||
findRepoMatchingSlugForPaste,
|
||||
lookupGitHubItemByOwnerRepo,
|
||||
resolvePasteIntent,
|
||||
type PasteRepoCandidate
|
||||
} from './smart-source-paste-intent'
|
||||
@@ -12,7 +13,7 @@ describe('resolvePasteIntent', () => {
|
||||
it('classifies a GitHub issue/PR URL as a github-link', () => {
|
||||
expect(resolvePasteIntent('https://github.com/acme/widgets/pull/12')).toEqual({
|
||||
kind: 'github-link',
|
||||
link: { slug: { owner: 'acme', repo: 'widgets' }, number: 12, type: 'pr' }
|
||||
link: { slug: { owner: 'acme', repo: 'widgets', host: 'github.com' }, number: 12, type: 'pr' }
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,6 +48,14 @@ describe('deriveRepoSlug', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves an Enterprise upstream host', () => {
|
||||
expect(
|
||||
deriveRepoSlug({
|
||||
upstream: { owner: 'up', repo: 'stream', host: 'github.corp.example' }
|
||||
})
|
||||
).toEqual({ owner: 'up', repo: 'stream', host: 'github.corp.example' })
|
||||
})
|
||||
|
||||
it('parses an SSH remote URL', () => {
|
||||
expect(
|
||||
deriveRepoSlug({ gitRemoteIdentity: { remoteUrl: 'git@github.com:acme/widgets.git' } })
|
||||
@@ -78,6 +87,16 @@ describe('findRepoMatchingSlug', () => {
|
||||
expect(findRepoMatchingSlug(repos, { owner: 'other', repo: 'thing' })).toBeNull()
|
||||
})
|
||||
|
||||
it('does not match a GHES link to a same-named github.com repo', () => {
|
||||
expect(
|
||||
findRepoMatchingSlug(repos, {
|
||||
owner: 'acme',
|
||||
repo: 'widgets',
|
||||
host: 'github.corp.example'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to the host-aware repo slug RPC for SSH and enterprise repos', async () => {
|
||||
const calls: string[] = []
|
||||
const client = {
|
||||
@@ -86,7 +105,10 @@ describe('findRepoMatchingSlug', () => {
|
||||
calls.push(repo)
|
||||
return {
|
||||
ok: true,
|
||||
result: repo === 'id:b' ? { owner: 'enterprise', repo: 'widgets' } : null
|
||||
result:
|
||||
repo === 'id:b'
|
||||
? { owner: 'enterprise', repo: 'widgets', host: 'github.corp.example' }
|
||||
: null
|
||||
}
|
||||
}
|
||||
} as unknown as RpcClient
|
||||
@@ -94,7 +116,7 @@ describe('findRepoMatchingSlug', () => {
|
||||
findRepoMatchingSlugForPaste(
|
||||
client,
|
||||
repos,
|
||||
{ owner: 'enterprise', repo: 'widgets' },
|
||||
{ owner: 'enterprise', repo: 'widgets', host: 'github.corp.example' },
|
||||
new Map()
|
||||
)
|
||||
).resolves.toMatchObject({ id: 'b' })
|
||||
@@ -139,4 +161,26 @@ describe('findRepoMatchingSlug', () => {
|
||||
)
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('sends the pasted host through the exact owner/repo lookup RPC', async () => {
|
||||
const sendRequest = vi.fn().mockResolvedValue({ ok: true, result: null })
|
||||
const client = { sendRequest } as unknown as RpcClient
|
||||
|
||||
await lookupGitHubItemByOwnerRepo(
|
||||
client,
|
||||
'repo-1',
|
||||
{ owner: 'acme', repo: 'widgets', host: 'github.corp.example' },
|
||||
7,
|
||||
'pr'
|
||||
)
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledWith('github.workItemByOwnerRepo', {
|
||||
repo: 'id:repo-1',
|
||||
owner: 'acme',
|
||||
ownerRepo: 'widgets',
|
||||
host: 'github.corp.example',
|
||||
number: 7,
|
||||
type: 'pr'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import { parseGitLabIssueOrMRLink } from '../../../src/shared/new-workspace/gitl
|
||||
import { isSmartWorkspaceSourceQueryWithinLimit } from '../../../src/shared/new-workspace/smart-workspace-source-results'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import { githubRepoIdentityKey } from '../../../src/shared/github-repository-identity-key'
|
||||
|
||||
// A repo the picker can switch to for a cross-repo GitHub paste. Slug is derived
|
||||
// best-effort from the repo's remote metadata.
|
||||
@@ -58,11 +59,15 @@ export function resolvePasteIntent(query: string): PasteIntent {
|
||||
// Pure: derive an owner/repo slug from a repo's remote metadata so a pasted
|
||||
// cross-repo URL can be matched to a locally known repo.
|
||||
export function deriveRepoSlug(repo: {
|
||||
upstream?: { owner: string; repo: string } | null
|
||||
upstream?: { owner: string; repo: string; host?: string } | null
|
||||
gitRemoteIdentity?: { remoteUrl?: string; canonicalKey?: string } | null
|
||||
}): RepoSlug | null {
|
||||
if (repo.upstream?.owner && repo.upstream.repo) {
|
||||
return { owner: repo.upstream.owner, repo: repo.upstream.repo }
|
||||
return {
|
||||
owner: repo.upstream.owner,
|
||||
repo: repo.upstream.repo,
|
||||
...(repo.upstream.host ? { host: repo.upstream.host } : {})
|
||||
}
|
||||
}
|
||||
const source = repo.gitRemoteIdentity?.remoteUrl ?? repo.gitRemoteIdentity?.canonicalKey ?? ''
|
||||
const match = /(?:github\.com[/:]|^)([^/\s:]+)\/([^/\s]+?)(?:\.git)?$/i.exec(source)
|
||||
@@ -76,9 +81,7 @@ function slugsEqual(a: RepoSlug | null, b: RepoSlug | null): boolean {
|
||||
if (!a || !b) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
a.owner.toLowerCase() === b.owner.toLowerCase() && a.repo.toLowerCase() === b.repo.toLowerCase()
|
||||
)
|
||||
return githubRepoIdentityKey(a) === githubRepoIdentityKey(b)
|
||||
}
|
||||
|
||||
export function findRepoMatchingSlug(
|
||||
@@ -148,6 +151,7 @@ export async function lookupGitHubItemByOwnerRepo(
|
||||
repo: `id:${repoId}`,
|
||||
owner: slug.owner,
|
||||
ownerRepo: slug.repo,
|
||||
...(slug.host ? { host: slug.host } : {}),
|
||||
number,
|
||||
type
|
||||
})
|
||||
|
||||
@@ -22,7 +22,11 @@ const DEBOUNCE_MS = 200
|
||||
const RESULT_LIMIT = 36
|
||||
|
||||
export type SmartCrossRepoPrompt = {
|
||||
link: { slug: { owner: string; repo: string }; number: number; type: 'issue' | 'pr' }
|
||||
link: {
|
||||
slug: { owner: string; repo: string; host?: string }
|
||||
number: number
|
||||
type: 'issue' | 'pr'
|
||||
}
|
||||
matchingRepo: PasteRepoCandidate
|
||||
}
|
||||
|
||||
@@ -73,7 +77,9 @@ export function useSmartWorkspaceSource(args: UseSmartWorkspaceSourceArgs) {
|
||||
// the mode/repo changes so one provider's rows never render under another tab.
|
||||
const scopeRef = useRef('')
|
||||
const dismissedPasteRef = useRef<string>('')
|
||||
const repoSlugCacheRef = useRef<Map<string, { owner: string; repo: string } | null>>(new Map())
|
||||
const repoSlugCacheRef = useRef<
|
||||
Map<string, { owner: string; repo: string; host?: string } | null>
|
||||
>(new Map())
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !enabled || mode === 'text') {
|
||||
@@ -185,7 +191,7 @@ async function runSmartSearch(args: {
|
||||
linearWorkspaceId: string | null | undefined
|
||||
repos: readonly PasteRepoCandidate[]
|
||||
dismissedPasteRef: { current: string }
|
||||
repoSlugCache: Map<string, { owner: string; repo: string } | null>
|
||||
repoSlugCache: Map<string, { owner: string; repo: string; host?: string } | null>
|
||||
}): Promise<{
|
||||
fan: SmartFanOutResult
|
||||
paste: PasteResolved
|
||||
|
||||
@@ -128,6 +128,90 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('useHostClient', () => {
|
||||
it('rebinds when Expo reuses a screen between two connected cached hosts', async () => {
|
||||
const host2 = { ...HOST, id: 'host-2', name: 'Host 2' }
|
||||
const client1 = makeFakeClient('connected')
|
||||
const client2 = makeFakeClient('connected')
|
||||
connectMock.mockReturnValueOnce(client1).mockReturnValueOnce(client2)
|
||||
loadHostsMock.mockResolvedValue([HOST, host2])
|
||||
|
||||
let selectedHostId = HOST.id
|
||||
let selectedClient: RpcClient | null = null
|
||||
let selectedState: ConnectionState = 'disconnected'
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
function Probe(): null {
|
||||
const selected = useHostClient(selectedHostId)
|
||||
selectedClient = selected.client
|
||||
selectedState = selected.state
|
||||
useHostClient(host2.id)
|
||||
return null
|
||||
}
|
||||
|
||||
const restore = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(selectedClient).toBe(client1)
|
||||
expect(selectedState).toBe('connected')
|
||||
|
||||
selectedHostId = host2.id
|
||||
client2.emitState('disconnected')
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(RpcClientProvider, null, createElement(Probe)))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(selectedClient).toBe(client2)
|
||||
expect(selectedState).toBe('disconnected')
|
||||
expect(connectMock).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
restore()
|
||||
act(() => renderer?.unmount())
|
||||
}
|
||||
})
|
||||
|
||||
it('stays disconnected while a reused screen resolves an uncached host', async () => {
|
||||
const client = makeFakeClient('connected')
|
||||
connectMock.mockReturnValue(client)
|
||||
loadHostsMock.mockResolvedValueOnce([HOST]).mockReturnValueOnce(new Promise<never>(() => {}))
|
||||
|
||||
let selectedHostId = HOST.id
|
||||
let renderTick = 0
|
||||
const stateByRenderTick = new Map<number, ConnectionState>()
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
function Probe(): null {
|
||||
stateByRenderTick.set(renderTick, useHostClient(selectedHostId).state)
|
||||
return null
|
||||
}
|
||||
|
||||
const restore = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(stateByRenderTick.get(0)).toBe('connected')
|
||||
|
||||
selectedHostId = 'missing-host'
|
||||
renderTick = 1
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(RpcClientProvider, null, createElement(Probe)))
|
||||
})
|
||||
expect(stateByRenderTick.get(1)).toBe('disconnected')
|
||||
|
||||
renderTick = 2
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(RpcClientProvider, null, createElement(Probe)))
|
||||
})
|
||||
expect(stateByRenderTick.get(2)).toBe('disconnected')
|
||||
} finally {
|
||||
restore()
|
||||
act(() => renderer?.unmount())
|
||||
}
|
||||
})
|
||||
|
||||
it('drops the closed client when the host entry is removed', async () => {
|
||||
const fake = makeFakeClient('connected')
|
||||
connectMock.mockReturnValue(fake)
|
||||
|
||||
@@ -344,13 +344,16 @@ export function useHostClient(hostId: string | undefined): {
|
||||
hostId ? ctx.getState(hostId) : 'disconnected'
|
||||
)
|
||||
const clientRef = useRef<RpcClient | null>(null)
|
||||
const clientHostIdRef = useRef<string | undefined>(hostId)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId) {
|
||||
clientRef.current = null
|
||||
clientHostIdRef.current = undefined
|
||||
setState('disconnected')
|
||||
return
|
||||
}
|
||||
clientHostIdRef.current = hostId
|
||||
let cancelled = false
|
||||
// Subscribe before acquire so any state change during open is captured.
|
||||
const unsub = ctx.subscribeHostState(hostId, (next) => {
|
||||
@@ -370,28 +373,29 @@ export function useHostClient(hostId: string | undefined): {
|
||||
}
|
||||
})
|
||||
const initial = ctx.acquire(hostId)
|
||||
clientRef.current = initial
|
||||
setState(ctx.getState(hostId))
|
||||
if (initial) {
|
||||
clientRef.current = initial
|
||||
setState(ctx.getState(hostId))
|
||||
// Why: two cached hosts can both be connected, so equal state values cannot reveal the replacement client.
|
||||
force((n) => n + 1)
|
||||
}
|
||||
return () => {
|
||||
cancelled = true
|
||||
unsub()
|
||||
ctx.release(hostId)
|
||||
clientRef.current = null
|
||||
clientHostIdRef.current = undefined
|
||||
}
|
||||
}, [ctx, hostId])
|
||||
|
||||
return { client: clientRef.current, state }
|
||||
// Why: Expo can reuse the screen before effects bind the next host; never expose the prior host's client or state in that render.
|
||||
const bound = clientHostIdRef.current === hostId
|
||||
const boundState = bound ? state : hostId ? ctx.getState(hostId) : 'disconnected'
|
||||
return { client: bound ? clientRef.current : null, state: boundState }
|
||||
}
|
||||
|
||||
// Why: refcounting prevents a double-open when a host-detail screen shares one of these hosts.
|
||||
export function useAllHostClients(hostIds: string[]): Array<{
|
||||
hostId: string
|
||||
client: RpcClient
|
||||
state: ConnectionState
|
||||
path: MobileConnectionPath
|
||||
}> {
|
||||
export function useAllHostClients(hostIds: string[]) {
|
||||
const ctx = useRpcClientContext()
|
||||
// Stable key so we don't tear down on every render of the array.
|
||||
const key = useMemo(() => [...hostIds].sort().join(','), [hostIds])
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import { useHostStatusGates, type HostStatusGates } from './host-status-gates'
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
describe('useHostStatusGates', () => {
|
||||
it('clears every prior-host gate and ignores its late response while the client is replaced', async () => {
|
||||
let resolveOldStatus: ((response: unknown) => void) | null = null
|
||||
const pendingOldStatus = new Promise((resolve) => {
|
||||
resolveOldStatus = resolve
|
||||
})
|
||||
const oldSendRequest = vi.fn().mockReturnValue(pendingOldStatus)
|
||||
const oldClient = { sendRequest: oldSendRequest } as unknown as RpcClient
|
||||
const newSendRequest = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
result: {
|
||||
capabilities: ['terminal.quick-commands.v1'],
|
||||
floatingWorkspaceEnabled: true
|
||||
}
|
||||
})
|
||||
const newClient = { sendRequest: newSendRequest } as unknown as RpcClient
|
||||
let gates: HostStatusGates | null = null
|
||||
const firstRenderByHost = new Map<string, HostStatusGates>()
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
function Probe({ hostId, client }: { hostId: string; client: RpcClient }): null {
|
||||
gates = useHostStatusGates({ hostId, client, connState: 'connected' })
|
||||
if (!firstRenderByHost.has(hostId)) {
|
||||
firstRenderByHost.set(hostId, gates)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const restore = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Probe, { hostId: 'host-1', client: oldClient }))
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Probe, { hostId: 'host-2', client: newClient }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(firstRenderByHost.get('host-2')).toMatchObject({
|
||||
hostCapabilities: [],
|
||||
floatingWorkspaceEnabled: false,
|
||||
compatVerdict: { kind: 'ok' }
|
||||
})
|
||||
expect(gates).toMatchObject({
|
||||
hostCapabilities: ['terminal.quick-commands.v1'],
|
||||
floatingWorkspaceEnabled: true
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
resolveOldStatus?.({
|
||||
ok: true,
|
||||
result: {
|
||||
capabilities: ['browser.screencast.v1'],
|
||||
floatingWorkspaceEnabled: true
|
||||
}
|
||||
})
|
||||
await pendingOldStatus
|
||||
})
|
||||
expect(gates).toMatchObject({
|
||||
hostCapabilities: ['terminal.quick-commands.v1'],
|
||||
floatingWorkspaceEnabled: true
|
||||
})
|
||||
expect(oldSendRequest).toHaveBeenCalledOnce()
|
||||
expect(newSendRequest).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
restore()
|
||||
renderer?.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('loads gates from the connected host', async () => {
|
||||
const sendRequest = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
result: {
|
||||
capabilities: ['browser.screencast.v1'],
|
||||
floatingWorkspaceEnabled: true
|
||||
}
|
||||
})
|
||||
const client = { sendRequest } as unknown as RpcClient
|
||||
let gates: HostStatusGates | null = null
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
function Probe({ hostId }: { hostId: string }): null {
|
||||
gates = useHostStatusGates({ hostId, client, connState: 'connected' })
|
||||
return null
|
||||
}
|
||||
|
||||
const restore = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Probe, { hostId: 'host-1' }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(gates).toMatchObject({
|
||||
hostCapabilities: ['browser.screencast.v1'],
|
||||
floatingWorkspaceEnabled: true
|
||||
})
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
restore()
|
||||
renderer?.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('fails closed while the same client reconnects', async () => {
|
||||
let resolveReconnect: ((response: unknown) => void) | null = null
|
||||
const pendingReconnect = new Promise((resolve) => {
|
||||
resolveReconnect = resolve
|
||||
})
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { capabilities: ['browser.screencast.v1'], floatingWorkspaceEnabled: true }
|
||||
})
|
||||
.mockReturnValueOnce(pendingReconnect)
|
||||
const client = { sendRequest } as unknown as RpcClient
|
||||
let gates: HostStatusGates | null = null
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
function Probe({ connState }: { connState: 'connected' | 'disconnected' }): null {
|
||||
gates = useHostStatusGates({ hostId: 'host-1', client, connState })
|
||||
return null
|
||||
}
|
||||
|
||||
const restore = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Probe, { connState: 'connected' }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(gates?.floatingWorkspaceEnabled).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Probe, { connState: 'disconnected' }))
|
||||
})
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Probe, { connState: 'connected' }))
|
||||
})
|
||||
expect(gates).toMatchObject({
|
||||
hostCapabilities: [],
|
||||
floatingWorkspaceEnabled: false
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
resolveReconnect?.({
|
||||
ok: true,
|
||||
result: { capabilities: ['terminal.quick-commands.v1'], floatingWorkspaceEnabled: true }
|
||||
})
|
||||
await pendingReconnect
|
||||
})
|
||||
expect(gates).toMatchObject({
|
||||
hostCapabilities: ['terminal.quick-commands.v1'],
|
||||
floatingWorkspaceEnabled: true
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
renderer?.unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { ConnectionState, RpcSuccess } from './types'
|
||||
import { evaluateCompat, type CompatVerdict } from './protocol-compat'
|
||||
import type { DesktopStatus } from '../worktree/host-worktree-rpc-types'
|
||||
|
||||
export type HostStatusGates = {
|
||||
hostCapabilities: string[]
|
||||
floatingWorkspaceEnabled: boolean
|
||||
compatVerdict: CompatVerdict
|
||||
}
|
||||
|
||||
type LoadedHostStatusGates = HostStatusGates & {
|
||||
hostId: string | undefined
|
||||
client: RpcClient
|
||||
}
|
||||
|
||||
const EMPTY_HOST_CAPABILITIES: string[] = []
|
||||
|
||||
// Reads status.get on connect for capabilities, protocol-compat verdict, and the
|
||||
// floating-workspace flag. Compat constants are wide-open today so this never blocks yet.
|
||||
export function useHostStatusGates(args: {
|
||||
hostId: string | undefined
|
||||
client: RpcClient | null
|
||||
connState: ConnectionState
|
||||
}): HostStatusGates {
|
||||
const { hostId, client, connState } = args
|
||||
const [loaded, setLoaded] = useState<LoadedHostStatusGates | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (connState !== 'connected' || !client) {
|
||||
// Why: reconnecting the same host/client must revalidate gates instead of reviving its prior status response.
|
||||
setLoaded(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
const requestClient = client
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await requestClient.sendRequest('status.get')
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
if (!response.ok) {
|
||||
return
|
||||
}
|
||||
const status = (response as RpcSuccess).result as DesktopStatus & {
|
||||
capabilities?: string[]
|
||||
}
|
||||
const verdict = evaluateCompat({
|
||||
desktopProtocolVersion: status.protocolVersion,
|
||||
desktopMinCompatibleMobileVersion: status.minCompatibleMobileVersion
|
||||
})
|
||||
setLoaded({
|
||||
hostId,
|
||||
client: requestClient,
|
||||
hostCapabilities: status.capabilities ?? [],
|
||||
floatingWorkspaceEnabled: status.floatingWorkspaceEnabled === true,
|
||||
compatVerdict: verdict
|
||||
})
|
||||
if (verdict.kind === 'blocked') {
|
||||
// Why: support breadcrumb to confirm a block fired vs a render bug; no PII, just version ints.
|
||||
console.warn('[protocol-compat] blocked', {
|
||||
reason: verdict.reason,
|
||||
desktopVersion: verdict.desktopVersion,
|
||||
requiredMobileVersion: verdict.requiredMobileVersion,
|
||||
requiredDesktopVersion: verdict.requiredDesktopVersion
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Why: sendRequest can throw on transport tear-down; the fail-closed return below keeps gated actions hidden.
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [client, connState, hostId])
|
||||
|
||||
// Why: effects run after render, so key loaded gates by host and client to fail closed during route reuse.
|
||||
if (
|
||||
connState !== 'connected' ||
|
||||
!client ||
|
||||
!loaded ||
|
||||
loaded.hostId !== hostId ||
|
||||
loaded.client !== client
|
||||
) {
|
||||
return {
|
||||
hostCapabilities: EMPTY_HOST_CAPABILITIES,
|
||||
floatingWorkspaceEnabled: false,
|
||||
compatVerdict: { kind: 'ok' }
|
||||
}
|
||||
}
|
||||
return {
|
||||
hostCapabilities: loaded.hostCapabilities,
|
||||
floatingWorkspaceEnabled: loaded.floatingWorkspaceEnabled,
|
||||
compatVerdict: loaded.compatVerdict
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import type { ExecutionHostId } from '../../../src/shared/execution-host'
|
||||
export type DesktopStatus = {
|
||||
protocolVersion?: number
|
||||
minCompatibleMobileVersion?: number
|
||||
// Why: absent on hosts that predate the mobile Floating Workspace entry;
|
||||
// treat absence as unsupported and hide the entry.
|
||||
floatingWorkspaceEnabled?: boolean
|
||||
}
|
||||
|
||||
export type RepoSummary = {
|
||||
|
||||
@@ -20,5 +20,6 @@ describe('mobile worktree activation', () => {
|
||||
|
||||
expect(openSession).toContain("sendRequest('worktree.activate'")
|
||||
expect(openSession).toContain('notifyClients: false')
|
||||
expect(openSession).toContain("navigation: 'caller'")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,709 +0,0 @@
|
||||
# Skill Auto-Update Design
|
||||
|
||||
Status: SUPERSEDED in direction by `skill-freshness-design.md` (2026-07-13): Phases 2-4
|
||||
(background writes, WSL, SSH reconcilers) are retired; Phase-1 detection and all empirical
|
||||
findings below remain valid inputs. Original status: revised after design, OSS review, and
|
||||
macOS + Windows + Linux empirical validation (2026-07-12). Phase 1 + background updates were
|
||||
implemented on PR #8496 (branch brennanb2025/skill-auto-update-research), now archived as
|
||||
reference for the write machinery. Windows validation confirmed the skills CLI writes CRLF (drove the
|
||||
text-normalized package-identity rule); Linux confirmed verbatim-LF installs and the
|
||||
`XDG_STATE_HOME` lock-location rule.
|
||||
|
||||
## Problem
|
||||
|
||||
Orca ships agent skills in `skills/` (orca-cli, orchestration, computer-use, orca-linear,
|
||||
linear-tickets, orca-per-workspace-env, orca-emulator, orca-emulator-android). Users install
|
||||
them with the skills CLI:
|
||||
|
||||
```sh
|
||||
npx skills add https://github.com/stablyai/orca --skill <names> --global
|
||||
```
|
||||
|
||||
Nothing then keeps those installations aligned with the Orca release they describe. Settings
|
||||
surface a manual `npx skills update <name> --global` command, but users receive no update
|
||||
signal. A stale skill can therefore tell an agent to use commands that are wrong or unsafe for
|
||||
the Orca binary it is driving.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Keep an Orca skill current after Orca has safely adopted or installed that physical copy.
|
||||
2. Never overwrite a modified, unknown, externally managed, project-scoped, or third-party
|
||||
skill.
|
||||
3. Never install a skill the user did not request.
|
||||
4. Work on macOS, Linux, Windows, WSL, and SSH, with reconciliation performed on the host
|
||||
where the agent reads the skill.
|
||||
5. Coexist with the skills CLI, symlink and copy installs, dotfile managers, read-only or
|
||||
generated configurations, and multiple Orca builds sharing a home directory.
|
||||
6. Keep skill content compatible with the Orca app release that is allowed to manage it.
|
||||
|
||||
Non-goals: updating third-party skills; replacing the skills CLI as the normal install path;
|
||||
managing repo-scoped `.agents/skills` or `.claude/skills`; mutating plugin caches; merging user
|
||||
edits into a new skill release.
|
||||
|
||||
## Research conclusion
|
||||
|
||||
The common package-manager pattern is not to scan arbitrary same-named directories and infer
|
||||
that they are writable. The updater manages packages inside an ownership boundary established
|
||||
at install or adoption time, records package identity outside user content, stages a complete
|
||||
replacement, and treats external links/custom installations as unmanaged.
|
||||
|
||||
The skills CLI lock is useful supporting evidence, but it is not an ownership ledger:
|
||||
|
||||
- The global lock is `$XDG_STATE_HOME/skills/.skill-lock.json` when `XDG_STATE_HOME` is set,
|
||||
otherwise `~/.agents/.skill-lock.json`.
|
||||
- A v3 entry records source, source URL/type, Git ref, path within the repo, an upstream folder
|
||||
hash, and timestamps.
|
||||
- It does not record a physical install path, symlink/copy topology, per-skill global agent
|
||||
placements, the current on-disk hash, local modifications, or app compatibility.
|
||||
- `skills update` compares the stored source hash with upstream and then re-runs installation.
|
||||
It does not prove that installed files are unchanged first.
|
||||
|
||||
Orca therefore reads supported lock versions as a provenance hint but never treats a lock
|
||||
entry by itself as permission to write. Orca never writes the foreign lock format.
|
||||
|
||||
## Decision: detect, adopt, then manage
|
||||
|
||||
Only a physical destination recorded in Orca's management ledger is eligible for background
|
||||
writes. A destination enters that ledger in one of two ways:
|
||||
|
||||
1. Orca's install UI invokes the skills CLI and then verifies and records the resulting
|
||||
physical installation.
|
||||
2. A legacy installation is adopted after its complete on-disk package matches a known,
|
||||
released Orca snapshot and its topology is eligible. Phase 1 makes this an explicit
|
||||
“Manage and update” action. Background auto-update never silently claims a newly
|
||||
discovered path.
|
||||
|
||||
This one-time adoption cost is intentional. Without it, no app can distinguish an official
|
||||
copy from a same-named user copy or establish which manager is allowed to replace it.
|
||||
|
||||
Do not rely on users discovering adoption passively in settings. When the bounded inventory
|
||||
finds exact official snapshots in eligible topologies, show one non-repeating banner/toast:
|
||||
“N installed Orca skills can be kept up to date,” with a one-click review/adoption action.
|
||||
Prioritize recording ownership immediately after every successful Orca-driven skills CLI
|
||||
install so new installations never require a later adoption step.
|
||||
|
||||
“Non-repeating” is scoped per eligible destination snapshot, not globally or per session. Store
|
||||
a dismissed-adoption tuple containing host identity, physical destination identity, skill name,
|
||||
and matched snapshot digest. Do not prompt again for that unchanged tuple, but allow a future
|
||||
prompt when a newly installed skill or genuinely different official snapshot creates a tuple
|
||||
the user has never dismissed. Removing another candidate alone does not clear prior dismissals.
|
||||
|
||||
## Build and release artifacts
|
||||
|
||||
Package `skills/` into app resources on every supported platform and generate a current bundle
|
||||
manifest. For each skill it contains:
|
||||
|
||||
- canonical name and repo-relative source path;
|
||||
- release revision and Orca app version;
|
||||
- deterministic whole-package digest (composed from the per-file identities below);
|
||||
- every regular file's relative path, size, executable bit, a per-file text/binary
|
||||
classification, an exact-byte SHA-256, and — for text files — a text-normalized SHA-256 with
|
||||
line endings folded to LF;
|
||||
- the upstream Git tree SHA when available;
|
||||
- minimum/maximum compatible app version if a skill is not backward-compatible;
|
||||
- schema version.
|
||||
|
||||
The build rejects absolute paths, traversal, case-colliding paths, special files, and symlinks
|
||||
inside the shipped package. Executable modes and the exact bytes of binary files are part of
|
||||
package identity. For text files, identity is the line-ending-normalized content, not the exact
|
||||
bytes: supported installers apply platform- and Git-config-dependent EOL translation, so an
|
||||
exact-byte hash is not stable across hosts. This is validated, not hypothetical — the skills CLI
|
||||
writes CRLF on a default Windows Git install (see Empirical validation), so a macOS-built
|
||||
exact-byte hash never matches a Windows install and would misclassify every Windows copy as
|
||||
modified. Because the whole-package digest composes the per-file normalized-or-exact hashes, one
|
||||
official snapshot has a single identity across macOS, Linux, and Windows and across
|
||||
`core.autocrlf` settings.
|
||||
|
||||
Maintain a compact, checked-in registry of every generated Orca skill snapshot plus a separate
|
||||
release mapping that identifies which revisions actually shipped. Historical file bytes are
|
||||
unnecessary; historical paths and hashes are sufficient to prove that an existing package is
|
||||
an exact official snapshot and to map a skills CLI folder hash to an Orca release. Only a
|
||||
revision present in the release mapping is eligible as legacy-install provenance; an
|
||||
unreleased candidate cannot be adopted merely because it appeared on main.
|
||||
|
||||
Release revision assignment is mechanical, not a hand-edited field. The manifest generator
|
||||
compares each package digest with the latest generated registry entry: unchanged content keeps
|
||||
its revision; changed content appends the next integer. The generator is the only writer of
|
||||
registry entries, and existing entries are immutable. Pull-request CI verifies generated
|
||||
output, but the same generation/monotonicity check must rerun against the merge-queue head and
|
||||
on main pushes so two independently green PRs cannot record different content with the same
|
||||
revision. Release creation adds the current revisions to the release mapping and fails unless
|
||||
main's generated registry is current and the packaged manifest exactly matches it.
|
||||
|
||||
Do not rely on `metadata.version` inside `SKILL.md` as the authority. Installers can transform
|
||||
frontmatter, users can edit it, and a value inside the package cannot prove the rest of the
|
||||
package is intact.
|
||||
|
||||
The exact-snapshot model depends on supported skills CLI installations preserving the shipped
|
||||
package. Add a release CI round trip on macOS, Linux, and Windows that installs representative
|
||||
single- and multi-file Orca skills through a pinned supported CLI version in both symlink and
|
||||
copy/fallback shapes, then compares paths, bytes, and applicable executable modes with the
|
||||
generated bundle manifest. Also exercise the newest CLI as an early-warning job. The bundle
|
||||
manifest remains generated from Orca's shipped source; if an installer intentionally transforms
|
||||
content, model that installation shape explicitly or mark it ineligible rather than silently
|
||||
changing the authoritative digest. A mismatch blocks background-update rollout for that shape.
|
||||
macOS verbatim behavior and Windows CRLF translation are both confirmed (see Empirical
|
||||
validation); the round trip must assert LF-normalized identity holds — not exact bytes — and
|
||||
cover `core.autocrlf` on/off plus the copy-fallback and junction shapes as a regression guard.
|
||||
|
||||
## Eligible roots and topology
|
||||
|
||||
Use an explicit registry of global, user-owned skill roots. Do not derive writable roots from
|
||||
all discovery sources: discovery also includes repo roots and the Codex plugin cache, neither
|
||||
of which this feature may mutate.
|
||||
|
||||
Classify every discovered physical destination before offering adoption:
|
||||
|
||||
| Installation topology | Behavior |
|
||||
| --- | --- |
|
||||
| Canonical `~/.agents/skills/<name>` with a known official snapshot | Eligible for adoption |
|
||||
| Provider symlink/junction resolving to that canonical copy | Dedupe; manage the canonical copy once |
|
||||
| Independent copy in an approved global provider root with a known official snapshot | Eligible for separate adoption |
|
||||
| Modified, incomplete, or unknown same-named copy | Never auto-write; show diff/replacement action |
|
||||
| Symlink/junction into dotfiles, a checkout, Nix/Home Manager output, network storage, or another external tree | Unmanaged; never write through the link |
|
||||
| Broken/dangling provider symlink (target missing) | Inaccessible; never adopt or write through; offer provenance-verified repair only with consent |
|
||||
| Read-only/generated root | Detection only |
|
||||
| Repo-scoped skill or plugin cache | Out of scope; never mutate |
|
||||
|
||||
Hardlinks, directory junctions, case-insensitive aliases, and symlinked parent directories must
|
||||
be deduplicated by physical identity where the host API exposes it, with normalized real paths
|
||||
as the fallback. Revalidate the entry type and resolved parent immediately before every
|
||||
mutation so a link swap cannot redirect a verified write.
|
||||
|
||||
Management and agent visibility are separate. Updating a canonical `~/.agents/skills` copy
|
||||
does not make it visible to an agent that reads only its provider-specific root. If no verified
|
||||
provider link/copy exists, settings must say “managed but not visible to <agent>” rather than
|
||||
reporting the provider as current. Creating or repairing a provider link is a separate,
|
||||
provenance-verified, user-approved follow-up; auto-update never invents a missing placement.
|
||||
|
||||
## Orca management ledger
|
||||
|
||||
Store state in Orca-owned application state on the execution host, never inside a user skill
|
||||
directory. Use one record per adopted physical destination:
|
||||
|
||||
- stable execution-host identity and user/home identity;
|
||||
- logical root kind and unresolved destination path;
|
||||
- last verified physical identity, entry type, and resolved path;
|
||||
- skill name, source, source path, and source ref/hash evidence;
|
||||
- installed release revision and whole-package digest;
|
||||
- per-file paths, hashes, modes, and the digest Orca last wrote;
|
||||
- last attempted bundle fingerprint, outcome, and error category;
|
||||
- adoption source and timestamp.
|
||||
|
||||
Local, WSL distro, and SSH records are isolated. SSH identity must include the persisted target
|
||||
identity plus the resolved remote user/home; an in-memory provider object or relay connection
|
||||
ID is not durable identity. Host-side state lets a remote remember ownership across desktop
|
||||
reinstalls and reconnects.
|
||||
|
||||
Corrupt, missing, migrated, or mismatched state fails closed. It can be reconstructed only by
|
||||
the same exact-snapshot adoption rules; it never grants ownership from a name alone.
|
||||
|
||||
## Detection and adoption
|
||||
|
||||
For each Orca skill found in an approved global root:
|
||||
|
||||
1. `lstat` the logical path and classify its topology without following external targets for
|
||||
mutation.
|
||||
2. Dedupe aliases that resolve to the same eligible canonical destination.
|
||||
3. Read the supported skills CLI lock as optional evidence. Bind a lock entry only to the
|
||||
canonical installation shape it describes; never apply one name-level entry to every
|
||||
same-named copy.
|
||||
4. Hash the complete physical package — exact bytes for binary/executable files, LF-normalized
|
||||
content for text files — and compare with the released-manifest registry, so installer EOL
|
||||
translation never misclassifies an official Windows install as modified.
|
||||
5. Classify it as current, update available, newer known release, modified, unknown,
|
||||
externally managed, or inaccessible.
|
||||
6. Offer “Manage and update” only for an exact known official snapshot in an eligible
|
||||
topology. Show a diff and explicit destructive replacement action for modified/unknown
|
||||
content; that action is not adoption and must preserve a backup until success.
|
||||
|
||||
Never send skill contents, diffs, paths, or user edits to telemetry or normal logs.
|
||||
|
||||
## Reconcile algorithm for adopted destinations
|
||||
|
||||
For each adopted physical destination whose bundle revision is newer and app-compatible:
|
||||
|
||||
1. Acquire an Orca transaction lock scoped to the execution host and destination. Dedupe
|
||||
in-flight work within the process as well. Other managers do not honor this lock, so also
|
||||
revalidate immediately before publish.
|
||||
2. Re-read topology and hash the installed package. Continue only when it equals the exact
|
||||
digest in the ledger. Any local edit, extra file, missing file, skills CLI update, or link
|
||||
change turns the destination into a conflict and cancels the write.
|
||||
3. Stage the complete bundled package in a unique directory under the reserved transaction
|
||||
workspace on the same filesystem. Write exact bytes and modes, then validate the staged
|
||||
package against the manifest.
|
||||
4. Recheck the live destination digest and identity after staging. If either changed, delete
|
||||
the staging directory and report a concurrent modification.
|
||||
5. Publish using the strongest package-level replacement supported by that host. Consumers
|
||||
require a fixed skill path, so a universally atomic directory swap is not possible,
|
||||
especially on Windows. Where package-level replacement is unavailable, first preserve a
|
||||
complete rollback copy, then use this required order:
|
||||
1. publish every new/changed non-`SKILL.md` file with same-directory temp + rename;
|
||||
2. publish `SKILL.md` with same-directory temp + rename as the semantic commit marker;
|
||||
3. unlink only obsolete files recorded in the old ledger, rechecking that each still has
|
||||
its old verified hash, then remove only empty recorded directories.
|
||||
This order ensures a removed file cannot make the new digest permanently unreachable and
|
||||
avoids deleting an asset while the new entry point is not yet present. It does not make a
|
||||
multi-file update atomic: a reader may briefly observe the old entry point with new assets,
|
||||
or the new entry point with harmless obsolete assets. Skills requiring cross-file atomicity
|
||||
must use package-level replacement; if the host cannot provide it, skip and retry rather
|
||||
than use the in-place fallback. Do not claim stronger consistency than the host provides.
|
||||
6. Verify the live package digest. Only then update the destination ledger and remove the
|
||||
backup. On failure, restore the old package when possible, retain the prior ledger digest,
|
||||
and leave the destination retryable.
|
||||
7. Notify discovery and show one aggregated toast for successfully updated skills. Running
|
||||
agent sessions pick changes up at their next skill load/session start.
|
||||
|
||||
Never repair equal-revision drift automatically. Equal revision plus different content is a
|
||||
conflict, not proof of a partial write. Never downgrade a known newer release.
|
||||
|
||||
Line endings are a rendering of text content, not part of it. The bundle ships LF, but supported
|
||||
installers write platform-native endings (validated: CRLF on default Windows Git). Compare text
|
||||
provenance and drift on LF-normalized content; stage and publish text files in the destination's
|
||||
existing EOL convention, defaulting to what a fresh supported install would produce on that host
|
||||
when adopting a copy that has none; write binary and executable files as exact bytes. This keeps
|
||||
an updated file byte-shaped like a fresh CLI install, so an EOL difference alone never counts as
|
||||
a conflict and Orca and the skills CLI do not reclassify each other's writes.
|
||||
|
||||
### Transaction workspace and crash recovery
|
||||
|
||||
Staging and rollback packages must be on the same filesystem/volume as the live destination,
|
||||
but must not appear as candidate skills. Use an Orca-reserved transaction root adjacent to the
|
||||
skill root when possible (for example, beside `skills/`, not as another child skill), verify
|
||||
same-filesystem identity, and fall back to a reserved child only when the skill root is itself
|
||||
a mount boundary. Both general skill discovery and updater inventory must hard-exclude the
|
||||
reserved transaction root; a leading dot alone is not an exclusion rule.
|
||||
|
||||
Each transaction directory contains an Orca marker with schema version, transaction ID,
|
||||
destination identity, creation time, and an atomically advanced transaction phase before it
|
||||
receives skill files. On host startup and before reconciliation, sweep only marked orphan
|
||||
transactions whose owning lock is absent/stale: restore a verified rollback package when the
|
||||
marker/ledger phase says publication was incomplete, otherwise remove the verified
|
||||
staging/backup directory. Never delete an unmarked directory based on its name, age, or
|
||||
resemblance to a skill. Coordinate cleanup with the same destination lock so one Orca process
|
||||
cannot sweep another process's live transaction.
|
||||
|
||||
### Removed files and retired skills
|
||||
|
||||
Package-level replacement naturally omits files removed by a newer managed package. The
|
||||
in-place fallback explicitly removes old-ledger files after publishing the new `SKILL.md`, as
|
||||
specified above. Never delete an unrecorded extra file from a live destination; its presence
|
||||
causes the pre-publish digest check to fail before any write.
|
||||
|
||||
Retired skills are detection/prompt-only in the initial implementation. A future cleanup may
|
||||
delete only individually recorded, unchanged files and then empty directories. It must never
|
||||
recursively delete a skill directory or follow a link target.
|
||||
|
||||
## Fast path and triggers
|
||||
|
||||
State is per destination, not one success bit for an entire host. A failed, inaccessible, or
|
||||
partially reconciled destination remains retryable even when other destinations succeeded.
|
||||
|
||||
At launch, after first paint:
|
||||
|
||||
1. Perform a bounded inventory of approved global roots to detect newly installed, removed,
|
||||
or topology-changed Orca skills.
|
||||
2. For adopted destinations, skip content hashing only when that destination already records
|
||||
successful reconciliation with the current bundle and its cheap identity/stat signature is
|
||||
unchanged.
|
||||
3. Hash only new, changed, failed, or bundle-mismatched candidates.
|
||||
|
||||
Also run/invalidate on:
|
||||
|
||||
- successful Orca-driven skill installation;
|
||||
- `notifyInstalledAgentSkillsChanged()` after an install/update terminal exits;
|
||||
- WSL distro first activation;
|
||||
- SSH connection after the host runtime is ready;
|
||||
- restart into a newly installed Orca app version.
|
||||
|
||||
Do not reconcile on the updater's “download complete” event: the running process still owns
|
||||
the old app resources until restart. Coalesce triggers and cap concurrency so launch, WSL, and
|
||||
SSH activation cannot fan out unbounded filesystem or network work.
|
||||
|
||||
## Host execution
|
||||
|
||||
- **macOS/Linux:** use Node filesystem APIs and platform app-state paths; do not shell out.
|
||||
- **Windows:** use `path` APIs, preserve exact bytes, support junction/copy topology, handle
|
||||
case-insensitive identity and long/UNC paths, and retry bounded `EPERM`/`EBUSY` replacement
|
||||
failures. A skipped destination remains retryable and never advances its ledger digest. A
|
||||
long-running agent may keep an obsolete file open; if its hash-verified unlink still fails
|
||||
after bounded retries, roll back the whole update and retry after the handle is released.
|
||||
- **WSL:** run reconciliation inside the selected distro through a host-side runtime/RPC
|
||||
operation. Do not mix Windows UNC mutation semantics with Linux locks, modes, and renames.
|
||||
State is scoped to distro plus Linux user/home.
|
||||
- **SSH:** run discovery, hashing, staging, locking, and publication on the remote host through
|
||||
the runtime/filesystem abstraction. Transfer only the selected bundled package and manifest.
|
||||
Do not assemble shell commands. Support Linux, macOS, and Windows SSH targets, and fail closed
|
||||
when the remote runtime lacks a required safe filesystem primitive.
|
||||
|
||||
## Multiple writers and app compatibility
|
||||
|
||||
The same global roots may be touched by stable Orca, a development build, and the skills CLI.
|
||||
Orca cannot guarantee that one shared global package simultaneously matches two incompatible
|
||||
app binaries.
|
||||
|
||||
- Production stable Orca is the only automatic writer by default.
|
||||
- Main-process runtime identity is authoritative: `app.isPackaged`, the signed release channel,
|
||||
and the resolved user-data/home roots determine whether writes are allowed. Renderer build
|
||||
flags alone are insufficient. An unpackaged build or development channel is detection-only
|
||||
unless both skill home and user-data roots are explicitly isolated from production.
|
||||
- A stable app writes only a bundle declared compatible with that app version.
|
||||
- A newer known installed release is never downgraded.
|
||||
- If `npx skills update` changes an adopted package, the next Orca check sees a ledger-digest
|
||||
mismatch and stops managing it until the new content matches a known released snapshot and
|
||||
is explicitly re-adopted.
|
||||
- Provenance and drift comparison fold text line endings to LF, so a stable app and the skills
|
||||
CLI never treat each other's platform-native EOL output as a conflict; only real content
|
||||
changes do.
|
||||
- Skills should remain backward-compatible across supported stable app versions where
|
||||
practical; compatibility metadata is still required for exceptions.
|
||||
|
||||
This avoids version ping-pong. A monotonic number inside user content alone cannot solve
|
||||
multiple incompatible writers.
|
||||
|
||||
## Consent and settings UX
|
||||
|
||||
The background setting is “Keep managed Orca agent skills up to date.” It controls only
|
||||
already adopted/Orca-installed destinations and may default on. It does not authorize claiming
|
||||
new paths.
|
||||
|
||||
Settings show each physical installation as one of:
|
||||
|
||||
- managed and current;
|
||||
- managed, update available;
|
||||
- known official copy, available to manage;
|
||||
- modified/unknown, review required;
|
||||
- externally managed/read-only;
|
||||
- inaccessible or update failed.
|
||||
|
||||
Managed and known-snapshot rows show the released skill revision, Orca app release, and a short
|
||||
digest for human/support diagnosis. Do not add a second, non-authoritative version marker to
|
||||
`SKILL.md`; it can be transformed independently of the package and mistaken for write
|
||||
authority.
|
||||
|
||||
Every successful background batch produces one toast. Conflicts and failures remain visible in
|
||||
settings without repeated error toasts. Explicit replacement shows a local diff, warns that it
|
||||
discards edits, and keeps a rollback backup until verification succeeds.
|
||||
|
||||
## Empirical validation (2026-07-12, macOS + Windows + Linux)
|
||||
|
||||
Both load-bearing assumptions were tested on a real developer machine against live installed
|
||||
skills, not deferred to Phase 1 telemetry. Results are recorded so the evidence travels with
|
||||
the design.
|
||||
|
||||
- **Verbatim install (decisive).** A clean-room `npx skills add https://github.com/stablyai/orca
|
||||
--skill orca-cli orchestration --global --yes` into a throwaway home produced files
|
||||
byte-identical to `origin/main`: equal Git blob hashes, equal byte counts, zero CRLF, exactly
|
||||
one `SKILL.md` per skill, and no injected or stripped files. The CLI does not transform content
|
||||
on macOS, so exact-content provenance is viable.
|
||||
- **Historical-snapshot match on real stale installs.** The machine's genuinely stale `orca-cli`,
|
||||
`computer-use`, and `orchestration` have on-disk bytes that exist verbatim as committed Git
|
||||
blobs in repo history. Exact-content adoption against a released-snapshot registry would
|
||||
recognize real, messy installs — not just freshly installed ones.
|
||||
- **Release-mapping guard justified by data.** Those stale blobs are reachable at commits dated
|
||||
after their recorded install time, i.e. identical bytes existed in a checkout/branch before or
|
||||
independently of shipping. Presence in history is therefore not proof of an official release;
|
||||
adoption must gate on the release mapping, and content identity — never timestamps — is the
|
||||
arbiter. This is exactly the hole the separate release mapping closes.
|
||||
- **Topology and dedup.** Provider skills under `~/.claude/skills` are symlinks (both relative
|
||||
`../../.agents/...` and absolute forms) into canonical `~/.agents/skills`; realpath resolution
|
||||
collapses provider and canonical to one physical destination, confirming physical-identity
|
||||
dedup yields a single write. A live broken/dangling provider symlink was also present (target
|
||||
missing), which is why the topology table has an explicit inaccessible row.
|
||||
- **Lockfile corroboration present (macOS).** Lock entries carry `source: stablyai/orca` and a
|
||||
folder hash, usable only as corroboration, consistent with the design.
|
||||
|
||||
**Windows (2026-07-12, validated on a real machine via the handoff below).** A clean-room
|
||||
`npx skills add ... --skill orca-cli --global` on a default, non-Developer-Mode Windows install
|
||||
produced:
|
||||
|
||||
- **CRLF translation, not byte-identical.** Installed `SKILL.md` was 21180 bytes with 318 CR
|
||||
bytes; repo-main source was 20862 bytes with 0 CR. The size delta equals the CR count exactly,
|
||||
i.e. a pure LF→CRLF translation with identical text. This is why text-file package identity is
|
||||
LF-normalized rather than exact-byte; an exact-byte model would have adopted nothing on Windows.
|
||||
- **Copy-fallback shape.** `.agents\skills\orca-cli` was a plain directory copy (no link), and no
|
||||
`.claude` provider copy was created (Developer Mode off, process unelevated). Confirms the
|
||||
Windows copy path and the need to manage independent per-root copies, not only a canonical
|
||||
symlink target.
|
||||
- **Same file set.** Only `SKILL.md` in both source and install — no injected or stripped files.
|
||||
- **Lockfile populated.** The lock recorded `orca-cli`; the known Windows empty-lockfile bug did
|
||||
not reproduce on this machine/version. Corroboration signal is therefore sometimes available on
|
||||
Windows, but the design still relies on content-match as primary since it is not guaranteed.
|
||||
|
||||
Still gated behind the cross-platform CI round trip as a regression guard, and untested:
|
||||
`core.autocrlf=false` on Windows (would install LF), and the junction/symlink shape under
|
||||
Developer Mode. LF-normalized identity covers the autocrlf variance by construction.
|
||||
|
||||
**Linux (2026-07-12, throwaway Docker container, reached over SSH).** Ran the same clean-room
|
||||
install on `Linux 6.12 aarch64` (node 22, npx 10.9, git 2.39). The container served SSH (sshd
|
||||
listening); the CLI check was executed on the box, since SSH transport does not change what the
|
||||
CLI writes to disk:
|
||||
|
||||
- **Verbatim LF, like macOS.** `orca-cli` and `orchestration` installed byte-identical to
|
||||
`origin/main`: equal SHA-256, exact byte counts (20862 / 22850), **CR=0**, only `SKILL.md` in
|
||||
each dir. Linux needs no separate manifest shape — it is covered by the LF identity.
|
||||
- **Symlink topology.** `~/.claude/skills/orca-cli` is a relative symlink
|
||||
(`../../.agents/skills/orca-cli`) into the canonical `~/.agents/skills` copy, matching macOS;
|
||||
realpath dedup collapses provider and canonical to one write.
|
||||
- **XDG lockfile path confirmed, with a sharper rule.** With `XDG_STATE_HOME` unset the lock is
|
||||
`~/.agents/.skill-lock.json` (`source: stablyai/orca`); with `XDG_STATE_HOME` set the lock is
|
||||
at `$XDG_STATE_HOME/skills/.skill-lock.json` and **not** at `~/.agents` — it moves, it is not
|
||||
duplicated. So the corroboration reader must resolve `XDG_STATE_HOME` and read the single
|
||||
correct location; checking only `~/.agents` finds no lock at all on such hosts. This is a
|
||||
property of the Linux/host environment, not of Orca's SSH transport, so it applies equally to
|
||||
native Linux, WSL, and SSH Linux targets — the reconciler must resolve the remote host's
|
||||
`XDG_STATE_HOME` when reading lock corroboration remotely.
|
||||
|
||||
Not exercised here: Orca's own remote reconciler over SSH (Phase 4, unbuilt — nothing to drive
|
||||
yet). This validated the Linux CLI install shape and the remote lock-location rule the reconciler
|
||||
will depend on.
|
||||
|
||||
## Windows validation handoff
|
||||
|
||||
Result (2026-07-12): **FAIL — CRLF translation**, resolved by LF-normalized text identity (see
|
||||
Build and release artifacts and Empirical validation). The procedure is retained for CI
|
||||
regression and for the still-untested `core.autocrlf=false` and Developer-Mode junction shapes.
|
||||
|
||||
Give this to an agent on a Windows machine. It is self-contained, non-destructive (sandboxes the
|
||||
skills CLI to a throwaway profile), and requires only PowerShell, Node/npx, and Git. It answers
|
||||
one question: does `npx skills add` on Windows write skill bytes identical to the repo source, or
|
||||
does it translate line endings / change the file set — and where does the lockfile land.
|
||||
|
||||
Goal and pass/fail:
|
||||
|
||||
- PASS (design safe as written): the installed `SKILL.md` is byte-identical to the repo source
|
||||
(equal SHA-256, equal byte count, zero CRLF), and the skill directory contains the same file
|
||||
set as the source.
|
||||
- FAIL (design must model the Windows shape explicitly or mark it ineligible): the installed file
|
||||
differs only by CRLF/line endings, or the file set differs, or the lockfile `skills` object is
|
||||
empty after a successful install (the known Windows lockfile-not-written failure), which means
|
||||
lockfile corroboration is unavailable on Windows and content-match must carry provenance alone.
|
||||
|
||||
Run this in PowerShell and paste the full transcript back:
|
||||
|
||||
```powershell
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$sandbox = Join-Path $env:TEMP ("orca-skilltest-" + [Guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $sandbox | Out-Null
|
||||
# Sandbox the CLI's global install to a throwaway profile so real skills are untouched.
|
||||
$old = @{ USERPROFILE=$env:USERPROFILE; HOME=$env:HOME; XDG_STATE_HOME=$env:XDG_STATE_HOME }
|
||||
$env:USERPROFILE = $sandbox; $env:HOME = $sandbox; Remove-Item Env:XDG_STATE_HOME -ErrorAction SilentlyContinue
|
||||
try {
|
||||
npx --yes skills add https://github.com/stablyai/orca --skill orca-cli --global --yes 2>&1 | Tee-Object "$sandbox\install.log" | Out-Null
|
||||
|
||||
$installed = Join-Path $sandbox '.agents\skills\orca-cli\SKILL.md'
|
||||
$truth = Join-Path $sandbox 'truth-SKILL.md'
|
||||
# Ground truth = exact bytes Git stores on main (LF), fetched without transformation.
|
||||
Invoke-WebRequest 'https://raw.githubusercontent.com/stablyai/orca/main/skills/orca-cli/SKILL.md' -OutFile $truth
|
||||
|
||||
function Info($label,$f){
|
||||
if(!(Test-Path $f)){ Write-Host "$label`: MISSING"; return }
|
||||
$bytes=[IO.File]::ReadAllBytes($f)
|
||||
$crlf=($bytes | Where-Object {$_ -eq 13}).Count
|
||||
Write-Host ("{0}: sha256={1} bytes={2} CR={3}" -f $label,(Get-FileHash $f -Algorithm SHA256).Hash.Substring(0,16),$bytes.Length,$crlf)
|
||||
}
|
||||
Write-Host "`n=== byte fidelity ==="
|
||||
Info 'installed' $installed
|
||||
Info 'repo-main ' $truth
|
||||
$same = (Get-FileHash $installed -Algorithm SHA256).Hash -eq (Get-FileHash $truth -Algorithm SHA256).Hash
|
||||
Write-Host ("VERDICT: {0}" -f ($(if($same){'VERBATIM (pass)'}else{'DIFFERS (inspect CR counts: CRLF-only diff = autocrlf translation)'})))
|
||||
|
||||
Write-Host "`n=== link shape (junction/symlink/copy) for provider + canonical ==="
|
||||
foreach($p in @("$sandbox\.claude\skills\orca-cli","$sandbox\.agents\skills\orca-cli")){
|
||||
if(Test-Path $p){ $i=Get-Item $p; Write-Host ("{0} -> LinkType={1} Target={2}" -f $p,$i.LinkType,($i.Target -join ',')) }
|
||||
else { Write-Host "$p -> (absent)" }
|
||||
}
|
||||
|
||||
Write-Host "`n=== file set in installed skill dir (extra/stripped files?) ==="
|
||||
Get-ChildItem (Join-Path $sandbox '.agents\skills\orca-cli') -Recurse -File | ForEach-Object { $_.FullName.Substring($sandbox.Length) }
|
||||
|
||||
Write-Host "`n=== lockfile location + whether skills object populated (Windows #-not-written bug) ==="
|
||||
foreach($lp in @("$sandbox\.agents\.skill-lock.json", "$env:XDG_STATE_HOME\skills\.skill-lock.json")){
|
||||
if($lp -and (Test-Path $lp)){
|
||||
$j=Get-Content $lp -Raw | ConvertFrom-Json
|
||||
Write-Host ("{0} -> skills keys: {1}" -f $lp, (($j.skills.PSObject.Properties.Name) -join ','))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$env:USERPROFILE=$old.USERPROFILE; $env:HOME=$old.HOME; if($old.XDG_STATE_HOME){$env:XDG_STATE_HOME=$old.XDG_STATE_HOME}
|
||||
Remove-Item $sandbox -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Write-Host "`n(sandbox removed; your real skills were never touched)"
|
||||
}
|
||||
```
|
||||
|
||||
Also report, in words: (1) is Windows Developer Mode on (decides whether junctions or copy
|
||||
fallback occurred)? (2) the VERDICT line, (3) whether CR counts differ between installed and
|
||||
repo-main (CRLF-only difference = `core.autocrlf` translation → design must treat the Windows
|
||||
install shape as its own manifest or mark it ineligible), (4) the link shapes, (5) the file set,
|
||||
(6) whether any lockfile `skills` object was populated. If Developer Mode can be toggled, run the
|
||||
block once with it on and once off to capture both junction and copy-fallback shapes.
|
||||
|
||||
## Historical rollout (superseded; do not implement)
|
||||
|
||||
The steps below preserve the retired write-based rollout for research context only. The
|
||||
active rollout is defined in `skill-freshness-design.md` and uses read-only detection plus a
|
||||
user-invoked, targeted `npx skills update <names...> --global` command; it has no ledger,
|
||||
adoption flow, background updater, transactional writer, WSL reconciler, or SSH reconciler.
|
||||
|
||||
1. **Detection and adoption:** ship current/historical manifests, lockfile parsing, topology
|
||||
classification, destination-scoped ledger, settings states, and an explicit “Manage and
|
||||
update” action. No background writes.
|
||||
2. **Local background updates:** enable adopted destinations on native macOS/Linux/Windows,
|
||||
including transaction, rollback, concurrency, and restart tests.
|
||||
3. **WSL:** move the same host-side reconciler into the distro runtime and validate Linux
|
||||
semantics independently of UNC discovery.
|
||||
4. **SSH:** expose the reconciler through remote runtime/RPC and validate Linux/macOS/Windows
|
||||
remote hosts, reconnects, and multiple desktop clients.
|
||||
|
||||
Do not advance phases based only on unit tests. Each phase needs a real-host package update,
|
||||
failure injection between every transaction boundary, and proof that modified/external content
|
||||
was not written.
|
||||
|
||||
## Implementation touchpoints
|
||||
|
||||
Grounded in the current codebase (verified 2026-07-12); an implementer starts here. Follow the
|
||||
repo naming rule — concrete domain names, no `helpers`/`utils`.
|
||||
|
||||
Main process (skill engine):
|
||||
|
||||
- `src/main/skills/skill-discovery-sources.ts` — the approved writable-root registry lives here
|
||||
or beside it. Today it enumerates `~/.codex/skills`, `~/.agents/skills`, `~/.claude/skills`
|
||||
(sourceKind `home`) plus a Codex plugin cache (`plugin`) and repo roots. The updater must
|
||||
include only `home` roots and exclude the plugin cache and repo roots.
|
||||
- `src/main/skills/discovery.ts` and `src/main/ipc/skills.ts` (`registerSkillsHandlers(store)`,
|
||||
wired in `src/main/ipc/register-core-handlers.ts`) — extend discovery to classify topology and
|
||||
provenance, and add IPC for ledger states, the adoption action, and explicit replace. The
|
||||
handler already receives the persistence `store`.
|
||||
- New modules, e.g. `src/main/skills/skill-manifest.ts`, `skill-ledger.ts`, `skill-reconcile.ts`
|
||||
— manifest load, content identity (LF-normalized text / exact-byte binary), transaction
|
||||
workspace, publish/rollback.
|
||||
- `src/shared/skill-metadata.ts` — existing top-level-only frontmatter parser; reuse for
|
||||
name/description in settings. It need not read a version (identity is manifest-based, not
|
||||
`metadata.version`).
|
||||
|
||||
State and host identity:
|
||||
|
||||
- `src/main/persistence.ts` (`store`, host-partitioned `orca-data.json`) — home for the
|
||||
management ledger; already host-aware and passed to the skills handler.
|
||||
- `src/shared/execution-host.ts` — `ExecutionHostId = 'local' | ssh:<id> | runtime:<id>`; add a
|
||||
`wsl:<distro>` variant (none today) so ledger records key per host. Do not key off
|
||||
`src/main/git/git-capability-state.ts`: it is in-memory only and scopes SSH by provider object
|
||||
identity, which is not durable across reconnects or restarts.
|
||||
|
||||
Renderer and UX:
|
||||
|
||||
- The ~13 setting/feature surfaces that today print raw `npx skills ...` strings from
|
||||
`src/shared/agent-feature-install-commands.ts` (CliSection, OrchestrationPane, BrowserUsePane,
|
||||
EphemeralVmsPane, ComputerUseSkillSetupPanel, the linear/emulator CTAs, and the feature-wall /
|
||||
feature-tip cards) become ledger-state rows with adoption/update actions.
|
||||
- `src/renderer/src/hooks/useInstalledAgentSkills.ts` — `notifyInstalledAgentSkillsChanged()` is
|
||||
the post-write refresh signal, already listened to on focus and on the install event.
|
||||
|
||||
Build and CI:
|
||||
|
||||
- `config/electron-builder.config.cjs` (+ `config/scripts/electron-builder-config.test.mjs`) —
|
||||
extraResources is already contract-tested; add `skills/` bundling and the generated manifest.
|
||||
- Manifest generator, released-snapshot registry, and the monotonic-revision check run on PR and
|
||||
re-run on merge-queue / main pushes (the localization-catalog check in `pnpm lint` is the
|
||||
precedent for a generated-output gate).
|
||||
- Cross-platform round trip asserts LF-normalized identity (not exact bytes) for text files and
|
||||
exact bytes for binary/executable files.
|
||||
|
||||
Tests:
|
||||
|
||||
- `tests/e2e/settings-skill-detection.spec.ts` — extend for update-available / adoption /
|
||||
conflict / rollback states.
|
||||
- Keep filesystem-transaction and host-isolation coverage as deterministic integration tests
|
||||
below E2E, per the Test matrix.
|
||||
|
||||
### Phase 1 definition of done
|
||||
|
||||
Ship-ready, with no background writes, when:
|
||||
|
||||
- The bundled manifest and released-snapshot registry are generated and CI-verified (monotonic
|
||||
per-skill revisions, immutable history, release mapping).
|
||||
- Discovery classifies every `home`-root Orca skill as current / update-available / newer-known /
|
||||
modified / unknown / externally-managed / inaccessible, using LF-normalized text identity.
|
||||
- The ledger records adopted destinations in the host-partitioned store, keyed by
|
||||
`ExecutionHostId` (plus `wsl:<distro>` where applicable).
|
||||
- Settings shows those states; a proactive non-repeating adoption nudge exists; "Manage and
|
||||
update" and explicit destructive replace (with backup and diff) work on the local host.
|
||||
- Orca-driven installs auto-record ownership.
|
||||
- No path is written in the background and no path is adopted silently.
|
||||
|
||||
Phase 1 exists to validate the two field assumptions before the background writer is built:
|
||||
the byte/EOL identity match rate on real installs, and the adoption take-rate on the nudge.
|
||||
|
||||
## Test matrix
|
||||
|
||||
### Content and provenance
|
||||
|
||||
- exact current and historical official snapshot;
|
||||
- edited, missing, extra, truncated, and mode-changed file;
|
||||
- foreign same-name skill;
|
||||
- equal revision/different content;
|
||||
- newer known and unknown release;
|
||||
- missing, corrupt, old-version, XDG-located, and spoofed skills CLI lock;
|
||||
- lost/corrupt Orca ledger and app reinstall;
|
||||
- source ref/path changes and repo/skill rename;
|
||||
- supported pinned and newest skills CLI round trips match bundle identity on macOS, Linux, and
|
||||
Windows, including symlink and copy/fallback installation: exact bytes/modes for binary and
|
||||
executable files, LF-normalized content for text files;
|
||||
- CRLF vs LF install (`core.autocrlf` on and off) adopts and stays managed via normalized
|
||||
identity; an EOL-only difference is never a conflict; a real content change still is;
|
||||
- Orca-published text files keep the destination's existing EOL convention and do not trigger a
|
||||
skills-CLI re-update loop.
|
||||
- two same-skill PRs that independently change content cannot pass the merge queue/main
|
||||
monotonicity gate with one revision;
|
||||
- adoption dismissal suppresses the same destination snapshot but a newly installed official
|
||||
destination remains eligible for one proactive prompt.
|
||||
|
||||
### Topology
|
||||
|
||||
- canonical copy with provider symlinks;
|
||||
- independent copy mode and Windows symlink-to-copy fallback;
|
||||
- parent-directory symlink, relative/absolute skill symlink, Windows junction, hardlink, and
|
||||
case-variant alias;
|
||||
- external dotfiles/chezmoi/stow target;
|
||||
- Nix/Home Manager/generated and read-only roots;
|
||||
- network/UNC home and long Windows paths;
|
||||
- repo-scoped and plugin-cache same-name skills remain untouched;
|
||||
- partial provider presence and custom provider home.
|
||||
|
||||
### Transactions and concurrency
|
||||
|
||||
- failure after stage, backup, publish, verify, ledger write, and cleanup;
|
||||
- crash-orphaned staging and rollback directories are excluded from discovery, recovered or
|
||||
swept from their markers, and never offered for adoption;
|
||||
- unmarked lookalike directories under or near the reserved transaction path are never swept;
|
||||
- `EPERM`, `EBUSY`, disk full, permission loss, and process crash;
|
||||
- two Orca windows and duplicate triggers;
|
||||
- stable/dev attempts and isolated dev home;
|
||||
- skills CLI or user mutation before stage, during stage, and immediately before publish;
|
||||
- failed destinations retry without reprocessing successful siblings.
|
||||
|
||||
### Hosts and E2E
|
||||
|
||||
- native macOS, Linux, and Windows;
|
||||
- multiple WSL distros/users, distro shutdown mid-update, and state isolation;
|
||||
- SSH Linux/macOS/Windows, disconnect/reconnect mid-update, old remote runtime, two desktop
|
||||
clients, and host/user identity changes;
|
||||
- settings update-available, adoption, managed-current, conflict, rollback, and retry states;
|
||||
- proactive adoption nudge is non-repeating, opens review, and never claims a path by itself;
|
||||
- canonical-only installs report provider visibility accurately and provider-link repair stays
|
||||
separately consented;
|
||||
- post-restart app update uses the new bundle, never the pre-restart bundle.
|
||||
|
||||
Extend `tests/e2e/settings-skill-detection.spec.ts`, but keep filesystem transaction and
|
||||
host-isolation coverage below E2E as deterministic integration tests.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Exact platform paths and schema migration policy for the host-local management ledger.
|
||||
- Whether the skills CLI can expose a supported machine-readable placement/ownership API in
|
||||
the future; until then its private lock remains read-only supporting evidence.
|
||||
- Which custom agent homes Orca can identify from the actual launch environment rather than
|
||||
ambient desktop environment variables.
|
||||
- Exact provider-link repair scope after a canonical install is managed but not visible to an
|
||||
agent; this remains separately consented from content updates.
|
||||
- Toast copy and whether settings should link to a per-skill changelog.
|
||||
@@ -1,262 +0,0 @@
|
||||
# Skill Freshness: Thin Stubs + Read-Only Detection
|
||||
|
||||
Status: adopted direction 2026-07-13. Phase 1 (guide sources + binary-served CLI) is
|
||||
implemented, pending release; detection and stub migration are not implemented. This is the
|
||||
authoritative plan. It supersedes the write phases of `skill-auto-update-design.md` (Phases
|
||||
2–4: background updates, WSL, SSH) and the migration section of
|
||||
`skill-guide-indirection-design.md`. The detection and content-identity research in those
|
||||
notes still applies and is referenced below.
|
||||
|
||||
## Problem (unchanged)
|
||||
|
||||
Orca ships agent skills that teach coding agents to drive the `orca` CLI. Users install them
|
||||
with `npx skills add stablyai/orca --global`. Installed copies are frozen files; the Orca
|
||||
binary keeps moving. A stale skill tells an agent to use commands that are wrong or unsafe
|
||||
for the binary it is driving.
|
||||
|
||||
## Decision
|
||||
|
||||
Three moves, replacing the in-app write machinery entirely:
|
||||
|
||||
1. **Structural fix — content lives in the binary.** All version-sensitive skill content is
|
||||
served by the CLI (`orca skills get <topic>`), compiled at build time from authoritative
|
||||
`skill-guides/` sources. Generated `skills/<name>/SKILL.md` files are the installable
|
||||
discovery surface and become permanent thin stubs. Staleness becomes impossible for the
|
||||
content that matters, rather than mitigated.
|
||||
2. **Residual freshness — read-only detection, ecosystem-rail updates.** Orca detects
|
||||
outdated official copies (content-addressed, LF-normalized identity) and pre-fills a
|
||||
targeted `npx skills update <names...> --global` command in a terminal. The user reviews
|
||||
and runs the skills CLI's own update command; Orca never submits it automatically and
|
||||
never writes a byte into a skill directory.
|
||||
3. **No persistent ownership or update state.** No ownership ledger, no adoption consent,
|
||||
no background writer, no transactional publish/rollback stack, no settings toggle. The
|
||||
one content migration (fat skill → stub) rides the same user-invoked npx rail. A small
|
||||
dismissed-nudge set remains in app state; it grants no write authority.
|
||||
|
||||
### Why the write machinery was dropped (decision record)
|
||||
|
||||
- **Maintenance-per-use.** The transactional mutation stack (staging, rollback, crash
|
||||
recovery, orphan sweeping, ownership ledger, installer attribution) is ~3.2K production
|
||||
lines before its tests. It is the most correctness-critical code in the tree, built to
|
||||
write into user-owned directories forever. Under the stub model it would run meaningfully
|
||||
once. Every module is permanent Windows/WSL/SSH edge-case surface for a one-shot job.
|
||||
- **Trust posture.** Ecosystem discourse (2026) consistently favors pinning and reviewable,
|
||||
user-invoked updates over silent writes into `$HOME`; Orca has first-hand precedent of
|
||||
user backlash from writing into user-owned config directories. Read-only detection has no
|
||||
trust cost at all.
|
||||
- **The rail already exists.** `npx skills update <names...> --global` is the ecosystem's
|
||||
documented remedy, and once `skills/` contains stubs it delivers the migration without an
|
||||
Orca-owned writer. The rail is treated as an external dependency with a tested contract,
|
||||
not assumed trustworthy from its lock file alone.
|
||||
- The full write implementation exists, reviewed and green, on branch
|
||||
`brennanb2025/skill-auto-update-research` (PR #8496, closed as superseded). If in-app
|
||||
writes are ever genuinely needed, start from that branch, not from scratch.
|
||||
|
||||
## Design
|
||||
|
||||
### A. Binary-served guides
|
||||
|
||||
```sh
|
||||
orca skills list # one line per topic: name + when to use
|
||||
orca skills get <topic> # full version-matched guide, markdown to stdout
|
||||
orca skills get <topic> --full # include bundled reference docs, if any
|
||||
```
|
||||
|
||||
- Topics are the skill names (orca-cli, orchestration, computer-use, …).
|
||||
- Full version-sensitive content lives in `skill-guides/<topic>.md`. A generator embeds it
|
||||
in a concrete CLI module and emits the installable `skills/<name>/SKILL.md` projection.
|
||||
During the pre-stub release this projection may remain fat; after migration it is a stub.
|
||||
- The generated-output-current check uses the existing generated-artifact-gate pattern used
|
||||
by the manifest verifier. It asserts that generated guide data and installable projections
|
||||
match their sources and that every stub topic resolves against the compiled guide table.
|
||||
No network or runtime filesystem lookup is required. Unknown topic → nonzero exit + topic
|
||||
list.
|
||||
- Authoritative guides, generated projections, and embedded TypeScript are pinned to LF, and
|
||||
the generator normalizes input before embedding it. Detection still normalizes text identity
|
||||
because already-installed Windows copies may retain the historical CRLF shape.
|
||||
- Topic names are append-only and aliased forever: a stub installed in 2026 must still
|
||||
resolve in 2028. Renames add an alias, never remove one.
|
||||
- Verb is `skills get` (not `guide`) to match the convention agents are already taught by
|
||||
other tools in the wild.
|
||||
|
||||
### B. Stub format and command resolution
|
||||
|
||||
One stub per skill — frontmatter descriptions are the agent-routing layer and stay
|
||||
per-skill registry entries.
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: orca-cli
|
||||
description: <unchanged per-skill trigger copy — the discovery surface>
|
||||
allowed-tools: <the supported Orca CLI command names>
|
||||
---
|
||||
|
||||
# Orca CLI
|
||||
|
||||
This file is a discovery stub, not the usage guide. The full, version-matched reference
|
||||
lives in the `orca` binary itself.
|
||||
|
||||
Before using Orca commands, resolve the Orca CLI for this session and load the guide once:
|
||||
|
||||
<resolved-orca-cli> skills get orca-cli
|
||||
|
||||
Don't guess subcommands or flags from memory or from cached copies of this skill — they
|
||||
change between Orca releases; the command above always matches the installed binary that
|
||||
will handle subsequent Orca commands.
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- The permanent body says when to engage Orca, how to resolve its CLI, and where to fetch
|
||||
the version-matched guide. It does not carry the changing command reference.
|
||||
- A stub must never blindly invoke bare `orca` outside an Orca-managed terminal on Linux;
|
||||
that name commonly resolves to the GNOME Orca screen reader. The contract must cover
|
||||
packaged `orca`, Linux/WSL `orca-ide`, SSH relay `orca`, and development `orca-dev`, and
|
||||
`allowed-tools` must cover every command the resolution contract can select.
|
||||
- **Linux command decision (2026-07-13):** do not install a uniform global bare `orca` alias;
|
||||
it would shadow or risk launching the GNOME Orca screen reader. Keep `orca-ide` outside
|
||||
managed Linux terminals, the existing managed-terminal/SSH `orca` shims, and `orca-dev` for
|
||||
development. The permanent stub therefore needs the short resolver exercised by the spike.
|
||||
- First-generation stubs are hybrid: a minimal safe bootstrap plus the guide pointer. Thin
|
||||
them further only after pointer compliance and old-binary behavior are measured. If
|
||||
`skills get` is unavailable, the hybrid must provide a bounded legacy workflow and tell the
|
||||
user that updating Orca restores the full version-matched guide; it must not dead-end or
|
||||
invite the agent to guess the missing command surface.
|
||||
|
||||
### C. Read-only detection (kept from Phase 1, slimmed)
|
||||
|
||||
Kept as-is:
|
||||
|
||||
- Bundled `skills/` packages + current manifest + released-snapshot registry + release
|
||||
mapping, with the generation script and merge-queue monotonicity gate (static data + CI,
|
||||
not runtime machinery).
|
||||
- LF-normalized text identity / exact-byte binary identity (the Windows CRLF finding
|
||||
stands: exact-byte matching would misclassify every Windows install as modified).
|
||||
- Bounded inventory work limits, topology classification (symlink dedup, external links,
|
||||
plugin caches and repo scopes excluded), and the launch / focus / post-install triggers.
|
||||
- The skills-CLI round-trip CI on macOS/Linux/Windows — extended from current-install tests
|
||||
to historical-fat-install → targeted global update → stub migration. The matrix covers
|
||||
LF/CRLF and provider aliases as a positive convergence contract, plus independent-copy
|
||||
observation that accepts only unchanged historical or exact-current bytes. Post-update
|
||||
bytes, not exit status, decide.
|
||||
|
||||
Slimmed:
|
||||
|
||||
- Statuses collapse to: `current`, `outdated` (exact match of an older released snapshot),
|
||||
`newer-known`, `unrecognized`, and `inaccessible`. Without a ledger, Orca cannot honestly
|
||||
distinguish a locally modified official copy from unrelated same-named content;
|
||||
`unrecognized` says it may be edited or from another source. All `managed-*` states, the
|
||||
ledger, adoption eligibility, and attribution are removed.
|
||||
- Status and action eligibility are separate. External links, read-only locations, plugin
|
||||
caches, repo scopes, and unsupported topologies remain informational even when their bytes
|
||||
match an official snapshot.
|
||||
- Dismissal state for the nudge is a simple local dismissed-set keyed by
|
||||
(physical identity, skill, bundled revision) in app state — not a consent ledger.
|
||||
|
||||
### D. Surfacing
|
||||
|
||||
- **Surfaces (venue decision 2026-07-14):** a lingering toast, an update modal, and the
|
||||
existing Settings setup rails for CLI, Orchestration, Computer Use, and Per-Workspace
|
||||
Environments. Their installed pills carry safe freshness status, while their existing
|
||||
Update and Re-check actions remain the per-skill path. The Skills page was
|
||||
de-linked by #4535 (2026-06-02) — its only entry, the sidebar toolbox menu, was removed —
|
||||
so it is no longer a venue; the freshness surface moved off it entirely. The behavior
|
||||
contracts below (name-scoped eligibility, no auto-run, dismissal keys, re-inventory
|
||||
triggers) are unchanged; only the venue moved.
|
||||
- **Per-placement rows** (read-only): name, status badge, one-line explanation. `newer-known`,
|
||||
`unrecognized`, `inaccessible`, and unsupported-topology rows are informational. They live in
|
||||
the modal's collapsed **Details** section (auto-expanded when a placement is blocked).
|
||||
- **Name-scoped update eligibility:** eligibility is computed across all discovered placements
|
||||
of a name, not per row. Offer a name only when at least one placement is `outdated` and every
|
||||
placement is an exact `current` or `outdated` official snapshot in a topology the validated
|
||||
rail actually converges. With skills CLI 1.5.17 that means the canonical global copy and
|
||||
provider aliases to it. Independent provider copies are informational and poison the offer:
|
||||
empirical copy-mode testing produced both stale and converged provider copies in otherwise
|
||||
equivalent 1.5.17 environments, so that topology is not deterministic enough to offer. One
|
||||
`newer-known`, unrecognized, external, read-only,
|
||||
inaccessible, repo/plugin, independent-copy, or otherwise unsupported placement poisons the
|
||||
update offer for that name entirely.
|
||||
- The action combines only eligible outdated Orca names into
|
||||
`npx skills update <names...> --global` and opens the update modal's editable terminal with
|
||||
that command pre-filled, leaving execution to the user. Never use an unscoped bulk update and
|
||||
never auto-submit the command. Re-inventory after terminal exit, modal close, or focus; only
|
||||
observed bytes, not the skills CLI exit status, determine success. When the eligible set
|
||||
empties and every placement is `current`, the modal shows an up-to-date state; if placements
|
||||
remain outdated-but-blocked or unrecognized, it says so honestly instead.
|
||||
- **One lingering, non-repeating nudge**: count only eligible outdated skill names and offer the
|
||||
same targeted action, which opens the update modal. The toast lingers (no auto-close) until the
|
||||
user opens the modal or explicitly dismisses it; ignoring it (app quit) records nothing, so a
|
||||
still-outdated skill may prompt once more next launch. A later inventory that resolves or blocks
|
||||
the offered tuple retracts the stale toast without recording a dismissal. An outdated name
|
||||
poisoned by another placement remains visible in the modal's Details but never produces an
|
||||
unsafe nudge action. Dismissal is recorded per (physical identity, name, bundled revision) only
|
||||
on explicit dismissal, so a newly outdated official placement or genuinely newer stub revision
|
||||
may prompt once more. No
|
||||
toggle — nothing automatic happens that would need one.
|
||||
|
||||
### E. Migration (fat → stub)
|
||||
|
||||
1. **Implemented, pending release:** from a fresh main-based PR, add authoritative guide
|
||||
sources, generated embedded data, `orca skills list/get`, aliases, generated-output checks,
|
||||
and local/SSH/WSL/dev tests. Keep distributed skills fat and ship this release first.
|
||||
2. From a separate PR, land slim read-only detection and Skills-page/nudge UI, including the
|
||||
name-scoped targeted update action and the real migration-rail CI. Keep distributed
|
||||
skills fat.
|
||||
3. Run the pointer-compliance spike against the released guide-serving binary, not a checkout
|
||||
artifact. The binary must be publicly released before a stub PR merges because the skills
|
||||
CLI installs from repository main, independently of Orca's desktop release train.
|
||||
4. In one PR, convert only `orca-cli` to a first-generation hybrid stub and keep any final
|
||||
thinning of that stub in the same change. This bumps its registry revision like any content
|
||||
change. Existing users see an `outdated` exact snapshot and may run the targeted global
|
||||
update; users of pre-guide binaries retain the hybrid bootstrap.
|
||||
5. Cut an RC before the stable release and use that validation window to measure compliance,
|
||||
task success, old-binary behavior, and token cost. Ship the thin form in stable only if those
|
||||
gates pass; otherwise retain the hybrid. Convert the remaining skills gradually in later PRs.
|
||||
Users who ignore the nudge keep working with their existing fat skills.
|
||||
|
||||
## Spike gate (before any stub ships)
|
||||
|
||||
Using the released guide-serving binary, install the proposed hybrid `orca-cli` stub in a
|
||||
test home and run real agents (Claude Code, Codex) on representative Orca tasks. Measure:
|
||||
|
||||
- how often the agent resolves the correct packaged/Linux/WSL/SSH/dev command and fetches the
|
||||
guide before its first Orca command;
|
||||
- task success versus the fat skill;
|
||||
- old-binary failure behavior; and
|
||||
- net token cost (stub preload + one fetch versus fat preload).
|
||||
|
||||
Then test a thinner stub against the same corpus. Nothing converts, and the hybrid stub does
|
||||
not thin, until the relevant variant passes.
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
- **npx rail reliability.** The skills CLI update path has had false "up to date" results,
|
||||
global Windows failures, missing global lock tracking, lossy lock migration, and copy-mode
|
||||
topology changes. The historical-fat → targeted-global-update → stub CI is a release gate,
|
||||
not an early-warning job. Detection always re-checks bytes after the user updates, so a
|
||||
failed or no-op update re-surfaces `outdated` instead of lying. Minimum validated version is
|
||||
1.5.17: 1.5.16 failed the provider-alias convergence contract, while 1.5.17 copy convergence
|
||||
still varies by environment. CI pins 1.5.17 and probes latest; monitor and contribute upstream
|
||||
fixes before broadening eligibility.
|
||||
- **Trigger-copy iteration slows.** Improvements to stub descriptions reach existing
|
||||
installs only when users run the npx command. Acceptable at stub-change cadence; the
|
||||
compiled guides (the content that matters) are exempt by construction.
|
||||
- **Multi-file skills.** Current shipped packages are single-file. If a future skill needs
|
||||
scripts/assets, either the binary serves them (`--full` / `--script`) or that skill
|
||||
accepts the fat-file decay model. Decide when it happens.
|
||||
- **Remote hosts.** Detection ships local-host-only. Stubs make remote _content_ a non-issue:
|
||||
SSH/WSL launchers forward to the host's bundled CLI, so the guide matches the command
|
||||
surface that will handle subsequent requests. Remote stub installs can lag on trigger
|
||||
copy, which is the accepted residual. The WSL/SSH reconciler phases of the old design are
|
||||
retired, not deferred.
|
||||
- **Agent Skills spec evolution** (frontmatter fields, allowed-tools syntax) is the most
|
||||
likely future cause of a real stub update wave; the nudge path covers it.
|
||||
|
||||
## Relationship to prior notes
|
||||
|
||||
- `skill-auto-update-design.md`: Problem statement, empirical CLI-behavior findings
|
||||
(verbatim-LF mac/linux, CRLF Windows, XDG lock location, symlink topology, released-blob
|
||||
provenance) and the Phase-1 detection design remain valid inputs. Phases 2–4 (background
|
||||
writes, WSL, SSH reconcilers) are retired by this document.
|
||||
- `skill-guide-indirection-design.md`: The stub/CLI contract and prior-art survey are
|
||||
folded in here; its migration-via-in-app-updater section is superseded by §E.
|
||||
@@ -1,148 +0,0 @@
|
||||
# Skill Guide Indirection (Thin Stubs + `orca skills get`)
|
||||
|
||||
Status: FOLDED INTO `skill-freshness-design.md` (2026-07-13) — read that instead. The stub/CLI
|
||||
contract and prior-art survey carried over; the migration-via-in-app-updater section here is
|
||||
superseded (migration now rides `npx skills update`, no in-app writes).
|
||||
|
||||
## Principle
|
||||
|
||||
Version-sensitive content must not live in distributed files; only discovery metadata should.
|
||||
Every hard problem in the current system — staleness, adoption consent, installer attribution,
|
||||
transactional replacement, remote-host reconciliation — descends from shipping full skill
|
||||
bodies as mutable files that must track the installed Orca binary. Move the bodies into the
|
||||
binary and the problems shrink to a residue the existing machinery already handles.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. The binary serves the instructions
|
||||
|
||||
New CLI surface (topic names match skill names):
|
||||
|
||||
```sh
|
||||
orca skills list # enumerate available guides, one line each
|
||||
orca skills get <topic> # full version-matched guide for one skill, markdown to stdout
|
||||
orca skills get <topic> --full # include bundled reference docs, if any
|
||||
```
|
||||
|
||||
- Content is authored in `skill-guides/<topic>.md`. A generator embeds those authoritative
|
||||
sources in the CLI and emits `skills/<name>/SKILL.md` as an installable projection;
|
||||
`skills/` is generated output, not an authoring source.
|
||||
- Output contract: plain markdown on stdout, exit 0; unknown topic exits nonzero with the
|
||||
topic list. No network, no filesystem reads outside the binary's own resources.
|
||||
- Verb choice: `skills get` (not `guide`) to match the convention agents are already being
|
||||
taught by other tools (see Prior art).
|
||||
|
||||
### 2. Historical stub sketch (superseded; do not copy)
|
||||
|
||||
This sketch records the indirection idea only. The resolver and first-generation hybrid stub
|
||||
contract in `skill-freshness-design.md` are authoritative and must cover packaged `orca`,
|
||||
Linux/WSL `orca-ide`, SSH `orca`, and development `orca-dev` without blindly invoking bare
|
||||
`orca` on Linux.
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: orca-cli
|
||||
description: <unchanged per-skill trigger copy — this is the discovery surface>
|
||||
allowed-tools: <all supported Orca CLI command names>
|
||||
---
|
||||
|
||||
# Orca CLI
|
||||
|
||||
This file is a discovery stub, not the usage guide. The full, version-matched reference
|
||||
lives in the `orca` binary itself.
|
||||
|
||||
Before using Orca commands, resolve the CLI for this session and load the guide once:
|
||||
|
||||
<resolved-orca-cli> skills get orca-cli
|
||||
|
||||
Don't guess subcommands or flags from memory or from cached copies of this skill — they
|
||||
change between Orca releases; the command above always matches the installed binary.
|
||||
```
|
||||
|
||||
Stub rules:
|
||||
- Body is deliberately version-independent: it says when to engage Orca and where to fetch
|
||||
the how — never the how itself. A stub should survive many releases unchanged.
|
||||
- `allowed-tools` must cover every executable that the authoritative resolver can select.
|
||||
- Stub must not ship before the binary that serves its topic: gate stub rollout on the
|
||||
release that includes `skills get` (a stub pointing at a command that does not exist
|
||||
is worse than a fat skill). Enforce with a build check: every stub topic must resolve
|
||||
against the compiled guide table.
|
||||
- Stub should degrade honestly when no supported Orca command is on PATH and must retain a
|
||||
bounded legacy bootstrap for binaries that predate `skills get`.
|
||||
|
||||
### 3. What this retires, what it keeps
|
||||
|
||||
Retired / collapsed:
|
||||
- The ownership ledger, adoption and installer-attribution flows, background updater,
|
||||
transactional publish/rollback/orphan sweep, and all automatic writes into user-owned
|
||||
skill directories.
|
||||
- Phases 3–4 of skill-auto-update-design.md (WSL/SSH remote file reconcilers). Wherever the
|
||||
skill is useful the `orca` binary is present, and the remote binary serves the guide
|
||||
matching its own host's version. No remote file-sync problem remains.
|
||||
|
||||
Kept (read-only):
|
||||
- Bounded discovery, LF-normalized content identities, the released-snapshot registry,
|
||||
release mapping, and CI gates. Statuses are `current`, `outdated`, `newer-known`,
|
||||
`unrecognized`, and `inaccessible`; no ledger is needed to compute them.
|
||||
- Name-scoped eligibility across every placement. One newer, unrecognized, external,
|
||||
read-only, repo-scoped, plugin, or inaccessible placement poisons the update offer for
|
||||
that skill name.
|
||||
- The skills-CLI round-trip CI, extended to prove historical fat installs migrate to stubs
|
||||
through targeted global updates across supported hosts and topologies.
|
||||
- Read-only settings rows and a dismissible nudge that pre-fill a targeted
|
||||
`npx skills update <eligible-names...> --global` command. Orca never submits it or writes
|
||||
into a skill directory.
|
||||
|
||||
## Prior art (verified live 2026-07-13)
|
||||
|
||||
- vercel-labs/agent-browser — canonical stub + `agent-browser skills get core`; docs frame
|
||||
it explicitly: "the installed SKILL.md rarely changes, while the CLI always serves content
|
||||
matching its own version." Stub self-describes as a discovery stub that "cannot change
|
||||
between releases."
|
||||
- Canner/WrenAI (skills/wren/SKILL.md) — independent (non-Vercel) adopter: "The actual
|
||||
workflow guides … live inside the `wren` CLI itself, so they always match the installed
|
||||
wrenai version (no skill cache, no version drift)." Uses `wren skills list` /
|
||||
`wren skills get <topic>` / `--full` — the verb convention to match.
|
||||
- vercel-labs/zerolang (skills/zero/SKILL.md) — "This file is only a discovery stub… ask the
|
||||
installed compiler for the skill content that matches that exact binary." Adds the nuance
|
||||
of warning agents not to replace a pinned binary.
|
||||
- vercel/next.js (skills/next-dev-loop/SKILL.md) — consumes the pattern: instructs agents to
|
||||
"run `agent-browser skills get core` once for the version-matched usage guide — don't
|
||||
guess subcommands from memory." Normalization signal.
|
||||
- Ecosystem discourse (Snyk threat model, HN, vercel-labs/skills issues #500/#542, Anthropic
|
||||
skill-trust guidance) demands pinning + reviewable updates and condemns silent pulls from
|
||||
mutable remotes. Stub indirection satisfies the audit-once trust model: the audited file
|
||||
never changes meaning; served content is exactly as trusted as the installed binary.
|
||||
|
||||
## Migration plan
|
||||
|
||||
0. Release `orca skills list/get` first from authoritative `skill-guides/` sources while
|
||||
distributed skills remain fat. No stub may reach repository main before a public binary
|
||||
can serve it.
|
||||
1. Add read-only freshness detection, name-scoped update eligibility, the targeted
|
||||
user-invoked `npx skills update <names...> --global` action, and migration-rail CI. Keep
|
||||
distributed skills fat.
|
||||
2. Spike pointer compliance against the released guide-serving binary with Claude Code and
|
||||
Codex, including Linux/WSL/SSH/dev command resolution, old-binary fallback, task success,
|
||||
and token cost.
|
||||
3. Convert only `orca-cli` to a first-generation hybrid stub. Existing exact official fat
|
||||
copies become eligible for the targeted ecosystem update rail; users who ignore the
|
||||
nudge retain their existing skills.
|
||||
4. Cut an RC, measure the gates, and thin the hybrid only if it passes. Convert remaining
|
||||
skills gradually in later PRs.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Compliance failure mode: if agents skim the stub and skip the fetch, options are stronger
|
||||
stub wording, frontmatter `description` nudging ("requires running orca skills get"),
|
||||
or hybrid stubs carrying a minimal command table plus the pointer. Spike decides.
|
||||
- Multi-file skills: current shipped packages are single-file; if a future skill needs
|
||||
scripts/assets, decide whether the binary serves them (`--script <name>` like WrenAI) or
|
||||
they stay in the package (then that skill keeps the fat-update path).
|
||||
- Topic/verb naming: `orca skills get` collides conceptually with the `skills` installer
|
||||
CLI; confirm no confusion in agent behavior during the spike.
|
||||
- Old binaries: a user can hold a stub while running an older orca without `skills get`
|
||||
(downgrade case). Stub wording should fail gracefully ("if the command is missing, update
|
||||
Orca"); acceptable residual.
|
||||
- Whether settings should surface "guide served by binary" as a distinct row state so
|
||||
support can tell stub-era installs from fat-era ones at a glance.
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.147-rc.4",
|
||||
"version": "1.4.148-rc.1",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
@@ -77,6 +77,7 @@
|
||||
"build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && ORCA_MAC_RELEASE=1 pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac",
|
||||
"build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux AppImage deb",
|
||||
"test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project=electron-headless",
|
||||
"test:e2e:multi-client-navigation": "node config/scripts/run-multi-client-navigation-e2e.mjs",
|
||||
"test:e2e:floating-mobile-emulator": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/floating-mobile-emulator-tab.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts tests/e2e/terminal-webgl-atlas-budget.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"test:e2e:terminal-rendering-release-evidence": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts tests/e2e/terminal-long-table-scroll-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2",
|
||||
|
||||
@@ -45,7 +45,8 @@ const DEFAULT_TERMINAL_WAIT_RPC_TIMEOUT_MS = 5 * 60 * 1000
|
||||
|
||||
const terminalFocusHandler: CommandHandler = async ({ flags, client, cwd, json }) => {
|
||||
const result = await client.call<{ focus: RuntimeTerminalFocus }>('terminal.focus', {
|
||||
terminal: await getTerminalHandle(flags, cwd, client)
|
||||
terminal: await getTerminalHandle(flags, cwd, client),
|
||||
navigation: 'host'
|
||||
})
|
||||
printResult(result, json, formatTerminalFocus)
|
||||
}
|
||||
|
||||
@@ -191,7 +191,10 @@ describe('command aliases dispatch to the canonical handler', () => {
|
||||
|
||||
await main(['terminal', 'focus', '--terminal', 'term_abc', '--json'], '/tmp/repo')
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.focus', expect.objectContaining({}))
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'terminal.focus',
|
||||
expect.objectContaining({ navigation: 'host' })
|
||||
)
|
||||
})
|
||||
|
||||
it('serves `agent-context --json` without contacting the runtime', async () => {
|
||||
|
||||
@@ -13,7 +13,10 @@ export function buildPosixHookPayloadCapture(
|
||||
}
|
||||
|
||||
export const WINDOWS_HOOK_STDIN_DRAIN_LABEL = 'orca_agent_hook_drain_stdin'
|
||||
export const WINDOWS_HOOK_STDIN_DRAIN_COMMAND = '"%SystemRoot%\\System32\\more.com" >nul 2>nul'
|
||||
// Why: qualify the stdin reader because Windows searches the worktree for
|
||||
// executables before PATH and hook payloads must not reach repo-local code.
|
||||
export const WINDOWS_HOOK_STDIN_READER = '"%SystemRoot%\\System32\\more.com"'
|
||||
export const WINDOWS_HOOK_STDIN_DRAIN_COMMAND = `${WINDOWS_HOOK_STDIN_READER} >nul 2>nul`
|
||||
|
||||
// Why: batch payloads stream directly to curl and cannot be buffered safely in
|
||||
// environment variables, so guard failures share one EOF-draining epilogue.
|
||||
@@ -27,11 +30,5 @@ export function buildWindowsHookEnvironmentGuardLines(): string[] {
|
||||
}
|
||||
|
||||
export function buildWindowsHookStdinDrainEpilogue(): string[] {
|
||||
return [
|
||||
`:${WINDOWS_HOOK_STDIN_DRAIN_LABEL}`,
|
||||
// Why: qualify the inbox reader because Windows searches the worktree for
|
||||
// executables before PATH and hook payloads must not reach repo-local code.
|
||||
WINDOWS_HOOK_STDIN_DRAIN_COMMAND,
|
||||
'exit /b 0'
|
||||
]
|
||||
return [`:${WINDOWS_HOOK_STDIN_DRAIN_LABEL}`, WINDOWS_HOOK_STDIN_DRAIN_COMMAND, 'exit /b 0']
|
||||
}
|
||||
|
||||
@@ -207,8 +207,11 @@ async function generatePosixScripts(): Promise<Map<string, string>> {
|
||||
const generated = [...memory.fs.files.entries()].filter(
|
||||
([path]) => path.includes('/.orca/agent-hooks/') && path.endsWith('.sh')
|
||||
)
|
||||
expect(generated, `${entry.agent} generated scripts`).toHaveLength(1)
|
||||
scripts.set(entry.agent, generated[0][1])
|
||||
// Why: Claude ships a second managed script (the statusline usage feed); the stdin lifecycle contract applies to every generated script.
|
||||
expect(generated.length, `${entry.agent} generated scripts`).toBeGreaterThan(0)
|
||||
for (const [path, script] of generated) {
|
||||
scripts.set(`${entry.agent} ${path.split('/').pop()}`, script)
|
||||
}
|
||||
}
|
||||
return scripts
|
||||
}
|
||||
@@ -377,14 +380,13 @@ describe.skipIf(process.platform === 'win32')('managed hook stdin lifecycle', ()
|
||||
it('accepts a large payload without Orca environment or a broken writer', async () => {
|
||||
const scripts = await generatePosixScripts()
|
||||
for (const [agent, script] of scripts) {
|
||||
const extraEnv =
|
||||
agent === 'command-code'
|
||||
? {
|
||||
ORCA_AGENT_HOOK_PORT: '1',
|
||||
ORCA_AGENT_HOOK_TOKEN: 'test-token',
|
||||
ORCA_PANE_KEY: 'test-pane'
|
||||
}
|
||||
: {}
|
||||
const extraEnv = agent.startsWith('command-code')
|
||||
? {
|
||||
ORCA_AGENT_HOOK_PORT: '1',
|
||||
ORCA_AGENT_HOOK_TOKEN: 'test-token',
|
||||
ORCA_PANE_KEY: 'test-pane'
|
||||
}
|
||||
: {}
|
||||
const result = await runPosixHook(script, extraEnv)
|
||||
expect(result.exitCode, `${agent} exit code`).toBe(0)
|
||||
expect(result.stdinErrors, `${agent} stdin errors`).toHaveLength(0)
|
||||
@@ -392,7 +394,7 @@ describe.skipIf(process.platform === 'win32')('managed hook stdin lifecycle', ()
|
||||
})
|
||||
|
||||
it('drains before Claude skips hooks imported by Devin', async () => {
|
||||
const script = (await generatePosixScripts()).get('claude')
|
||||
const script = (await generatePosixScripts()).get('claude claude-hook.sh')
|
||||
expect(script).toBeDefined()
|
||||
const result = await runPosixHook(script!, { DEVIN_PROJECT_DIR: '/tmp/devin-project' })
|
||||
expect(result.exitCode).toBe(0)
|
||||
|
||||
@@ -112,6 +112,8 @@ const JSON_INSTALLERS = [
|
||||
] as const
|
||||
|
||||
const MANAGED_HOOKS_DIR_NEEDLE = '/.orca/agent-hooks/'
|
||||
// Why: statusLine is not a hook — Claude's schema has no timeout field (type/command/padding/refreshInterval), and a slow statusline can't block agent turns.
|
||||
const STATUSLINE_SCRIPT_NEEDLE = '-statusline.'
|
||||
|
||||
// Walk the parsed config and assert every Orca-managed command carrier (a node
|
||||
// with a `command`/`bash`/`powershell` string pointing at the managed script
|
||||
@@ -121,8 +123,13 @@ const MANAGED_HOOKS_DIR_NEEDLE = '/.orca/agent-hooks/'
|
||||
function countManagedCarriersWithTimeout(
|
||||
node: unknown,
|
||||
expectedTimeout: number,
|
||||
isManagedCarrier = (value: string): boolean =>
|
||||
value.replaceAll('\\', '/').includes(MANAGED_HOOKS_DIR_NEEDLE)
|
||||
isManagedCarrier = (value: string): boolean => {
|
||||
const normalized = value.replaceAll('\\', '/')
|
||||
return (
|
||||
normalized.includes(MANAGED_HOOKS_DIR_NEEDLE) &&
|
||||
!normalized.includes(STATUSLINE_SCRIPT_NEEDLE)
|
||||
)
|
||||
}
|
||||
): number {
|
||||
if (Array.isArray(node)) {
|
||||
return node.reduce<number>(
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// Why: locks the /statusline/claude loopback contract — form-encoded posts from the
|
||||
// managed statusline script must reach the listener, and junk must fail open (204).
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { AgentHookServer } from './server'
|
||||
import type { ClaudeStatusLineRateLimits } from '../../shared/claude-statusline-rate-limits'
|
||||
|
||||
describe('AgentHookServer /statusline/claude', () => {
|
||||
let server: AgentHookServer
|
||||
|
||||
beforeEach(async () => {
|
||||
server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
server.stop()
|
||||
})
|
||||
|
||||
function post(body: string, token?: string): Promise<Response> {
|
||||
const env = server.buildPtyEnv()
|
||||
return fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/statusline/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-Orca-Agent-Hook-Token': token ?? env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body
|
||||
})
|
||||
}
|
||||
|
||||
it('forwards parsed rate limits to the statusline listener', async () => {
|
||||
const events: ClaudeStatusLineRateLimits[] = []
|
||||
server.setClaudeStatusLineListener((event) => {
|
||||
events.push(event)
|
||||
})
|
||||
|
||||
const payload = JSON.stringify({
|
||||
rate_limits: {
|
||||
five_hour: { used_percentage: 12.5, resets_at: 1738425600 },
|
||||
seven_day: { used_percentage: 40, resets_at: 1712059200 }
|
||||
}
|
||||
})
|
||||
const body = new URLSearchParams({
|
||||
paneKey: 'pane-1',
|
||||
configDir: '/home/dev/managed',
|
||||
payload
|
||||
}).toString()
|
||||
|
||||
await expect(post(body)).resolves.toMatchObject({ status: 204 })
|
||||
expect(events).toEqual([
|
||||
{
|
||||
configDir: '/home/dev/managed',
|
||||
fiveHour: { used_percentage: 12.5, resets_at: 1738425600 },
|
||||
sevenDay: { used_percentage: 40, resets_at: 1712059200 }
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects posts with a bad token and ignores payloads without rate limits', async () => {
|
||||
const events: ClaudeStatusLineRateLimits[] = []
|
||||
server.setClaudeStatusLineListener((event) => {
|
||||
events.push(event)
|
||||
})
|
||||
|
||||
await expect(post('payload={}', 'wrong-token')).resolves.toMatchObject({ status: 403 })
|
||||
|
||||
const noLimits = new URLSearchParams({
|
||||
paneKey: 'pane-1',
|
||||
payload: JSON.stringify({ context_window: { used_percentage: 8 } })
|
||||
}).toString()
|
||||
await expect(post(noLimits)).resolves.toMatchObject({ status: 204 })
|
||||
|
||||
await expect(post('payload=not-json')).resolves.toMatchObject({ status: 204 })
|
||||
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -32,6 +32,11 @@ import {
|
||||
type HookListenerState
|
||||
} from '../../shared/agent-hook-listener'
|
||||
import type { AgentHookSource } from '../../shared/agent-hook-relay'
|
||||
import {
|
||||
CLAUDE_STATUSLINE_PATHNAME,
|
||||
parseClaudeStatusLineBody,
|
||||
type ClaudeStatusLineRateLimits
|
||||
} from '../../shared/claude-statusline-rate-limits'
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
type AgentStatusClearIpcPayload,
|
||||
@@ -445,6 +450,7 @@ export class AgentHookServer {
|
||||
// Why: identifies this Orca instance so the server can detect dev vs. prod cross-talk; set at start() from packaged-build knowledge.
|
||||
private env = 'production'
|
||||
private onAgentStatus: ((payload: EnrichedAgentHookEventPayload) => void) | null = null
|
||||
private onClaudeStatusLine: ((event: ClaudeStatusLineRateLimits) => void) | null = null
|
||||
private onPaneStatusCleared: PaneStatusClearListener | null = null
|
||||
private statusChangeListeners = new Set<StatusChangeListener>()
|
||||
// Why: set via start()'s userDataPath so the class has no direct Electron dependency (mockable in vitest node env).
|
||||
@@ -486,6 +492,13 @@ export class AgentHookServer {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: statusline posts carry live Claude usage windows, not agent status; they feed RateLimitService directly.
|
||||
setClaudeStatusLineListener(
|
||||
listener: ((event: ClaudeStatusLineRateLimits) => void) | null
|
||||
): void {
|
||||
this.onClaudeStatusLine = listener
|
||||
}
|
||||
|
||||
subscribeStatusChanges(listener: StatusChangeListener): () => void {
|
||||
this.statusChangeListeners.add(listener)
|
||||
return () => {
|
||||
@@ -1472,6 +1485,15 @@ export class AgentHookServer {
|
||||
try {
|
||||
const body = await readRequestBody(req)
|
||||
const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname
|
||||
if (pathname === CLAUDE_STATUSLINE_PATHNAME) {
|
||||
const statusLineEvent = parseClaudeStatusLineBody(body)
|
||||
if (statusLineEvent) {
|
||||
this.onClaudeStatusLine?.(statusLineEvent)
|
||||
}
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
const source = resolveHookSource(pathname)
|
||||
if (!source) {
|
||||
res.writeHead(404)
|
||||
|
||||
@@ -137,6 +137,71 @@ describe('markCopilotFolderTrusted', () => {
|
||||
})
|
||||
|
||||
describe('markCodexProjectTrusted', () => {
|
||||
it('trusts the main repository root for a linked worktree without reading commondir', () => {
|
||||
const fixtureRoot = mkdtempSync(join(tmpdir(), 'orca-codex-linked-ws-'))
|
||||
const repository = join(fixtureRoot, 'repo')
|
||||
const workspace = join(fixtureRoot, 'worktrees', 'feature')
|
||||
const worktreeGitDir = join(repository, '.git', 'worktrees', 'feature')
|
||||
try {
|
||||
mkdirSync(worktreeGitDir, { recursive: true })
|
||||
mkdirSync(workspace, { recursive: true })
|
||||
writeFileSync(join(workspace, '.git'), `gitdir: ${worktreeGitDir}\n`, 'utf-8')
|
||||
writeFileSync(join(worktreeGitDir, 'gitdir'), join(workspace, '.git'), 'utf-8')
|
||||
|
||||
markCodexProjectTrusted(workspace)
|
||||
|
||||
const repositoryRoot = realpathSync.native(repository)
|
||||
const workspaceRoot = realpathSync.native(workspace)
|
||||
const configPath = join(testState.fakeHomeDir, '.codex', 'config.toml')
|
||||
const runtimeConfigPath = join(
|
||||
testState.userDataDir,
|
||||
'codex-runtime-home',
|
||||
'home',
|
||||
'config.toml'
|
||||
)
|
||||
for (const written of [
|
||||
readFileSync(configPath, 'utf-8'),
|
||||
readFileSync(runtimeConfigPath, 'utf-8')
|
||||
]) {
|
||||
expect(written).toContain(`[projects."${escapeTomlBasicString(repositoryRoot)}"]`)
|
||||
expect(written).not.toContain(`[projects."${escapeTomlBasicString(workspaceRoot)}"]`)
|
||||
}
|
||||
} finally {
|
||||
rmSync(fixtureRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not broaden trust through arbitrary or adversarial Git metadata', () => {
|
||||
const fixtureRoot = mkdtempSync(join(tmpdir(), 'orca-codex-untrusted-gitdir-'))
|
||||
const workspace = join(fixtureRoot, 'workspace')
|
||||
const arbitraryGitDir = join(fixtureRoot, 'metadata', 'feature')
|
||||
const unrelatedRoot = join(fixtureRoot, 'unrelated')
|
||||
try {
|
||||
mkdirSync(arbitraryGitDir, { recursive: true })
|
||||
mkdirSync(workspace, { recursive: true })
|
||||
mkdirSync(unrelatedRoot, { recursive: true })
|
||||
writeFileSync(join(workspace, '.git'), `gitdir: ${arbitraryGitDir}\n`, 'utf-8')
|
||||
writeFileSync(join(arbitraryGitDir, 'commondir'), join(unrelatedRoot, '.git'), 'utf-8')
|
||||
|
||||
markCodexProjectTrusted(workspace)
|
||||
const structuredGitDir = join(unrelatedRoot, '.git', 'worktrees', 'feature')
|
||||
mkdirSync(structuredGitDir, { recursive: true })
|
||||
writeFileSync(join(workspace, '.git'), `gitdir: ${structuredGitDir}\n`, 'utf-8')
|
||||
writeFileSync(join(structuredGitDir, 'gitdir'), join(unrelatedRoot, '.git'), 'utf-8')
|
||||
markCodexProjectTrusted(workspace)
|
||||
|
||||
const written = readFileSync(join(testState.fakeHomeDir, '.codex', 'config.toml'), 'utf-8')
|
||||
expect(written).toContain(
|
||||
`[projects."${escapeTomlBasicString(realpathSync.native(workspace))}"]`
|
||||
)
|
||||
expect(written).not.toContain(
|
||||
`[projects."${escapeTomlBasicString(realpathSync.native(unrelatedRoot))}"]`
|
||||
)
|
||||
} finally {
|
||||
rmSync(fixtureRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('writes ~/.codex/config.toml with the project marked trusted', () => {
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'orca-codex-ws-'))
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { writeFileAtomically } from './codex-accounts/fs-utils'
|
||||
import { getOrcaManagedCodexHomePath } from './codex/codex-home-paths'
|
||||
import { upsertProjectTrustLevel } from './codex/config-toml-trust'
|
||||
@@ -109,7 +109,7 @@ export function markCopilotFolderTrusted(workspacePath: string): void {
|
||||
* codex-rs/core/src/config/config_tests.rs in the Codex CLI source.
|
||||
*/
|
||||
export function markCodexProjectTrusted(workspacePath: string): void {
|
||||
const absPath = canonicalize(workspacePath)
|
||||
const absPath = resolveCodexProjectTrustRoot(workspacePath)
|
||||
const configPath = join(homedir(), '.codex', 'config.toml')
|
||||
upsertProjectTrustLevel(configPath, absPath, 'trusted')
|
||||
// Why: Orca-launched Codex runs with an Orca-owned CODEX_HOME, so the trust
|
||||
@@ -117,6 +117,42 @@ export function markCodexProjectTrusted(workspacePath: string): void {
|
||||
upsertProjectTrustLevel(join(getOrcaManagedCodexHomePath(), 'config.toml'), absPath, 'trusted')
|
||||
}
|
||||
|
||||
function resolveCodexProjectTrustRoot(workspacePath: string): string {
|
||||
const absPath = canonicalize(workspacePath)
|
||||
try {
|
||||
const gitDirReference = readFileSync(join(absPath, '.git'), 'utf-8').trim()
|
||||
if (!gitDirReference.startsWith('gitdir:')) {
|
||||
return absPath
|
||||
}
|
||||
const gitDirPath = gitDirReference.slice('gitdir:'.length).trim()
|
||||
if (!gitDirPath) {
|
||||
return absPath
|
||||
}
|
||||
const gitDir = resolve(absPath, gitDirPath)
|
||||
const worktreesDir = dirname(gitDir)
|
||||
if (basename(worktreesDir) !== 'worktrees') {
|
||||
return absPath
|
||||
}
|
||||
// Why: workspace-controlled .git metadata must not broaden trust without Git's reciprocal link.
|
||||
const gitDirBacklink = readFileSync(join(gitDir, 'gitdir'), 'utf-8').trim()
|
||||
if (!gitDirBacklink) {
|
||||
return absPath
|
||||
}
|
||||
const resolvedBacklink = resolve(gitDir, gitDirBacklink)
|
||||
const workspaceGitFile = join(absPath, '.git')
|
||||
if (
|
||||
resolvedBacklink !== workspaceGitFile &&
|
||||
canonicalize(resolvedBacklink) !== canonicalize(workspaceGitFile)
|
||||
) {
|
||||
return absPath
|
||||
}
|
||||
// Why: mirror Codex's validated .git/worktrees/<name> traversal instead of trusting arbitrary commondir contents.
|
||||
return canonicalize(dirname(dirname(worktreesDir)))
|
||||
} catch {
|
||||
return absPath
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalize(p: string): string {
|
||||
// Why: macOS reports `/tmp/x` and `/private/tmp/x` as the same inode, but
|
||||
// both Cursor and Copilot's trust comparators run realpath() before the
|
||||
|
||||
@@ -14,8 +14,11 @@ vi.mock('../ipc/runtime-environment-transport-routing', () => ({
|
||||
callRuntimeEnvironment: mocks.callRuntimeEnvironment
|
||||
}))
|
||||
|
||||
const { getSavedRuntimeAiVaultHostInfos, scanRuntimeAiVaultSessions } =
|
||||
await import('./runtime-session-scanner')
|
||||
const {
|
||||
getSavedRuntimeAiVaultHostInfos,
|
||||
prepareRuntimeAiVaultSessionResume,
|
||||
scanRuntimeAiVaultSessions
|
||||
} = await import('./runtime-session-scanner')
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -108,6 +111,42 @@ describe('runtime AI Vault session scanner', () => {
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('prepares a resume on the transcript-owning runtime', async () => {
|
||||
mocks.callRuntimeEnvironment.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { useRealCodexHome: true }
|
||||
})
|
||||
const args = {
|
||||
agent: 'codex' as const,
|
||||
filePath: '/managed/sessions/2026/07/20/rollout-a.jsonl',
|
||||
codexHome: '/managed',
|
||||
executionHostId: 'runtime:env-1' as const
|
||||
}
|
||||
|
||||
await expect(prepareRuntimeAiVaultSessionResume('/user-data', 'env-1', args)).resolves.toEqual({
|
||||
useRealCodexHome: true
|
||||
})
|
||||
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith(
|
||||
'/user-data',
|
||||
'env-1',
|
||||
'aiVault.prepareSessionResume',
|
||||
args
|
||||
)
|
||||
})
|
||||
|
||||
it('fails retryably when runtime preparation returns an invalid result', async () => {
|
||||
mocks.callRuntimeEnvironment.mockResolvedValueOnce({ ok: true, result: {} })
|
||||
|
||||
await expect(
|
||||
prepareRuntimeAiVaultSessionResume('/user-data', 'env-1', {
|
||||
agent: 'codex',
|
||||
filePath: '/managed/sessions/2026/07/20/rollout-a.jsonl',
|
||||
codexHome: '/managed',
|
||||
executionHostId: 'runtime:env-1'
|
||||
})
|
||||
).rejects.toThrow('Invalid aiVault.prepareSessionResume response')
|
||||
})
|
||||
})
|
||||
|
||||
function result(
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
import { normalizeExecutionHostId, toRuntimeExecutionHostId } from '../../shared/execution-host'
|
||||
import { listEnvironments } from '../../shared/runtime-environment-store'
|
||||
import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing'
|
||||
import type {
|
||||
AiVaultPrepareSessionResumeArgs,
|
||||
AiVaultPrepareSessionResumeResult
|
||||
} from '../../shared/ai-vault-resume-preparation'
|
||||
|
||||
export type RuntimeAiVaultHostInfo = {
|
||||
environmentId: string
|
||||
@@ -99,6 +103,8 @@ const aiVaultListResultSchema = z.object({
|
||||
scannedAt: z.string()
|
||||
})
|
||||
|
||||
const aiVaultPrepareSessionResumeResultSchema = z.object({ useRealCodexHome: z.boolean() })
|
||||
|
||||
export function getSavedRuntimeAiVaultHostInfos(
|
||||
userDataPath: string
|
||||
): readonly RuntimeAiVaultHostInfo[] {
|
||||
@@ -151,6 +157,29 @@ export async function scanRuntimeAiVaultSessions(
|
||||
})
|
||||
}
|
||||
|
||||
export async function prepareRuntimeAiVaultSessionResume(
|
||||
userDataPath: string,
|
||||
environmentId: string,
|
||||
args: AiVaultPrepareSessionResumeArgs
|
||||
): Promise<AiVaultPrepareSessionResumeResult> {
|
||||
const response = await callRuntimeEnvironment(
|
||||
userDataPath,
|
||||
environmentId,
|
||||
'aiVault.prepareSessionResume',
|
||||
args
|
||||
)
|
||||
if (response.ok !== true) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
const parsed = aiVaultPrepareSessionResumeResultSchema.safeParse(response.result)
|
||||
if (!parsed.success) {
|
||||
throw new Error(
|
||||
`Invalid aiVault.prepareSessionResume response: ${parsed.error.issues[0]?.message ?? 'unexpected result shape'}`
|
||||
)
|
||||
}
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
function withRuntimeExecutionHost(
|
||||
result: AiVaultListResult,
|
||||
executionHostId: `runtime:${string}`
|
||||
|
||||
@@ -67,7 +67,8 @@ import {
|
||||
// inside a try/catch. Override the private method to inject our mock.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(AgentBrowserBridge.prototype as any).getWebContents = function (id: number) {
|
||||
return webContentsFromIdMock(id) ?? null
|
||||
const target = webContentsFromIdMock(id)
|
||||
return target && !target.isDestroyed() ? target : null
|
||||
}
|
||||
|
||||
function mockBrowserManager(
|
||||
@@ -89,10 +90,14 @@ function mockBrowserManager(
|
||||
}
|
||||
|
||||
function mockWebContents(id: number, url = 'https://example.com', title = 'Example') {
|
||||
let currentUrl = url
|
||||
return {
|
||||
id,
|
||||
getURL: () => url,
|
||||
getURL: () => currentUrl,
|
||||
getTitle: () => title,
|
||||
loadURL: vi.fn(async (nextUrl: string) => {
|
||||
currentUrl = nextUrl
|
||||
}),
|
||||
isDestroyed: () => false,
|
||||
invalidate: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
@@ -308,9 +313,9 @@ describe('AgentBrowserBridge', () => {
|
||||
expect(args[args.indexOf('--session') + 1]).toBe('orca-tab-tab-1')
|
||||
})
|
||||
|
||||
// ── --cdp first-use only ──
|
||||
// ── Embedded CDP ownership ──
|
||||
|
||||
it('passes --cdp only on first command for a session', async () => {
|
||||
it('passes --cdp on every helper command family so a restarted daemon cannot launch Chrome', async () => {
|
||||
succeedWith({ snapshot: '...' })
|
||||
await bridge.snapshot()
|
||||
|
||||
@@ -322,16 +327,24 @@ describe('AgentBrowserBridge', () => {
|
||||
const cdpIdx = (snapshotCall![1] as string[]).indexOf('--cdp')
|
||||
expect((snapshotCall![1] as string[])[cdpIdx + 1]).toBe('9222')
|
||||
|
||||
succeedWith({ clicked: '@e1' })
|
||||
await bridge.click('@e1')
|
||||
await bridge.mouseMove(10, 20)
|
||||
await bridge.setOffline('on')
|
||||
await bridge.consoleLog()
|
||||
await bridge.exec('get title')
|
||||
|
||||
const clickCall = execFileMock.mock.calls.find((c: unknown[]) =>
|
||||
(c[1] as string[]).includes('click')
|
||||
)
|
||||
expect(clickCall![1]).not.toContain('--cdp')
|
||||
for (const command of ['click', 'mouse', 'set', 'console', 'get']) {
|
||||
const call = execFileMock.mock.calls.find((candidate: unknown[]) =>
|
||||
(candidate[1] as string[]).includes(command)
|
||||
)
|
||||
expect(call).toBeDefined()
|
||||
const args = call![1] as string[]
|
||||
expect(args).toContain('--cdp')
|
||||
expect(args[args.indexOf('--cdp') + 1]).toBe('9222')
|
||||
}
|
||||
})
|
||||
|
||||
it('continues when stale agent-browser session close hangs during session creation', async () => {
|
||||
it('fails closed when stale agent-browser session ownership cannot be reset', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const closeKill = vi.fn()
|
||||
@@ -349,17 +362,17 @@ describe('AgentBrowserBridge', () => {
|
||||
)
|
||||
|
||||
const promise = bridge.snapshot()
|
||||
let settled = false
|
||||
void promise.finally(() => {
|
||||
settled = true
|
||||
const rejection = expect(promise).rejects.toMatchObject({
|
||||
code: 'browser_owner_unavailable',
|
||||
message:
|
||||
'Could not reset stale helper session orca-tab-tab-1; retry after agent-browser exits'
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(settled).toBe(true)
|
||||
await expect(promise).resolves.toEqual({ browserPageId: 'tab-1', snapshot: 'ready' })
|
||||
await rejection
|
||||
expect(closeKill).toHaveBeenCalled()
|
||||
expect(execFileMock.mock.calls.some((call) => call[1].includes('snapshot'))).toBe(false)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
@@ -876,6 +889,8 @@ describe('AgentBrowserBridge', () => {
|
||||
)
|
||||
expect(routeCalls).toHaveLength(2)
|
||||
expect(routeCalls.at(-1)).toContain('https://old.example/**')
|
||||
expect(routeCalls.at(-1)).toContain('--cdp')
|
||||
expect(routeCalls.at(-1)).toContain('9222')
|
||||
})
|
||||
|
||||
it('clears stale sessions after direct CDP visibility re-registration', async () => {
|
||||
@@ -1119,7 +1134,11 @@ describe('AgentBrowserBridge', () => {
|
||||
const killedError = Object.assign(new Error('timeout'), { killed: true })
|
||||
|
||||
execFileMock.mockImplementation(
|
||||
(_bin: string, _args: string[], _opts: unknown, cb: Function) => {
|
||||
(_bin: string, args: string[], _opts: unknown, cb: Function) => {
|
||||
if (args.includes('close')) {
|
||||
cb(null, JSON.stringify({ success: true, data: null }), '')
|
||||
return
|
||||
}
|
||||
cb(killedError, '', '')
|
||||
}
|
||||
)
|
||||
@@ -1588,13 +1607,282 @@ describe('AgentBrowserBridge', () => {
|
||||
|
||||
// ── goto command ──
|
||||
|
||||
it('passes url to goto command', async () => {
|
||||
succeedWith({ url: 'https://example.com', title: 'Example' })
|
||||
await bridge.goto('https://example.com')
|
||||
it('navigates the registered webContents without spawning agent-browser', async () => {
|
||||
const wc = mockWebContents(100, 'https://example.com/start', 'Example')
|
||||
webContentsFromIdMock.mockReturnValue(wc)
|
||||
|
||||
const args = execFileMock.mock.calls.at(-1)![1] as string[]
|
||||
expect(args).toContain('goto')
|
||||
expect(args).toContain('https://example.com')
|
||||
await expect(bridge.goto('https://example.com/next')).resolves.toEqual({
|
||||
url: 'https://example.com/next',
|
||||
title: 'Example'
|
||||
})
|
||||
|
||||
expect(wc.loadURL).toHaveBeenCalledWith('https://example.com/next')
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves scheme-less navigation semantics at the direct WebContents boundary', async () => {
|
||||
const wc = mockWebContents(100)
|
||||
webContentsFromIdMock.mockReturnValue(wc)
|
||||
|
||||
await expect(bridge.goto('example.com')).resolves.toEqual({
|
||||
url: 'https://example.com/',
|
||||
title: 'Example'
|
||||
})
|
||||
expect(wc.loadURL).toHaveBeenCalledWith('https://example.com/')
|
||||
})
|
||||
|
||||
it('rejects unsupported direct navigation URLs without falling back to agent-browser', async () => {
|
||||
const wc = mockWebContents(100)
|
||||
webContentsFromIdMock.mockReturnValue(wc)
|
||||
|
||||
await expect(bridge.goto('javascript:alert(1)')).rejects.toMatchObject({
|
||||
code: 'invalid_argument',
|
||||
message: 'Unsupported browser URL: javascript:alert(1)'
|
||||
})
|
||||
expect(wc.loadURL).not.toHaveBeenCalled()
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed and releases the command queue when direct navigation never settles', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const wc = mockWebContents(100)
|
||||
wc.loadURL.mockReturnValue(new Promise<void>(() => {}))
|
||||
webContentsFromIdMock.mockReturnValue(wc)
|
||||
|
||||
const navigation = bridge.goto('https://example.com/hangs')
|
||||
const rejection = expect(navigation).rejects.toMatchObject({
|
||||
code: 'browser_error',
|
||||
message: 'Failed to navigate browser page tab-1: Browser navigation timed out after 30000ms'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
|
||||
await rejection
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
expect(
|
||||
(bridge as unknown as { commandQueues: Map<string, unknown[]> }).commandQueues.size
|
||||
).toBe(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('fails closed when direct navigation is aborted', async () => {
|
||||
const wc = mockWebContents(100, 'https://example.com/current', 'Current')
|
||||
wc.loadURL.mockRejectedValue(
|
||||
Object.assign(new Error('ERR_ABORTED (-3)'), { code: 'ERR_ABORTED' })
|
||||
)
|
||||
webContentsFromIdMock.mockReturnValue(wc)
|
||||
|
||||
await expect(bridge.goto('https://example.com/download')).rejects.toMatchObject({
|
||||
code: 'browser_error',
|
||||
message: 'Failed to navigate browser page tab-1: ERR_ABORTED (-3)'
|
||||
})
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
command: 'goto',
|
||||
run: (b: AgentBrowserBridge) => b.goto('https://embedded.example/next'),
|
||||
helperArg: 'goto',
|
||||
directMethod: 'loadURL'
|
||||
},
|
||||
{
|
||||
command: 'evaluate',
|
||||
run: (b: AgentBrowserBridge) => b.evaluate('document.title'),
|
||||
helperArg: 'eval',
|
||||
directMethod: 'Runtime.evaluate'
|
||||
}
|
||||
])(
|
||||
'does not route $command to another browser when the helper session is stale',
|
||||
async ({ run, helperArg, directMethod }) => {
|
||||
const wc = mockWebContents(100, 'https://embedded.example/current', 'Embedded')
|
||||
wc.debugger.sendCommand.mockImplementation(async (_method: string, params?: unknown) => ({
|
||||
result: {
|
||||
value:
|
||||
(params as { expression?: string } | undefined)?.expression === 'location.origin'
|
||||
? 'https://embedded.example'
|
||||
: 'Embedded'
|
||||
}
|
||||
}))
|
||||
webContentsFromIdMock.mockReturnValue(wc)
|
||||
const wrongOwnerCalls: string[][] = []
|
||||
let helperSessionIsStale = false
|
||||
|
||||
execFileMock.mockImplementation(
|
||||
(_bin: string, args: string[], _opts: unknown, cb: Function) => {
|
||||
if (args.includes('close')) {
|
||||
cb(null, JSON.stringify({ success: true, data: null }), '')
|
||||
} else if (args.includes('snapshot')) {
|
||||
cb(null, JSON.stringify({ success: true, data: { snapshot: 'ready' } }), '')
|
||||
} else {
|
||||
if (helperSessionIsStale && !args.includes('--cdp')) {
|
||||
wrongOwnerCalls.push(args)
|
||||
}
|
||||
cb(
|
||||
null,
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
data: { url: 'https://external.example', title: 'External', result: 'external' }
|
||||
}),
|
||||
''
|
||||
)
|
||||
}
|
||||
return { kill: vi.fn() }
|
||||
}
|
||||
)
|
||||
|
||||
await bridge.snapshot()
|
||||
helperSessionIsStale = true
|
||||
await run(bridge)
|
||||
|
||||
expect(wrongOwnerCalls).toEqual([])
|
||||
expect(
|
||||
execFileMock.mock.calls.some((call) => (call[1] as string[]).includes(helperArg))
|
||||
).toBe(false)
|
||||
if (directMethod === 'loadURL') {
|
||||
expect(wc.loadURL).toHaveBeenCalledWith('https://embedded.example/next')
|
||||
} else {
|
||||
expect(wc.debugger.sendCommand).toHaveBeenCalledWith(
|
||||
'Runtime.evaluate',
|
||||
expect.objectContaining({ expression: 'document.title' })
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('returns direct evaluation value and full page URL semantics without spawning agent-browser', async () => {
|
||||
const wc = mockWebContents(100, 'https://example.com/path?query=1')
|
||||
wc.debugger.sendCommand.mockResolvedValue({ result: { value: 42 } })
|
||||
webContentsFromIdMock.mockReturnValue(wc)
|
||||
|
||||
await expect(bridge.evaluate('6 * 7')).resolves.toEqual({
|
||||
result: '42',
|
||||
origin: 'https://example.com/path?query=1'
|
||||
})
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ answer: 42 }, '{"answer":42}'],
|
||||
[['a', 'b'], '["a","b"]']
|
||||
])('preserves structured direct evaluation values as JSON text', async (value, expected) => {
|
||||
const wc = mockWebContents(100)
|
||||
wc.debugger.sendCommand.mockResolvedValue({ result: { value } })
|
||||
webContentsFromIdMock.mockReturnValue(wc)
|
||||
|
||||
await expect(bridge.evaluate('structuredValue')).resolves.toMatchObject({ result: expected })
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces direct evaluation exceptions without falling back to agent-browser', async () => {
|
||||
const wc = mockWebContents(100)
|
||||
wc.debugger.sendCommand.mockResolvedValue({
|
||||
result: { type: 'object' },
|
||||
exceptionDetails: {
|
||||
text: 'Uncaught',
|
||||
exception: { description: 'ReferenceError: missingValue is not defined' }
|
||||
}
|
||||
})
|
||||
webContentsFromIdMock.mockReturnValue(wc)
|
||||
|
||||
await expect(bridge.evaluate('missingValue')).rejects.toMatchObject({
|
||||
code: 'browser_eval_error',
|
||||
message: 'ReferenceError: missingValue is not defined'
|
||||
})
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed when the registered webContents debugger is stale', async () => {
|
||||
const wc = mockWebContents(100)
|
||||
wc.debugger.sendCommand.mockRejectedValue(new Error('Debugger is detached'))
|
||||
webContentsFromIdMock.mockReturnValue(wc)
|
||||
|
||||
await expect(bridge.evaluate('document.title')).rejects.toMatchObject({
|
||||
code: 'browser_error',
|
||||
message: 'Failed to evaluate in browser page tab-1: Debugger is detached'
|
||||
})
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['goto', (b: AgentBrowserBridge) => b.goto('https://example.com/next', undefined, 'tab-1')],
|
||||
['evaluate', (b: AgentBrowserBridge) => b.evaluate('document.title', undefined, 'tab-1')]
|
||||
])('fails closed when direct %s targets a destroyed webContents', async (_command, run) => {
|
||||
const wc = mockWebContents(100)
|
||||
wc.isDestroyed = () => true
|
||||
webContentsFromIdMock.mockReturnValue(wc)
|
||||
const unregisterGuest = vi.fn()
|
||||
const b = new AgentBrowserBridge(
|
||||
mockBrowserManager(new Map([['tab-1', 100]]), new Map(), { unregisterGuest })
|
||||
)
|
||||
b.setActiveTab(100)
|
||||
|
||||
await expect(run(b)).rejects.toMatchObject({
|
||||
code: 'browser_tab_not_found',
|
||||
message: 'Browser page tab-1 is no longer available'
|
||||
})
|
||||
expect(unregisterGuest).toHaveBeenCalledWith('tab-1')
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns navigation state from a replacement registered during load', async () => {
|
||||
const tabs = new Map([['tab-1', 100]])
|
||||
const oldWc = mockWebContents(100, 'https://example.com/start', 'Old')
|
||||
const replacementWc = mockWebContents(200, 'https://example.com/final', 'Replacement')
|
||||
oldWc.loadURL.mockImplementation(async () => {
|
||||
tabs.set('tab-1', 200)
|
||||
})
|
||||
webContentsFromIdMock.mockImplementation((id: number) =>
|
||||
id === 100 ? oldWc : id === 200 ? replacementWc : null
|
||||
)
|
||||
const b = new AgentBrowserBridge(mockBrowserManager(tabs))
|
||||
b.setActiveTab(100)
|
||||
|
||||
await expect(b.goto('https://example.com/next')).resolves.toEqual({
|
||||
url: 'https://example.com/final',
|
||||
title: 'Replacement'
|
||||
})
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes direct commands to the replacement registration, not the stale session owner', async () => {
|
||||
const tabs = new Map([['tab-1', 100]])
|
||||
const oldWc = mockWebContents(100, 'https://old.example', 'Old')
|
||||
const replacementWc = mockWebContents(200, 'https://new.example', 'New')
|
||||
replacementWc.debugger.sendCommand.mockImplementation(
|
||||
async (_method: string, params?: unknown) => ({
|
||||
result: {
|
||||
value:
|
||||
(params as { expression?: string } | undefined)?.expression === 'location.origin'
|
||||
? 'https://new.example'
|
||||
: 'New'
|
||||
}
|
||||
})
|
||||
)
|
||||
webContentsFromIdMock.mockImplementation((id: number) =>
|
||||
id === 100 ? oldWc : id === 200 ? replacementWc : null
|
||||
)
|
||||
const b = new AgentBrowserBridge(mockBrowserManager(tabs))
|
||||
b.setActiveTab(100)
|
||||
|
||||
succeedWith({ snapshot: 'ready' })
|
||||
await b.snapshot()
|
||||
tabs.set('tab-1', 200)
|
||||
await b.onProcessSwap('tab-1', 200, 100)
|
||||
execFileMock.mockClear()
|
||||
|
||||
await expect(b.evaluate('document.title', undefined, 'tab-1')).resolves.toEqual({
|
||||
result: 'New',
|
||||
origin: 'https://new.example'
|
||||
})
|
||||
expect(replacementWc.debugger.sendCommand).toHaveBeenCalledWith(
|
||||
'Runtime.evaluate',
|
||||
expect.objectContaining({ expression: 'document.title' })
|
||||
)
|
||||
expect(oldWc.debugger.sendCommand).not.toHaveBeenCalled()
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects oversized browser clipboard writes before spawning agent-browser', async () => {
|
||||
@@ -2121,7 +2409,11 @@ describe('AgentBrowserBridge', () => {
|
||||
|
||||
it('passes stderr through as error message on execFile failure', async () => {
|
||||
execFileMock.mockImplementation(
|
||||
(_bin: string, _args: string[], _opts: unknown, cb: Function) => {
|
||||
(_bin: string, args: string[], _opts: unknown, cb: Function) => {
|
||||
if (args.includes('close')) {
|
||||
cb(null, JSON.stringify({ success: true, data: null }), '')
|
||||
return
|
||||
}
|
||||
cb(new Error('exit code 1'), '', 'daemon crashed: segfault')
|
||||
}
|
||||
)
|
||||
@@ -2130,7 +2422,11 @@ describe('AgentBrowserBridge', () => {
|
||||
|
||||
it('falls back to error.message when stderr is empty', async () => {
|
||||
execFileMock.mockImplementation(
|
||||
(_bin: string, _args: string[], _opts: unknown, cb: Function) => {
|
||||
(_bin: string, args: string[], _opts: unknown, cb: Function) => {
|
||||
if (args.includes('close')) {
|
||||
cb(null, JSON.stringify({ success: true, data: null }), '')
|
||||
return
|
||||
}
|
||||
cb(new Error('Command failed'), '', '')
|
||||
}
|
||||
)
|
||||
|
||||
@@ -48,6 +48,7 @@ import type {
|
||||
BrowserCookie
|
||||
} from '../../shared/runtime-types'
|
||||
import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text'
|
||||
import { normalizeBrowserNavigationUrl } from '../../shared/browser-url'
|
||||
import { iterateBrowserTextInsertionChunks } from './browser-text-insertion'
|
||||
|
||||
// Why: must exceed agent-browser's internal timeouts (goto 30s, wait 60s) so the bridge never kills a command before its own timeout fires.
|
||||
@@ -55,6 +56,7 @@ const EXEC_TIMEOUT_MS = 90_000
|
||||
const CONSECUTIVE_TIMEOUT_LIMIT = 3
|
||||
const WAIT_PROCESS_TIMEOUT_GRACE_MS = 1_000
|
||||
const STALE_SESSION_CLOSE_TIMEOUT_MS = 3_000
|
||||
const EMBEDDED_NAVIGATION_TIMEOUT_MS = 30_000
|
||||
export const AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES = 8 * 1024
|
||||
export const AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES = AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES
|
||||
|
||||
@@ -766,9 +768,53 @@ export class AgentBrowserBridge {
|
||||
}
|
||||
|
||||
async goto(url: string, worktreeId?: string, browserPageId?: string): Promise<BrowserGotoResult> {
|
||||
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
|
||||
return (await this.execAgentBrowser(sessionName, ['goto', url])) as BrowserGotoResult
|
||||
})
|
||||
return this.enqueueTargetedCommand(
|
||||
worktreeId,
|
||||
browserPageId,
|
||||
async (_sessionName, target) => {
|
||||
const wc = this.requireTargetWebContents(target)
|
||||
const navigationUrl = normalizeBrowserNavigationUrl(url)
|
||||
if (!navigationUrl) {
|
||||
throw new BrowserError('invalid_argument', `Unsupported browser URL: ${url}`)
|
||||
}
|
||||
let navigationTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
try {
|
||||
await Promise.race([
|
||||
wc.loadURL(navigationUrl),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
navigationTimeout = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
`Browser navigation timed out after ${EMBEDDED_NAVIGATION_TIMEOUT_MS}ms`
|
||||
)
|
||||
),
|
||||
EMBEDDED_NAVIGATION_TIMEOUT_MS
|
||||
)
|
||||
navigationTimeout.unref?.()
|
||||
})
|
||||
])
|
||||
} catch (error) {
|
||||
if (!this.getWebContents(target.webContentsId)) {
|
||||
throw this.createPageUnavailableError(`orca-tab-${target.browserPageId}`)
|
||||
}
|
||||
throw new BrowserError(
|
||||
'browser_error',
|
||||
`Failed to navigate browser page ${target.browserPageId}: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
} finally {
|
||||
if (navigationTimeout) {
|
||||
clearTimeout(navigationTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: cross-process navigation can replace the guest while retaining the same authoritative page id.
|
||||
const navigatedTarget = this.resolveCommandTarget(worktreeId, target.browserPageId)
|
||||
const navigatedWebContents = this.requireTargetWebContents(navigatedTarget)
|
||||
return { url: navigatedWebContents.getURL(), title: navigatedWebContents.getTitle() }
|
||||
},
|
||||
{ ensureSession: false }
|
||||
)
|
||||
}
|
||||
|
||||
async fill(
|
||||
@@ -1403,9 +1449,62 @@ export class AgentBrowserBridge {
|
||||
worktreeId?: string,
|
||||
browserPageId?: string
|
||||
): Promise<BrowserEvalResult> {
|
||||
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
|
||||
return (await this.execAgentBrowser(sessionName, ['eval', expression])) as BrowserEvalResult
|
||||
})
|
||||
return this.enqueueTargetedCommand(
|
||||
worktreeId,
|
||||
browserPageId,
|
||||
async (_sessionName, target) => {
|
||||
const wc = this.requireTargetWebContents(target)
|
||||
let releaseDebugger = (): void => {}
|
||||
try {
|
||||
releaseDebugger = acquireElectronDebugger(wc).release
|
||||
const { result, exceptionDetails } = (await wc.debugger.sendCommand('Runtime.evaluate', {
|
||||
expression,
|
||||
returnByValue: true,
|
||||
awaitPromise: true
|
||||
})) as {
|
||||
result: { value?: unknown; description?: string }
|
||||
exceptionDetails?: { text: string; exception?: { description?: string } }
|
||||
}
|
||||
if (exceptionDetails) {
|
||||
throw new BrowserError(
|
||||
'browser_eval_error',
|
||||
exceptionDetails.exception?.description ?? exceptionDetails.text
|
||||
)
|
||||
}
|
||||
|
||||
const currentTarget = this.resolveCommandTarget(worktreeId, target.browserPageId)
|
||||
if (currentTarget.webContentsId !== target.webContentsId) {
|
||||
throw new BrowserError(
|
||||
'browser_tab_changed',
|
||||
`Browser page ${target.browserPageId} changed while evaluating; retry the command`
|
||||
)
|
||||
}
|
||||
return {
|
||||
result:
|
||||
result.value !== undefined
|
||||
? typeof result.value === 'object' && result.value !== null
|
||||
? JSON.stringify(result.value)
|
||||
: String(result.value)
|
||||
: (result.description ?? ''),
|
||||
origin: wc.getURL()
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof BrowserError) {
|
||||
throw error
|
||||
}
|
||||
if (!this.getWebContents(target.webContentsId)) {
|
||||
throw this.createPageUnavailableError(`orca-tab-${target.browserPageId}`)
|
||||
}
|
||||
throw new BrowserError(
|
||||
'browser_error',
|
||||
`Failed to evaluate in browser page ${target.browserPageId}: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
} finally {
|
||||
releaseDebugger()
|
||||
}
|
||||
},
|
||||
{ ensureSession: false }
|
||||
)
|
||||
}
|
||||
|
||||
async hover(
|
||||
@@ -2283,12 +2382,9 @@ export class AgentBrowserBridge {
|
||||
const managesInterceptRoutes =
|
||||
commandArgs[0] === 'network' && (commandArgs[1] === 'route' || commandArgs[1] === 'unroute')
|
||||
|
||||
// Why: --cdp is init-only; pass the port (not a ws:// URL) so agent-browser's /json discovery sees only the proxy's webview, not the host renderer page.
|
||||
const needsInit = !session.initialized
|
||||
if (needsInit) {
|
||||
const port = session.proxy.getPort()
|
||||
args.push('--cdp', String(port))
|
||||
}
|
||||
// Why: a restarted named daemon auto-launches Chrome unless every invocation reasserts Orca's CDP owner.
|
||||
args.push('--cdp', String(session.proxy.getPort()))
|
||||
|
||||
// Why: exec passthrough can produce a large argv; spreading into push risks V8 argument limits.
|
||||
for (const commandArg of commandArgs) {
|
||||
@@ -2323,6 +2419,8 @@ export class AgentBrowserBridge {
|
||||
await this.runAgentBrowserRaw(sessionName, [
|
||||
'--session',
|
||||
sessionName,
|
||||
'--cdp',
|
||||
String(session.proxy.getPort()),
|
||||
'network',
|
||||
'route',
|
||||
urlPattern,
|
||||
@@ -2368,23 +2466,32 @@ export class AgentBrowserBridge {
|
||||
}
|
||||
|
||||
private closeStaleAgentBrowserSession(sessionName: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let child: ReturnType<typeof execFile> | null = null
|
||||
let settled = false
|
||||
|
||||
const finish = (): void => {
|
||||
const finish = (error?: Error): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
// Why: best-effort daemon cleanup — a wedged close must not block the real browser action.
|
||||
// Why: proceeding after an unverified close can reuse a daemon that owns an unrelated browser.
|
||||
const timeout = setTimeout(() => {
|
||||
child?.kill()
|
||||
finish()
|
||||
finish(
|
||||
new BrowserError(
|
||||
'browser_owner_unavailable',
|
||||
`Could not reset stale helper session ${sessionName}; retry after agent-browser exits`
|
||||
)
|
||||
)
|
||||
}, STALE_SESSION_CLOSE_TIMEOUT_MS)
|
||||
|
||||
try {
|
||||
@@ -2392,10 +2499,23 @@ export class AgentBrowserBridge {
|
||||
this.agentBrowserBin,
|
||||
['--session', sessionName, 'close'],
|
||||
{ timeout: STALE_SESSION_CLOSE_TIMEOUT_MS },
|
||||
finish
|
||||
(error) =>
|
||||
finish(
|
||||
error
|
||||
? new BrowserError(
|
||||
'browser_owner_unavailable',
|
||||
`Could not reset stale helper session ${sessionName}: ${error.message}`
|
||||
)
|
||||
: undefined
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
finish(
|
||||
new BrowserError(
|
||||
'browser_owner_unavailable',
|
||||
`Could not reset stale helper session ${sessionName}: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
finish()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2525,10 +2645,19 @@ export class AgentBrowserBridge {
|
||||
return null
|
||||
}
|
||||
|
||||
private requireTargetWebContents(target: ResolvedBrowserCommandTarget): WebContents {
|
||||
const wc = this.getWebContents(target.webContentsId)
|
||||
if (!wc || wc.isDestroyed()) {
|
||||
throw this.createPageUnavailableError(`orca-tab-${target.browserPageId}`)
|
||||
}
|
||||
return wc
|
||||
}
|
||||
|
||||
private getWebContents(webContentsId: number): Electron.WebContents | null {
|
||||
try {
|
||||
const { webContents } = require('electron')
|
||||
return webContents.fromId(webContentsId) ?? null
|
||||
const target = webContents.fromId(webContentsId)
|
||||
return target && !target.isDestroyed() ? target : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -3537,6 +3537,72 @@ describe('ClaudeRuntimeAuthService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the global WSL runtime for untargeted Claude preparation under auto', async () => {
|
||||
setPlatform('win32')
|
||||
vi.doMock('../wsl', () => ({
|
||||
getDefaultWslDistro: () => 'Ubuntu',
|
||||
getWslHome: () => null,
|
||||
toWindowsWslPath: (value: string) => value
|
||||
}))
|
||||
const ubuntuAuthPath = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'ubuntu-account',
|
||||
createClaudeCredentialsJson('ubuntu@example.com', 'ubuntu-token')
|
||||
)
|
||||
const settings = createSettings({
|
||||
localAccountRuntime: 'auto',
|
||||
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' },
|
||||
claudeManagedAccounts: [
|
||||
createClaudeAccount('ubuntu-account', ubuntuAuthPath, {
|
||||
managedAuthRuntime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth'
|
||||
})
|
||||
],
|
||||
activeClaudeManagedAccountId: null,
|
||||
activeClaudeManagedAccountIdsByRuntime: {
|
||||
host: null,
|
||||
wsl: { Ubuntu: 'ubuntu-account' }
|
||||
}
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
const preparation = await service.prepareForClaudeLaunch()
|
||||
|
||||
expect(preparation).toMatchObject({
|
||||
runtime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
provenance: 'managed:ubuntu-account:wsl:Ubuntu'
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores a persisted WSL account-runtime pin on non-Windows hosts', async () => {
|
||||
setPlatform('darwin')
|
||||
vi.doMock('../wsl', () => ({
|
||||
getDefaultWslDistro: () => 'Ubuntu',
|
||||
getWslHome: () => null,
|
||||
toWindowsWslPath: (value: string) => value
|
||||
}))
|
||||
const settings = createSettings({
|
||||
localAccountRuntime: 'wsl',
|
||||
localAccountWslDistro: 'Ubuntu'
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
const preparation = await service.prepareForClaudeLaunch()
|
||||
|
||||
expect(preparation).toMatchObject({
|
||||
runtime: 'host',
|
||||
wslDistro: null,
|
||||
provenance: 'system',
|
||||
stripAuthEnv: false
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps untargeted Claude preparation on host when account runtime is host', async () => {
|
||||
setPlatform('win32')
|
||||
vi.doMock('../wsl', () => ({
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
writeClaudeManagedAuthFile
|
||||
} from './managed-auth-path'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime'
|
||||
import { getDefaultWslDistro, getWslHome, toWindowsWslPath } from '../wsl'
|
||||
import { buildEncodedWslBashCommand } from '../wsl-bash-command'
|
||||
import { hasLiveClaudePtys } from './live-pty-gate'
|
||||
@@ -683,9 +684,10 @@ export class ClaudeRuntimeAuthService {
|
||||
private getDefaultAccountSelectionTarget(
|
||||
settings = this.store.getSettings()
|
||||
): ClaudeAccountSelectionTarget {
|
||||
if (process.platform === 'win32' && settings.localAccountRuntime === 'wsl') {
|
||||
// Why: auth defaults follow account runtime settings, not legacy terminal WSL settings that can outlive the Terminal UI.
|
||||
return { runtime: 'wsl', wslDistro: settings.localAccountWslDistro ?? null }
|
||||
// Why: Windows auth follows the resolved account runtime; stale cross-platform WSL pins must stay local-host.
|
||||
const resolved = resolveLocalAccountRuntimeTarget(settings)
|
||||
if (process.platform === 'win32' && resolved.runtime === 'wsl') {
|
||||
return { runtime: 'wsl', wslDistro: resolved.wslDistro }
|
||||
}
|
||||
return { runtime: 'host' }
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import { ClaudeHookService } from './hook-service'
|
||||
import { OPENCLAUDE_HOOK_SETTINGS } from './hook-settings'
|
||||
|
||||
const CLAUDE_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh'
|
||||
const STATUSLINE_SCRIPT_FILE_NAME =
|
||||
process.platform === 'win32' ? 'claude-statusline.cmd' : 'claude-statusline.sh'
|
||||
const OPENCLAUDE_SCRIPT_FILE_NAME =
|
||||
process.platform === 'win32' ? 'openclaude-hook.cmd' : 'openclaude-hook.sh'
|
||||
const WINDOWS_POWERSHELL_LAUNCHER =
|
||||
@@ -181,6 +183,130 @@ describe('ClaudeHookService.install', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('installs the managed statusLine command and forwards rate_limits posts', () => {
|
||||
const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-statusline-'))
|
||||
vi.stubEnv('HOME', tmpHome)
|
||||
vi.stubEnv('USERPROFILE', tmpHome)
|
||||
try {
|
||||
expect(new ClaudeHookService().install().state).toBe('installed')
|
||||
|
||||
const settings = JSON.parse(
|
||||
readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8')
|
||||
) as { statusLine?: { type: string; command: string } }
|
||||
expect(settings.statusLine?.type).toBe('command')
|
||||
expect(settings.statusLine?.command).toContain('claude-statusline')
|
||||
|
||||
const script = readFileSync(
|
||||
join(tmpHome, '.orca', 'agent-hooks', STATUSLINE_SCRIPT_FILE_NAME),
|
||||
'utf-8'
|
||||
)
|
||||
expect(script).toContain('/statusline/claude')
|
||||
// Why: non-subscriber sessions never carry rate_limits; both branches must guard before spawning curl.
|
||||
if (process.platform === 'win32') {
|
||||
expect(script).toContain('findstr.exe" /c:\\"rate_limits\\"')
|
||||
expect(script).toContain('--data-urlencode "payload@%ORCA_STATUSLINE_PAYLOAD_FILE%"')
|
||||
} else {
|
||||
expect(script).toContain('"rate_limits"')
|
||||
expect(script).toContain('--data-urlencode "payload@-"')
|
||||
}
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
rmSync(tmpHome, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('never overwrites a user-owned statusLine command', () => {
|
||||
const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-user-statusline-'))
|
||||
vi.stubEnv('HOME', tmpHome)
|
||||
vi.stubEnv('USERPROFILE', tmpHome)
|
||||
try {
|
||||
const settingsPath = join(tmpHome, '.claude', 'settings.json')
|
||||
mkdirSync(join(tmpHome, '.claude'), { recursive: true })
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({ statusLine: { type: 'command', command: '/usr/local/bin/my-statusline' } })
|
||||
)
|
||||
|
||||
expect(new ClaudeHookService().install().state).toBe('installed')
|
||||
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'))
|
||||
expect(settings.statusLine).toEqual({
|
||||
type: 'command',
|
||||
command: '/usr/local/bin/my-statusline'
|
||||
})
|
||||
|
||||
// remove() must also leave the user's statusLine untouched.
|
||||
new ClaudeHookService().remove()
|
||||
const afterRemove = JSON.parse(readFileSync(settingsPath, 'utf-8'))
|
||||
expect(afterRemove.statusLine).toEqual({
|
||||
type: 'command',
|
||||
command: '/usr/local/bin/my-statusline'
|
||||
})
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
rmSync(tmpHome, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('removes the managed statusLine on remove()', () => {
|
||||
const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-statusline-remove-'))
|
||||
vi.stubEnv('HOME', tmpHome)
|
||||
vi.stubEnv('USERPROFILE', tmpHome)
|
||||
try {
|
||||
new ClaudeHookService().install()
|
||||
new ClaudeHookService().remove()
|
||||
const settings = JSON.parse(readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8'))
|
||||
expect(settings.statusLine).toBeUndefined()
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
rmSync(tmpHome, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not re-install a managed statusLine the user deleted, until remove() resets the opt-out', () => {
|
||||
const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-statusline-optout-'))
|
||||
vi.stubEnv('HOME', tmpHome)
|
||||
vi.stubEnv('USERPROFILE', tmpHome)
|
||||
try {
|
||||
const settingsPath = join(tmpHome, '.claude', 'settings.json')
|
||||
new ClaudeHookService().install()
|
||||
expect(JSON.parse(readFileSync(settingsPath, 'utf-8')).statusLine).toBeTruthy()
|
||||
|
||||
// The user deletes the managed statusLine from settings.json (e.g. via /statusline or an editor).
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'))
|
||||
delete settings.statusLine
|
||||
writeFileSync(settingsPath, JSON.stringify(settings))
|
||||
|
||||
// A later install (app restart) must respect the deletion — statusLine is opportunistic, not required.
|
||||
new ClaudeHookService().install()
|
||||
expect(JSON.parse(readFileSync(settingsPath, 'utf-8')).statusLine).toBeUndefined()
|
||||
|
||||
// An Orca-level remove() resets the opt-out memory, so a fresh install re-adds it.
|
||||
new ClaudeHookService().remove()
|
||||
new ClaudeHookService().install()
|
||||
expect(JSON.parse(readFileSync(settingsPath, 'utf-8')).statusLine).toBeTruthy()
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
rmSync(tmpHome, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps refreshing a still-managed statusLine across installs', () => {
|
||||
const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-statusline-refresh-'))
|
||||
vi.stubEnv('HOME', tmpHome)
|
||||
vi.stubEnv('USERPROFILE', tmpHome)
|
||||
try {
|
||||
const settingsPath = join(tmpHome, '.claude', 'settings.json')
|
||||
new ClaudeHookService().install()
|
||||
new ClaudeHookService().install()
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'))
|
||||
expect(settings.statusLine?.command).toContain('claude-statusline')
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
rmSync(tmpHome, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// Why: #6078 — Claude Code runs hooks through Git Bash, and an unquoted path
|
||||
// with a space (e.g. `C:/Users/Jane Doe`) splits at the space. The managed
|
||||
// command must use an encoded launcher so Git Bash/cmd.exe never splits or
|
||||
@@ -279,6 +405,10 @@ describe('ClaudeHookService.installRemote', () => {
|
||||
expect(script).toContain('--data-urlencode "payload@-"')
|
||||
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
|
||||
expect(fs.modes.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toBe(0o755)
|
||||
// Why: no remote statusLine — this path serves SSH remotes and WSL guests, whose relay
|
||||
// listener doesn't route /statusline/claude and whose accounts aren't attributable locally.
|
||||
expect(parsed.statusLine).toBeUndefined()
|
||||
expect(fs.files.get('/home/dev/.orca/agent-hooks/claude-statusline.sh')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports parse error when remote settings.json cannot be parsed', async () => {
|
||||
@@ -364,6 +494,8 @@ describe('OpenClaudeHookService-compatible install', () => {
|
||||
expect(
|
||||
readFileSync(join(tmpHome, '.orca', 'agent-hooks', OPENCLAUDE_SCRIPT_FILE_NAME), 'utf-8')
|
||||
).not.toContain('DEVIN_PROJECT_DIR')
|
||||
// Why: the statusline usage feed is Claude-only; OpenClaude installs must not set statusLine.
|
||||
expect(parsed.statusLine).toBeUndefined()
|
||||
expect(existsSync(join(tmpHome, '.claude', 'settings.json'))).toBe(false)
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { existsSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
|
||||
import {
|
||||
buildWindowsAgentHookCurlPostCommand,
|
||||
readHooksJson,
|
||||
writeHooksJson,
|
||||
writeManagedScript
|
||||
writeManagedScript,
|
||||
type HooksConfig
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import {
|
||||
readHooksJsonRemote,
|
||||
@@ -17,8 +19,10 @@ import {
|
||||
buildWindowsHookStdinDrainEpilogue,
|
||||
WINDOWS_HOOK_STDIN_DRAIN_LABEL
|
||||
} from '../agent-hooks/hook-stdin-contract'
|
||||
import { getManagedStatusLineScript } from './statusline-script'
|
||||
import {
|
||||
applyManagedHooks,
|
||||
applyManagedStatusLine,
|
||||
CLAUDE_EVENTS,
|
||||
CLAUDE_HOOK_SETTINGS,
|
||||
getManagedScriptFileName,
|
||||
@@ -28,7 +32,12 @@ import {
|
||||
getPosixManagedScriptFileName,
|
||||
getRemoteConfigPath,
|
||||
getRemoteManagedCommand,
|
||||
getStatusLineInstallMarkerPath,
|
||||
getStatusLineScriptFileName,
|
||||
getStatusLineScriptPath,
|
||||
getStatusLineSlotState,
|
||||
removeManagedHooks,
|
||||
removeManagedStatusLine,
|
||||
type ClaudeCompatibleHookSettings
|
||||
} from './hook-settings'
|
||||
|
||||
@@ -175,7 +184,7 @@ export class ClaudeHookService {
|
||||
}
|
||||
|
||||
const command = getManagedCommand(scriptPath)
|
||||
const nextConfig = applyManagedHooks(
|
||||
let nextConfig = applyManagedHooks(
|
||||
config,
|
||||
command,
|
||||
getManagedScriptFileName(this.options.settings)
|
||||
@@ -184,10 +193,38 @@ export class ClaudeHookService {
|
||||
scriptPath,
|
||||
getManagedScript('local', { skipWhenDevinImportsClaude: this.options.agent === 'claude' })
|
||||
)
|
||||
// Why: the statusline usage feed is Claude-only — OpenClaude data would be misattributed to the Claude provider.
|
||||
if (this.options.agent === 'claude') {
|
||||
nextConfig = this.installManagedStatusLine(nextConfig)
|
||||
}
|
||||
writeHooksJson(configPath, nextConfig)
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
// Why: the statusline feed is opportunistic (usage display, not agent status); a user who deleted the
|
||||
// managed entry has opted out, and the marker distinguishes that deletion from a first install.
|
||||
private installManagedStatusLine(config: HooksConfig): HooksConfig {
|
||||
const scriptFileName = getStatusLineScriptFileName(this.options.settings)
|
||||
const markerPath = getStatusLineInstallMarkerPath(this.options.settings)
|
||||
const slot = getStatusLineSlotState(config, scriptFileName)
|
||||
if (slot === 'user' || (slot === 'empty' && existsSync(markerPath))) {
|
||||
return config
|
||||
}
|
||||
const statusLineScriptPath = getStatusLineScriptPath(this.options.settings)
|
||||
writeManagedScript(statusLineScriptPath, getManagedStatusLineScript('local'))
|
||||
const next = applyManagedStatusLine(
|
||||
config,
|
||||
getManagedCommand(statusLineScriptPath),
|
||||
scriptFileName
|
||||
)
|
||||
try {
|
||||
writeFileSync(markerPath, '')
|
||||
} catch {
|
||||
// Best-effort: a missing marker only means one future user deletion gets re-installed once.
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// Why: install the Claude hook on the remote box (via SFTP); POSIX-only by design (Windows-remote deferred).
|
||||
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
|
||||
// Why: remote-Windows is out of scope; ship POSIX paths. process.platform here is the local box, not the remote, so it can't gate this.
|
||||
@@ -218,6 +255,9 @@ export class ClaudeHookService {
|
||||
remoteScriptPath,
|
||||
getManagedScript('posix', { skipWhenDevinImportsClaude: this.options.agent === 'claude' })
|
||||
)
|
||||
// Why: no statusline install here — this path serves SSH remotes and WSL guests, whose relay hook
|
||||
// listener doesn't route /statusline/claude, and an SSH box's Claude login can be a different
|
||||
// account than the locally selected one, so its usage must not feed the local bar (live feed is host-local only).
|
||||
await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig)
|
||||
|
||||
return {
|
||||
@@ -250,13 +290,25 @@ export class ClaudeHookService {
|
||||
detail: `Could not parse ${this.options.displayName} settings.json`
|
||||
}
|
||||
}
|
||||
const { config: nextConfig, changed } = removeManagedHooks(
|
||||
const { config: hooksRemoved, changed: hooksChanged } = removeManagedHooks(
|
||||
config,
|
||||
getManagedScriptFileName(this.options.settings)
|
||||
)
|
||||
if (changed) {
|
||||
const { config: nextConfig, changed: statusLineChanged } = removeManagedStatusLine(
|
||||
hooksRemoved,
|
||||
getStatusLineScriptFileName(this.options.settings)
|
||||
)
|
||||
if (hooksChanged || statusLineChanged) {
|
||||
writeHooksJson(configPath, nextConfig)
|
||||
}
|
||||
if (this.options.agent === 'claude') {
|
||||
try {
|
||||
// Why: an Orca-level uninstall resets the opt-out memory so a later re-enable installs the statusline again.
|
||||
rmSync(getStatusLineInstallMarkerPath(this.options.settings), { force: true })
|
||||
} catch {
|
||||
// ignore — marker cleanup is best-effort
|
||||
}
|
||||
}
|
||||
return this.getStatus()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildManagedCommandHook,
|
||||
createManagedCommandMatcher,
|
||||
getSharedManagedScriptPath,
|
||||
isPlainObject,
|
||||
removeManagedCommands,
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsGitBashHookCommand,
|
||||
@@ -64,6 +65,24 @@ export function getConfigPath(settings = CLAUDE_HOOK_SETTINGS): string {
|
||||
return join(homedir(), settings.configDirName, 'settings.json')
|
||||
}
|
||||
|
||||
export function getStatusLineScriptBaseName(settings = CLAUDE_HOOK_SETTINGS): string {
|
||||
return settings.scriptBaseName.replace(/-hook$/, '-statusline')
|
||||
}
|
||||
|
||||
export function getStatusLineScriptFileName(settings = CLAUDE_HOOK_SETTINGS): string {
|
||||
return process.platform === 'win32'
|
||||
? `${getStatusLineScriptBaseName(settings)}.cmd`
|
||||
: getPosixStatusLineScriptFileName(settings)
|
||||
}
|
||||
|
||||
export function getPosixStatusLineScriptFileName(settings = CLAUDE_HOOK_SETTINGS): string {
|
||||
return `${getStatusLineScriptBaseName(settings)}.sh`
|
||||
}
|
||||
|
||||
export function getStatusLineScriptPath(settings = CLAUDE_HOOK_SETTINGS): string {
|
||||
return getSharedManagedScriptPath(getStatusLineScriptFileName(settings))
|
||||
}
|
||||
|
||||
export function getManagedScriptFileName(settings = CLAUDE_HOOK_SETTINGS): string {
|
||||
return process.platform === 'win32'
|
||||
? `${settings.scriptBaseName}.cmd`
|
||||
@@ -113,6 +132,58 @@ export function applyManagedHooks(
|
||||
return { ...config, hooks: nextHooks }
|
||||
}
|
||||
|
||||
export type StatusLineSlotState = 'managed' | 'user' | 'empty'
|
||||
|
||||
// Why: install policy needs "user owns the slot" vs "slot is empty" vs "ours" — an empty slot
|
||||
// after a prior install means the user deleted the managed entry, which install must respect.
|
||||
export function getStatusLineSlotState(
|
||||
config: HooksConfig,
|
||||
scriptFileName = getStatusLineScriptFileName()
|
||||
): StatusLineSlotState {
|
||||
const isManagedCommand = createManagedCommandMatcher(scriptFileName)
|
||||
const current = config.statusLine
|
||||
const currentCommand =
|
||||
isPlainObject(current) && typeof current.command === 'string' ? current.command : null
|
||||
if (!currentCommand) {
|
||||
return 'empty'
|
||||
}
|
||||
return isManagedCommand(currentCommand) ? 'managed' : 'user'
|
||||
}
|
||||
|
||||
// Why: records that the managed statusline was installed once, so a later empty slot reads as user opt-out.
|
||||
export function getStatusLineInstallMarkerPath(settings = CLAUDE_HOOK_SETTINGS): string {
|
||||
return getSharedManagedScriptPath(`${getStatusLineScriptBaseName(settings)}.installed`)
|
||||
}
|
||||
|
||||
// Why: statusLine is a single settings slot, not a hooks array — never overwrite a
|
||||
// user-owned status line; the usage feed then simply falls back to the OAuth poll.
|
||||
export function applyManagedStatusLine(
|
||||
config: HooksConfig,
|
||||
command: string,
|
||||
scriptFileName = getStatusLineScriptFileName()
|
||||
): HooksConfig {
|
||||
if (getStatusLineSlotState(config, scriptFileName) === 'user') {
|
||||
return config
|
||||
}
|
||||
return { ...config, statusLine: { type: 'command', command } }
|
||||
}
|
||||
|
||||
export function removeManagedStatusLine(
|
||||
config: HooksConfig,
|
||||
scriptFileName = getStatusLineScriptFileName()
|
||||
): { config: HooksConfig; changed: boolean } {
|
||||
const isManagedCommand = createManagedCommandMatcher(scriptFileName)
|
||||
const current = config.statusLine
|
||||
const currentCommand =
|
||||
isPlainObject(current) && typeof current.command === 'string' ? current.command : null
|
||||
if (!currentCommand || !isManagedCommand(currentCommand)) {
|
||||
return { config, changed: false }
|
||||
}
|
||||
const next = { ...config }
|
||||
delete next.statusLine
|
||||
return { config: next, changed: true }
|
||||
}
|
||||
|
||||
export function removeManagedHooks(
|
||||
config: HooksConfig,
|
||||
scriptFileName = getManagedScriptFileName()
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { getManagedStatusLineScript } from './statusline-script'
|
||||
|
||||
const ORIGINAL_PLATFORM = process.platform
|
||||
|
||||
function stubPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: ORIGINAL_PLATFORM })
|
||||
})
|
||||
|
||||
describe('getManagedStatusLineScript (posix)', () => {
|
||||
it('guards on rate_limits before sourcing the endpoint or spawning curl', () => {
|
||||
stubPlatform('darwin')
|
||||
const script = getManagedStatusLineScript('local')
|
||||
expect(script).toBe(getManagedStatusLineScript('posix'))
|
||||
const guardIndex = script.indexOf('*\'"rate_limits"\'*')
|
||||
const endpointIndex = script.indexOf('ORCA_AGENT_HOOK_ENDPOINT')
|
||||
const curlIndex = script.indexOf('curl -sS')
|
||||
expect(guardIndex).toBeGreaterThan(-1)
|
||||
expect(guardIndex).toBeLessThan(endpointIndex)
|
||||
expect(endpointIndex).toBeLessThan(curlIndex)
|
||||
expect(script).toContain('/statusline/claude')
|
||||
expect(script).toContain('--data-urlencode "payload@-"')
|
||||
})
|
||||
|
||||
it('returns the posix script even on win32 when targeting a remote', () => {
|
||||
stubPlatform('win32')
|
||||
const script = getManagedStatusLineScript('posix')
|
||||
expect(script).toContain('#!/bin/sh')
|
||||
expect(script).not.toContain('curl.exe')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getManagedStatusLineScript (win32 local)', () => {
|
||||
it('guards on rate_limits via findstr before the endpoint call and curl spawn', () => {
|
||||
stubPlatform('win32')
|
||||
const script = getManagedStatusLineScript('local')
|
||||
const captureIndex = script.indexOf('more.com')
|
||||
// Why: the \"-escaped needle makes findstr match the quoted JSON key, not any path containing rate_limits.
|
||||
const guardIndex = script.indexOf('findstr.exe" /c:\\"rate_limits\\"')
|
||||
const endpointIndex = script.indexOf('call "%ORCA_AGENT_HOOK_ENDPOINT%"')
|
||||
const curlIndex = script.indexOf('curl.exe')
|
||||
expect(captureIndex).toBeGreaterThan(-1)
|
||||
expect(guardIndex).toBeGreaterThan(captureIndex)
|
||||
expect(guardIndex).toBeLessThan(endpointIndex)
|
||||
expect(endpointIndex).toBeLessThan(curlIndex)
|
||||
expect(script).toContain('if errorlevel 1 goto :orca_statusline_cleanup')
|
||||
})
|
||||
|
||||
it('posts the buffered payload file and deletes it afterwards', () => {
|
||||
stubPlatform('win32')
|
||||
const script = getManagedStatusLineScript('local')
|
||||
// Why: the temp file is per-pane (pane key with ":" mapped to "_") because %RANDOM%
|
||||
// collides across cmd instances spawned in the same second.
|
||||
expect(script).toContain(
|
||||
'set "ORCA_STATUSLINE_PAYLOAD_FILE=%TEMP%\\orca-claude-statusline-%ORCA_PANE_KEY::=_%.tmp"'
|
||||
)
|
||||
expect(script).toContain('--data-urlencode "payload@%ORCA_STATUSLINE_PAYLOAD_FILE%"')
|
||||
expect(script).not.toContain('payload@-')
|
||||
const curlIndex = script.indexOf('curl.exe')
|
||||
const delIndex = script.indexOf('del "%ORCA_STATUSLINE_PAYLOAD_FILE%"')
|
||||
expect(delIndex).toBeGreaterThan(curlIndex)
|
||||
})
|
||||
|
||||
it('never posts a literal %CLAUDE_CONFIG_DIR% token when the var is unset', () => {
|
||||
stubPlatform('win32')
|
||||
const script = getManagedStatusLineScript('local')
|
||||
// Why: the posted field comes from an always-defined variable so an unset
|
||||
// CLAUDE_CONFIG_DIR yields "configDir=" (matching POSIX + the null snapshot).
|
||||
expect(script).toContain('set "ORCA_STATUSLINE_CONFIG_DIR_FIELD=configDir="')
|
||||
expect(script).toContain(
|
||||
'if defined CLAUDE_CONFIG_DIR set "ORCA_STATUSLINE_CONFIG_DIR_FIELD=configDir=%CLAUDE_CONFIG_DIR%"'
|
||||
)
|
||||
expect(script).toContain('--data-urlencode "%ORCA_STATUSLINE_CONFIG_DIR_FIELD%"')
|
||||
expect(script).not.toContain('"configDir=%CLAUDE_CONFIG_DIR%"')
|
||||
})
|
||||
|
||||
it('drains stdin before exiting when the pane key is missing', () => {
|
||||
stubPlatform('win32')
|
||||
const script = getManagedStatusLineScript('local')
|
||||
const paneGuardIndex = script.indexOf(
|
||||
'if "%ORCA_PANE_KEY%"=="" goto :orca_agent_hook_drain_stdin'
|
||||
)
|
||||
const captureIndex = script.indexOf('more.com')
|
||||
expect(paneGuardIndex).toBeGreaterThan(-1)
|
||||
expect(paneGuardIndex).toBeLessThan(captureIndex)
|
||||
expect(script).toContain(':orca_agent_hook_drain_stdin')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
buildPosixHookPayloadCapture,
|
||||
buildWindowsHookStdinDrainEpilogue,
|
||||
WINDOWS_HOOK_STDIN_DRAIN_LABEL,
|
||||
WINDOWS_HOOK_STDIN_READER
|
||||
} from '../agent-hooks/hook-stdin-contract'
|
||||
import { CLAUDE_STATUSLINE_PATHNAME } from '../../shared/claude-statusline-rate-limits'
|
||||
|
||||
const STATUSLINE_CLEANUP_LABEL = 'orca_statusline_cleanup'
|
||||
|
||||
// Why: Claude Code pipes `rate_limits` to the statusLine command on every turn; forwarding
|
||||
// it gives Orca live usage without spending the OAuth usage endpoint's tight budget.
|
||||
// Emits no stdout so the in-terminal status line stays visually unchanged.
|
||||
export function getManagedStatusLineScript(target: 'local' | 'posix' = 'local'): string {
|
||||
if (target === 'local' && process.platform === 'win32') {
|
||||
return [
|
||||
'@echo off',
|
||||
'setlocal',
|
||||
// Why: pane key is static PTY env (the endpoint file never sets it), so it can gate before stdin is consumed.
|
||||
`if "%ORCA_PANE_KEY%"=="" goto :${WINDOWS_HOOK_STDIN_DRAIN_LABEL}`,
|
||||
// Why: cmd has no builtin stdin capture, so buffer the payload in a per-pane temp file
|
||||
// (%RANDOM% collides across same-second cmd spawns) to guard before any curl spawn.
|
||||
'set "ORCA_STATUSLINE_PAYLOAD_FILE=%TEMP%\\orca-claude-statusline-%ORCA_PANE_KEY::=_%.tmp"',
|
||||
`${WINDOWS_HOOK_STDIN_READER} >"%ORCA_STATUSLINE_PAYLOAD_FILE%" 2>nul`,
|
||||
// Why: rate_limits appears only for Claude.ai-subscriber sessions after the first API response; the
|
||||
// statusline ticks ~3x/sec during streaming, so skip the endpoint call and curl spawn otherwise.
|
||||
// Why: \" is the MSVC argv escape — findstr sees the quoted JSON key, so a cwd containing rate_limits can't false-match (POSIX guard parity).
|
||||
'"%SystemRoot%\\System32\\findstr.exe" /c:\\"rate_limits\\" "%ORCA_STATUSLINE_PAYLOAD_FILE%" >nul 2>nul',
|
||||
`if errorlevel 1 goto :${STATUSLINE_CLEANUP_LABEL}`,
|
||||
// Why: call the endpoint file to refresh port/token — a PTY that survived an Orca restart carries stale env; falls through to PTY env if missing.
|
||||
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
|
||||
`if "%ORCA_AGENT_HOOK_PORT%"=="" goto :${STATUSLINE_CLEANUP_LABEL}`,
|
||||
`if "%ORCA_AGENT_HOOK_TOKEN%"=="" goto :${STATUSLINE_CLEANUP_LABEL}`,
|
||||
// Why: pre-build the field from an always-defined variable so an unset CLAUDE_CONFIG_DIR posts
|
||||
// empty (matching POSIX and the null attribution snapshot), never a literal %VAR% token.
|
||||
'set "ORCA_STATUSLINE_CONFIG_DIR_FIELD=configDir="',
|
||||
'if defined CLAUDE_CONFIG_DIR set "ORCA_STATUSLINE_CONFIG_DIR_FIELD=configDir=%CLAUDE_CONFIG_DIR%"',
|
||||
[
|
||||
'"%SystemRoot%\\System32\\curl.exe" -sS -X POST',
|
||||
`"http://127.0.0.1:%ORCA_AGENT_HOOK_PORT%${CLAUDE_STATUSLINE_PATHNAME}"`,
|
||||
'--connect-timeout 0.5 --max-time 1.5',
|
||||
'-H "Content-Type: application/x-www-form-urlencoded"',
|
||||
'-H "X-Orca-Agent-Hook-Token: %ORCA_AGENT_HOOK_TOKEN%"',
|
||||
'--data-urlencode "paneKey=%ORCA_PANE_KEY%"',
|
||||
'--data-urlencode "%ORCA_STATUSLINE_CONFIG_DIR_FIELD%"',
|
||||
'--data-urlencode "env=%ORCA_AGENT_HOOK_ENV%"',
|
||||
'--data-urlencode "version=%ORCA_AGENT_HOOK_VERSION%"',
|
||||
'--data-urlencode "payload@%ORCA_STATUSLINE_PAYLOAD_FILE%"',
|
||||
'>nul 2>&1'
|
||||
].join(' '),
|
||||
`:${STATUSLINE_CLEANUP_LABEL}`,
|
||||
'del "%ORCA_STATUSLINE_PAYLOAD_FILE%" >nul 2>nul',
|
||||
'exit /b 0',
|
||||
...buildWindowsHookStdinDrainEpilogue(),
|
||||
''
|
||||
].join('\r\n')
|
||||
}
|
||||
|
||||
return [
|
||||
'#!/bin/sh',
|
||||
...buildPosixHookPayloadCapture(),
|
||||
// Why: rate_limits appears only for Claude.ai-subscriber sessions after the first API response; skip the post (and its curl spawn) otherwise.
|
||||
'case "$payload" in',
|
||||
' *\'"rate_limits"\'*) ;;',
|
||||
' *) exit 0 ;;',
|
||||
'esac',
|
||||
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
|
||||
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
|
||||
'fi',
|
||||
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
|
||||
' exit 0',
|
||||
'fi',
|
||||
`printf '%s' "$payload" | curl -sS -X POST "http://127.0.0.1:\${ORCA_AGENT_HOOK_PORT}${CLAUDE_STATUSLINE_PATHNAME}" \\`,
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
|
||||
' --data-urlencode "configDir=${CLAUDE_CONFIG_DIR}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
import type { CodexRateLimitAccountsState, GlobalSettings } from '../../shared/types'
|
||||
import { buildWslCodexAvailabilityArgs, buildWslCodexLoginArgs } from './wsl-codex-command'
|
||||
import type { readHookTrustEntries as ReadHookTrustEntries } from '../codex/config-toml-trust'
|
||||
|
||||
@@ -938,6 +938,179 @@ describe('CodexAccountService config sync', () => {
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'the active account',
|
||||
accountId: 'account-1',
|
||||
outcome: 'success',
|
||||
expectedActiveAccountId: 'account-1',
|
||||
expectedUpdateCount: 2
|
||||
},
|
||||
{
|
||||
label: 'a different account',
|
||||
accountId: 'account-2',
|
||||
outcome: 'success',
|
||||
expectedActiveAccountId: 'account-1',
|
||||
expectedUpdateCount: 2
|
||||
},
|
||||
{
|
||||
label: 'a login that fails',
|
||||
accountId: 'account-1',
|
||||
outcome: 'login-failure',
|
||||
expectedActiveAccountId: null,
|
||||
expectedUpdateCount: 1
|
||||
},
|
||||
{
|
||||
label: 'credentials rejected by runtime validation',
|
||||
accountId: 'account-1',
|
||||
outcome: 'runtime-validation-failure',
|
||||
expectedActiveAccountId: null,
|
||||
expectedUpdateCount: 3
|
||||
}
|
||||
])('keeps host selection semantics when reauthenticating $label', async (testCase) => {
|
||||
vi.resetModules()
|
||||
|
||||
const hostAccounts = ['account-1', 'account-2'].map((id, index) => ({
|
||||
id,
|
||||
email: `${id}@example.com`,
|
||||
managedHomePath: createManagedHome(
|
||||
testState.userDataDir,
|
||||
id,
|
||||
'',
|
||||
createCodexAuthJson(`${id}@example.com`, `provider-${id}`, `refresh-${id}`)
|
||||
),
|
||||
providerAccountId: `provider-${id}`,
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: `provider-${id}`,
|
||||
createdAt: index + 1,
|
||||
updatedAt: index + 1,
|
||||
lastAuthenticatedAt: index + 1
|
||||
}))
|
||||
const wslAccount = {
|
||||
id: 'account-wsl',
|
||||
email: 'account-wsl@example.com',
|
||||
managedHomePath: createManagedHome(
|
||||
testState.userDataDir,
|
||||
'account-wsl',
|
||||
'',
|
||||
createCodexAuthJson('account-wsl@example.com', 'provider-wsl', 'refresh-wsl')
|
||||
),
|
||||
managedHomeRuntime: 'wsl' as const,
|
||||
wslDistro: 'Ubuntu',
|
||||
wslLinuxHomePath: '/home/test/.local/share/orca/codex-accounts/account-wsl/home',
|
||||
providerAccountId: 'provider-wsl',
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: 'provider-wsl',
|
||||
createdAt: 3,
|
||||
updatedAt: 3,
|
||||
lastAuthenticatedAt: 3
|
||||
}
|
||||
const settings = createSettings({
|
||||
codexManagedAccounts: [...hostAccounts, wslAccount],
|
||||
activeCodexManagedAccountId: 'account-1',
|
||||
activeCodexManagedAccountIdsByRuntime: {
|
||||
host: 'account-1',
|
||||
wsl: { Ubuntu: 'account-wsl' }
|
||||
}
|
||||
})
|
||||
const store = createStore(settings)
|
||||
const runtimeHome = createRuntimeHome()
|
||||
const spawnMock = vi.fn(
|
||||
(_command: string, _args: string[], options: { env: NodeJS.ProcessEnv }) => {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: PassThrough
|
||||
stderr: PassThrough
|
||||
kill: () => void
|
||||
}
|
||||
child.stdout = new PassThrough()
|
||||
child.stderr = new PassThrough()
|
||||
child.kill = vi.fn()
|
||||
const current = store.getSettings()
|
||||
store.updateSettings({
|
||||
activeCodexManagedAccountId: null,
|
||||
activeCodexManagedAccountIdsByRuntime: {
|
||||
...current.activeCodexManagedAccountIdsByRuntime!,
|
||||
host: null,
|
||||
wsl: { Ubuntu: null }
|
||||
}
|
||||
})
|
||||
if (testCase.outcome === 'login-failure') {
|
||||
queueMicrotask(() => child.emit('close', 1))
|
||||
return child
|
||||
}
|
||||
writeFileSync(
|
||||
join(options.env.CODEX_HOME!, 'auth.json'),
|
||||
createCodexAuthJson('reauthenticated@example.com', 'provider-new', 'refresh-new'),
|
||||
'utf-8'
|
||||
)
|
||||
queueMicrotask(() => child.emit('close', 0))
|
||||
return child
|
||||
}
|
||||
)
|
||||
vi.doMock('node:child_process', () => ({
|
||||
execFileSync: vi.fn(),
|
||||
spawn: spawnMock
|
||||
}))
|
||||
vi.doMock('../codex-cli/command', () => ({
|
||||
resolveCodexCommand: () => 'codex'
|
||||
}))
|
||||
|
||||
runtimeHome.syncForCurrentSelection.mockImplementation(() => {
|
||||
if (testCase.outcome !== 'runtime-validation-failure') {
|
||||
return
|
||||
}
|
||||
const current = store.getSettings()
|
||||
store.updateSettings({
|
||||
activeCodexManagedAccountId: null,
|
||||
activeCodexManagedAccountIdsByRuntime: {
|
||||
...current.activeCodexManagedAccountIdsByRuntime!,
|
||||
host: null
|
||||
}
|
||||
})
|
||||
})
|
||||
const rateLimits = createRateLimits()
|
||||
const { CodexAccountService } = await import('./service')
|
||||
const service = new CodexAccountService(
|
||||
store as never,
|
||||
rateLimits as never,
|
||||
runtimeHome as never
|
||||
)
|
||||
|
||||
let result: CodexRateLimitAccountsState | null = null
|
||||
if (testCase.outcome === 'login-failure') {
|
||||
await expect(service.reauthenticateAccount(testCase.accountId)).rejects.toThrow(
|
||||
'Codex login exited with code 1.'
|
||||
)
|
||||
} else {
|
||||
result = await service.reauthenticateAccount(testCase.accountId)
|
||||
}
|
||||
|
||||
expect(result?.activeAccountId ?? null).toBe(testCase.expectedActiveAccountId)
|
||||
if (result) {
|
||||
expect(result.activeAccountIdsByRuntime).toEqual({
|
||||
host: testCase.expectedActiveAccountId,
|
||||
wsl: { Ubuntu: null }
|
||||
})
|
||||
}
|
||||
expect(store.getSettings()).toMatchObject({
|
||||
activeCodexManagedAccountId: testCase.expectedActiveAccountId,
|
||||
activeCodexManagedAccountIdsByRuntime: {
|
||||
host: testCase.expectedActiveAccountId,
|
||||
wsl: { Ubuntu: null }
|
||||
}
|
||||
})
|
||||
const completedLogin = testCase.outcome !== 'login-failure'
|
||||
expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledTimes(completedLogin ? 1 : 0)
|
||||
if (completedLogin) {
|
||||
expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledWith({ runtime: 'host' })
|
||||
expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalledWith(undefined, {
|
||||
runtime: 'host'
|
||||
})
|
||||
}
|
||||
expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalledTimes(completedLogin ? 1 : 0)
|
||||
expect(store.updateSettings).toHaveBeenCalledTimes(testCase.expectedUpdateCount)
|
||||
})
|
||||
|
||||
it('does not recreate a missing managed home at a different account path', async () => {
|
||||
vi.resetModules()
|
||||
const managedHomePath = join(testState.userDataDir, 'codex-accounts', 'other-account', 'home')
|
||||
@@ -1252,6 +1425,7 @@ describe('CodexAccountService config sync', () => {
|
||||
}
|
||||
return ''
|
||||
})
|
||||
let clearSelectionDuringLogin = (): void => {}
|
||||
const spawnMock = vi.fn((command: string, args: string[]) => {
|
||||
expect(command).toBe('wsl.exe')
|
||||
expect(args).toEqual(buildWslCodexLoginArgs('Ubuntu', wslLinuxHomePath))
|
||||
@@ -1263,6 +1437,7 @@ describe('CodexAccountService config sync', () => {
|
||||
child.stdout = new PassThrough()
|
||||
child.stderr = new PassThrough()
|
||||
child.kill = vi.fn()
|
||||
clearSelectionDuringLogin()
|
||||
writeFileSync(
|
||||
join(wslManagedHomePath, 'auth.json'),
|
||||
JSON.stringify({
|
||||
@@ -1307,9 +1482,22 @@ describe('CodexAccountService config sync', () => {
|
||||
lastAuthenticatedAt: 1
|
||||
}
|
||||
],
|
||||
activeCodexManagedAccountId: 'account-1'
|
||||
activeCodexManagedAccountId: null,
|
||||
activeCodexManagedAccountIdsByRuntime: {
|
||||
host: null,
|
||||
wsl: { Ubuntu: 'account-1' }
|
||||
}
|
||||
})
|
||||
const store = createStore(settings)
|
||||
clearSelectionDuringLogin = () => {
|
||||
const current = store.getSettings()
|
||||
store.updateSettings({
|
||||
activeCodexManagedAccountIdsByRuntime: {
|
||||
...current.activeCodexManagedAccountIdsByRuntime!,
|
||||
wsl: { Ubuntu: null }
|
||||
}
|
||||
})
|
||||
}
|
||||
const rateLimits = createRateLimits()
|
||||
const runtimeHome = createRuntimeHome()
|
||||
|
||||
@@ -1328,7 +1516,20 @@ describe('CodexAccountService config sync', () => {
|
||||
managedHomeRuntime: 'wsl',
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalled()
|
||||
expect(result.activeAccountId).toBe(null)
|
||||
expect(result.activeAccountIdsByRuntime).toEqual({
|
||||
host: null,
|
||||
wsl: { Ubuntu: 'account-1' }
|
||||
})
|
||||
expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledWith({
|
||||
runtime: 'wsl',
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalledWith(undefined, {
|
||||
runtime: 'wsl',
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
expect(store.updateSettings).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
@@ -1555,12 +1756,14 @@ describe('CodexAccountService config sync', () => {
|
||||
const store = createStore(settings)
|
||||
const rateLimits = createRateLimits()
|
||||
const runtimeHome = createRuntimeHome()
|
||||
const onHostSystemDefaultSelected = vi.fn()
|
||||
|
||||
const { CodexAccountService } = await import('./service')
|
||||
const service = new CodexAccountService(
|
||||
store as never,
|
||||
rateLimits as never,
|
||||
runtimeHome as never
|
||||
runtimeHome as never,
|
||||
{ onHostSystemDefaultSelected }
|
||||
)
|
||||
|
||||
const result = await service.selectAccount(null)
|
||||
@@ -1568,6 +1771,7 @@ describe('CodexAccountService config sync', () => {
|
||||
expect(result.activeAccountId).toBe(null)
|
||||
expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalled()
|
||||
expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalled()
|
||||
expect(onHostSystemDefaultSelected).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('selectAccount immediately rewrites the shared runtime auth for existing terminals', async () => {
|
||||
|
||||
@@ -79,6 +79,10 @@ export type CodexAccountAddTarget = {
|
||||
wslDistro?: string | null
|
||||
}
|
||||
|
||||
export type CodexAccountServiceLifecycle = {
|
||||
onHostSystemDefaultSelected?: () => void
|
||||
}
|
||||
|
||||
type ManagedHomeLocation = {
|
||||
managedHomePath: string
|
||||
managedHomeRuntime: 'host' | 'wsl'
|
||||
@@ -158,7 +162,8 @@ export class CodexAccountService {
|
||||
constructor(
|
||||
private readonly store: Store,
|
||||
private readonly rateLimits: RateLimitService,
|
||||
private readonly runtimeHome: CodexRuntimeHomeService
|
||||
private readonly runtimeHome: CodexRuntimeHomeService,
|
||||
private readonly lifecycle: CodexAccountServiceLifecycle = {}
|
||||
) {
|
||||
this.safeSyncCanonicalConfigToManagedHomes()
|
||||
}
|
||||
@@ -258,6 +263,11 @@ export class CodexAccountService {
|
||||
private async doReauthenticateAccount(accountId: string): Promise<CodexRateLimitAccountsState> {
|
||||
const account = this.requireAccount(accountId)
|
||||
const managedHomePath = this.ensureManagedHomeForReauthentication(account)
|
||||
const accountTarget = getCodexSelectionTargetForAccount(account)
|
||||
const selectedAccountId = getSelectedCodexAccountIdForTarget(
|
||||
this.store.getSettings(),
|
||||
accountTarget
|
||||
)
|
||||
|
||||
this.safeSyncCanonicalConfigIntoManagedHome(managedHomePath, undefined, account.id)
|
||||
await this.runCodexLogin(managedHomePath)
|
||||
@@ -281,19 +291,24 @@ export class CodexAccountService {
|
||||
}
|
||||
: entry
|
||||
)
|
||||
const activeSelection = setSelectedCodexAccountIdForTarget(
|
||||
normalizeCodexRuntimeSelection(settings),
|
||||
selectedAccountId,
|
||||
accountTarget
|
||||
)
|
||||
|
||||
// Why: login can transiently clear this runtime's selection; unrelated runtime validation must remain authoritative.
|
||||
this.store.updateSettings({
|
||||
codexManagedAccounts: updatedAccounts
|
||||
codexManagedAccounts: updatedAccounts,
|
||||
activeCodexManagedAccountId: activeSelection.host,
|
||||
activeCodexManagedAccountIdsByRuntime: activeSelection
|
||||
})
|
||||
this.safeSyncCanonicalConfigToManagedHomes()
|
||||
this.runtimeHome.clearLastWrittenAuthJson(accountId)
|
||||
this.runtimeHome.syncForCurrentSelection(getCodexSelectionTargetForAccount(account))
|
||||
this.runtimeHome.syncForCurrentSelection(accountTarget)
|
||||
|
||||
// Why: re-auth can change the underlying Codex identity, so force a fresh read to avoid showing stale quota.
|
||||
await this.rateLimits.refreshForCodexAccountChange(
|
||||
undefined,
|
||||
getCodexSelectionTargetForAccount(account)
|
||||
)
|
||||
await this.rateLimits.refreshForCodexAccountChange(undefined, accountTarget)
|
||||
return this.getSnapshot()
|
||||
}
|
||||
|
||||
@@ -314,6 +329,9 @@ export class CodexAccountService {
|
||||
activeCodexManagedAccountIdsByRuntime: nextSelection
|
||||
})
|
||||
this.runtimeHome.syncForCurrentSelection()
|
||||
if (account.managedHomeRuntime === 'host' && nextSelection.host === null) {
|
||||
this.lifecycle.onHostSystemDefaultSelected?.()
|
||||
}
|
||||
|
||||
this.safeRemoveManagedHome(account.managedHomePath, account.id)
|
||||
// Why: a removed account can no longer appear in the switcher dropdown,
|
||||
@@ -361,6 +379,12 @@ export class CodexAccountService {
|
||||
})
|
||||
this.safeSyncCanonicalConfigToManagedHomes()
|
||||
this.runtimeHome.syncForCurrentSelection(effectiveTarget)
|
||||
if (
|
||||
accountId === null &&
|
||||
normalizeCodexAccountSelectionTarget(effectiveTarget).runtime === 'host'
|
||||
) {
|
||||
this.lifecycle.onHostSystemDefaultSelected?.()
|
||||
}
|
||||
|
||||
await this.rateLimits.refreshForCodexAccountChange(outgoingAccountId, effectiveTarget)
|
||||
return this.getSnapshot()
|
||||
@@ -510,6 +534,9 @@ export class CodexAccountService {
|
||||
activeCodexManagedAccountId: nextSelection.host,
|
||||
activeCodexManagedAccountIdsByRuntime: nextSelection
|
||||
})
|
||||
if (selection.host !== null && nextSelection.host === null) {
|
||||
this.lifecycle.onHostSystemDefaultSelected?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
import { prepareLegacySharedCodexSessionResume } from './codex-legacy-session-resume'
|
||||
|
||||
describe('prepareLegacySharedCodexSessionResume', () => {
|
||||
let root: string
|
||||
let legacyHome: string
|
||||
let systemHome: string
|
||||
let rolloutPath: string
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'orca-legacy-codex-resume-'))
|
||||
legacyHome = join(root, 'codex-runtime-home', 'home')
|
||||
systemHome = join(root, 'real-codex-home')
|
||||
rolloutPath = join(
|
||||
legacyHome,
|
||||
'sessions',
|
||||
'2026',
|
||||
'07',
|
||||
'20',
|
||||
'rollout-2026-07-20T12-00-00-session.jsonl'
|
||||
)
|
||||
mkdirSync(join(rolloutPath, '..'), { recursive: true })
|
||||
writeFileSync(rolloutPath, '{"type":"session_meta"}\n', 'utf-8')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('materializes an immediate legacy resume into the real home', async () => {
|
||||
const result = await prepare()
|
||||
const targetPath = targetRolloutPath()
|
||||
|
||||
expect(result).toEqual({ useRealCodexHome: true })
|
||||
expect(readFileSync(targetPath, 'utf-8')).toBe('{"type":"session_meta"}\n')
|
||||
expect(statSync(targetPath).ino).toBe(statSync(rolloutPath).ino)
|
||||
})
|
||||
|
||||
it('materializes a compressed legacy rollout without changing its representation', async () => {
|
||||
const compressedPath = `${rolloutPath}.zst`
|
||||
rmSync(rolloutPath)
|
||||
rolloutPath = compressedPath
|
||||
writeFileSync(rolloutPath, 'compressed-rollout', 'utf-8')
|
||||
|
||||
await expect(prepare()).resolves.toEqual({ useRealCodexHome: true })
|
||||
expect(readFileSync(targetRolloutPath(), 'utf-8')).toBe('compressed-rollout')
|
||||
})
|
||||
|
||||
it('coalesces concurrent resumes of the same legacy rollout', async () => {
|
||||
const [first, second] = await Promise.all([prepare(), prepare()])
|
||||
|
||||
expect(first.useRealCodexHome).toBe(true)
|
||||
expect(second.useRealCodexHome).toBe(true)
|
||||
expect(readFileSync(targetRolloutPath(), 'utf-8')).toContain('session_meta')
|
||||
})
|
||||
|
||||
it('fails clearly and retryably instead of approving a missing rollout', async () => {
|
||||
rmSync(rolloutPath)
|
||||
|
||||
await expect(prepare()).rejects.toThrow(/Retry resume/)
|
||||
expect(existsSync(targetRolloutPath())).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed when a different rollout already owns the target path', async () => {
|
||||
mkdirSync(join(targetRolloutPath(), '..'), { recursive: true })
|
||||
writeFileSync(targetRolloutPath(), '{"different":true}\n', 'utf-8')
|
||||
|
||||
await expect(prepare()).rejects.toThrow(/Retry resume/)
|
||||
expect(readFileSync(targetRolloutPath(), 'utf-8')).toBe('{"different":true}\n')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['per-account home', join(rootPlaceholder(), 'codex-accounts', 'account-1', 'home'), 'local'],
|
||||
['custom home', join(rootPlaceholder(), 'custom-codex-home'), 'local'],
|
||||
['WSL home', '\\\\wsl.localhost\\Ubuntu\\home\\me\\.codex', 'local'],
|
||||
['SSH session', rootPlaceholder(), 'ssh:server-1']
|
||||
])(
|
||||
'preserves %s without materializing it',
|
||||
async (_label, codexHomeTemplate, executionHostId) => {
|
||||
const codexHome = codexHomeTemplate.replace(rootPlaceholder(), root)
|
||||
const result = await prepareLegacySharedCodexSessionResume(
|
||||
{
|
||||
agent: 'codex',
|
||||
filePath: rolloutPath,
|
||||
codexHome,
|
||||
executionHostId: executionHostId as 'local'
|
||||
},
|
||||
options()
|
||||
)
|
||||
|
||||
expect(result).toEqual({ useRealCodexHome: false })
|
||||
expect(existsSync(targetRolloutPath())).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['managed account', 'custom CODEX_HOME'])(
|
||||
'preserves the legacy home while the %s lane is selected',
|
||||
async () => {
|
||||
const result = await prepareLegacySharedCodexSessionResume(legacyArgs(), {
|
||||
...options(),
|
||||
isHostSystemDefaultRealHome: () => false
|
||||
})
|
||||
|
||||
expect(result).toEqual({ useRealCodexHome: false })
|
||||
expect(existsSync(targetRolloutPath())).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
function prepare() {
|
||||
return prepareLegacySharedCodexSessionResume(legacyArgs(), options())
|
||||
}
|
||||
|
||||
function legacyArgs() {
|
||||
return {
|
||||
agent: 'codex' as const,
|
||||
filePath: rolloutPath,
|
||||
codexHome: legacyHome,
|
||||
executionHostId: 'local' as const
|
||||
}
|
||||
}
|
||||
|
||||
function options() {
|
||||
return {
|
||||
isHostSystemDefaultRealHome: () => true,
|
||||
legacyCodexHomePath: legacyHome,
|
||||
systemCodexHomePath: systemHome
|
||||
}
|
||||
}
|
||||
|
||||
function targetRolloutPath(): string {
|
||||
return join(systemHome, 'sessions', '2026', '07', '20', basename(rolloutPath))
|
||||
}
|
||||
})
|
||||
|
||||
function rootPlaceholder(): string {
|
||||
return '__ROOT__'
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { link, lstat, mkdir } from 'node:fs/promises'
|
||||
import { dirname, join, relative, resolve, sep } from 'node:path'
|
||||
import type {
|
||||
AiVaultPrepareSessionResumeArgs,
|
||||
AiVaultPrepareSessionResumeResult
|
||||
} from '../../shared/ai-vault-resume-preparation'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
|
||||
import {
|
||||
appendCodexSessionHealAuditRecord,
|
||||
createCodexSessionBackfillAuditWriter
|
||||
} from './codex-session-backfill-audit'
|
||||
import {
|
||||
copySessionFileWithoutOverwrite,
|
||||
isAtomicNoReplaceUnsupportedError
|
||||
} from './codex-session-backfill-copy'
|
||||
import { resolveCodexSessionBackfillPaths } from './codex-session-backfill'
|
||||
|
||||
const RETRYABLE_RESUME_ERROR =
|
||||
'Orca could not safely move this legacy Codex session into your system Codex home. Retry resume; if it still fails, check that both Codex session folders are readable and writable.'
|
||||
|
||||
const materializations = new Map<string, Promise<void>>()
|
||||
|
||||
export async function prepareLegacySharedCodexSessionResume(
|
||||
args: AiVaultPrepareSessionResumeArgs,
|
||||
options: {
|
||||
isHostSystemDefaultRealHome: () => boolean
|
||||
legacyCodexHomePath?: string
|
||||
systemCodexHomePath?: string
|
||||
}
|
||||
): Promise<AiVaultPrepareSessionResumeResult> {
|
||||
const paths = resolveCodexSessionBackfillPaths(options.systemCodexHomePath)
|
||||
const legacyCodexHomePath = options.legacyCodexHomePath ?? dirname(paths.managedSessionsRoot)
|
||||
const managedSessionsRoot = join(legacyCodexHomePath, 'sessions')
|
||||
if (
|
||||
args.agent !== 'codex' ||
|
||||
args.executionHostId !== LOCAL_EXECUTION_HOST_ID ||
|
||||
!args.codexHome ||
|
||||
!sameRuntimePath(args.codexHome, legacyCodexHomePath) ||
|
||||
!options.isHostSystemDefaultRealHome()
|
||||
) {
|
||||
return { useRealCodexHome: false }
|
||||
}
|
||||
|
||||
const sourcePath = resolve(args.filePath)
|
||||
const relativePath = relative(resolve(managedSessionsRoot), sourcePath)
|
||||
if (!isLegacyRolloutRelativePath(relativePath)) {
|
||||
throw new Error(RETRYABLE_RESUME_ERROR)
|
||||
}
|
||||
const targetPath = join(paths.systemSessionsRoot, relativePath)
|
||||
const key = `${normalizeRuntimePathForComparison(sourcePath)}\0${normalizeRuntimePathForComparison(targetPath)}`
|
||||
let task = materializations.get(key)
|
||||
if (!task) {
|
||||
task = materializeLegacyRollout(sourcePath, targetPath, paths.auditLogPath)
|
||||
materializations.set(key, task)
|
||||
void task.then(
|
||||
() => {
|
||||
if (materializations.get(key) === task) {
|
||||
materializations.delete(key)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (materializations.get(key) === task) {
|
||||
materializations.delete(key)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await task
|
||||
} catch (error) {
|
||||
console.warn('[codex-legacy-session-resume] Targeted session migration failed:', error)
|
||||
throw new Error(RETRYABLE_RESUME_ERROR, { cause: error })
|
||||
}
|
||||
return { useRealCodexHome: true }
|
||||
}
|
||||
|
||||
async function materializeLegacyRollout(
|
||||
sourcePath: string,
|
||||
targetPath: string,
|
||||
auditLogPath: string
|
||||
): Promise<void> {
|
||||
const sourceStat = await lstat(sourcePath)
|
||||
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
|
||||
throw new Error('Legacy rollout source is not a regular file.')
|
||||
}
|
||||
await mkdir(dirname(targetPath), { recursive: true })
|
||||
try {
|
||||
await link(sourcePath, targetPath)
|
||||
} catch (linkError) {
|
||||
if (isExistsError(linkError)) {
|
||||
await assertMatchingExistingTarget(sourcePath, targetPath)
|
||||
} else {
|
||||
try {
|
||||
await copySessionFileWithoutOverwrite(sourcePath, targetPath)
|
||||
} catch (copyError) {
|
||||
if (isExistsError(copyError)) {
|
||||
await assertMatchingExistingTarget(sourcePath, targetPath)
|
||||
} else {
|
||||
if (isAtomicNoReplaceUnsupportedError(copyError)) {
|
||||
throw new Error('The target filesystem cannot safely install this rollout.', {
|
||||
cause: copyError
|
||||
})
|
||||
}
|
||||
throw copyError
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
stopped: false,
|
||||
scannedFiles: 1,
|
||||
linkedFiles: 0,
|
||||
copiedFiles: 0,
|
||||
skippedExistingFiles: 0,
|
||||
skippedUnexpectedFiles: 0,
|
||||
skippedSymlinkFiles: 0,
|
||||
skippedUnsupportedFilesystemFiles: 0,
|
||||
failedDirectories: 0,
|
||||
failedFiles: 0,
|
||||
failedHealAuditRecords: 0
|
||||
}
|
||||
await appendCodexSessionHealAuditRecord(
|
||||
createCodexSessionBackfillAuditWriter(auditLogPath),
|
||||
summary,
|
||||
{ action: 'targeted-resume', source: sourcePath, target: targetPath }
|
||||
)
|
||||
}
|
||||
|
||||
async function assertMatchingExistingTarget(sourcePath: string, targetPath: string): Promise<void> {
|
||||
const [sourceStat, targetStat] = await Promise.all([lstat(sourcePath), lstat(targetPath)])
|
||||
if (
|
||||
!targetStat.isFile() ||
|
||||
targetStat.isSymbolicLink() ||
|
||||
sourceStat.size !== targetStat.size ||
|
||||
((sourceStat.dev !== targetStat.dev || sourceStat.ino !== targetStat.ino) &&
|
||||
(await fileDigest(sourcePath)) !== (await fileDigest(targetPath)))
|
||||
) {
|
||||
throw new Error('A different rollout already occupies the real-home target path.')
|
||||
}
|
||||
}
|
||||
|
||||
async function fileDigest(filePath: string): Promise<string> {
|
||||
const hash = createHash('sha256')
|
||||
for await (const chunk of createReadStream(filePath)) {
|
||||
hash.update(chunk as Buffer)
|
||||
}
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
function isLegacyRolloutRelativePath(relativePath: string): boolean {
|
||||
if (!relativePath || relativePath.startsWith('..') || resolve(relativePath) === relativePath) {
|
||||
return false
|
||||
}
|
||||
const parts = relativePath.split(sep)
|
||||
return (
|
||||
parts.length === 4 &&
|
||||
/^\d{4}$/.test(parts[0] ?? '') &&
|
||||
/^\d{2}$/.test(parts[1] ?? '') &&
|
||||
/^\d{2}$/.test(parts[2] ?? '') &&
|
||||
/^rollout-.+\.jsonl(?:\.zst)?$/.test(parts[3] ?? '')
|
||||
)
|
||||
}
|
||||
|
||||
function sameRuntimePath(left: string, right: string): boolean {
|
||||
return normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right)
|
||||
}
|
||||
|
||||
function isExistsError(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
|
||||
}
|
||||
@@ -58,6 +58,34 @@ export async function* listCodexSessionJsonlFilesIncrementally(
|
||||
rootPath: string,
|
||||
options: CodexSessionBridgeIncrementalOptions,
|
||||
onDirectoryError?: (directoryPath: string, error: unknown) => void | Promise<void>
|
||||
): AsyncGenerator<string> {
|
||||
yield* listCodexSessionFilesIncrementally(
|
||||
rootPath,
|
||||
options,
|
||||
(fileName) => fileName.endsWith('.jsonl'),
|
||||
onDirectoryError
|
||||
)
|
||||
}
|
||||
|
||||
/** Yields both physical representations that current Codex can resume. */
|
||||
export async function* listCodexSessionRolloutFilesIncrementally(
|
||||
rootPath: string,
|
||||
options: CodexSessionBridgeIncrementalOptions,
|
||||
onDirectoryError?: (directoryPath: string, error: unknown) => void | Promise<void>
|
||||
): AsyncGenerator<string> {
|
||||
yield* listCodexSessionFilesIncrementally(
|
||||
rootPath,
|
||||
options,
|
||||
(fileName) => fileName.endsWith('.jsonl') || fileName.endsWith('.jsonl.zst'),
|
||||
onDirectoryError
|
||||
)
|
||||
}
|
||||
|
||||
async function* listCodexSessionFilesIncrementally(
|
||||
rootPath: string,
|
||||
options: CodexSessionBridgeIncrementalOptions,
|
||||
isSessionFile: (fileName: string) => boolean,
|
||||
onDirectoryError?: (directoryPath: string, error: unknown) => void | Promise<void>
|
||||
): AsyncGenerator<string> {
|
||||
const batchSize = Math.max(1, options.batchSize ?? INCREMENTAL_BRIDGE_BATCH_SIZE)
|
||||
const yieldMs = Math.max(0, options.yieldMs ?? INCREMENTAL_BRIDGE_YIELD_MS)
|
||||
@@ -75,7 +103,7 @@ export async function* listCodexSessionJsonlFilesIncrementally(
|
||||
const childPath = join(currentDirectory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
pendingDirectories.push(childPath)
|
||||
} else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
||||
} else if (entry.isFile() && isSessionFile(entry.name)) {
|
||||
yield childPath
|
||||
}
|
||||
entriesSinceYield += 1
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createCodexSessionMigrationScheduler } from './codex-session-migration-scheduler'
|
||||
|
||||
describe('createCodexSessionMigrationScheduler', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
it('runs after a managed-account startup switches to host system default', async () => {
|
||||
let eligible = false
|
||||
const startBackfill = vi.fn().mockResolvedValue(null)
|
||||
const startIndexHeal = vi.fn().mockResolvedValue(null)
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => eligible,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => undefined,
|
||||
startBackfill,
|
||||
startIndexHeal
|
||||
})
|
||||
|
||||
scheduler.scheduleInitialRun()
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
expect(startBackfill).not.toHaveBeenCalled()
|
||||
|
||||
eligible = true
|
||||
scheduler.requestRun()
|
||||
await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce())
|
||||
expect(startBackfill).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('coalesces concurrent run requests and stops before index heal after opt-out', async () => {
|
||||
let eligible = true
|
||||
let releaseBackfill: (() => void) | undefined
|
||||
const startBackfill = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseBackfill = resolve
|
||||
})
|
||||
)
|
||||
const startIndexHeal = vi.fn().mockResolvedValue(null)
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => eligible,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => '/custom/history',
|
||||
startBackfill,
|
||||
startIndexHeal
|
||||
})
|
||||
|
||||
scheduler.requestRun()
|
||||
scheduler.requestRun()
|
||||
expect(startBackfill).toHaveBeenCalledOnce()
|
||||
expect(startBackfill).toHaveBeenCalledWith(expect.any(Object), '/custom/history')
|
||||
|
||||
eligible = false
|
||||
releaseBackfill?.()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(startIndexHeal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reruns after a stopping migration becomes eligible again', async () => {
|
||||
let eligible = true
|
||||
let releaseFirstBackfill: ((result: { stopped: boolean }) => void) | undefined
|
||||
const startBackfill = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
(_options) =>
|
||||
new Promise<{ stopped: boolean }>((resolve) => {
|
||||
releaseFirstBackfill = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({ stopped: false })
|
||||
const startIndexHeal = vi.fn().mockResolvedValue(null)
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => eligible,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => undefined,
|
||||
startBackfill,
|
||||
startIndexHeal
|
||||
})
|
||||
|
||||
scheduler.requestRun()
|
||||
const firstRunOptions = startBackfill.mock.calls[0]?.[0]
|
||||
eligible = false
|
||||
expect(firstRunOptions?.shouldStop()).toBe(true)
|
||||
eligible = true
|
||||
scheduler.requestRun()
|
||||
releaseFirstBackfill?.({ stopped: true })
|
||||
|
||||
await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2))
|
||||
await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { CodexSessionBackfillOptions } from './codex-session-backfill-types'
|
||||
|
||||
type MigrationRun = (
|
||||
options: CodexSessionBackfillOptions,
|
||||
systemCodexHomePathOverride?: string
|
||||
) => Promise<unknown>
|
||||
|
||||
export type CodexSessionMigrationScheduler = {
|
||||
scheduleInitialRun(): void
|
||||
requestRun(): void
|
||||
}
|
||||
|
||||
export function createCodexSessionMigrationScheduler(args: {
|
||||
isEligible: () => boolean
|
||||
isQuitting: () => boolean
|
||||
resolveSystemCodexHomePathOverride: () => string | undefined
|
||||
startBackfill: MigrationRun
|
||||
startIndexHeal: MigrationRun
|
||||
initialDelayMs?: number
|
||||
}): CodexSessionMigrationScheduler {
|
||||
let initialTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let migrationTask: Promise<void> | null = null
|
||||
let activeRunStopObserved = false
|
||||
let rerunRequested = false
|
||||
|
||||
const requestRun = (): void => {
|
||||
if (args.isQuitting() || !args.isEligible()) {
|
||||
return
|
||||
}
|
||||
if (migrationTask) {
|
||||
// Why: an account transition can re-enable migration while the prior run is still stopping.
|
||||
rerunRequested ||= activeRunStopObserved
|
||||
return
|
||||
}
|
||||
activeRunStopObserved = false
|
||||
rerunRequested = false
|
||||
const shouldStop = (): boolean => {
|
||||
const stopped = args.isQuitting() || !args.isEligible()
|
||||
activeRunStopObserved ||= stopped
|
||||
return stopped
|
||||
}
|
||||
const systemCodexHomePathOverride = args.resolveSystemCodexHomePathOverride()
|
||||
let stoppedBackfill = false
|
||||
const task = args
|
||||
.startBackfill({ shouldStop }, systemCodexHomePathOverride)
|
||||
.then((result) => {
|
||||
stoppedBackfill = isStoppedMigrationResult(result)
|
||||
if (stoppedBackfill || shouldStop()) {
|
||||
return
|
||||
}
|
||||
return args.startIndexHeal({ shouldStop }, systemCodexHomePathOverride)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.warn('[codex-session-migration] Background session migration failed:', error)
|
||||
})
|
||||
.then(() => undefined)
|
||||
migrationTask = task
|
||||
void task.finally(() => {
|
||||
if (migrationTask === task) {
|
||||
migrationTask = null
|
||||
const shouldRerun = rerunRequested || stoppedBackfill
|
||||
rerunRequested = false
|
||||
activeRunStopObserved = false
|
||||
if (shouldRerun) {
|
||||
requestRun()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
scheduleInitialRun(): void {
|
||||
if (initialTimer) {
|
||||
return
|
||||
}
|
||||
initialTimer = setTimeout(() => {
|
||||
initialTimer = null
|
||||
requestRun()
|
||||
}, args.initialDelayMs ?? 15_000)
|
||||
},
|
||||
requestRun
|
||||
}
|
||||
}
|
||||
|
||||
function isStoppedMigrationResult(result: unknown): boolean {
|
||||
return Boolean(result && typeof result === 'object' && 'stopped' in result && result.stopped)
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
findTrustedCodexSessionResume,
|
||||
resolveTrustedCodexSessionResumeHome
|
||||
} from './codex-session-resume-home'
|
||||
|
||||
const tempRoots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of tempRoots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('resolveTrustedCodexSessionResumeHome', () => {
|
||||
it('returns the trusted home containing a persisted rollout', () => {
|
||||
expect(
|
||||
resolveTrustedCodexSessionResumeHome({
|
||||
transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-session.jsonl',
|
||||
trustedCodexHomes: ['/managed/account/home', '/Users/example/.codex'],
|
||||
fileIsRegular: () => true
|
||||
})
|
||||
).toBe('/Users/example/.codex')
|
||||
})
|
||||
|
||||
it('accepts Windows paths case-insensitively', () => {
|
||||
expect(
|
||||
resolveTrustedCodexSessionResumeHome({
|
||||
transcriptPath: 'C:\\Users\\Example\\.codex\\sessions\\2026\\07\\20\\rollout-a.jsonl',
|
||||
trustedCodexHomes: ['c:\\users\\example\\.codex'],
|
||||
fileIsRegular: () => true
|
||||
})
|
||||
).toBe('c:\\users\\example\\.codex')
|
||||
})
|
||||
|
||||
it('rejects paths outside trusted homes or outside the rollout layout', () => {
|
||||
const fileIsRegular = vi.fn((): boolean => true)
|
||||
expect(
|
||||
resolveTrustedCodexSessionResumeHome({
|
||||
transcriptPath: '/tmp/sessions/2026/07/20/rollout-a.jsonl',
|
||||
trustedCodexHomes: ['/Users/example/.codex'],
|
||||
fileIsRegular
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
resolveTrustedCodexSessionResumeHome({
|
||||
transcriptPath: '/Users/example/.codex/sessions/index.jsonl',
|
||||
trustedCodexHomes: ['/Users/example/.codex'],
|
||||
fileIsRegular
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
resolveTrustedCodexSessionResumeHome({
|
||||
transcriptPath:
|
||||
'/Users/example/.codex/sessions/2026/07/20/rollout-a/../../../../outside.jsonl',
|
||||
trustedCodexHomes: ['/Users/example/.codex'],
|
||||
fileIsRegular
|
||||
})
|
||||
).toBeNull()
|
||||
expect(fileIsRegular).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a trusted-looking path when the rollout no longer exists', () => {
|
||||
expect(
|
||||
resolveTrustedCodexSessionResumeHome({
|
||||
transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-a.jsonl',
|
||||
trustedCodexHomes: ['/Users/example/.codex'],
|
||||
fileIsRegular: () => false
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('requires the transcript provenance to name a regular rollout file', () => {
|
||||
const homePath = mkdtempSync(join(tmpdir(), 'orca-codex-resume-home-'))
|
||||
tempRoots.push(homePath)
|
||||
const rolloutDirectory = join(homePath, 'sessions', '2026', '07', '20', 'rollout-a.jsonl')
|
||||
mkdirSync(rolloutDirectory, { recursive: true })
|
||||
|
||||
expect(
|
||||
resolveTrustedCodexSessionResumeHome({
|
||||
transcriptPath: rolloutDirectory,
|
||||
trustedCodexHomes: [homePath]
|
||||
})
|
||||
).toBeNull()
|
||||
|
||||
const rolloutFile = join(homePath, 'sessions', '2026', '07', '20', 'rollout-b.jsonl')
|
||||
writeFileSync(rolloutFile, '{}\n')
|
||||
expect(
|
||||
resolveTrustedCodexSessionResumeHome({
|
||||
transcriptPath: rolloutFile,
|
||||
trustedCodexHomes: [homePath]
|
||||
})
|
||||
).toBe(homePath)
|
||||
})
|
||||
|
||||
it('follows Codex when a persisted plain rollout was compressed in place', async () => {
|
||||
const homePath = mkdtempSync(join(tmpdir(), 'orca-codex-resume-home-'))
|
||||
tempRoots.push(homePath)
|
||||
const plainPath = join(
|
||||
homePath,
|
||||
'sessions',
|
||||
'2026',
|
||||
'07',
|
||||
'20',
|
||||
'rollout-2026-07-20T12-00-00-session.jsonl'
|
||||
)
|
||||
const compressedPath = `${plainPath}.zst`
|
||||
mkdirSync(join(plainPath, '..'), { recursive: true })
|
||||
writeFileSync(compressedPath, 'compressed-rollout')
|
||||
|
||||
await expect(
|
||||
findTrustedCodexSessionResume({
|
||||
sessionId: 'session-a',
|
||||
transcriptPath: plainPath,
|
||||
trustedCodexHomes: [homePath]
|
||||
})
|
||||
).resolves.toEqual({ homePath, transcriptPath: compressedPath })
|
||||
|
||||
writeFileSync(plainPath, 'active-rollout')
|
||||
await expect(
|
||||
findTrustedCodexSessionResume({
|
||||
sessionId: 'session-a',
|
||||
transcriptPath: compressedPath,
|
||||
trustedCodexHomes: [homePath]
|
||||
})
|
||||
).resolves.toEqual({ homePath, transcriptPath: plainPath })
|
||||
})
|
||||
|
||||
it('finds compressed rollouts for legacy records without transcript provenance', async () => {
|
||||
const homePath = mkdtempSync(join(tmpdir(), 'orca-codex-resume-home-'))
|
||||
tempRoots.push(homePath)
|
||||
const sessionId = '019f81b9-19a9-7651-a8d1-352d9420bd11'
|
||||
const compressedPath = join(
|
||||
homePath,
|
||||
'sessions',
|
||||
'2026',
|
||||
'07',
|
||||
'20',
|
||||
`rollout-2026-07-20T12-00-00-${sessionId}.jsonl.zst`
|
||||
)
|
||||
mkdirSync(join(compressedPath, '..'), { recursive: true })
|
||||
writeFileSync(compressedPath, 'compressed-rollout')
|
||||
|
||||
await expect(
|
||||
findTrustedCodexSessionResume({
|
||||
sessionId,
|
||||
transcriptPath: undefined,
|
||||
trustedCodexHomes: [homePath]
|
||||
})
|
||||
).resolves.toEqual({ homePath, transcriptPath: compressedPath })
|
||||
})
|
||||
|
||||
it('finds older saved sessions by id when transcript provenance is absent', async () => {
|
||||
const sessionId = '019f81b9-19a9-7651-a8d1-352d9420bd11'
|
||||
const rolloutPath = `/managed/account/home/sessions/2026/07/20/rollout-2026-07-20T15-50-19-${sessionId}.jsonl`
|
||||
const listSessionFiles = async function* (sessionsRoot: string): AsyncIterable<string> {
|
||||
if (sessionsRoot === '/managed/account/home/sessions') {
|
||||
yield `/managed/account/home/sessions/misplaced-${sessionId}.jsonl`
|
||||
yield rolloutPath
|
||||
}
|
||||
}
|
||||
|
||||
await expect(
|
||||
findTrustedCodexSessionResume({
|
||||
sessionId,
|
||||
transcriptPath: undefined,
|
||||
trustedCodexHomes: ['/Users/example/.codex', '/managed/account/home'],
|
||||
listSessionFiles
|
||||
})
|
||||
).resolves.toEqual({ homePath: '/managed/account/home', transcriptPath: rolloutPath })
|
||||
})
|
||||
|
||||
it('does not scan session trees when exact transcript provenance is valid', async () => {
|
||||
const transcriptPath =
|
||||
'/managed/account/home/sessions/2026/07/20/rollout-2026-07-20-session.jsonl'
|
||||
const listSessionFiles = vi.fn((): AsyncIterable<string> => {
|
||||
throw new Error('must not scan')
|
||||
})
|
||||
|
||||
await expect(
|
||||
findTrustedCodexSessionResume({
|
||||
sessionId: 'session-a',
|
||||
transcriptPath,
|
||||
trustedCodexHomes: ['/managed/account/home'],
|
||||
fileIsRegular: () => true,
|
||||
listSessionFiles
|
||||
})
|
||||
).resolves.toEqual({ homePath: '/managed/account/home', transcriptPath })
|
||||
expect(listSessionFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not replace rejected transcript provenance with a same-id rollout from another home', async () => {
|
||||
const sessionId = '019f81b9-19a9-7651-a8d1-352d9420bd11'
|
||||
const listSessionFiles = vi.fn((): AsyncIterable<string> => {
|
||||
throw new Error('must not scan')
|
||||
})
|
||||
|
||||
await expect(
|
||||
findTrustedCodexSessionResume({
|
||||
sessionId,
|
||||
transcriptPath: `/managed/origin/home/sessions/2026/07/20/rollout-${sessionId}.jsonl`,
|
||||
trustedCodexHomes: ['/managed/origin/home', '/managed/other/home'],
|
||||
fileIsRegular: () => false,
|
||||
listSessionFiles
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
expect(listSessionFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not scan homes for an untrusted legacy session id shape', async () => {
|
||||
const listSessionFiles = (): AsyncIterable<string> => {
|
||||
throw new Error('must not scan')
|
||||
}
|
||||
await expect(
|
||||
findTrustedCodexSessionResume({
|
||||
sessionId: '../session',
|
||||
transcriptPath: undefined,
|
||||
trustedCodexHomes: ['/Users/example/.codex'],
|
||||
listSessionFiles
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import { existsSync, lstatSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
getRuntimePathBasename,
|
||||
normalizeRuntimePathForComparison,
|
||||
relativePathInsideRoot
|
||||
} from '../../shared/cross-platform-path'
|
||||
import { listCodexSessionRolloutFilesIncrementally } from './codex-session-file-listing'
|
||||
|
||||
// Why: only Codex's dated rollout layout may establish account-home provenance; nested/misplaced JSONL must not select credentials.
|
||||
const ROLLOUT_RELATIVE_PATH = /^\d{4}\/\d{2}\/\d{2}\/rollout-[^/]+\.jsonl(?:\.zst)?$/
|
||||
|
||||
function isCodexRolloutInsideSessionsRoot(sessionsRoot: string, filePath: string): boolean {
|
||||
const relativePath = relativePathInsideRoot(sessionsRoot, filePath)
|
||||
return Boolean(relativePath && ROLLOUT_RELATIVE_PATH.test(relativePath.replace(/\\/g, '/')))
|
||||
}
|
||||
|
||||
function isRegularFile(filePath: string): boolean {
|
||||
try {
|
||||
return lstatSync(filePath).isFile()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function resolveExistingRolloutPath(
|
||||
transcriptPath: string,
|
||||
fileIsRegular: (filePath: string) => boolean
|
||||
): string | null {
|
||||
const plainPath = transcriptPath.endsWith('.jsonl.zst')
|
||||
? transcriptPath.slice(0, -'.zst'.length)
|
||||
: transcriptPath.endsWith('.jsonl')
|
||||
? transcriptPath
|
||||
: null
|
||||
if (!plainPath) {
|
||||
return fileIsRegular(transcriptPath) ? transcriptPath : null
|
||||
}
|
||||
if (fileIsRegular(plainPath)) {
|
||||
return plainPath
|
||||
}
|
||||
const compressedPath = `${plainPath}.zst`
|
||||
return fileIsRegular(compressedPath) ? compressedPath : null
|
||||
}
|
||||
|
||||
function resolveTrustedCodexSessionResume(args: {
|
||||
transcriptPath: string | undefined
|
||||
trustedCodexHomes: readonly string[]
|
||||
fileIsRegular?: (filePath: string) => boolean
|
||||
}): { homePath: string; transcriptPath: string } | null {
|
||||
const persistedPath = args.transcriptPath?.trim()
|
||||
if (!persistedPath) {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const homePath of args.trustedCodexHomes) {
|
||||
const sessionsRoot = join(homePath, 'sessions')
|
||||
if (!isCodexRolloutInsideSessionsRoot(sessionsRoot, persistedPath)) {
|
||||
continue
|
||||
}
|
||||
const transcriptPath = resolveExistingRolloutPath(
|
||||
persistedPath,
|
||||
args.fileIsRegular ?? isRegularFile
|
||||
)
|
||||
if (transcriptPath) {
|
||||
return { homePath, transcriptPath }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function resolveTrustedCodexSessionResumeHome(args: {
|
||||
transcriptPath: string | undefined
|
||||
trustedCodexHomes: readonly string[]
|
||||
fileIsRegular?: (filePath: string) => boolean
|
||||
}): string | null {
|
||||
return resolveTrustedCodexSessionResume(args)?.homePath ?? null
|
||||
}
|
||||
|
||||
export async function findTrustedCodexSessionResume(args: {
|
||||
sessionId: string
|
||||
transcriptPath: string | undefined
|
||||
trustedCodexHomes: readonly string[]
|
||||
fileIsRegular?: (filePath: string) => boolean
|
||||
listSessionFiles?: (sessionsRoot: string) => AsyncIterable<string>
|
||||
}): Promise<{ homePath: string; transcriptPath: string } | null> {
|
||||
const directSession = resolveTrustedCodexSessionResume(args)
|
||||
if (directSession) {
|
||||
return directSession
|
||||
}
|
||||
if (args.transcriptPath?.trim()) {
|
||||
// Why: stale/rejected provenance must not select a same-id rollout under different account credentials; scanning is legacy-only.
|
||||
return null
|
||||
}
|
||||
if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(args.sessionId)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const listSessionFiles =
|
||||
args.listSessionFiles ??
|
||||
((sessionsRoot: string) =>
|
||||
listCodexSessionRolloutFilesIncrementally(sessionsRoot, { batchSize: 64, yieldMs: 0 }))
|
||||
const expectedSuffix = `-${args.sessionId}.jsonl`.toLowerCase()
|
||||
const seenHomes = new Set<string>()
|
||||
for (const homePath of args.trustedCodexHomes) {
|
||||
const comparisonHome = normalizeRuntimePathForComparison(homePath)
|
||||
if (seenHomes.has(comparisonHome)) {
|
||||
continue
|
||||
}
|
||||
seenHomes.add(comparisonHome)
|
||||
const sessionsRoot = join(homePath, 'sessions')
|
||||
if (!args.listSessionFiles && !existsSync(sessionsRoot)) {
|
||||
continue
|
||||
}
|
||||
for await (const filePath of listSessionFiles(sessionsRoot)) {
|
||||
const plainFilePath = filePath.endsWith('.jsonl.zst')
|
||||
? filePath.slice(0, -'.zst'.length)
|
||||
: filePath
|
||||
// Why: the directory entry already proves the compressed file exists; only probe its preferred plain sibling.
|
||||
const preferredFilePath =
|
||||
plainFilePath !== filePath && (args.fileIsRegular ?? isRegularFile)(plainFilePath)
|
||||
? plainFilePath
|
||||
: filePath
|
||||
const plainFileName = getRuntimePathBasename(preferredFilePath)
|
||||
.toLowerCase()
|
||||
.replace(/\.zst$/, '')
|
||||
if (
|
||||
isCodexRolloutInsideSessionsRoot(sessionsRoot, preferredFilePath) &&
|
||||
plainFileName.endsWith(expectedSuffix)
|
||||
) {
|
||||
return { homePath, transcriptPath: preferredFilePath }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -3,9 +3,10 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'
|
||||
|
||||
describe('foreground-confirmation daemon protocol', () => {
|
||||
it('rejects daemons from before the fresh-confirmation RPC', () => {
|
||||
expect(PROTOCOL_VERSION).toBe(24)
|
||||
expect(PROTOCOL_VERSION).toBe(25)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(24)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Why: daemons survive app updates, so wire behavior must be version-gated.
|
||||
export const PROTOCOL_VERSION = 24
|
||||
export const PTY_STARTUP_INGRESS_PROTOCOL_VERSION = 24
|
||||
export const PROTOCOL_VERSION = 25
|
||||
export const PTY_STARTUP_INGRESS_PROTOCOL_VERSION = 25
|
||||
export const GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION = 22
|
||||
export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24
|
||||
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24
|
||||
] as const
|
||||
|
||||
export function supportsPtyStartupIngress(protocolVersion: number): boolean {
|
||||
|
||||
@@ -151,10 +151,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
rows: 24,
|
||||
startupIngress: {
|
||||
colors: { foreground: '#2e3434', background: '#ffffff' },
|
||||
deadlineMs: 5_000,
|
||||
...(process.platform === 'win32'
|
||||
? { echoProjection: 'windows-conpty-esc-stripped' as const }
|
||||
: {})
|
||||
deadlineMs: 5_000
|
||||
}
|
||||
})
|
||||
const query = '\x1b]10;?\x07'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user