mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/native-chat-multiline-send
This commit is contained in:
@@ -63,6 +63,41 @@ describe('lightweight Run CLI handlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('passes Run pagination flags to the runtime', async () => {
|
||||
callMock.mockResolvedValue({
|
||||
result: { runs: [], nextCursor: null }
|
||||
})
|
||||
|
||||
await ORCHESTRATION_HANDLERS['orchestration run-list']({
|
||||
flags: new Map([
|
||||
['limit', '25'],
|
||||
['cursor', 'next-page']
|
||||
]),
|
||||
client: { call: callMock },
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.runList', {
|
||||
limit: 25,
|
||||
cursor: 'next-page'
|
||||
})
|
||||
})
|
||||
|
||||
it('opts into bounded Run pagination by default', async () => {
|
||||
callMock.mockResolvedValue({ result: { runs: [], nextCursor: null } })
|
||||
|
||||
await ORCHESTRATION_HANDLERS['orchestration run-list']({
|
||||
flags: new Map(),
|
||||
client: { call: callMock },
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.runList', {
|
||||
limit: 100,
|
||||
cursor: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('passes explicit legacy takeover only when requested', async () => {
|
||||
callMock.mockResolvedValue({
|
||||
result: { run: { id: 'run_adopted', objective: 'Recovered work' } }
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
import type { NativeChatMessage } from '../../shared/native-chat-types'
|
||||
import type { RuntimeTerminalRead } from '../../shared/runtime-types'
|
||||
import { orchestrationMigrationData } from '../../shared/orchestration-rpc-contract'
|
||||
import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../shared/orchestration-run-pagination'
|
||||
import {
|
||||
formatMessageReadOnlyTag,
|
||||
formatOrchestrationCheckText,
|
||||
@@ -463,19 +464,25 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
)
|
||||
},
|
||||
|
||||
'orchestration run-list': async ({ client, json }) => {
|
||||
'orchestration run-list': async ({ flags, client, json }) => {
|
||||
const result = await client.call<{
|
||||
runs: { id: string; objective: string; legacy: number }[]
|
||||
}>('orchestration.runList', {})
|
||||
printResult(result, json, (r) =>
|
||||
r.runs.length === 0
|
||||
? 'No Runs found.'
|
||||
: r.runs
|
||||
.map(
|
||||
(run) => `${run.id}${run.legacy ? ' [legacy, inspect only]' : ''} ${run.objective}`
|
||||
)
|
||||
.join('\n')
|
||||
)
|
||||
nextCursor: string | null
|
||||
}>('orchestration.runList', {
|
||||
limit: getOptionalPositiveIntegerFlag(flags, 'limit') ?? ORCHESTRATION_RUN_PAGE_LIMIT,
|
||||
cursor: getOptionalStringFlag(flags, 'cursor')
|
||||
})
|
||||
printResult(result, json, (r) => {
|
||||
const rows =
|
||||
r.runs.length === 0
|
||||
? 'No Runs found.'
|
||||
: r.runs
|
||||
.map(
|
||||
(run) => `${run.id}${run.legacy ? ' [legacy, inspect only]' : ''} ${run.objective}`
|
||||
)
|
||||
.join('\n')
|
||||
return r.nextCursor ? `${rows}\nMore Runs: --cursor ${r.nextCursor}` : rows
|
||||
})
|
||||
},
|
||||
|
||||
'orchestration run-show': async ({ flags, client, json }) => {
|
||||
|
||||
@@ -32,8 +32,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
{
|
||||
path: ['orchestration', 'run-list'],
|
||||
summary: 'List lightweight orchestration Runs',
|
||||
usage: 'orca orchestration run-list [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS]
|
||||
usage: 'orca orchestration run-list [--limit <n>] [--cursor <cursor>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'limit', 'cursor']
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'run-show'],
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { PtyBackgroundStreamEvent } from '../providers/types'
|
||||
import { combineUnsubscribes } from './combine-unsubscribes'
|
||||
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events'
|
||||
|
||||
export class DaemonPtyAdapterSubscriptionFanout {
|
||||
private unsubscribers: (() => void)[] = []
|
||||
private dataListeners: ((payload: DaemonPtyRouterDataEvent) => void)[] = []
|
||||
private exitListeners: ((payload: DaemonPtyRouterExitEvent) => void)[] = []
|
||||
|
||||
constructor(
|
||||
private readonly adapters: readonly DaemonPtyAdapter[],
|
||||
onAdapterExit: (id: string) => void
|
||||
) {
|
||||
for (const adapter of adapters) {
|
||||
this.unsubscribers.push(
|
||||
adapter.onData((payload) => {
|
||||
for (const listener of this.dataListeners) {
|
||||
listener(payload)
|
||||
}
|
||||
}),
|
||||
adapter.onExit((payload) => {
|
||||
onAdapterExit(payload.id)
|
||||
for (const listener of this.exitListeners) {
|
||||
listener(payload)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onData(callback: (payload: DaemonPtyRouterDataEvent) => void): () => void {
|
||||
this.dataListeners.push(callback)
|
||||
return () => {
|
||||
const index = this.dataListeners.indexOf(callback)
|
||||
if (index !== -1) {
|
||||
this.dataListeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void {
|
||||
return combineUnsubscribes(
|
||||
this.adapters.map((adapter) => adapter.onBackgroundStreamEvent(callback))
|
||||
)
|
||||
}
|
||||
|
||||
// Why: main subscribes on the routed provider, so without this the dead-endpoint
|
||||
// fan-out never reaches the renderer and only the written pane recovers (STA-2373).
|
||||
onWriteUnavailable(callback: (payload: { id: string }) => void): () => void {
|
||||
return combineUnsubscribes(this.adapters.map((adapter) => adapter.onWriteUnavailable(callback)))
|
||||
}
|
||||
|
||||
onReplay(_callback: (payload: { id: string; data: string }) => void): () => void {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
onExit(callback: (payload: DaemonPtyRouterExitEvent) => void): () => void {
|
||||
this.exitListeners.push(callback)
|
||||
return () => {
|
||||
const index = this.exitListeners.indexOf(callback)
|
||||
if (index !== -1) {
|
||||
this.exitListeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const unsubscribe of this.unsubscribers.splice(0)) {
|
||||
unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
|
||||
export type DaemonPtyRouterDataEvent = {
|
||||
id: string
|
||||
data: string
|
||||
sequenceChars?: number
|
||||
transformed?: boolean
|
||||
seq?: number
|
||||
}
|
||||
|
||||
export type DaemonPtyRouterExitEvent = {
|
||||
id: string
|
||||
code: number
|
||||
incarnationId?: PtyIncarnationId
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import { combineUnsubscribes } from './combine-unsubscribes'
|
||||
import { DaemonPtyAdapterSubscriptionFanout } from './daemon-pty-adapter-subscription-fanout'
|
||||
import type {
|
||||
IPtyProvider,
|
||||
PtyBackgroundStreamEvent,
|
||||
@@ -8,48 +8,23 @@ import type {
|
||||
PtySpawnOptions,
|
||||
PtySpawnResult
|
||||
} from '../providers/types'
|
||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
import type { PtyProcessInspection } from '../providers/pty-process-inspection'
|
||||
import { probePtyOwners } from './daemon-pty-liveness-probe'
|
||||
import { shouldHandoffDaemonHistory } from './daemon-history-handoff'
|
||||
import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events'
|
||||
|
||||
export class DaemonPtyRouter implements IPtyProvider {
|
||||
private current: DaemonPtyAdapter
|
||||
private legacy: DaemonPtyAdapter[]
|
||||
private sessionAdapters = new Map<string, DaemonPtyAdapter>()
|
||||
private unsubscribers: (() => void)[] = []
|
||||
private dataListeners: ((payload: {
|
||||
id: string
|
||||
data: string
|
||||
sequenceChars?: number
|
||||
transformed?: boolean
|
||||
seq?: number
|
||||
}) => void)[] = []
|
||||
private exitListeners: ((payload: {
|
||||
id: string
|
||||
code: number
|
||||
incarnationId?: PtyIncarnationId
|
||||
}) => void)[] = []
|
||||
private readonly subscriptions: DaemonPtyAdapterSubscriptionFanout
|
||||
|
||||
constructor(opts: { current: DaemonPtyAdapter; legacy: DaemonPtyAdapter[] }) {
|
||||
this.current = opts.current
|
||||
this.legacy = opts.legacy
|
||||
|
||||
for (const adapter of this.allAdapters()) {
|
||||
this.unsubscribers.push(
|
||||
adapter.onData((payload) => {
|
||||
for (const listener of this.dataListeners) {
|
||||
listener(payload)
|
||||
}
|
||||
}),
|
||||
adapter.onExit((payload) => {
|
||||
this.sessionAdapters.delete(payload.id)
|
||||
for (const listener of this.exitListeners) {
|
||||
listener(payload)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
this.subscriptions = new DaemonPtyAdapterSubscriptionFanout(this.allAdapters(), (id) => {
|
||||
this.sessionAdapters.delete(id)
|
||||
})
|
||||
}
|
||||
|
||||
async discoverLegacySessions(): Promise<void> {
|
||||
@@ -231,52 +206,24 @@ export class DaemonPtyRouter implements IPtyProvider {
|
||||
return this.current.getProfiles()
|
||||
}
|
||||
|
||||
onData(
|
||||
callback: (payload: {
|
||||
id: string
|
||||
data: string
|
||||
sequenceChars?: number
|
||||
transformed?: boolean
|
||||
seq?: number
|
||||
}) => void
|
||||
): () => void {
|
||||
this.dataListeners.push(callback)
|
||||
return () => {
|
||||
const idx = this.dataListeners.indexOf(callback)
|
||||
if (idx !== -1) {
|
||||
this.dataListeners.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
onData(callback: (payload: DaemonPtyRouterDataEvent) => void): () => void {
|
||||
return this.subscriptions.onData(callback)
|
||||
}
|
||||
|
||||
onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void {
|
||||
return combineUnsubscribes(
|
||||
this.allAdapters().map((adapter) => adapter.onBackgroundStreamEvent(callback))
|
||||
)
|
||||
return this.subscriptions.onBackgroundStreamEvent(callback)
|
||||
}
|
||||
|
||||
// Why: main subscribes on the routed provider, so without this the dead-endpoint
|
||||
// fan-out never reaches the renderer and only the written pane recovers (STA-2373).
|
||||
onWriteUnavailable(callback: (payload: { id: string }) => void): () => void {
|
||||
return combineUnsubscribes(
|
||||
this.allAdapters().map((adapter) => adapter.onWriteUnavailable(callback))
|
||||
)
|
||||
return this.subscriptions.onWriteUnavailable(callback)
|
||||
}
|
||||
|
||||
onReplay(_callback: (payload: { id: string; data: string }) => void): () => void {
|
||||
return () => {}
|
||||
onReplay(callback: (payload: { id: string; data: string }) => void): () => void {
|
||||
return this.subscriptions.onReplay(callback)
|
||||
}
|
||||
|
||||
onExit(
|
||||
callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void
|
||||
): () => void {
|
||||
this.exitListeners.push(callback)
|
||||
return () => {
|
||||
const idx = this.exitListeners.indexOf(callback)
|
||||
if (idx !== -1) {
|
||||
this.exitListeners.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
onExit(callback: (payload: DaemonPtyRouterExitEvent) => void): () => void {
|
||||
return this.subscriptions.onExit(callback)
|
||||
}
|
||||
|
||||
ackColdRestore(sessionId: string): void {
|
||||
@@ -314,9 +261,7 @@ export class DaemonPtyRouter implements IPtyProvider {
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const unsubscribe of this.unsubscribers.splice(0)) {
|
||||
unsubscribe()
|
||||
}
|
||||
this.subscriptions.dispose()
|
||||
for (const adapter of this.allAdapters()) {
|
||||
adapter.dispose()
|
||||
}
|
||||
@@ -330,15 +275,11 @@ export class DaemonPtyRouter implements IPtyProvider {
|
||||
// Without this, each restart leaked a router instance pinned by the legacy
|
||||
// adapters' listener arrays (one pair per adapter per restart).
|
||||
disposeRouterOnly(): void {
|
||||
for (const unsubscribe of this.unsubscribers.splice(0)) {
|
||||
unsubscribe()
|
||||
}
|
||||
this.subscriptions.dispose()
|
||||
}
|
||||
|
||||
async disconnectOnly(): Promise<void> {
|
||||
for (const unsubscribe of this.unsubscribers.splice(0)) {
|
||||
unsubscribe()
|
||||
}
|
||||
this.subscriptions.dispose()
|
||||
await Promise.all([...this.allAdapters()].map((adapter) => adapter.disconnectOnly()))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { jsonUtf8ByteLength } from './json-utf8-byte-length'
|
||||
|
||||
describe('jsonUtf8ByteLength', () => {
|
||||
it('matches JSON.stringify for escapes, Unicode, surrogates, and nested values', () => {
|
||||
const values: unknown[] = [
|
||||
'',
|
||||
'"\\\b\t\n\f\r\u0000\u001f',
|
||||
'plain ASCII',
|
||||
'é漢😀',
|
||||
'\ud800 lone high \udc00 lone low',
|
||||
{
|
||||
omitted: undefined,
|
||||
finite: -1.25e100,
|
||||
nonFinite: Number.POSITIVE_INFINITY,
|
||||
nested: ['😀', undefined, null, { control: '\u0001' }]
|
||||
}
|
||||
]
|
||||
|
||||
for (const value of values) {
|
||||
const json = JSON.stringify(value)
|
||||
expect(jsonUtf8ByteLength(value)).toBe(Buffer.byteLength(json, 'utf8'))
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects the same unsupported structural values as JSON.stringify', () => {
|
||||
const circular: Record<string, unknown> = {}
|
||||
circular.self = circular
|
||||
|
||||
expect(() => jsonUtf8ByteLength(circular)).toThrow('circular')
|
||||
expect(() => jsonUtf8ByteLength(1n)).toThrow('BigInt')
|
||||
})
|
||||
})
|
||||
@@ -1,94 +0,0 @@
|
||||
function jsonStringUtf8Bytes(value: string): number {
|
||||
let bytes = 2
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const codeUnit = value.charCodeAt(index)
|
||||
if (codeUnit === 0x22 || codeUnit === 0x5c || codeUnit === 0x08 || codeUnit === 0x09) {
|
||||
bytes += 2
|
||||
} else if (codeUnit === 0x0a || codeUnit === 0x0c || codeUnit === 0x0d) {
|
||||
bytes += 2
|
||||
} else if (codeUnit < 0x20) {
|
||||
bytes += 6
|
||||
} else if (codeUnit < 0x80) {
|
||||
bytes += 1
|
||||
} else if (codeUnit < 0x800) {
|
||||
bytes += 2
|
||||
} else if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
|
||||
const next = value.charCodeAt(index + 1)
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
bytes += 4
|
||||
index += 1
|
||||
} else {
|
||||
bytes += 6
|
||||
}
|
||||
} else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
|
||||
bytes += 6
|
||||
} else {
|
||||
bytes += 3
|
||||
}
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
export function jsonUtf8ByteLength(value: unknown): number {
|
||||
const activeObjects = new Set<object>()
|
||||
|
||||
const measure = (current: unknown, arrayElement: boolean): number | null => {
|
||||
if (current === null) {
|
||||
return 4
|
||||
}
|
||||
switch (typeof current) {
|
||||
case 'string':
|
||||
return jsonStringUtf8Bytes(current)
|
||||
case 'boolean':
|
||||
return current ? 4 : 5
|
||||
case 'number':
|
||||
return Number.isFinite(current) ? JSON.stringify(current).length : 4
|
||||
case 'undefined':
|
||||
case 'function':
|
||||
case 'symbol':
|
||||
return arrayElement ? 4 : null
|
||||
case 'bigint':
|
||||
throw new TypeError('Do not know how to serialize a BigInt')
|
||||
case 'object':
|
||||
break
|
||||
}
|
||||
|
||||
const object = current as object
|
||||
if (activeObjects.has(object)) {
|
||||
throw new TypeError('Converting circular structure to JSON')
|
||||
}
|
||||
activeObjects.add(object)
|
||||
try {
|
||||
if (Array.isArray(object)) {
|
||||
let bytes = 2
|
||||
for (let index = 0; index < object.length; index += 1) {
|
||||
if (index > 0) {
|
||||
bytes += 1
|
||||
}
|
||||
bytes += measure(object[index], true) ?? 4
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
let bytes = 2
|
||||
let entries = 0
|
||||
for (const key of Object.keys(object)) {
|
||||
const propertyBytes = measure((object as Record<string, unknown>)[key], false)
|
||||
if (propertyBytes === null) {
|
||||
continue
|
||||
}
|
||||
bytes += (entries > 0 ? 1 : 0) + jsonStringUtf8Bytes(key) + 1 + propertyBytes
|
||||
entries += 1
|
||||
}
|
||||
return bytes
|
||||
} finally {
|
||||
activeObjects.delete(object)
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = measure(value, false)
|
||||
if (bytes === null) {
|
||||
throw new TypeError('Value is not JSON serializable')
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalSnapshot } from './types'
|
||||
import { serializeTerminalCheckpointWithinLimit } from './terminal-checkpoint-serializer'
|
||||
|
||||
function snapshot(overrides: Partial<TerminalSnapshot> = {}): TerminalSnapshot {
|
||||
return {
|
||||
snapshotAnsi: 'visible',
|
||||
scrollbackAnsi: '',
|
||||
rehydrateSequences: '',
|
||||
cwd: '/workspace',
|
||||
modes: {
|
||||
bracketedPaste: false,
|
||||
mouseTracking: false,
|
||||
applicationCursor: false,
|
||||
alternateScreen: false
|
||||
},
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
scrollbackLines: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
cwd: '/workspace',
|
||||
generation: 1,
|
||||
checkpointedAt: '2026-07-29T00:00:00.000Z'
|
||||
}
|
||||
|
||||
describe('terminal checkpoint serializer', () => {
|
||||
it('matches JSON.stringify exactly at the UTF-8 byte limit', async () => {
|
||||
const input = snapshot({
|
||||
snapshotAnsi: `é漢😀${String.fromCharCode(0xd800, 0xdc00)}"\\${String.fromCharCode(
|
||||
0x00,
|
||||
0x08,
|
||||
0x09,
|
||||
0x0a,
|
||||
0x0c,
|
||||
0x0d,
|
||||
0x1f,
|
||||
0xd800,
|
||||
0xdc00
|
||||
)}`,
|
||||
oscLinks: [{ row: 0, startCol: 0, endCol: 1, uri: 'https://example.com/😀\n' }]
|
||||
})
|
||||
const expected = JSON.stringify({
|
||||
snapshotAnsi: input.snapshotAnsi,
|
||||
scrollbackAnsi: input.scrollbackAnsi,
|
||||
oscLinks: input.oscLinks,
|
||||
rehydrateSequences: input.rehydrateSequences,
|
||||
cwd: metadata.cwd,
|
||||
cols: input.cols,
|
||||
rows: input.rows,
|
||||
modes: input.modes,
|
||||
scrollbackLines: input.scrollbackLines,
|
||||
generation: metadata.generation,
|
||||
checkpointedAt: metadata.checkpointedAt
|
||||
})
|
||||
const exactBytes = Buffer.byteLength(expected, 'utf8')
|
||||
|
||||
await expect(serializeTerminalCheckpointWithinLimit(input, metadata, exactBytes)).resolves.toBe(
|
||||
expected
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects multibyte input whose code-unit length fits under the byte cap', async () => {
|
||||
const input = snapshot({
|
||||
scrollbackAnsi: 'é\r\n'.repeat(100),
|
||||
scrollbackLines: 100
|
||||
})
|
||||
const expected = JSON.stringify({
|
||||
snapshotAnsi: input.snapshotAnsi,
|
||||
scrollbackAnsi: input.scrollbackAnsi,
|
||||
oscLinks: input.oscLinks,
|
||||
rehydrateSequences: input.rehydrateSequences,
|
||||
cwd: metadata.cwd,
|
||||
cols: input.cols,
|
||||
rows: input.rows,
|
||||
modes: input.modes,
|
||||
scrollbackLines: input.scrollbackLines,
|
||||
generation: metadata.generation,
|
||||
checkpointedAt: metadata.checkpointedAt
|
||||
})
|
||||
const maxBytes = expected.length + 1
|
||||
|
||||
expect(expected.length).toBeLessThan(maxBytes)
|
||||
expect(Buffer.byteLength(expected, 'utf8')).toBeGreaterThan(maxBytes)
|
||||
|
||||
const serialized = await serializeTerminalCheckpointWithinLimit(input, metadata, maxBytes)
|
||||
|
||||
expect(serialized).not.toBe(expected)
|
||||
expect(Buffer.byteLength(serialized, 'utf8')).toBeLessThanOrEqual(maxBytes)
|
||||
})
|
||||
|
||||
it('does not materialize and rescan a passing candidate', async () => {
|
||||
let reads = 0
|
||||
const input = snapshot()
|
||||
Object.defineProperty(input, 'snapshotAnsi', {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
reads += 1
|
||||
return 'visible'
|
||||
}
|
||||
})
|
||||
const stringify = vi.spyOn(JSON, 'stringify')
|
||||
const byteLength = vi.spyOn(Buffer, 'byteLength')
|
||||
|
||||
try {
|
||||
await serializeTerminalCheckpointWithinLimit(input, metadata, 20 * 1024)
|
||||
|
||||
expect({
|
||||
fullCandidateStringifies: stringify.mock.calls.filter(
|
||||
([value]) => typeof value === 'object' && value !== null
|
||||
).length,
|
||||
byteLengthCalls: byteLength.mock.calls.length,
|
||||
snapshotReads: reads
|
||||
}).toEqual({ fullCandidateStringifies: 0, byteLengthCalls: 0, snapshotReads: 1 })
|
||||
} finally {
|
||||
stringify.mockRestore()
|
||||
byteLength.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an oversized escaped candidate without materializing it', async () => {
|
||||
const oversized = String.fromCharCode(0).repeat(100_000)
|
||||
const stringify = vi.spyOn(JSON, 'stringify')
|
||||
|
||||
try {
|
||||
await serializeTerminalCheckpointWithinLimit(
|
||||
snapshot({ snapshotAnsi: oversized }),
|
||||
metadata,
|
||||
512
|
||||
)
|
||||
|
||||
const materializedOversizedCandidate = stringify.mock.calls.some(([value]) => {
|
||||
return (value as { snapshotAnsi?: unknown })?.snapshotAnsi === oversized
|
||||
})
|
||||
expect(materializedOversizedCandidate).toBe(false)
|
||||
} finally {
|
||||
stringify.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { TerminalCheckpointFile, TerminalSnapshot } from './types'
|
||||
import { ColdRestoreReplayWriter } from './cold-restore-replay-writer'
|
||||
import { HeadlessEmulator } from './headless-emulator'
|
||||
import { jsonUtf8ByteLength } from './json-utf8-byte-length'
|
||||
|
||||
type CheckpointMetadata = {
|
||||
cwd: string | null
|
||||
@@ -32,15 +31,187 @@ function checkpointFile(
|
||||
}
|
||||
}
|
||||
|
||||
class BoundedJsonWriter {
|
||||
private output = ''
|
||||
private chunk = ''
|
||||
private bytes = 0
|
||||
private exceeded = false
|
||||
|
||||
constructor(private readonly maxBytes: number) {}
|
||||
|
||||
append(value: string, bytes: number): boolean {
|
||||
if (this.bytes + bytes > this.maxBytes) {
|
||||
this.exceeded = true
|
||||
this.output = ''
|
||||
this.chunk = ''
|
||||
return false
|
||||
}
|
||||
this.bytes += bytes
|
||||
this.chunk += value
|
||||
if (this.chunk.length >= 16 * 1024) {
|
||||
this.output += this.chunk
|
||||
this.chunk = ''
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
result(): string | null {
|
||||
return this.exceeded ? null : this.output + this.chunk
|
||||
}
|
||||
}
|
||||
|
||||
function escapedCodeUnit(codeUnit: number): string | null {
|
||||
switch (codeUnit) {
|
||||
case 0x08:
|
||||
return '\\b'
|
||||
case 0x09:
|
||||
return '\\t'
|
||||
case 0x0a:
|
||||
return '\\n'
|
||||
case 0x0c:
|
||||
return '\\f'
|
||||
case 0x0d:
|
||||
return '\\r'
|
||||
case 0x22:
|
||||
return '\\"'
|
||||
case 0x5c:
|
||||
return '\\\\'
|
||||
default:
|
||||
return codeUnit < 0x20 ? `\\u${codeUnit.toString(16).padStart(4, '0')}` : null
|
||||
}
|
||||
}
|
||||
|
||||
function appendJsonString(writer: BoundedJsonWriter, value: string): boolean {
|
||||
if (!writer.append('"', 1)) {
|
||||
return false
|
||||
}
|
||||
let spanStart = 0
|
||||
let spanBytes = 0
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const codeUnit = value.charCodeAt(index)
|
||||
let escaped = escapedCodeUnit(codeUnit)
|
||||
if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
|
||||
const next = value.charCodeAt(index + 1)
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
spanBytes += 4
|
||||
index += 1
|
||||
} else {
|
||||
escaped = `\\u${codeUnit.toString(16)}`
|
||||
}
|
||||
} else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
|
||||
escaped = `\\u${codeUnit.toString(16)}`
|
||||
} else if (escaped === null) {
|
||||
spanBytes += codeUnit < 0x80 ? 1 : codeUnit < 0x800 ? 2 : 3
|
||||
}
|
||||
|
||||
if (escaped !== null) {
|
||||
if (
|
||||
(index > spanStart && !writer.append(value.slice(spanStart, index), spanBytes)) ||
|
||||
!writer.append(escaped, escaped.length)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
spanStart = index + 1
|
||||
spanBytes = 0
|
||||
} else if (index + 1 - spanStart >= 16 * 1024) {
|
||||
if (!writer.append(value.slice(spanStart, index + 1), spanBytes)) {
|
||||
return false
|
||||
}
|
||||
spanStart = index + 1
|
||||
spanBytes = 0
|
||||
}
|
||||
}
|
||||
if (spanStart < value.length && !writer.append(value.slice(spanStart), spanBytes)) {
|
||||
return false
|
||||
}
|
||||
return writer.append('"', 1)
|
||||
}
|
||||
|
||||
function omittedByJson(value: unknown): boolean {
|
||||
return value === undefined || typeof value === 'function' || typeof value === 'symbol'
|
||||
}
|
||||
|
||||
function appendJsonValue(
|
||||
writer: BoundedJsonWriter,
|
||||
value: unknown,
|
||||
activeObjects: Set<object>
|
||||
): boolean {
|
||||
if (value === null) {
|
||||
return writer.append('null', 4)
|
||||
}
|
||||
switch (typeof value) {
|
||||
case 'string':
|
||||
return appendJsonString(writer, value)
|
||||
case 'boolean':
|
||||
return writer.append(value ? 'true' : 'false', value ? 4 : 5)
|
||||
case 'number': {
|
||||
const json = Number.isFinite(value) ? JSON.stringify(value) : 'null'
|
||||
return writer.append(json, json.length)
|
||||
}
|
||||
case 'bigint':
|
||||
throw new TypeError('Do not know how to serialize a BigInt')
|
||||
case 'undefined':
|
||||
case 'function':
|
||||
case 'symbol':
|
||||
return false
|
||||
case 'object':
|
||||
break
|
||||
}
|
||||
|
||||
if (activeObjects.has(value)) {
|
||||
throw new TypeError('Converting circular structure to JSON')
|
||||
}
|
||||
activeObjects.add(value)
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
if (!writer.append('[', 1)) {
|
||||
return false
|
||||
}
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
if (index > 0 && !writer.append(',', 1)) {
|
||||
return false
|
||||
}
|
||||
const entry = value[index]
|
||||
if (omittedByJson(entry)) {
|
||||
if (!writer.append('null', 4)) {
|
||||
return false
|
||||
}
|
||||
} else if (!appendJsonValue(writer, entry, activeObjects)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return writer.append(']', 1)
|
||||
}
|
||||
|
||||
if (!writer.append('{', 1)) {
|
||||
return false
|
||||
}
|
||||
let entries = 0
|
||||
for (const key of Object.keys(value)) {
|
||||
const entry = (value as Record<string, unknown>)[key]
|
||||
if (omittedByJson(entry)) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
(entries > 0 && !writer.append(',', 1)) ||
|
||||
!appendJsonString(writer, key) ||
|
||||
!writer.append(':', 1) ||
|
||||
!appendJsonValue(writer, entry, activeObjects)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
entries += 1
|
||||
}
|
||||
return writer.append('}', 1)
|
||||
} finally {
|
||||
activeObjects.delete(value)
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyWithinLimit(checkpoint: TerminalCheckpointFile, maxBytes: number): string | null {
|
||||
if (jsonUtf8ByteLength(checkpoint) > maxBytes) {
|
||||
return null
|
||||
}
|
||||
const json = JSON.stringify(checkpoint)
|
||||
if (Buffer.byteLength(json, 'utf8') > maxBytes) {
|
||||
throw new Error('Terminal checkpoint size estimator mismatch')
|
||||
}
|
||||
return json
|
||||
const writer = new BoundedJsonWriter(maxBytes)
|
||||
appendJsonValue(writer, checkpoint, new Set())
|
||||
return writer.result()
|
||||
}
|
||||
|
||||
async function replaySnapshot(snapshot: TerminalSnapshot): Promise<HeadlessEmulator> {
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import path from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
|
||||
const { copyFileMock, handleMock, lstatMock, realpathMock, renameMock } = vi.hoisted(() => ({
|
||||
copyFileMock: vi.fn(),
|
||||
handleMock: vi.fn(),
|
||||
lstatMock: vi.fn(),
|
||||
realpathMock: vi.fn(),
|
||||
renameMock: vi.fn()
|
||||
}))
|
||||
const handlers = new Map<string, (_event: unknown, args: unknown) => Promise<unknown>>()
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: { handle: handleMock }
|
||||
}))
|
||||
|
||||
vi.mock('fs/promises', async () => {
|
||||
const actual = await vi.importActual<typeof FsPromises>('fs/promises')
|
||||
return {
|
||||
...actual,
|
||||
copyFile: copyFileMock,
|
||||
lstat: lstatMock,
|
||||
realpath: realpathMock,
|
||||
rename: renameMock
|
||||
}
|
||||
})
|
||||
|
||||
import { renameLocalPathSerializedByDestination } from './destination-serialized-local-rename'
|
||||
import { registerFilesystemMutationHandlers } from './ipc/filesystem-mutations'
|
||||
import { RuntimeFileCommands } from './runtime/orca-runtime-files'
|
||||
|
||||
const REPO_PATH = path.resolve('/workspace/repo')
|
||||
const store = {
|
||||
getRepos: () => [
|
||||
{ id: 'repo-1', path: REPO_PATH, displayName: 'repo', badgeColor: '#000', addedAt: 0 }
|
||||
],
|
||||
getSettings: () => ({ workspaceDir: path.resolve('/workspace') })
|
||||
}
|
||||
|
||||
function enoent(): Error {
|
||||
return Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
}
|
||||
|
||||
function mockStats(dev: number, ino: number) {
|
||||
return { dev, ino, isDirectory: () => false }
|
||||
}
|
||||
|
||||
function createRuntimeCommands(): RuntimeFileCommands {
|
||||
return new RuntimeFileCommands({
|
||||
requireStore: () => store,
|
||||
resolveRuntimeFileTarget: async () => ({
|
||||
worktree: { id: 'wt-1', repoId: 'repo-1', path: REPO_PATH }
|
||||
})
|
||||
} as never)
|
||||
}
|
||||
|
||||
function createGate(): { promise: Promise<void>; release: () => void } {
|
||||
let release!: () => void
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
return { promise, release }
|
||||
}
|
||||
|
||||
async function reachNextMacrotask(): Promise<void> {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
describe('renameLocalPathSerializedByDestination', () => {
|
||||
beforeEach(() => {
|
||||
handlers.clear()
|
||||
copyFileMock.mockReset()
|
||||
handleMock.mockReset()
|
||||
lstatMock.mockReset()
|
||||
realpathMock.mockReset()
|
||||
renameMock.mockReset()
|
||||
handleMock.mockImplementation((channel: string, handler: never) => {
|
||||
handlers.set(channel, handler)
|
||||
})
|
||||
lstatMock.mockRejectedValue(enoent())
|
||||
realpathMock.mockImplementation(async (filePath: string) => filePath)
|
||||
renameMock.mockResolvedValue(undefined)
|
||||
registerFilesystemMutationHandlers(store as never)
|
||||
})
|
||||
|
||||
it('serializes exact Unicode aliases across IPC and runtime entry points', async () => {
|
||||
const ipcSource = path.join(REPO_PATH, 'ipc-source.md')
|
||||
const runtimeSource = path.join(REPO_PATH, 'runtime-source.md')
|
||||
const sharpSDestination = path.join(REPO_PATH, 'Straße.md')
|
||||
const expandedDestination = path.join(REPO_PATH, 'STRASSE.md')
|
||||
const firstRenameGate = createGate()
|
||||
let destinationExists = false
|
||||
lstatMock.mockImplementation(async (filePath: string) => {
|
||||
if (
|
||||
destinationExists &&
|
||||
(filePath === sharpSDestination || filePath === expandedDestination)
|
||||
) {
|
||||
return mockStats(1, 30)
|
||||
}
|
||||
if (filePath === ipcSource) {
|
||||
return mockStats(1, 10)
|
||||
}
|
||||
if (filePath === runtimeSource) {
|
||||
return mockStats(1, 20)
|
||||
}
|
||||
throw enoent()
|
||||
})
|
||||
renameMock.mockImplementation(async () => {
|
||||
if (renameMock.mock.calls.length === 1) {
|
||||
await firstRenameGate.promise
|
||||
}
|
||||
destinationExists = true
|
||||
})
|
||||
|
||||
const ipcRename = handlers.get('fs:rename')!(null, {
|
||||
oldPath: ipcSource,
|
||||
newPath: sharpSDestination
|
||||
})
|
||||
await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1))
|
||||
const runtimeRename = createRuntimeCommands().renameFileExplorerPath(
|
||||
'id:wt-1',
|
||||
'runtime-source.md',
|
||||
'STRASSE.md',
|
||||
undefined,
|
||||
undefined,
|
||||
'local'
|
||||
)
|
||||
await reachNextMacrotask()
|
||||
const callsWhileFirstBlocked = renameMock.mock.calls.length
|
||||
firstRenameGate.release()
|
||||
const results = await Promise.allSettled([ipcRename, runtimeRename])
|
||||
|
||||
expect(callsWhileFirstBlocked).toBe(1)
|
||||
expect(results.map(({ status }) => status).sort()).toEqual(['fulfilled', 'rejected'])
|
||||
expect(renameMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('releases an alias queue after the leading rename errors', async () => {
|
||||
const firstRenameGate = createGate()
|
||||
renameMock.mockImplementation(async () => {
|
||||
if (renameMock.mock.calls.length === 1) {
|
||||
await firstRenameGate.promise
|
||||
throw new Error('EACCES')
|
||||
}
|
||||
})
|
||||
const destination = path.join(REPO_PATH, 'destination.md')
|
||||
|
||||
const firstRename = renameLocalPathSerializedByDestination(
|
||||
path.join(REPO_PATH, 'first.md'),
|
||||
destination
|
||||
)
|
||||
await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1))
|
||||
const secondRename = renameLocalPathSerializedByDestination(
|
||||
path.join(REPO_PATH, 'second.md'),
|
||||
destination
|
||||
)
|
||||
await reachNextMacrotask()
|
||||
expect(renameMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
firstRenameGate.release()
|
||||
await expect(firstRename).rejects.toThrow('EACCES')
|
||||
await expect(secondRename).resolves.toBeUndefined()
|
||||
expect(renameMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not serialize destinations in unrelated parent directories', async () => {
|
||||
const firstRenameGate = createGate()
|
||||
renameMock.mockImplementation(async () => {
|
||||
if (renameMock.mock.calls.length === 1) {
|
||||
await firstRenameGate.promise
|
||||
}
|
||||
})
|
||||
|
||||
const firstRename = renameLocalPathSerializedByDestination(
|
||||
path.join(REPO_PATH, 'first', 'source.md'),
|
||||
path.join(REPO_PATH, 'first', 'alpha.md')
|
||||
)
|
||||
await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1))
|
||||
const secondRename = renameLocalPathSerializedByDestination(
|
||||
path.join(REPO_PATH, 'second', 'source.md'),
|
||||
path.join(REPO_PATH, 'second', 'beta.md')
|
||||
)
|
||||
await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(2))
|
||||
|
||||
firstRenameGate.release()
|
||||
await expect(Promise.all([firstRename, secondRename])).resolves.toEqual([undefined, undefined])
|
||||
})
|
||||
|
||||
it('serializes symlink aliases of one destination parent', async () => {
|
||||
const firstRenameGate = createGate()
|
||||
renameMock.mockImplementation(async () => {
|
||||
if (renameMock.mock.calls.length === 1) {
|
||||
await firstRenameGate.promise
|
||||
}
|
||||
})
|
||||
const firstParent = path.join(REPO_PATH, 'parent-link-a')
|
||||
const secondParent = path.join(REPO_PATH, 'parent-link-b')
|
||||
const canonicalParent = path.join(REPO_PATH, 'canonical-parent')
|
||||
realpathMock.mockImplementation(async (filePath: string) => {
|
||||
if (filePath === firstParent || filePath === secondParent) {
|
||||
return canonicalParent
|
||||
}
|
||||
return filePath
|
||||
})
|
||||
|
||||
const firstRename = renameLocalPathSerializedByDestination(
|
||||
path.join(firstParent, 'source.md'),
|
||||
path.join(firstParent, 'alpha.md')
|
||||
)
|
||||
await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1))
|
||||
const secondRename = renameLocalPathSerializedByDestination(
|
||||
path.join(secondParent, 'source.md'),
|
||||
path.join(secondParent, 'beta.md')
|
||||
)
|
||||
await reachNextMacrotask()
|
||||
expect(renameMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
firstRenameGate.release()
|
||||
await expect(Promise.all([firstRename, secondRename])).resolves.toEqual([undefined, undefined])
|
||||
expect(renameMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('scopes conservative serialization to one destination parent', async () => {
|
||||
const firstRenameGate = createGate()
|
||||
renameMock.mockImplementation(async () => {
|
||||
if (renameMock.mock.calls.length === 1) {
|
||||
await firstRenameGate.promise
|
||||
}
|
||||
})
|
||||
|
||||
const firstRename = renameLocalPathSerializedByDestination(
|
||||
path.join(REPO_PATH, 'first.md'),
|
||||
path.join(REPO_PATH, 'dotless-ı.md')
|
||||
)
|
||||
await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1))
|
||||
const secondRename = renameLocalPathSerializedByDestination(
|
||||
path.join(REPO_PATH, 'second.md'),
|
||||
path.join(REPO_PATH, 'dotless-I.md')
|
||||
)
|
||||
await reachNextMacrotask()
|
||||
expect(renameMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
firstRenameGate.release()
|
||||
await expect(Promise.all([firstRename, secondRename])).resolves.toEqual([undefined, undefined])
|
||||
expect(renameMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { realpath, rename } from 'node:fs/promises'
|
||||
import { dirname, normalize } from 'node:path'
|
||||
import { assertNoClobberRenameDestinationAvailable } from '../shared/filesystem-rename-collision'
|
||||
|
||||
// Why: parent scope covers native Unicode aliases without guessing each filesystem's collation.
|
||||
const pendingRenamesByParent = new Map<string, Promise<void>>()
|
||||
|
||||
function isENOENT(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT'
|
||||
)
|
||||
}
|
||||
|
||||
async function destinationParentKey(filePath: string): Promise<string> {
|
||||
const parentPath = dirname(filePath)
|
||||
try {
|
||||
return normalize(await realpath(parentPath))
|
||||
} catch (error) {
|
||||
// A missing parent will fail the rename; retain its path so that failure
|
||||
// does not prevent unrelated destinations from progressing.
|
||||
if (isENOENT(error)) {
|
||||
return normalize(parentPath)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function renameLocalPathSerializedByDestination(
|
||||
oldPath: string,
|
||||
newPath: string
|
||||
): Promise<void> {
|
||||
const key = await destinationParentKey(newPath)
|
||||
const previous = pendingRenamesByParent.get(key) ?? Promise.resolve()
|
||||
let release!: () => void
|
||||
const current = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
pendingRenamesByParent.set(key, current)
|
||||
|
||||
await previous
|
||||
try {
|
||||
await assertNoClobberRenameDestinationAvailable(oldPath, newPath)
|
||||
await rename(oldPath, newPath)
|
||||
} finally {
|
||||
release()
|
||||
if (pendingRenamesByParent.get(key) === current) {
|
||||
pendingRenamesByParent.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,6 +171,36 @@ describe('registerFilesystemMutationHandlers', () => {
|
||||
expect(renameMock).toHaveBeenCalledWith(oldPath, newPath)
|
||||
})
|
||||
|
||||
it('serializes concurrent local renames targeting the same destination', async () => {
|
||||
const firstPath = path.resolve('/workspace/repo/first.ts')
|
||||
const secondPath = path.resolve('/workspace/repo/second.ts')
|
||||
const destinationPath = path.resolve('/workspace/repo/destination.ts')
|
||||
let destinationExists = false
|
||||
lstatMock.mockImplementation(async (filePath: string) => {
|
||||
if (filePath === destinationPath && destinationExists) {
|
||||
return mockStats(1, 30)
|
||||
}
|
||||
if (filePath === firstPath) {
|
||||
return mockStats(1, 10)
|
||||
}
|
||||
if (filePath === secondPath) {
|
||||
return mockStats(1, 20)
|
||||
}
|
||||
throw enoent()
|
||||
})
|
||||
renameMock.mockImplementation(async () => {
|
||||
destinationExists = true
|
||||
})
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
handlers.get('fs:rename')!(null, { oldPath: firstPath, newPath: destinationPath }),
|
||||
handlers.get('fs:rename')!(null, { oldPath: secondPath, newPath: destinationPath })
|
||||
])
|
||||
|
||||
expect(results.map(({ status }) => status).sort()).toEqual(['fulfilled', 'rejected'])
|
||||
expect(renameMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects rename when destination already exists as a true collision', async () => {
|
||||
const oldPath = path.resolve('/workspace/repo/old.ts')
|
||||
const resolvedNewPath = path.resolve('/workspace/repo/new.ts')
|
||||
@@ -197,12 +227,30 @@ describe('registerFilesystemMutationHandlers', () => {
|
||||
it('allows case-only rename when destination is the same entry in the same parent', async () => {
|
||||
const oldPath = path.resolve('/workspace/repo/README.md')
|
||||
const newPath = path.resolve('/workspace/repo/readme.md')
|
||||
const canonicalPath = path.resolve('/workspace/repo/README.md')
|
||||
lstatMock.mockImplementation(async (p: string) => {
|
||||
if (p === oldPath || p === newPath) {
|
||||
return mockStats(2, 20)
|
||||
}
|
||||
throw enoent()
|
||||
})
|
||||
mockRealpath({ [oldPath]: canonicalPath, [newPath]: canonicalPath })
|
||||
|
||||
await handlers.get('fs:rename')!(null, { oldPath, newPath })
|
||||
|
||||
expect(renameMock).toHaveBeenCalledWith(oldPath, newPath)
|
||||
})
|
||||
|
||||
it('allows a native Unicode alias rename for the same directory entry', async () => {
|
||||
const oldPath = path.resolve('/workspace/repo/Straße.md')
|
||||
const newPath = path.resolve('/workspace/repo/STRASSE.md')
|
||||
lstatMock.mockImplementation(async (p: string) => {
|
||||
if (p === oldPath || p === newPath) {
|
||||
return mockStats(2, 21)
|
||||
}
|
||||
throw enoent()
|
||||
})
|
||||
mockRealpath({ [oldPath]: oldPath, [newPath]: oldPath })
|
||||
|
||||
await handlers.get('fs:rename')!(null, { oldPath, newPath })
|
||||
|
||||
@@ -226,6 +274,25 @@ describe('registerFilesystemMutationHandlers', () => {
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed when same-entry realpath encounters a permission error', async () => {
|
||||
const oldPath = path.resolve('/workspace/repo/README.md')
|
||||
const newPath = path.resolve('/workspace/repo/readme.md')
|
||||
lstatMock.mockImplementation(async (p: string) => {
|
||||
if (p === oldPath || p === newPath) {
|
||||
return mockStats(3, 30)
|
||||
}
|
||||
throw enoent()
|
||||
})
|
||||
realpathMock.mockRejectedValue(
|
||||
Object.assign(new Error('permission denied'), { code: 'EACCES' })
|
||||
)
|
||||
|
||||
await expect(handlers.get('fs:rename')!(null, { oldPath, newPath })).rejects.toMatchObject({
|
||||
code: 'EACCES'
|
||||
})
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects cross-parent case-only rename collisions even when dev and ino match', async () => {
|
||||
const oldPath = path.resolve('/workspace/repo/src/README.md')
|
||||
const newPath = path.resolve('/workspace/repo/docs/readme.md')
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
open,
|
||||
readdir,
|
||||
realpath,
|
||||
rename,
|
||||
rm,
|
||||
unlink,
|
||||
writeFile
|
||||
@@ -21,9 +20,9 @@ import { authorizeExternalPath, resolveAuthorizedPath, isENOENT } from './filesy
|
||||
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import { resolveLocalDroppedPathsForAgent } from './dropped-path-resolution'
|
||||
import { importExternalPathsSsh } from './filesystem-import-ssh'
|
||||
import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision'
|
||||
import type { SshMutationExpectation } from '../../shared/ssh-types'
|
||||
import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation'
|
||||
import { renameLocalPathSerializedByDestination } from '../destination-serialized-local-rename'
|
||||
|
||||
/**
|
||||
* Re-throw filesystem errors with user-friendly messages.
|
||||
@@ -146,8 +145,7 @@ export function registerFilesystemMutationHandlers(store: Store): void {
|
||||
// accidentally write into a symlinked destination name.
|
||||
const oldPath = await resolveAuthorizedPath(args.oldPath, store, { preserveSymlink: true })
|
||||
const newPath = await resolveAuthorizedPath(args.newPath, store, { preserveSymlink: true })
|
||||
await assertNoClobberRenameDestinationAvailable(oldPath, newPath)
|
||||
await rename(oldPath, newPath)
|
||||
await renameLocalPathSerializedByDestination(oldPath, newPath)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
|
||||
import type { RuntimeStatus } from '../../shared/runtime-types'
|
||||
import type { Store } from '../persistence'
|
||||
import { verifyAndAddRuntimeEnvironmentFromPairingCode } from './runtime-environment-pairing-verification'
|
||||
import { closeRemoteRuntimeRequestConnection } from './runtime-environment-request-connections'
|
||||
import {
|
||||
callRuntimeEnvironment,
|
||||
@@ -64,6 +65,16 @@ export function registerRuntimeEnvironmentConnectivityHandlers({
|
||||
return { environment: redactRuntimeEnvironment(environment) }
|
||||
}
|
||||
)
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:verifyAndAddFromPairingCode',
|
||||
async (_event, args: { name: string; pairingCode: string; allowLoopback?: boolean }) => {
|
||||
const result = await verifyAndAddRuntimeEnvironmentFromPairingCode(getUserDataPath(), args)
|
||||
if (result.ok) {
|
||||
manuallyDisconnectedEnvironmentIds.delete(result.environment.id)
|
||||
}
|
||||
return result
|
||||
}
|
||||
)
|
||||
ipcMain.handle('runtimeEnvironments:resolve', (_event, args: { selector: string }) =>
|
||||
redactRuntimeEnvironment(resolveEnvironment(getUserDataPath(), args.selector))
|
||||
)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [
|
||||
'runtimeEnvironments:list',
|
||||
'runtimeEnvironments:addFromPairingCode',
|
||||
'runtimeEnvironments:verifyAndAddFromPairingCode',
|
||||
'runtimeEnvironments:resolve',
|
||||
'runtimeEnvironments:remove',
|
||||
'runtimeEnvironments:disconnect',
|
||||
'runtimeEnvironments:connect',
|
||||
'runtimeEnvironments:getStatus',
|
||||
'runtimeEnvironments:call',
|
||||
'runtimeEnvironments:subscribe',
|
||||
'runtimeEnvironments:unsubscribe'
|
||||
] as const
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
addEnvironmentFromPairingCode,
|
||||
RuntimeEnvironmentStoreError
|
||||
} from '../../shared/runtime-environment-store'
|
||||
import { parseHostAccessLink } from '../../shared/remote-pairing-address'
|
||||
import {
|
||||
verifyRemotePairingRuntimeStatus,
|
||||
type VerifyAndAddRuntimeEnvironmentResult
|
||||
} from '../../shared/remote-pairing-verification'
|
||||
import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error'
|
||||
import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client'
|
||||
import { redactRuntimeEnvironment } from '../../shared/runtime-environments'
|
||||
import type { RuntimeStatus } from '../../shared/runtime-types'
|
||||
|
||||
type VerifyAndAddRuntimeEnvironmentArgs = {
|
||||
name: string
|
||||
pairingCode: string
|
||||
allowLoopback?: boolean
|
||||
}
|
||||
|
||||
export async function verifyAndAddRuntimeEnvironmentFromPairingCode(
|
||||
userDataPath: string,
|
||||
args: VerifyAndAddRuntimeEnvironmentArgs
|
||||
): Promise<VerifyAndAddRuntimeEnvironmentResult> {
|
||||
const parsed = parseHostAccessLink(args.pairingCode)
|
||||
if (!parsed.ok) {
|
||||
return { ok: false, kind: 'access-link-invalid', message: parsed.message }
|
||||
}
|
||||
if (parsed.value.endpointKind === 'loopback' && !args.allowLoopback) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'host-unreachable',
|
||||
message: 'This access link points back to this device.'
|
||||
}
|
||||
}
|
||||
|
||||
let runtimeStatus: RuntimeStatus
|
||||
try {
|
||||
const response = await sendRemoteRuntimeRequest<RuntimeStatus>(
|
||||
parsed.value.pairing,
|
||||
'status.get',
|
||||
undefined,
|
||||
15_000
|
||||
)
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'connection-interrupted',
|
||||
message: response.error.message
|
||||
}
|
||||
}
|
||||
const statusVerification = verifyRemotePairingRuntimeStatus(response.result)
|
||||
if (!statusVerification.ok) {
|
||||
return statusVerification
|
||||
}
|
||||
runtimeStatus = statusVerification.runtimeStatus
|
||||
} catch (error) {
|
||||
return classifyPairingVerificationError(error, parsed.value.displayEndpoint)
|
||||
}
|
||||
|
||||
const usesSshTunnel = parsed.value.endpointKind === 'loopback' && args.allowLoopback === true
|
||||
let environment: ReturnType<typeof addEnvironmentFromPairingCode>
|
||||
try {
|
||||
environment = addEnvironmentFromPairingCode(userDataPath, {
|
||||
...args,
|
||||
...(usesSshTunnel ? { connectionDependency: 'ssh-tunnel' as const } : {})
|
||||
})
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'environment-save-failed',
|
||||
message:
|
||||
error instanceof RuntimeEnvironmentStoreError && error.code === 'invalid_argument'
|
||||
? error.message
|
||||
: 'Orca verified the host but could not save it. Check local settings storage and try again.'
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
environment: redactRuntimeEnvironment(environment),
|
||||
runtimeStatus
|
||||
}
|
||||
}
|
||||
|
||||
function classifyPairingVerificationError(
|
||||
error: unknown,
|
||||
endpoint: string
|
||||
): VerifyAndAddRuntimeEnvironmentResult {
|
||||
if (error instanceof RemoteRuntimeClientError) {
|
||||
if (error.code === 'invalid_argument') {
|
||||
return invalidAccessLinkResult()
|
||||
}
|
||||
if (error.code === 'unauthorized' || error.pairingStage === 'access-grant') {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'access-link-invalid',
|
||||
message: 'This access link is no longer valid. Generate a new link on the other host.'
|
||||
}
|
||||
}
|
||||
if (error.pairingStage === 'host-identity') {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'host-identity-mismatch',
|
||||
message: `Orca reached ${endpoint}, but that host does not match this access link.`
|
||||
}
|
||||
}
|
||||
if (error.pairingStage === 'runtime') {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'connection-interrupted',
|
||||
message: `The connection to ${endpoint} was interrupted during verification.`
|
||||
}
|
||||
}
|
||||
if (error.pairingStage === 'connect') {
|
||||
return unreachableHostResult(endpoint)
|
||||
}
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message.startsWith('Invalid public key')
|
||||
? invalidAccessLinkResult()
|
||||
: { ok: false, kind: 'connection-interrupted', message: error.message }
|
||||
}
|
||||
return unreachableHostResult(endpoint)
|
||||
}
|
||||
|
||||
function invalidAccessLinkResult(): VerifyAndAddRuntimeEnvironmentResult {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'access-link-invalid',
|
||||
message: 'This access link contains invalid connection details.'
|
||||
}
|
||||
}
|
||||
|
||||
function unreachableHostResult(endpoint: string): VerifyAndAddRuntimeEnvironmentResult {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'host-unreachable',
|
||||
message: `Cannot reach Orca at ${endpoint}. Confirm the other host is running and reachable.`
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,12 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { encodePairingOffer } from '../../shared/pairing'
|
||||
import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version'
|
||||
import {
|
||||
MIN_COMPATIBLE_RUNTIME_SERVER_VERSION,
|
||||
REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY
|
||||
} from '../../shared/protocol-version'
|
||||
import * as environmentStore from '../../shared/runtime-environment-store'
|
||||
import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error'
|
||||
|
||||
const {
|
||||
handleMock,
|
||||
@@ -79,6 +83,18 @@ function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string {
|
||||
})
|
||||
}
|
||||
|
||||
function runtimeStatus(): Record<string, unknown> {
|
||||
return {
|
||||
runtimeId: 'runtime-a',
|
||||
rendererGraphEpoch: 1,
|
||||
graphStatus: 'ready',
|
||||
authoritativeWindowId: 1,
|
||||
liveTabCount: 0,
|
||||
liveLeafCount: 0,
|
||||
protocolVersion: 999_999
|
||||
}
|
||||
}
|
||||
|
||||
function handler<TArgs, TResult>(
|
||||
channel: string
|
||||
): (_event: unknown, args: TArgs) => TResult | Promise<TResult> {
|
||||
@@ -132,6 +148,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
expect(handleMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
'runtimeEnvironments:list',
|
||||
'runtimeEnvironments:addFromPairingCode',
|
||||
'runtimeEnvironments:verifyAndAddFromPairingCode',
|
||||
'runtimeEnvironments:resolve',
|
||||
'runtimeEnvironments:remove',
|
||||
'runtimeEnvironments:disconnect',
|
||||
@@ -153,6 +170,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
expect(removeHandlerMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
'runtimeEnvironments:list',
|
||||
'runtimeEnvironments:addFromPairingCode',
|
||||
'runtimeEnvironments:verifyAndAddFromPairingCode',
|
||||
'runtimeEnvironments:resolve',
|
||||
'runtimeEnvironments:remove',
|
||||
'runtimeEnvironments:disconnect',
|
||||
@@ -211,6 +229,173 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
expect(await list(null, undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('blocks loopback before verification unless an SSH tunnel is declared', async () => {
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
const verifyAndAdd = handler<
|
||||
{ name: string; pairingCode: string; allowLoopback?: boolean },
|
||||
{ ok: boolean; kind?: string }
|
||||
>('runtimeEnvironments:verifyAndAddFromPairingCode')
|
||||
|
||||
await expect(
|
||||
verifyAndAdd(null, { name: 'desk', pairingCode: pairingCode() })
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
kind: 'host-unreachable'
|
||||
})
|
||||
expect(sendRemoteRuntimeRequestMock).not.toHaveBeenCalled()
|
||||
expect(environmentStore.listEnvironments(userDataPath)).toEqual([])
|
||||
})
|
||||
|
||||
it('verifies identity, access, status, and compatibility before saving', async () => {
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
sendRemoteRuntimeRequestMock.mockResolvedValue({
|
||||
id: 'status',
|
||||
ok: true,
|
||||
result: runtimeStatus(),
|
||||
_meta: { runtimeId: 'runtime-a' }
|
||||
})
|
||||
const verifyAndAdd = handler<
|
||||
{ name: string; pairingCode: string; allowLoopback?: boolean },
|
||||
{ ok: boolean; environment?: { name: string; connectionDependency?: string } }
|
||||
>('runtimeEnvironments:verifyAndAddFromPairingCode')
|
||||
|
||||
const result = await verifyAndAdd(null, {
|
||||
name: 'desk',
|
||||
pairingCode: pairingCode(),
|
||||
allowLoopback: true
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
environment: { name: 'desk', connectionDependency: 'ssh-tunnel' }
|
||||
})
|
||||
expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ endpoint: 'ws://127.0.0.1:6768' }),
|
||||
'status.get',
|
||||
undefined,
|
||||
15_000
|
||||
)
|
||||
expect(environmentStore.listEnvironments(userDataPath)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not mark non-loopback hosts as SSH-tunnel dependent', async () => {
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
sendRemoteRuntimeRequestMock.mockResolvedValue({
|
||||
id: 'status',
|
||||
ok: true,
|
||||
result: runtimeStatus(),
|
||||
_meta: { runtimeId: 'runtime-a' }
|
||||
})
|
||||
const verifyAndAdd = handler<
|
||||
{ name: string; pairingCode: string; allowLoopback?: boolean },
|
||||
{ ok: boolean; environment?: { connectionDependency?: string } }
|
||||
>('runtimeEnvironments:verifyAndAddFromPairingCode')
|
||||
|
||||
const result = await verifyAndAdd(null, {
|
||||
name: 'desk',
|
||||
pairingCode: pairingCode('ws://100.76.32.125:6768'),
|
||||
allowLoopback: true
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ ok: true })
|
||||
expect(result.environment).not.toHaveProperty('connectionDependency')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ protocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION - 1 }, 'protocol-incompatible'],
|
||||
[{ protocolVersion: 999_999, deviceScope: 'mobile' }, 'access-link-invalid'],
|
||||
[null, 'connection-interrupted']
|
||||
])('does not save a host with rejected status %o', async (status, expectedKind) => {
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
sendRemoteRuntimeRequestMock.mockResolvedValue({
|
||||
id: 'status',
|
||||
ok: true,
|
||||
result: status,
|
||||
_meta: { runtimeId: 'runtime-a' }
|
||||
})
|
||||
const verifyAndAdd = handler<
|
||||
{ name: string; pairingCode: string },
|
||||
{ ok: boolean; kind?: string }
|
||||
>('runtimeEnvironments:verifyAndAddFromPairingCode')
|
||||
|
||||
await expect(
|
||||
verifyAndAdd(null, {
|
||||
name: 'desk',
|
||||
pairingCode: pairingCode('ws://100.76.32.125:6768')
|
||||
})
|
||||
).resolves.toMatchObject({ ok: false, kind: expectedKind })
|
||||
expect(environmentStore.listEnvironments(userDataPath)).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
new RemoteRuntimeClientError('remote_runtime_unavailable', 'closed', {
|
||||
pairingStage: 'host-identity',
|
||||
closeCode: 4001
|
||||
}),
|
||||
'host-identity-mismatch'
|
||||
],
|
||||
[
|
||||
new RemoteRuntimeClientError('unauthorized', 'rejected', {
|
||||
pairingStage: 'access-grant'
|
||||
}),
|
||||
'access-link-invalid'
|
||||
],
|
||||
[
|
||||
new RemoteRuntimeClientError('remote_runtime_unavailable', 'offline', {
|
||||
pairingStage: 'connect'
|
||||
}),
|
||||
'host-unreachable'
|
||||
],
|
||||
[new Error('Invalid public key: expected 32 bytes, got 3'), 'access-link-invalid']
|
||||
] as const)('returns a structured pairing failure for %s', async (error, expectedKind) => {
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
sendRemoteRuntimeRequestMock.mockRejectedValue(error)
|
||||
const verifyAndAdd = handler<
|
||||
{ name: string; pairingCode: string },
|
||||
{ ok: boolean; kind?: string; message?: string }
|
||||
>('runtimeEnvironments:verifyAndAddFromPairingCode')
|
||||
|
||||
const result = await verifyAndAdd(null, {
|
||||
name: 'desk',
|
||||
pairingCode: pairingCode('ws://100.76.32.125:6768')
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ ok: false, kind: expectedKind })
|
||||
expect(result.message).not.toContain('4001')
|
||||
expect(environmentStore.listEnvironments(userDataPath)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns a structured failure when a verified host cannot be persisted', async () => {
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
environmentStore.addEnvironmentFromPairingCode(userDataPath, {
|
||||
name: 'desk',
|
||||
pairingCode: pairingCode('ws://100.76.32.125:6768')
|
||||
})
|
||||
sendRemoteRuntimeRequestMock.mockResolvedValue({
|
||||
id: 'status',
|
||||
ok: true,
|
||||
result: runtimeStatus(),
|
||||
_meta: { runtimeId: 'runtime-a' }
|
||||
})
|
||||
const verifyAndAdd = handler<
|
||||
{ name: string; pairingCode: string },
|
||||
{ ok: boolean; kind?: string; message?: string }
|
||||
>('runtimeEnvironments:verifyAndAddFromPairingCode')
|
||||
|
||||
await expect(
|
||||
verifyAndAdd(null, {
|
||||
name: 'desk',
|
||||
pairingCode: pairingCode('ws://100.76.32.125:6768')
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
kind: 'environment-save-failed',
|
||||
message: 'A server named "desk" already exists.'
|
||||
})
|
||||
expect(environmentStore.listEnvironments(userDataPath)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('requires an explicit Advanced selection before removing the Active Server', async () => {
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
const add = handler<
|
||||
|
||||
@@ -19,19 +19,7 @@ import {
|
||||
resetSharedControlSupport,
|
||||
subscribeRuntimeEnvironment
|
||||
} from './runtime-environment-transport-routing'
|
||||
|
||||
const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [
|
||||
'runtimeEnvironments:list',
|
||||
'runtimeEnvironments:addFromPairingCode',
|
||||
'runtimeEnvironments:resolve',
|
||||
'runtimeEnvironments:remove',
|
||||
'runtimeEnvironments:disconnect',
|
||||
'runtimeEnvironments:connect',
|
||||
'runtimeEnvironments:getStatus',
|
||||
'runtimeEnvironments:call',
|
||||
'runtimeEnvironments:subscribe',
|
||||
'runtimeEnvironments:unsubscribe'
|
||||
] as const
|
||||
import { RUNTIME_ENVIRONMENT_HANDLER_CHANNELS } from './runtime-environment-handler-channels'
|
||||
|
||||
type RetainedRemoteRuntimeSubscription = RemoteRuntimeSubscription & {
|
||||
environmentId: string
|
||||
|
||||
@@ -80,7 +80,6 @@ import {
|
||||
isWatcherProcessFailure,
|
||||
WatcherProcessFailure
|
||||
} from '../ipc/parcel-watcher-process-failure'
|
||||
import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision'
|
||||
import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths'
|
||||
import {
|
||||
rankRuntimeMobileFilePaths,
|
||||
@@ -89,6 +88,7 @@ import {
|
||||
import { beginWatcherInstall } from '../ipc/watcher-removal-gate'
|
||||
import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation'
|
||||
import { toSshExecutionHostId } from '../../shared/execution-host'
|
||||
import { renameLocalPathSerializedByDestination } from '../destination-serialized-local-rename'
|
||||
|
||||
const MOBILE_FILE_LIST_LIMIT = 5000
|
||||
const MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT = 20_000
|
||||
@@ -1728,8 +1728,7 @@ export class RuntimeFileCommands {
|
||||
const store = this.host.requireStore()
|
||||
const oldPath = await resolveAuthorizedPath(oldTarget.path, store, { preserveSymlink: true })
|
||||
const newPath = await resolveAuthorizedPath(newTarget.path, store, { preserveSymlink: true })
|
||||
await assertNoClobberRenameDestinationAvailable(oldPath, newPath)
|
||||
await rename(oldPath, newPath)
|
||||
await renameLocalPathSerializedByDestination(oldPath, newPath)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../shared/orchestration-rpc-c
|
||||
import { parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { OrchestrationError } from './orchestration-error'
|
||||
import { resolveOrchestrationMigrationStartVersion } from './orchestration-schema-version-skew'
|
||||
import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../../shared/orchestration-run-pagination'
|
||||
import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version'
|
||||
|
||||
// Why: leaf UUID is the remint-stable pane identity (tab half changes on break-out); exact match covers legacy/unparseable keys.
|
||||
@@ -158,6 +159,28 @@ function exposeRunTimestamps(run: RunRow): RunRow {
|
||||
}
|
||||
}
|
||||
|
||||
function encodeRunListCursor(run: RunRow): string {
|
||||
const cursor: RunListCursor = { createdAt: run.created_at, id: run.id }
|
||||
return Buffer.from(JSON.stringify(cursor)).toString('base64url')
|
||||
}
|
||||
|
||||
function decodeRunListCursor(value: string): RunListCursor {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(Buffer.from(value, 'base64url').toString('utf8'))
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== 'object' ||
|
||||
typeof (parsed as RunListCursor).createdAt !== 'string' ||
|
||||
typeof (parsed as RunListCursor).id !== 'string'
|
||||
) {
|
||||
throw new Error('invalid cursor shape')
|
||||
}
|
||||
return parsed as RunListCursor
|
||||
} catch {
|
||||
throw new OrchestrationError('cursor_invalid', 'The Run list cursor is invalid.')
|
||||
}
|
||||
}
|
||||
|
||||
function exposeDeliveryTimestamps(delivery: DeliveryRow): DeliveryRow {
|
||||
return {
|
||||
...delivery,
|
||||
@@ -213,8 +236,21 @@ export const LEGACY_RUN_ID = ORCHESTRATION_LEGACY_RUN_ID
|
||||
export const LEGACY_CONTRACT_VERSION = 0
|
||||
export const CURRENT_CONTRACT_VERSION = ORCHESTRATION_CONTRACT_VERSION
|
||||
|
||||
// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance.
|
||||
const SCHEMA_VERSION = 21
|
||||
const MUTATION_RECEIPT_MAX_ROWS = 10_000
|
||||
const MUTATION_RECEIPT_MAX_AGE_DAYS = 30
|
||||
|
||||
export type RunListPage = {
|
||||
runs: RunRow[]
|
||||
nextCursor: string | null
|
||||
}
|
||||
|
||||
type RunListCursor = {
|
||||
createdAt: string
|
||||
id: string
|
||||
}
|
||||
|
||||
// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup.
|
||||
const SCHEMA_VERSION = 22
|
||||
|
||||
function hardenOrchestrationDatabaseFiles(dbPath: string | ':memory:'): void {
|
||||
if (dbPath === ':memory:' || process.platform === 'win32') {
|
||||
@@ -457,6 +493,7 @@ export class OrchestrationDb {
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dispatch_task ON dispatch_contexts(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dispatch_status ON dispatch_contexts(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_handle ON dispatch_contexts(assignee_handle);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_gates (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -835,6 +872,12 @@ export class OrchestrationDb {
|
||||
if (current < 21) {
|
||||
this.migrateLegacySchedulerLossProvenance()
|
||||
}
|
||||
if (current < 22) {
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_handle
|
||||
ON dispatch_contexts(assignee_handle);
|
||||
`)
|
||||
}
|
||||
this.createUndeliveredInboxIndexIfPossible()
|
||||
|
||||
this.db.pragma(`user_version = ${SCHEMA_VERSION}`)
|
||||
@@ -1245,6 +1288,44 @@ export class OrchestrationDb {
|
||||
|
||||
// ── Durable mutation receipts ──
|
||||
|
||||
private ensureMutationReceiptCapacity(): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`DELETE FROM mutation_receipts
|
||||
WHERE state = 'completed'
|
||||
AND updated_at < datetime('now', ?)`
|
||||
)
|
||||
.run(`-${MUTATION_RECEIPT_MAX_AGE_DAYS} days`)
|
||||
|
||||
const row = this.db.prepare('SELECT COUNT(*) AS count FROM mutation_receipts').get() as {
|
||||
count: number
|
||||
}
|
||||
const completedToRemove = row.count - MUTATION_RECEIPT_MAX_ROWS + 1
|
||||
if (completedToRemove > 0) {
|
||||
this.db
|
||||
.prepare(
|
||||
`DELETE FROM mutation_receipts
|
||||
WHERE rowid IN (
|
||||
SELECT rowid FROM mutation_receipts
|
||||
WHERE state = 'completed'
|
||||
ORDER BY updated_at ASC, rowid ASC
|
||||
LIMIT ?
|
||||
)`
|
||||
)
|
||||
.run(completedToRemove)
|
||||
}
|
||||
|
||||
const retained = this.db.prepare('SELECT COUNT(*) AS count FROM mutation_receipts').get() as {
|
||||
count: number
|
||||
}
|
||||
if (retained.count >= MUTATION_RECEIPT_MAX_ROWS) {
|
||||
throw new OrchestrationError(
|
||||
'mutation_ledger_full',
|
||||
'The durable mutation ledger is full of unresolved operations. Resolve or inspect them before starting another mutation.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
beginMutationReceipt(params: {
|
||||
callerFingerprint: string
|
||||
requestId: string
|
||||
@@ -1267,6 +1348,7 @@ export class OrchestrationDb {
|
||||
this.db.exec('COMMIT')
|
||||
return { disposition: existing.state, row: existing }
|
||||
}
|
||||
this.ensureMutationReceiptCapacity()
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO mutation_receipts (
|
||||
@@ -2221,10 +2303,38 @@ export class OrchestrationDb {
|
||||
return run ? exposeRunTimestamps(run) : undefined
|
||||
}
|
||||
|
||||
listRuns(): RunRow[] {
|
||||
return (this.db.prepare('SELECT * FROM runs ORDER BY created_at DESC').all() as RunRow[]).map(
|
||||
exposeRunTimestamps
|
||||
listRuns(params: { limit?: number; cursor?: string } = {}): RunListPage {
|
||||
if (params.limit === undefined && params.cursor === undefined) {
|
||||
const rows = this.db
|
||||
.prepare('SELECT * FROM runs ORDER BY created_at DESC, id DESC')
|
||||
.all() as RunRow[]
|
||||
return { runs: rows.map(exposeRunTimestamps), nextCursor: null }
|
||||
}
|
||||
const limit = Math.min(
|
||||
Math.max(1, params.limit ?? ORCHESTRATION_RUN_PAGE_LIMIT),
|
||||
ORCHESTRATION_RUN_PAGE_LIMIT
|
||||
)
|
||||
const cursor = params.cursor ? decodeRunListCursor(params.cursor) : undefined
|
||||
const rows = (
|
||||
cursor
|
||||
? this.db
|
||||
.prepare(
|
||||
`SELECT * FROM runs
|
||||
WHERE created_at < ? OR (created_at = ? AND id < ?)
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT ?`
|
||||
)
|
||||
.all(cursor.createdAt, cursor.createdAt, cursor.id, limit + 1)
|
||||
: this.db
|
||||
.prepare('SELECT * FROM runs ORDER BY created_at DESC, id DESC LIMIT ?')
|
||||
.all(limit + 1)
|
||||
) as RunRow[]
|
||||
const hasMore = rows.length > limit
|
||||
const pageRows = hasMore ? rows.slice(0, limit) : rows
|
||||
return {
|
||||
runs: pageRows.map(exposeRunTimestamps),
|
||||
nextCursor: hasMore ? encodeRunListCursor(pageRows.at(-1) as RunRow) : null
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentRunForPane(paneKey: string): RunRow | undefined {
|
||||
@@ -3748,6 +3858,7 @@ export class OrchestrationDb {
|
||||
`Mutation ${receipt.requestId} already has a durable acceptance record.`
|
||||
)
|
||||
}
|
||||
this.ensureMutationReceiptCapacity()
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO mutation_receipts (
|
||||
@@ -4335,6 +4446,7 @@ export class OrchestrationDb {
|
||||
`Remote attachment request ${params.mutationReceipt.requestId} already exists.`
|
||||
)
|
||||
}
|
||||
this.ensureMutationReceiptCapacity()
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO mutation_receipts (
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import { OrchestrationDb } from './db'
|
||||
|
||||
const MUTATION_RECEIPT_MAX_ROWS = 10_000
|
||||
|
||||
function sqliteFor(db: OrchestrationDb): Database.Database {
|
||||
return (db as unknown as { db: Database.Database }).db
|
||||
}
|
||||
|
||||
function insertMutationReceipts(
|
||||
db: OrchestrationDb,
|
||||
count: number,
|
||||
state: 'pending' | 'completed'
|
||||
): void {
|
||||
sqliteFor(db)
|
||||
.prepare(
|
||||
`WITH RECURSIVE receipt_numbers(value) AS (
|
||||
VALUES (1)
|
||||
UNION ALL
|
||||
SELECT value + 1 FROM receipt_numbers WHERE value < ?
|
||||
)
|
||||
INSERT INTO mutation_receipts (
|
||||
caller_fingerprint, request_id, method, payload_hash, state
|
||||
)
|
||||
SELECT 'caller', printf('request_%05d', value), 'orchestration.send',
|
||||
printf('hash_%05d', value), ?
|
||||
FROM receipt_numbers`
|
||||
)
|
||||
.run(count, state)
|
||||
}
|
||||
|
||||
describe('OrchestrationDb bounded mutation receipts', () => {
|
||||
let db: OrchestrationDb | undefined
|
||||
|
||||
afterEach(() => db?.close())
|
||||
|
||||
it('prunes expired completed receipts but preserves unresolved receipts', () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
const sqlite = sqliteFor(db)
|
||||
sqlite.exec(`
|
||||
INSERT INTO mutation_receipts (
|
||||
caller_fingerprint, request_id, method, payload_hash, state, updated_at
|
||||
) VALUES
|
||||
('caller', 'expired', 'orchestration.send', 'hash_expired', 'completed', '2000-01-01 00:00:00'),
|
||||
('caller', 'unresolved', 'orchestration.send', 'hash_unresolved', 'pending', '2000-01-01 00:00:00');
|
||||
`)
|
||||
|
||||
db.beginMutationReceipt({
|
||||
callerFingerprint: 'caller',
|
||||
requestId: 'new',
|
||||
method: 'orchestration.send',
|
||||
payloadHash: 'hash_new'
|
||||
})
|
||||
|
||||
expect(db.getMutationReceipt('caller', 'expired')).toBeUndefined()
|
||||
expect(db.getMutationReceipt('caller', 'unresolved')).toMatchObject({ state: 'pending' })
|
||||
})
|
||||
|
||||
it('caps completed receipt count while retaining the newest replay records', () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
insertMutationReceipts(db, MUTATION_RECEIPT_MAX_ROWS, 'completed')
|
||||
|
||||
db.beginMutationReceipt({
|
||||
callerFingerprint: 'caller',
|
||||
requestId: 'new',
|
||||
method: 'orchestration.send',
|
||||
payloadHash: 'hash_new'
|
||||
})
|
||||
|
||||
const count = sqliteFor(db)
|
||||
.prepare('SELECT COUNT(*) AS count FROM mutation_receipts')
|
||||
.get() as { count: number }
|
||||
expect(count.count).toBe(MUTATION_RECEIPT_MAX_ROWS)
|
||||
expect(db.getMutationReceipt('caller', 'request_00001')).toBeUndefined()
|
||||
expect(db.getMutationReceipt('caller', 'request_10000')).toMatchObject({ state: 'completed' })
|
||||
expect(db.getMutationReceipt('caller', 'new')).toMatchObject({ state: 'pending' })
|
||||
})
|
||||
|
||||
it('fails closed when unresolved receipts alone fill the ledger', () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
insertMutationReceipts(db, MUTATION_RECEIPT_MAX_ROWS, 'pending')
|
||||
|
||||
expect(() =>
|
||||
db!.beginMutationReceipt({
|
||||
callerFingerprint: 'caller',
|
||||
requestId: 'overflow',
|
||||
method: 'orchestration.send',
|
||||
payloadHash: 'hash_overflow'
|
||||
})
|
||||
).toThrowError(expect.objectContaining({ code: 'mutation_ledger_full' }))
|
||||
expect(db.getMutationReceipt('caller', 'request_00001')).toMatchObject({ state: 'pending' })
|
||||
expect(db.getMutationReceipt('caller', 'overflow')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('prunes completed receipts before accepting a remote attachment', () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
insertMutationReceipts(db, MUTATION_RECEIPT_MAX_ROWS, 'completed')
|
||||
|
||||
db.createRemoteDispatchAttachment({
|
||||
dispatchId: 'ctx_remote_pruned',
|
||||
taskId: 'task_remote_pruned',
|
||||
homePeerFingerprint: 'caller',
|
||||
protocolVersion: 1,
|
||||
runtimeEpoch: 'worker_epoch',
|
||||
mutationReceipt: {
|
||||
callerFingerprint: 'caller',
|
||||
requestId: 'remote_pruned',
|
||||
method: 'orchestration.federationAttachStart',
|
||||
payloadHash: 'hash_remote_pruned'
|
||||
}
|
||||
})
|
||||
|
||||
const count = sqliteFor(db)
|
||||
.prepare('SELECT COUNT(*) AS count FROM mutation_receipts')
|
||||
.get() as { count: number }
|
||||
expect(count.count).toBe(MUTATION_RECEIPT_MAX_ROWS)
|
||||
expect(db.getMutationReceipt('caller', 'request_00001')).toBeUndefined()
|
||||
expect(db.getMutationReceipt('caller', 'remote_pruned')).toMatchObject({ state: 'pending' })
|
||||
expect(db.getRemoteDispatchAttachment('ctx_remote_pruned')).toBeDefined()
|
||||
})
|
||||
|
||||
it('rejects a remote attachment when pending receipts fill the ledger', () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
insertMutationReceipts(db, MUTATION_RECEIPT_MAX_ROWS, 'pending')
|
||||
|
||||
expect(() =>
|
||||
db!.createRemoteDispatchAttachment({
|
||||
dispatchId: 'ctx_remote_overflow',
|
||||
taskId: 'task_remote_overflow',
|
||||
homePeerFingerprint: 'caller',
|
||||
protocolVersion: 1,
|
||||
runtimeEpoch: 'worker_epoch',
|
||||
mutationReceipt: {
|
||||
callerFingerprint: 'caller',
|
||||
requestId: 'remote_overflow',
|
||||
method: 'orchestration.federationAttachStart',
|
||||
payloadHash: 'hash_remote_overflow'
|
||||
}
|
||||
})
|
||||
).toThrowError(expect.objectContaining({ code: 'mutation_ledger_full' }))
|
||||
expect(db.getMutationReceipt('caller', 'request_00001')).toMatchObject({ state: 'pending' })
|
||||
expect(db.getMutationReceipt('caller', 'remote_overflow')).toBeUndefined()
|
||||
expect(db.getRemoteDispatchAttachment('ctx_remote_overflow')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('guards atomic worker acceptance without changing task state', () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
const task = db.createTask({ spec: 'capacity check' })
|
||||
insertMutationReceipts(db, MUTATION_RECEIPT_MAX_ROWS, 'pending')
|
||||
|
||||
expect(() =>
|
||||
db!.createStartingWorkerDispatch({
|
||||
taskId: task.id,
|
||||
startOptions: {},
|
||||
mutationReceipt: {
|
||||
callerFingerprint: 'caller',
|
||||
requestId: 'worker_overflow',
|
||||
method: 'orchestration.workerStart',
|
||||
payloadHash: 'hash_worker_overflow'
|
||||
}
|
||||
})
|
||||
).toThrowError(expect.objectContaining({ code: 'mutation_ledger_full' }))
|
||||
expect(db.getTask(task.id)).toMatchObject({ status: 'ready' })
|
||||
expect(db.getMutationReceipt('caller', 'worker_overflow')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('OrchestrationDb Run pagination', () => {
|
||||
let db: OrchestrationDb | undefined
|
||||
|
||||
afterEach(() => db?.close())
|
||||
|
||||
it('returns stable bounded pages without skipping Runs sharing a timestamp', () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
const createdIds = Array.from({ length: 5 }, (_, index) => `run_page_${index}`)
|
||||
const insertRun = sqliteFor(db).prepare(
|
||||
`INSERT INTO runs (
|
||||
id, objective, coordinator_handle, coordinator_pane_key,
|
||||
consumer_generation, legacy, created_at
|
||||
) VALUES (?, ?, ?, ?, 1, 0, '2025-01-01 00:00:00')`
|
||||
)
|
||||
for (const [index, id] of createdIds.entries()) {
|
||||
insertRun.run(
|
||||
id,
|
||||
`Run ${index}`,
|
||||
`term_coord_${index}`,
|
||||
`tab_coord_${index}:11111111-1111-4111-8111-111111111111`
|
||||
)
|
||||
}
|
||||
const seen: string[] = []
|
||||
let cursor: string | undefined
|
||||
|
||||
do {
|
||||
const page = db.listRuns({ limit: 2, cursor })
|
||||
expect(page.runs.length).toBeLessThanOrEqual(2)
|
||||
expect(page.runs.every((run) => !seen.includes(run.id))).toBe(true)
|
||||
seen.push(...page.runs.map((run) => run.id))
|
||||
cursor = page.nextCursor ?? undefined
|
||||
} while (cursor)
|
||||
|
||||
expect(seen).toEqual(expect.arrayContaining(createdIds))
|
||||
expect(new Set(seen).size).toBe(createdIds.length + 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OrchestrationDb dispatch assignee index migration', () => {
|
||||
let db: OrchestrationDb | undefined
|
||||
let tempDir: string | undefined
|
||||
|
||||
afterEach(() => {
|
||||
db?.close()
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('migrates a populated v21 database idempotently', () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'orca-dispatch-index-migration-'))
|
||||
const dbPath = join(tempDir, 'orchestration.db')
|
||||
db = new OrchestrationDb(dbPath)
|
||||
const task = db.createTask({ spec: 'indexed lookup' })
|
||||
const dispatch = db.createDispatchContext(task.id, 'term_worker')
|
||||
db.close()
|
||||
db = undefined
|
||||
|
||||
const oldDb = new Database(dbPath)
|
||||
oldDb.exec('DROP INDEX IF EXISTS idx_dispatch_assignee_handle')
|
||||
oldDb.pragma('user_version = 21')
|
||||
oldDb.close()
|
||||
|
||||
db = new OrchestrationDb(dbPath)
|
||||
const sqlite = sqliteFor(db)
|
||||
expect(sqlite.pragma('user_version', { simple: true })).toBe(22)
|
||||
expect(db.getDispatchContextById(dispatch.id)).toMatchObject({ assignee_handle: 'term_worker' })
|
||||
expect(
|
||||
sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?")
|
||||
.get('idx_dispatch_assignee_handle')
|
||||
).toBeDefined()
|
||||
const plan = sqlite
|
||||
.prepare(
|
||||
`EXPLAIN QUERY PLAN
|
||||
SELECT * FROM dispatch_contexts
|
||||
WHERE assignee_handle = ? AND status IN ('pending', 'dispatched') LIMIT 1`
|
||||
)
|
||||
.all('term_worker') as { detail: string }[]
|
||||
expect(plan.map((row) => row.detail).join('\n')).toContain(
|
||||
'USING INDEX idx_dispatch_assignee_handle'
|
||||
)
|
||||
|
||||
db.close()
|
||||
db = new OrchestrationDb(dbPath)
|
||||
expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(22)
|
||||
expect(db.getDispatchContextById(dispatch.id)).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -151,7 +151,7 @@ describe('OrchestrationDb legacy contract storage', () => {
|
||||
|
||||
expect(db.getLegacyAdoption()).toBeUndefined()
|
||||
expect(db.listLegacyCompatibilityPrincipals(LEGACY_RUN_ID)).toEqual([])
|
||||
expect(db.listRuns()).toEqual([expect.objectContaining({ id: LEGACY_RUN_ID, legacy: 1 })])
|
||||
expect(db.listRuns().runs).toEqual([expect.objectContaining({ id: LEGACY_RUN_ID, legacy: 1 })])
|
||||
})
|
||||
|
||||
it('keeps current Delivery disjoint from adopted direct and audit-only mail', () => {
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('OrchestrationDb reset scopes', () => {
|
||||
|
||||
db!.resetAll()
|
||||
|
||||
expect(db!.listRuns()).toEqual([expect.objectContaining({ id: LEGACY_RUN_ID, legacy: 1 })])
|
||||
expect(db!.listRuns().runs).toEqual([expect.objectContaining({ id: LEGACY_RUN_ID, legacy: 1 })])
|
||||
expect(db!.getTask(state.task.id)).toBeUndefined()
|
||||
expect(db!.getWorkerDispatch(state.started.dispatch.id)).toBeUndefined()
|
||||
expect(db!.getFederatedDispatch(state.started.dispatch.id)).toBeUndefined()
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type Database from '../../sqlite/sync-database'
|
||||
import { OrchestrationDb } from './db'
|
||||
import { ORCHESTRATION_RUN_METHODS } from '../rpc/methods/orchestration-runs'
|
||||
|
||||
function sqliteFor(db: OrchestrationDb): Database.Database {
|
||||
return (db as unknown as { db: Database.Database }).db
|
||||
}
|
||||
|
||||
describe('orchestration Run list compatibility', () => {
|
||||
let db: OrchestrationDb | undefined
|
||||
|
||||
afterEach(() => db?.close())
|
||||
|
||||
it('rejects malformed pagination cursors', () => {
|
||||
const method = ORCHESTRATION_RUN_METHODS.find(
|
||||
(candidate) => candidate.name === 'orchestration.runList'
|
||||
)!
|
||||
|
||||
expect(() => method.params!.parse({ cursor: {} })).toThrow()
|
||||
expect(() => method.params!.parse({ cursor: '' })).toThrow()
|
||||
})
|
||||
|
||||
it('preserves the unpaginated RPC result for an old client request', async () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
const runCount = 101
|
||||
sqliteFor(db)
|
||||
.prepare(
|
||||
`WITH RECURSIVE run_numbers(value) AS (
|
||||
VALUES (1)
|
||||
UNION ALL
|
||||
SELECT value + 1 FROM run_numbers WHERE value < ?
|
||||
)
|
||||
INSERT INTO runs (id, objective)
|
||||
SELECT printf('run_compat_%03d', value), printf('Run %03d', value)
|
||||
FROM run_numbers`
|
||||
)
|
||||
.run(runCount)
|
||||
const method = ORCHESTRATION_RUN_METHODS.find(
|
||||
(candidate) => candidate.name === 'orchestration.runList'
|
||||
)!
|
||||
const params = method.params!.parse({})
|
||||
const result = (await method.handler(params, {
|
||||
runtime: { getOrchestrationDb: () => db }
|
||||
} as never)) as { runs: unknown[] }
|
||||
|
||||
expect(result.runs).toHaveLength(runCount + 1)
|
||||
})
|
||||
})
|
||||
@@ -88,6 +88,7 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet<string> = new Set([
|
||||
'agent_unconfigured',
|
||||
'terminal_worktree_mismatch',
|
||||
'request_mismatch',
|
||||
'mutation_ledger_full',
|
||||
'legacy_read_only',
|
||||
'orchestration_migration_required',
|
||||
'operation_unknown',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { OptionalBoolean, OptionalString, requiredString } from '../schemas'
|
||||
import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../../../shared/orchestration-run-pagination'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { OrchestrationError } from '../../orchestration/orchestration-error'
|
||||
|
||||
@@ -16,7 +17,10 @@ const RunUseParams = z.object({
|
||||
})
|
||||
|
||||
const RunCurrentParams = z.object({ from: requiredString('Missing coordinator terminal') })
|
||||
const RunListParams = z.object({})
|
||||
const RunListParams = z.object({
|
||||
limit: z.number().int().min(1).max(ORCHESTRATION_RUN_PAGE_LIMIT).optional(),
|
||||
cursor: z.string().min(1).optional()
|
||||
})
|
||||
const RunShowParams = z.object({ id: requiredString('Missing --id'), from: OptionalString })
|
||||
|
||||
function requireCallerPane(runtime: OrcaRuntimeService, handle: string): string {
|
||||
@@ -104,7 +108,7 @@ export const ORCHESTRATION_RUN_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'orchestration.runList',
|
||||
params: RunListParams,
|
||||
handler: (_params, { runtime }) => ({ runs: runtime.getOrchestrationDb().listRuns() })
|
||||
handler: (params, { runtime }) => runtime.getOrchestrationDb().listRuns(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'orchestration.runShow',
|
||||
|
||||
@@ -164,7 +164,7 @@ describe('orchestration RPC methods', () => {
|
||||
await expect(
|
||||
call('orchestration.runCreate', { objective: 'No pane', from: 'term_stale' })
|
||||
).rejects.toMatchObject({ code: 'stable_pane_required' })
|
||||
expect(db.listRuns().filter((run) => run.legacy === 0)).toHaveLength(0)
|
||||
expect(db.listRuns().runs.filter((run) => run.legacy === 0)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rebinds explicitly, lists Runs, and keeps the legacy Run inspect-only', async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Stats } from 'node:fs'
|
||||
import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing'
|
||||
|
||||
@@ -20,6 +20,10 @@ describe('isServerDriveListRequest', () => {
|
||||
})
|
||||
|
||||
describe('listWindowsDrives', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const statOnly = (mounted: string[]) => async (p: string) => {
|
||||
if (!mounted.includes(p)) {
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
@@ -51,14 +55,62 @@ describe('listWindowsDrives', () => {
|
||||
expect(result.entries.map((e) => e.name)).toEqual(['C:\\'])
|
||||
})
|
||||
|
||||
it.each(['EIO', 'EMFILE'])('surfaces systemic %s failures', async (code) => {
|
||||
it('skips an unreadable drive with an unexpected errno and logs it', async () => {
|
||||
const error = Object.assign(new Error('EIO'), { code: 'EIO' })
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const statPath = async (p: string): Promise<Stats> => {
|
||||
if (p === 'D:\\') {
|
||||
throw Object.assign(new Error(code), { code })
|
||||
throw error
|
||||
}
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
}
|
||||
|
||||
await expect(listWindowsDrives(statPath)).rejects.toMatchObject({ code })
|
||||
const result = await listWindowsDrives(statPath)
|
||||
|
||||
expect(result.entries).toEqual([])
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
expect(warn).toHaveBeenCalledWith('[windows-drive-listing] Failed to stat drive', {
|
||||
root: 'D:\\',
|
||||
error
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces programming errors from isDirectory', async () => {
|
||||
const error = new TypeError('programmer bug')
|
||||
const statPath = async (p: string): Promise<Stats> => {
|
||||
if (p === 'D:\\') {
|
||||
return {
|
||||
isDirectory: () => {
|
||||
throw error
|
||||
}
|
||||
} as unknown as Stats
|
||||
}
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
}
|
||||
|
||||
await expect(listWindowsDrives(statPath)).rejects.toBe(error)
|
||||
})
|
||||
|
||||
it('returns healthy drives among tolerated and unexpected stat failures', async () => {
|
||||
const error = Object.assign(new Error('EIO'), { code: 'EIO' })
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const statPath = async (p: string): Promise<Stats> => {
|
||||
if (p === 'C:\\' || p === 'F:\\') {
|
||||
return { isDirectory: () => true } as Stats
|
||||
}
|
||||
if (p === 'E:\\') {
|
||||
throw error
|
||||
}
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
}
|
||||
|
||||
const result = await listWindowsDrives(statPath)
|
||||
|
||||
expect(result.entries.map((entry) => entry.name)).toEqual(['C:\\', 'F:\\'])
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
expect(warn).toHaveBeenCalledWith('[windows-drive-listing] Failed to stat drive', {
|
||||
root: 'E:\\',
|
||||
error
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,15 +26,16 @@ export async function listWindowsDrives(
|
||||
const roots = await Promise.all(
|
||||
[...DRIVE_LETTERS].map(async (letter) => {
|
||||
const root = `${letter}:\\`
|
||||
let stats: Stats
|
||||
try {
|
||||
const stats = await statPath(root)
|
||||
return stats.isDirectory() ? root : null
|
||||
stats = await statPath(root)
|
||||
} catch (error) {
|
||||
if (isExpectedUnavailableDriveError(error)) {
|
||||
return null
|
||||
if (!isExpectedUnavailableDriveError(error)) {
|
||||
console.warn('[windows-drive-listing] Failed to stat drive', { root, error })
|
||||
}
|
||||
throw error
|
||||
return null
|
||||
}
|
||||
return stats.isDirectory() ? root : null
|
||||
})
|
||||
)
|
||||
return {
|
||||
|
||||
@@ -47,6 +47,7 @@ import type {
|
||||
} from '../shared/terminal-render-desync-evidence'
|
||||
import type { MobileRelayStatus } from '../shared/mobile-relay-status'
|
||||
import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode'
|
||||
import type { VerifyAndAddRuntimeEnvironmentResult } from '../shared/remote-pairing-verification'
|
||||
import type {
|
||||
SshMutationExpectation,
|
||||
SshConnectionState,
|
||||
@@ -3317,6 +3318,11 @@ export type PreloadApi = {
|
||||
name: string
|
||||
pairingCode: string
|
||||
}) => Promise<{ environment: PublicKnownRuntimeEnvironment }>
|
||||
verifyAndAddFromPairingCode: (args: {
|
||||
name: string
|
||||
pairingCode: string
|
||||
allowLoopback?: boolean
|
||||
}) => Promise<VerifyAndAddRuntimeEnvironmentResult>
|
||||
resolve: (args: { selector: string }) => Promise<PublicKnownRuntimeEnvironment>
|
||||
remove: (args: { selector: string }) => Promise<{ removed: PublicKnownRuntimeEnvironment }>
|
||||
disconnect: (args: {
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
} from '../shared/agent-session-resume'
|
||||
import type { MobileRelayStatus } from '../shared/mobile-relay-status'
|
||||
import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode'
|
||||
import type { VerifyAndAddRuntimeEnvironmentResult } from '../shared/remote-pairing-verification'
|
||||
import type {
|
||||
SshMutationExpectation,
|
||||
SshConnectionState,
|
||||
@@ -4283,6 +4284,12 @@ const api = {
|
||||
pairingCode: string
|
||||
}): Promise<{ environment: PublicKnownRuntimeEnvironment }> =>
|
||||
ipcRenderer.invoke('runtimeEnvironments:addFromPairingCode', args),
|
||||
verifyAndAddFromPairingCode: (args: {
|
||||
name: string
|
||||
pairingCode: string
|
||||
allowLoopback?: boolean
|
||||
}): Promise<VerifyAndAddRuntimeEnvironmentResult> =>
|
||||
ipcRenderer.invoke('runtimeEnvironments:verifyAndAddFromPairingCode', args),
|
||||
resolve: (args: { selector: string }): Promise<PublicKnownRuntimeEnvironment> =>
|
||||
ipcRenderer.invoke('runtimeEnvironments:resolve', args),
|
||||
remove: (args: { selector: string }): Promise<{ removed: PublicKnownRuntimeEnvironment }> =>
|
||||
|
||||
@@ -204,7 +204,7 @@ export default function ProjectCombobox({
|
||||
</span>
|
||||
<ProjectOptionDetail
|
||||
detail={selected.detail}
|
||||
className="min-w-0 flex-1 shrink-[999] justify-end text-xs text-muted-foreground"
|
||||
className="min-w-0 flex-1 shrink-[999] justify-end text-right text-xs text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
import { useAppStore } from '@/store'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import {
|
||||
captureFileExplorerOperationGuard,
|
||||
getFileExplorerOperationOwner
|
||||
@@ -26,6 +27,15 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('file explorer operation generations', () => {
|
||||
it('routes floating workspace file mutations to the local host', () => {
|
||||
const owner = getFileExplorerOperationOwner(FLOATING_TERMINAL_WORKTREE_ID)
|
||||
const guard = captureFileExplorerOperationGuard(FLOATING_TERMINAL_WORKTREE_ID, owner)
|
||||
|
||||
expect(owner).toEqual({ kind: 'local' })
|
||||
expect(guard.route.expectedExecutionHostId).toBe('local')
|
||||
expect(() => guard.assertCurrent()).not.toThrow()
|
||||
})
|
||||
|
||||
it('invalidates a nested SSH mutation when that target reconnects', () => {
|
||||
useAppStore.setState({
|
||||
repos: [],
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type WorktreeOperationRoute
|
||||
} from '@/lib/worktree-operation-route'
|
||||
import { captureWorktreeOperationGenerationGuard } from '@/lib/worktree-operation-generation'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
|
||||
export type FileExplorerOperationRoute = {
|
||||
settings: { activeRuntimeEnvironmentId: string | null }
|
||||
@@ -43,6 +44,9 @@ export function getFileExplorerOperationOwnerFromState(
|
||||
state: FileExplorerOwnerState,
|
||||
worktreeId: string | null | undefined
|
||||
): FileExplorerOperationOwner {
|
||||
if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) {
|
||||
return { kind: 'local' }
|
||||
}
|
||||
const parsedWorkspace = worktreeId ? parseWorkspaceKey(worktreeId) : null
|
||||
if (worktreeId && parsedWorkspace?.type !== 'folder') {
|
||||
const route = resolveWorktreeOperationRoute(state, worktreeId)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import { CloudVmSetupGuide } from './CloudVmSetupGuide'
|
||||
|
||||
describe('CloudVmSetupGuide', () => {
|
||||
afterEach(() => {
|
||||
useAppStore.setState({ settingsNavigationTarget: null })
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('explains creation and opens environment recipe setup', () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
|
||||
act(() => root.render(<CloudVmSetupGuide />))
|
||||
|
||||
expect(container.textContent).toContain('Create a Cloud VM')
|
||||
expect(container.textContent).toContain('Create a workspace')
|
||||
|
||||
const button = container.querySelector('button')
|
||||
if (!button) {
|
||||
throw new Error('Cloud VM setup button was not rendered')
|
||||
}
|
||||
act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true })))
|
||||
|
||||
expect(useAppStore.getState().settingsNavigationTarget).toEqual({
|
||||
pane: 'experimental',
|
||||
repoId: null,
|
||||
sectionId: 'ephemeral-vms'
|
||||
})
|
||||
act(() => root.unmount())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import type React from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { Button } from '../ui/button'
|
||||
|
||||
export function CloudVmSetupGuide(): React.JSX.Element {
|
||||
const openSettingsTarget = useAppStore((state) => state.openSettingsTarget)
|
||||
|
||||
return (
|
||||
<section className="space-y-3" data-settings-section="cloud-vm-setup">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">
|
||||
{translate('auto.components.settings.CloudVmSetupGuide.title', 'Create a Cloud VM')}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.CloudVmSetupGuide.description',
|
||||
'Cloud VMs are created from environment recipes when you create a workspace.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<ol className="ml-4 list-decimal space-y-1 text-xs text-muted-foreground">
|
||||
<li>
|
||||
{translate(
|
||||
'auto.components.settings.CloudVmSetupGuide.setupRecipe',
|
||||
'Set up an environment recipe for your cloud provider.'
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{translate(
|
||||
'auto.components.settings.CloudVmSetupGuide.createWorkspace',
|
||||
'Create a workspace and select that recipe under Run on.'
|
||||
)}
|
||||
</li>
|
||||
</ol>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
openSettingsTarget({
|
||||
pane: 'experimental',
|
||||
repoId: null,
|
||||
sectionId: 'ephemeral-vms'
|
||||
})
|
||||
}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.settings.CloudVmSetupGuide.openSetup',
|
||||
'Set up environment recipes'
|
||||
)}
|
||||
</Button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -113,7 +113,7 @@ describe('EphemeralVmRuntimesSection', () => {
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('renders active temporary VM runtimes and cleans one up', async () => {
|
||||
it('renders active Cloud VM runtimes and cleans one up', async () => {
|
||||
const container = await renderSection()
|
||||
|
||||
await vi.waitFor(() => expect(container.textContent).toContain('Fix Login Race'))
|
||||
@@ -139,7 +139,9 @@ describe('EphemeralVmRuntimesSection', () => {
|
||||
const container = await renderSection()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(container.textContent).toContain('No temporary VM runtimes need cleanup.')
|
||||
expect(container.textContent).toContain(
|
||||
'No Cloud VM runtimes yet. Create one from a workspace using an environment recipe.'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -68,8 +68,8 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element {
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: translate(
|
||||
'auto.components.settings.EphemeralVmRuntimesSection.loadFailed',
|
||||
'Couldn’t load temporary VM runtimes.'
|
||||
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmLoadFailed',
|
||||
'Couldn’t load Cloud VM runtimes.'
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -92,8 +92,8 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element {
|
||||
throw new Error(
|
||||
cleaned.cleanupLastError ??
|
||||
translate(
|
||||
'auto.components.settings.EphemeralVmRuntimesSection.cleanupFailedToast',
|
||||
'Couldn’t clean up temporary VM runtime.'
|
||||
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmCleanupFailedToast',
|
||||
'Couldn’t clean up Cloud VM runtime.'
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -101,12 +101,12 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element {
|
||||
toast.success(
|
||||
cleaned.cleanupStatus === 'disabled'
|
||||
? translate(
|
||||
'auto.components.settings.EphemeralVmRuntimesSection.markedCleaned',
|
||||
'Marked temporary VM runtime as cleaned.'
|
||||
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmMarkedCleaned',
|
||||
'Marked Cloud VM runtime as cleaned.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.EphemeralVmRuntimesSection.cleaned',
|
||||
'Cleaned up temporary VM runtime.'
|
||||
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmCleaned',
|
||||
'Cleaned up Cloud VM runtime.'
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -117,8 +117,8 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element {
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: translate(
|
||||
'auto.components.settings.EphemeralVmRuntimesSection.cleanupFailedToast',
|
||||
'Couldn’t clean up temporary VM runtime.'
|
||||
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmCleanupFailedToast',
|
||||
'Couldn’t clean up Cloud VM runtime.'
|
||||
)
|
||||
)
|
||||
await refresh()
|
||||
@@ -171,8 +171,8 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element {
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="text-sm font-medium">
|
||||
{translate(
|
||||
'auto.components.settings.EphemeralVmRuntimesSection.title',
|
||||
'Temporary VM runtimes'
|
||||
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmTitle',
|
||||
'Cloud VM runtimes'
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -187,12 +187,12 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element {
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={translate(
|
||||
'auto.components.settings.EphemeralVmRuntimesSection.refresh',
|
||||
'Refresh temporary VM runtimes'
|
||||
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmRefresh',
|
||||
'Refresh Cloud VM runtimes'
|
||||
)}
|
||||
title={translate(
|
||||
'auto.components.settings.EphemeralVmRuntimesSection.refresh',
|
||||
'Refresh temporary VM runtimes'
|
||||
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmRefresh',
|
||||
'Refresh Cloud VM runtimes'
|
||||
)}
|
||||
onClick={() => void refresh()}
|
||||
disabled={isLoading || cleaningId !== null}
|
||||
@@ -206,12 +206,12 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element {
|
||||
<div className="px-3 py-4 text-sm text-muted-foreground">
|
||||
{isLoading
|
||||
? translate(
|
||||
'auto.components.settings.EphemeralVmRuntimesSection.loading',
|
||||
'Checking temporary VM runtimes…'
|
||||
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmLoading',
|
||||
'Checking Cloud VM runtimes…'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.EphemeralVmRuntimesSection.empty',
|
||||
'No temporary VM runtimes need cleanup.'
|
||||
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmEmptyWithSetup',
|
||||
'No Cloud VM runtimes yet. Create one from a workspace using an environment recipe.'
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -29,10 +29,7 @@ export function EphemeralVmsExperimentalSetting({
|
||||
<div className="flex max-w-3xl items-start justify-between gap-4">
|
||||
<div className="min-w-0 shrink space-y-0.5">
|
||||
<Label>
|
||||
{translate(
|
||||
'auto.components.settings.ephemeralVms.search.title',
|
||||
'Per-Workspace Environments'
|
||||
)}
|
||||
{translate('auto.components.settings.ephemeralVms.search.cloudVmTitle', 'Cloud VM')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
@@ -44,8 +41,8 @@ export function EphemeralVmsExperimentalSetting({
|
||||
<SettingsSwitch
|
||||
checked={enabled}
|
||||
ariaLabel={translate(
|
||||
'auto.components.settings.ephemeralVmsExperimentalSetting.toggleLabel',
|
||||
'Toggle per-workspace environments'
|
||||
'auto.components.settings.ephemeralVmsExperimentalSetting.cloudVmToggleLabel',
|
||||
'Toggle Cloud VM'
|
||||
)}
|
||||
onChange={() => updateSettings({ experimentalEphemeralVms: !enabled })}
|
||||
/>
|
||||
|
||||
@@ -120,9 +120,7 @@ describe('EphemeralVmsPane', () => {
|
||||
const container = await renderPane()
|
||||
|
||||
await vi.waitFor(() => expect(container.textContent).toContain('Cloud Sandbox'))
|
||||
await vi.waitFor(() =>
|
||||
expect(container.textContent).toContain('Per-Workspace Environments skill')
|
||||
)
|
||||
await vi.waitFor(() => expect(container.textContent).toContain('Cloud VM setup skill'))
|
||||
expect(container.textContent).toContain('What the skill does, with you')
|
||||
const useButton = [...container.querySelectorAll('button')].find(
|
||||
(button) => button.textContent === 'Use in workspace'
|
||||
|
||||
@@ -144,8 +144,8 @@ export function EphemeralVmsPane(): React.JSX.Element {
|
||||
<div className="space-y-6" data-settings-section="ephemeral-vms">
|
||||
<AgentSkillSetupPanel
|
||||
title={translate(
|
||||
'auto.components.settings.EphemeralVmsPane.skillTitle',
|
||||
'Per-Workspace Environments skill'
|
||||
'auto.components.settings.EphemeralVmsPane.cloudVmSkillTitle',
|
||||
'Cloud VM setup skill'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.EphemeralVmsPane.skillDescription',
|
||||
@@ -153,8 +153,14 @@ export function EphemeralVmsPane(): React.JSX.Element {
|
||||
)}
|
||||
command={installCommand}
|
||||
installedCommand={updateCommand}
|
||||
terminalTitle="Ephemeral VMs setup"
|
||||
terminalAriaLabel="Ephemeral VMs skill install terminal"
|
||||
terminalTitle={translate(
|
||||
'auto.components.settings.EphemeralVmsPane.cloudVmTerminalTitle',
|
||||
'Cloud VM setup'
|
||||
)}
|
||||
terminalAriaLabel={translate(
|
||||
'auto.components.settings.EphemeralVmsPane.cloudVmTerminalAriaLabel',
|
||||
'Cloud VM skill install terminal'
|
||||
)}
|
||||
terminalWorktreeId="settings-ephemeral-vms-skill-terminal"
|
||||
terminalShellOverride={activeSkillRuntime.terminalShellOverride}
|
||||
installed={skillDetected}
|
||||
@@ -258,8 +264,8 @@ export function EphemeralVmsPane(): React.JSX.Element {
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={translate(
|
||||
'auto.components.settings.EphemeralVmsPane.refresh',
|
||||
'Refresh ephemeral VM recipes'
|
||||
'auto.components.settings.EphemeralVmsPane.cloudVmRefresh',
|
||||
'Refresh Cloud VM recipes'
|
||||
)}
|
||||
onClick={() => void refresh()}
|
||||
disabled={isLoading}
|
||||
|
||||
@@ -15,9 +15,7 @@ vi.mock('../../store', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('./EphemeralVmsPane', () => ({
|
||||
EphemeralVmsPane: () => (
|
||||
<div data-testid="ephemeral-vms-pane">Per-Workspace Environments pane</div>
|
||||
)
|
||||
EphemeralVmsPane: () => <div data-testid="ephemeral-vms-pane">Cloud VM pane</div>
|
||||
}))
|
||||
|
||||
vi.mock('../ui/select', async () => {
|
||||
@@ -191,23 +189,23 @@ describe('ExperimentalPane', () => {
|
||||
root.unmount()
|
||||
})
|
||||
|
||||
it('renders per-workspace environments as an off-by-default experimental subsection', () => {
|
||||
it('renders Cloud VM as an off-by-default experimental subsection', () => {
|
||||
const settings = getDefaultSettings('/tmp')
|
||||
const markup = renderToStaticMarkup(
|
||||
<ExperimentalPane settings={settings} updateSettings={vi.fn()} />
|
||||
)
|
||||
const entry = getExperimentalPaneSearchEntries().find(
|
||||
(searchEntry) => searchEntry.title === 'Per-Workspace Environments'
|
||||
(searchEntry) => searchEntry.title === 'Cloud VM'
|
||||
)
|
||||
|
||||
expect(settings.experimentalEphemeralVms).toBe(false)
|
||||
expect(markup).toContain('Per-Workspace Environments')
|
||||
expect(markup).toContain('Cloud VM')
|
||||
expect(markup).toContain('aria-checked="false"')
|
||||
expect(markup).not.toContain('Per-Workspace Environments pane')
|
||||
expect(markup).not.toContain('Cloud VM pane')
|
||||
expect(entry?.targetSectionId).toBe('ephemeral-vms')
|
||||
})
|
||||
|
||||
it('enables per-workspace environments through the experimental switch', async () => {
|
||||
it('enables Cloud VM through the experimental switch', async () => {
|
||||
const updateSettings = vi.fn()
|
||||
const { root, container } = await renderExperimentalPane({ updateSettings })
|
||||
|
||||
@@ -215,7 +213,7 @@ describe('ExperimentalPane', () => {
|
||||
'#ephemeral-vms button[role="switch"]'
|
||||
)
|
||||
if (!switchButton) {
|
||||
throw new Error('Per-workspace environments switch was not rendered')
|
||||
throw new Error('Cloud VM switch was not rendered')
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
@@ -226,7 +224,7 @@ describe('ExperimentalPane', () => {
|
||||
root.unmount()
|
||||
})
|
||||
|
||||
it('shows per-workspace environment setup controls when enabled', () => {
|
||||
it('shows Cloud VM setup controls when enabled', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<ExperimentalPane
|
||||
settings={{ ...getDefaultSettings('/tmp'), experimentalEphemeralVms: true }}
|
||||
@@ -234,7 +232,7 @@ describe('ExperimentalPane', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
expect(markup).toContain('Per-Workspace Environments pane')
|
||||
expect(markup).toContain('Cloud VM pane')
|
||||
expect(markup).toContain('aria-checked="true"')
|
||||
})
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY
|
||||
} from '../../../../shared/protocol-version'
|
||||
import { Button } from '../ui/button'
|
||||
import { Input } from '../ui/input'
|
||||
import { Label } from '../ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
@@ -48,6 +47,7 @@ import {
|
||||
} from '../ui/dialog'
|
||||
import { RuntimePairingUrlGenerator } from './RuntimePairingUrlGenerator'
|
||||
import { EphemeralVmRuntimesSection } from './EphemeralVmRuntimesSection'
|
||||
import { CloudVmSetupGuide } from './CloudVmSetupGuide'
|
||||
import {
|
||||
getRuntimeEnvironmentsSearchEntry,
|
||||
getWebRuntimeEnvironmentsSearchEntry
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
getRemoteServerManualUpdateHelp,
|
||||
RemoteServerUpdateStatus
|
||||
} from './RemoteServerUpdateStatus'
|
||||
import { RuntimeHostAccessForm, type RuntimeHostAccessFailure } from './RuntimeHostAccessForm'
|
||||
|
||||
const LOCAL_RUNTIME_VALUE = '__local__'
|
||||
const NO_RUNTIME_VALUE = '__none__'
|
||||
@@ -208,6 +209,7 @@ export function isRuntimeEnvironmentRemovalBlocked(
|
||||
}
|
||||
|
||||
type RuntimeServerConnectionState = 'connected' | 'checking' | 'disconnected'
|
||||
type RemoteServerWorkflow = 'connect' | 'cloud-vm' | 'share'
|
||||
|
||||
export function getRuntimeServerConnectionState(
|
||||
details: RuntimeHostDetails | undefined
|
||||
@@ -276,12 +278,14 @@ export function RuntimeEnvironmentsPane({
|
||||
const [pendingSwitchValue, setPendingSwitchValue] = useState<string | null>(null)
|
||||
const [pendingRemove, setPendingRemove] = useState<PublicKnownRuntimeEnvironment | null>(null)
|
||||
const [addServerFormOpen, setAddServerFormOpen] = useState(false)
|
||||
const [shareServerFormOpen, setShareServerFormOpen] = useState(false)
|
||||
const [shareServerFormOpen, setShareServerFormOpen] = useState(true)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const [workflow, setWorkflow] = useState<RemoteServerWorkflow>('connect')
|
||||
const [switchError, setSwitchError] = useState<string | null>(null)
|
||||
const [removeError, setRemoveError] = useState<string | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
const [pairingCode, setPairingCode] = useState('')
|
||||
const [addServerFailure, setAddServerFailure] = useState<RuntimeHostAccessFailure | null>(null)
|
||||
const remoteServerUpdates = useAppStore((state) => state.remoteServerUpdates)
|
||||
const remoteServerUpdatesChecking = useAppStore((state) => state.remoteServerUpdatesChecking)
|
||||
const remoteServerUpdatesRunning = useAppStore((state) => state.remoteServerUpdatesRunning)
|
||||
@@ -308,96 +312,115 @@ export function RuntimeEnvironmentsPane({
|
||||
? getRuntimeEnvironmentsSearchEntry()
|
||||
: getWebRuntimeEnvironmentsSearchEntry()
|
||||
|
||||
const loadEnvironments = useCallback(async (): Promise<void> => {
|
||||
if (mountedRef.current) {
|
||||
setIsLoading(true)
|
||||
}
|
||||
try {
|
||||
const nextEnvironments = await window.api.runtimeEnvironments.list()
|
||||
const visibleEnvironments = nextEnvironments.filter(isUserManagedRuntimeEnvironment)
|
||||
// Why: drop store status for servers no longer saved so stale hosts don't
|
||||
// linger in the sidebar registry.
|
||||
useAppStore.getState().setRuntimeEnvironments(nextEnvironments)
|
||||
const loadEnvironments = useCallback(
|
||||
async (verified?: { environmentId: string; runtimeStatus: RuntimeStatus }): Promise<void> => {
|
||||
if (mountedRef.current) {
|
||||
setEnvironments(visibleEnvironments)
|
||||
setDetailsByEnvironmentId((current) => {
|
||||
const next: Record<string, RuntimeHostDetails> = {}
|
||||
for (const environment of visibleEnvironments) {
|
||||
next[environment.id] = current[environment.id] ?? {
|
||||
status: 'loading',
|
||||
runtimeStatus: null,
|
||||
compatibility: null,
|
||||
error: null
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
setIsLoading(true)
|
||||
}
|
||||
await Promise.allSettled(
|
||||
visibleEnvironments.map(async (environment) => {
|
||||
try {
|
||||
const response = await window.api.runtimeEnvironments.getStatus({
|
||||
selector: environment.id,
|
||||
timeoutMs: 10_000
|
||||
})
|
||||
const runtimeStatus = unwrapRuntimeRpcResult<RuntimeStatus>(response)
|
||||
// Why: feed the live status into the store so sidebar host pickers
|
||||
// reflect manual refreshes, not just the settings pane.
|
||||
useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, {
|
||||
status: runtimeStatus,
|
||||
checkedAt: Date.now()
|
||||
})
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
try {
|
||||
const nextEnvironments = await window.api.runtimeEnvironments.list()
|
||||
const visibleEnvironments = nextEnvironments.filter(isUserManagedRuntimeEnvironment)
|
||||
// Why: drop store status for servers no longer saved so stale hosts don't
|
||||
// linger in the sidebar registry.
|
||||
useAppStore.getState().setRuntimeEnvironments(nextEnvironments)
|
||||
if (verified) {
|
||||
useAppStore.getState().setRuntimeEnvironmentStatus(verified.environmentId, {
|
||||
status: verified.runtimeStatus,
|
||||
checkedAt: Date.now()
|
||||
})
|
||||
}
|
||||
if (mountedRef.current) {
|
||||
setEnvironments(visibleEnvironments)
|
||||
setDetailsByEnvironmentId((current) => {
|
||||
const next: Record<string, RuntimeHostDetails> = {}
|
||||
for (const environment of visibleEnvironments) {
|
||||
next[environment.id] =
|
||||
verified?.environmentId === environment.id
|
||||
? {
|
||||
status: 'ready',
|
||||
runtimeStatus: verified.runtimeStatus,
|
||||
compatibility: evaluateHostDetails(verified.runtimeStatus),
|
||||
error: null
|
||||
}
|
||||
: (current[environment.id] ?? {
|
||||
status: 'loading',
|
||||
runtimeStatus: null,
|
||||
compatibility: null,
|
||||
error: null
|
||||
})
|
||||
}
|
||||
setDetailsByEnvironmentId((current) => ({
|
||||
...current,
|
||||
[environment.id]: {
|
||||
status: 'ready',
|
||||
runtimeStatus,
|
||||
compatibility: evaluateHostDetails(runtimeStatus),
|
||||
error: null
|
||||
return next
|
||||
})
|
||||
}
|
||||
await Promise.allSettled(
|
||||
visibleEnvironments
|
||||
.filter((environment) => environment.id !== verified?.environmentId)
|
||||
.map(async (environment) => {
|
||||
try {
|
||||
const response = await window.api.runtimeEnvironments.getStatus({
|
||||
selector: environment.id,
|
||||
timeoutMs: 10_000
|
||||
})
|
||||
const runtimeStatus = unwrapRuntimeRpcResult<RuntimeStatus>(response)
|
||||
// Why: feed the live status into the store so sidebar host pickers
|
||||
// reflect manual refreshes, not just the settings pane.
|
||||
useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, {
|
||||
status: runtimeStatus,
|
||||
checkedAt: Date.now()
|
||||
})
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
setDetailsByEnvironmentId((current) => ({
|
||||
...current,
|
||||
[environment.id]: {
|
||||
status: 'ready',
|
||||
runtimeStatus,
|
||||
compatibility: evaluateHostDetails(runtimeStatus),
|
||||
error: null
|
||||
}
|
||||
}))
|
||||
} catch (error) {
|
||||
// Why: record the failed probe (null status) so the sidebar can
|
||||
// distinguish unreachable from never-checked.
|
||||
useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, {
|
||||
status: null,
|
||||
checkedAt: Date.now()
|
||||
})
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
setDetailsByEnvironmentId((current) => ({
|
||||
...current,
|
||||
[environment.id]: {
|
||||
status: 'error',
|
||||
runtimeStatus: null,
|
||||
compatibility: null,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}))
|
||||
}
|
||||
}))
|
||||
} catch (error) {
|
||||
// Why: record the failed probe (null status) so the sidebar can
|
||||
// distinguish unreachable from never-checked.
|
||||
useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, {
|
||||
status: null,
|
||||
checkedAt: Date.now()
|
||||
})
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
setDetailsByEnvironmentId((current) => ({
|
||||
...current,
|
||||
[environment.id]: {
|
||||
status: 'error',
|
||||
runtimeStatus: null,
|
||||
compatibility: null,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}))
|
||||
}
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
if (mountedRef.current) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.e6410d72c3',
|
||||
'Failed to load runtime environments.'
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
if (mountedRef.current) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.e6410d72c3',
|
||||
'Failed to load runtime environments.'
|
||||
)
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}, [mountedRef])
|
||||
},
|
||||
[mountedRef]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void loadEnvironments()
|
||||
@@ -427,9 +450,10 @@ export function RuntimeEnvironmentsPane({
|
||||
setAddServerFormOpen(false)
|
||||
setName('')
|
||||
setPairingCode('')
|
||||
setAddServerFailure(null)
|
||||
}
|
||||
|
||||
const addEnvironment = async (): Promise<void> => {
|
||||
const addEnvironment = async (allowLoopback: boolean): Promise<void> => {
|
||||
const trimmedName = name.trim()
|
||||
const trimmedPairingCode = pairingCode.trim()
|
||||
if (!trimmedName || !trimmedPairingCode) {
|
||||
@@ -454,17 +478,28 @@ export function RuntimeEnvironmentsPane({
|
||||
)
|
||||
return
|
||||
}
|
||||
setAddServerFailure(null)
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const result = await window.api.runtimeEnvironments.addFromPairingCode({
|
||||
const result = await window.api.runtimeEnvironments.verifyAndAddFromPairingCode({
|
||||
name: trimmedName,
|
||||
pairingCode: trimmedPairingCode
|
||||
pairingCode: trimmedPairingCode,
|
||||
allowLoopback
|
||||
})
|
||||
if (!result.ok) {
|
||||
if (mountedRef.current) {
|
||||
setAddServerFailure({ kind: result.kind, message: result.message })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (mountedRef.current) {
|
||||
setName('')
|
||||
setPairingCode('')
|
||||
}
|
||||
await loadEnvironments()
|
||||
await loadEnvironments({
|
||||
environmentId: result.environment.id,
|
||||
runtimeStatus: result.runtimeStatus
|
||||
})
|
||||
if (!allowLocalRuntime) {
|
||||
const connected = await connectEnvironment(result.environment)
|
||||
if (!connected) {
|
||||
@@ -477,7 +512,7 @@ export function RuntimeEnvironmentsPane({
|
||||
toast.success(
|
||||
translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.7b5986c8df',
|
||||
'Saved {{value0}}. Use Advanced > Active Server to make it the default.',
|
||||
'Connected to {{value0}}. Use Advanced > Active Server to make it the default.',
|
||||
{ value0: result.environment.name }
|
||||
)
|
||||
)
|
||||
@@ -723,6 +758,7 @@ export function RuntimeEnvironmentsPane({
|
||||
}
|
||||
return environments.find((environment) => environment.id === value)?.name ?? 'remote server'
|
||||
}
|
||||
const visibleWorkflow: RemoteServerWorkflow = addServerFormOpen ? 'connect' : workflow
|
||||
|
||||
return (
|
||||
<SearchableSetting
|
||||
@@ -731,7 +767,84 @@ export function RuntimeEnvironmentsPane({
|
||||
keywords={searchEntry.keywords}
|
||||
className="space-y-4 py-2"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
role="group"
|
||||
aria-label={translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.workflow',
|
||||
'Remote server workflow'
|
||||
)}
|
||||
className={cn('grid gap-2 sm:grid-cols-2', canGeneratePairingUrl && 'sm:grid-cols-3')}
|
||||
>
|
||||
{(
|
||||
[
|
||||
[
|
||||
'connect',
|
||||
translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.connectWorkflow',
|
||||
'Connect to a host'
|
||||
),
|
||||
translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.connectWorkflowHelp',
|
||||
'This app joins another machine'
|
||||
)
|
||||
],
|
||||
[
|
||||
'share',
|
||||
translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.shareWorkflow',
|
||||
'Share this host'
|
||||
),
|
||||
translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.shareWorkflowHelp',
|
||||
'Other devices join this machine'
|
||||
)
|
||||
],
|
||||
[
|
||||
'cloud-vm',
|
||||
translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.cloudVmWorkflow',
|
||||
'Cloud VM'
|
||||
),
|
||||
translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.cloudVmWorkflowHelp',
|
||||
'Manage recipe-created cloud machines'
|
||||
)
|
||||
]
|
||||
] as const
|
||||
)
|
||||
.filter(([value]) => value !== 'share' || canGeneratePairingUrl)
|
||||
.map(([value, label, description]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={visibleWorkflow === value}
|
||||
onClick={() => {
|
||||
if (value !== 'connect') {
|
||||
closeAddServerForm()
|
||||
}
|
||||
setWorkflow(value)
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-lg border p-3 text-left transition-colors',
|
||||
visibleWorkflow === value
|
||||
? 'border-ring bg-accent text-accent-foreground'
|
||||
: 'border-border hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
<span className="block text-sm font-medium">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'mt-1 block text-xs',
|
||||
visibleWorkflow === value ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={cn('space-y-3', visibleWorkflow !== 'connect' && 'hidden')}>
|
||||
<div
|
||||
data-settings-section="remote-server-updates"
|
||||
className="flex items-center justify-between gap-3"
|
||||
@@ -800,89 +913,19 @@ export function RuntimeEnvironmentsPane({
|
||||
</div>
|
||||
|
||||
{addServerFormOpen ? (
|
||||
<form
|
||||
className="space-y-3 rounded-lg border border-border/50 bg-muted/20 p-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void addEnvironment()
|
||||
<RuntimeHostAccessForm
|
||||
name={name}
|
||||
accessLink={pairingCode}
|
||||
busy={isBusy}
|
||||
failure={addServerFailure}
|
||||
onNameChange={setName}
|
||||
onAccessLinkChange={(value) => {
|
||||
setPairingCode(value)
|
||||
setAddServerFailure(null)
|
||||
}}
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-[minmax(0,180px)_minmax(0,1fr)]">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="runtime-server-name">
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.54ebacc600',
|
||||
'Server name'
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="runtime-server-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder={translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.e038625857',
|
||||
'Dev box'
|
||||
)}
|
||||
className="h-8 text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="runtime-server-pairing-code">
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.9bc9b83474',
|
||||
'Pairing code'
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="runtime-server-pairing-code"
|
||||
aria-describedby="runtime-server-pairing-code-help"
|
||||
value={pairingCode}
|
||||
onChange={(event) => setPairingCode(event.target.value)}
|
||||
placeholder={translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.c3d772c514',
|
||||
'orca://pair?code=...'
|
||||
)}
|
||||
className="h-8 min-w-0 font-mono text-xs"
|
||||
/>
|
||||
<p id="runtime-server-pairing-code-help" className="text-xs text-muted-foreground">
|
||||
{translate('auto.components.settings.RuntimeEnvironmentsPane.163671f7b5', 'Run')}{' '}
|
||||
<span className="font-mono">
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.960e901ae4',
|
||||
'orca serve --pairing-address <host>'
|
||||
)}
|
||||
</span>{' '}
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.55fcc964cd',
|
||||
'on the server and paste the printed pairing URL.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={closeAddServerForm}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{translate('auto.components.settings.RuntimeEnvironmentsPane.af53761f31', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={isBusy || !name.trim() || !pairingCode.trim()}
|
||||
>
|
||||
{isSaving ? <Loader2 className="animate-spin" /> : <Plus />}
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.9bee6bbeeb',
|
||||
'Add Server'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
onCancel={closeAddServerForm}
|
||||
onSubmit={(allowLoopback) => void addEnvironment(allowLoopback)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-lg border border-border/50 bg-card/30">
|
||||
@@ -936,12 +979,17 @@ export function RuntimeEnvironmentsPane({
|
||||
) : null}
|
||||
</div>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{isActive
|
||||
{environment.connectionDependency === 'ssh-tunnel'
|
||||
? translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.activeServerRowHelp',
|
||||
'Active server for server-routed projects, terminals, and provider checks.'
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.sshTunnelRequired',
|
||||
'SSH tunnel required'
|
||||
)
|
||||
: getHostDetailsSummary(details)}
|
||||
: isActive
|
||||
? translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.activeServerRowHelp',
|
||||
'Active server for server-routed projects, terminals, and provider checks.'
|
||||
)
|
||||
: getHostDetailsSummary(details)}
|
||||
</p>
|
||||
{detailsDescription ? (
|
||||
<p
|
||||
@@ -1069,15 +1117,23 @@ export function RuntimeEnvironmentsPane({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EphemeralVmRuntimesSection />
|
||||
<div className={cn('space-y-5 pt-2', visibleWorkflow !== 'cloud-vm' && 'hidden')}>
|
||||
<CloudVmSetupGuide />
|
||||
<EphemeralVmRuntimesSection />
|
||||
</div>
|
||||
|
||||
<div data-settings-section="default-runtime">
|
||||
<div
|
||||
data-settings-section="default-runtime"
|
||||
className={visibleWorkflow !== 'connect' ? 'hidden' : undefined}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setAdvancedOpen((current) => !current)}
|
||||
className="-ml-2 text-xs"
|
||||
aria-expanded={advancedOpen}
|
||||
aria-controls="runtime-server-advanced-content"
|
||||
>
|
||||
{translate('auto.components.settings.RuntimeEnvironmentsPane.advanced', 'Advanced')}
|
||||
<ChevronDown
|
||||
@@ -1086,11 +1142,13 @@ export function RuntimeEnvironmentsPane({
|
||||
</Button>
|
||||
|
||||
<div
|
||||
id="runtime-server-advanced-content"
|
||||
className={cn(
|
||||
'grid overflow-hidden transition-[grid-template-rows] duration-200 ease-out',
|
||||
advancedOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
|
||||
)}
|
||||
aria-hidden={!advancedOpen}
|
||||
inert={!advancedOpen}
|
||||
>
|
||||
<div className="min-h-0">
|
||||
<div
|
||||
@@ -1234,7 +1292,7 @@ export function RuntimeEnvironmentsPane({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canGeneratePairingUrl ? (
|
||||
{visibleWorkflow === 'share' && canGeneratePairingUrl ? (
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-medium">
|
||||
@@ -1296,6 +1354,60 @@ export function RuntimeEnvironmentsPane({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{visibleWorkflow === 'connect' ? (
|
||||
<details className="group rounded-lg border border-border/60">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2 p-4 text-sm font-medium">
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.troubleshootWorkflow',
|
||||
'Connection troubleshooting'
|
||||
)}
|
||||
<ChevronDown className="ml-auto size-4 transition-transform group-open:rotate-180" />
|
||||
</summary>
|
||||
<div className="space-y-4 border-t border-border/50 p-4">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.troubleshootTitle',
|
||||
'Create a new link on the other host'
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.troubleshootDescription',
|
||||
'A link that uses 127.0.0.1 points back to the device opening it, not the computer that created it.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<ol className="ml-4 list-decimal space-y-1 text-xs text-muted-foreground">
|
||||
<li>
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.troubleshootStepShare',
|
||||
'On the other computer, open Share this host.'
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.troubleshootStepAddress',
|
||||
'Choose Another device and select its Tailscale or LAN address.'
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.troubleshootStepRegenerate',
|
||||
'Generate a new access link and use only the newest link here.'
|
||||
)}
|
||||
</li>
|
||||
</ol>
|
||||
<div className="rounded-md border border-border/60 bg-muted/30 p-3 text-xs">
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeEnvironmentsPane.troubleshootTunnel',
|
||||
'Using an SSH local forward? Return to Connect to a host, paste the loopback link, then enable “I am using an SSH tunnel” under Advanced.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
<Dialog
|
||||
open={pendingSwitchValue !== null}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../../../shared/pairing'
|
||||
import { RuntimeHostAccessForm } from './RuntimeHostAccessForm'
|
||||
|
||||
function accessLink(endpoint: string): string {
|
||||
return encodePairingOffer({
|
||||
v: PAIRING_OFFER_VERSION,
|
||||
endpoint,
|
||||
deviceToken: 'secret-device-token',
|
||||
publicKeyB64: 'secret-public-key',
|
||||
scope: 'runtime'
|
||||
})
|
||||
}
|
||||
|
||||
function renderForm(endpoint: string): string {
|
||||
return renderToStaticMarkup(
|
||||
<RuntimeHostAccessForm
|
||||
name="Linux workstation"
|
||||
accessLink={accessLink(endpoint)}
|
||||
busy={false}
|
||||
failure={null}
|
||||
onNameChange={vi.fn()}
|
||||
onAccessLinkChange={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('RuntimeHostAccessForm', () => {
|
||||
it('shows the sanitized destination without credential material', () => {
|
||||
const markup = renderForm('ws://100.76.32.125:6768')
|
||||
expect(markup).toContain('100.76.32.125:6768')
|
||||
expect(markup).toContain('Tailscale address')
|
||||
expect(markup).not.toContain('secret-device-token')
|
||||
expect(markup).not.toContain('secret-public-key')
|
||||
})
|
||||
|
||||
it('blocks accidental loopback and explains the recovery path', () => {
|
||||
const markup = renderForm('ws://127.0.0.1:6768')
|
||||
expect(markup).toContain('This link points back to this device')
|
||||
expect(markup).toContain('I am using an SSH tunnel')
|
||||
expect(markup).toContain('disabled')
|
||||
})
|
||||
|
||||
it('shows actionable validation for malformed access links', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<RuntimeHostAccessForm
|
||||
name="Linux workstation"
|
||||
accessLink="not-a-link"
|
||||
busy={false}
|
||||
failure={null}
|
||||
onNameChange={vi.fn()}
|
||||
onAccessLinkChange={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
/>
|
||||
)
|
||||
expect(markup).toContain('Enter an Orca access link or bare pairing code.')
|
||||
expect(markup).toContain('aria-invalid="true"')
|
||||
})
|
||||
|
||||
it('shows the persistence error after host verification succeeds', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<RuntimeHostAccessForm
|
||||
name="Linux workstation"
|
||||
accessLink={accessLink('ws://100.76.32.125:6768')}
|
||||
busy={false}
|
||||
failure={{
|
||||
kind: 'environment-save-failed',
|
||||
message: 'A server named "Linux workstation" already exists.'
|
||||
}}
|
||||
onNameChange={vi.fn()}
|
||||
onAccessLinkChange={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(markup).toContain('Could not save the host')
|
||||
expect(markup).toContain('A server named "Linux workstation" already exists.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,341 @@
|
||||
import { ChevronDown, Loader2, Plus } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { parseHostAccessLink } from '../../../../shared/remote-pairing-address'
|
||||
import type { RemotePairingFailureKind } from '../../../../shared/remote-pairing-verification'
|
||||
import { Badge } from '../ui/badge'
|
||||
import { Button } from '../ui/button'
|
||||
import { Checkbox } from '../ui/checkbox'
|
||||
import { Input } from '../ui/input'
|
||||
import { Label } from '../ui/label'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
translateHostAccessLinkError,
|
||||
translateRemotePairingEndpointKind,
|
||||
translateRemotePairingFailureDescription
|
||||
} from '@/lib/remote-pairing-copy'
|
||||
|
||||
export type RuntimeHostAccessFailure = {
|
||||
kind: RemotePairingFailureKind
|
||||
message: string
|
||||
}
|
||||
|
||||
type RuntimeHostAccessFormProps = {
|
||||
name: string
|
||||
accessLink: string
|
||||
busy: boolean
|
||||
failure: RuntimeHostAccessFailure | null
|
||||
onNameChange: (value: string) => void
|
||||
onAccessLinkChange: (value: string) => void
|
||||
onCancel: () => void
|
||||
onSubmit: (allowLoopback: boolean) => void
|
||||
}
|
||||
|
||||
export function RuntimeHostAccessForm({
|
||||
name,
|
||||
accessLink,
|
||||
busy,
|
||||
failure,
|
||||
onNameChange,
|
||||
onAccessLinkChange,
|
||||
onCancel,
|
||||
onSubmit
|
||||
}: RuntimeHostAccessFormProps): React.JSX.Element {
|
||||
const [allowLoopback, setAllowLoopback] = useState(false)
|
||||
const parsed = useMemo(() => parseHostAccessLink(accessLink), [accessLink])
|
||||
const tunnelOverrideEnabled =
|
||||
allowLoopback && parsed.ok && parsed.value.endpointKind === 'loopback'
|
||||
const loopbackBlocked =
|
||||
parsed.ok && parsed.value.endpointKind === 'loopback' && !tunnelOverrideEnabled
|
||||
const inputError = accessLink.trim() !== '' && !parsed.ok
|
||||
const describedBy = failure
|
||||
? 'runtime-server-verification-error'
|
||||
: inputError
|
||||
? 'runtime-server-access-link-error'
|
||||
: loopbackBlocked
|
||||
? 'runtime-server-loopback-error'
|
||||
: 'runtime-server-access-link-help'
|
||||
const canSubmit = name.trim() !== '' && parsed.ok && !loopbackBlocked && !busy
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4 rounded-lg border border-border/50 bg-muted/20 p-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (canSubmit) {
|
||||
onSubmit(tunnelOverrideEnabled)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2 rounded-md border border-border/60 bg-background/60 p-3">
|
||||
<div className="text-sm font-medium">
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.getLink',
|
||||
'Get an access link from the other host'
|
||||
)}
|
||||
</div>
|
||||
<ol className="ml-4 list-decimal space-y-1 text-xs text-muted-foreground">
|
||||
<li>
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.stepOpenShare',
|
||||
'Open Settings → Remote Orca Servers → Share this host.'
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.stepChooseAddress',
|
||||
'Choose Another device and select a reachable address.'
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.stepCopyLink',
|
||||
'Generate the link, then copy the “Pair another Orca client” link.'
|
||||
)}
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-[minmax(0,180px)_minmax(0,1fr)]">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="runtime-server-name">
|
||||
{translate('auto.components.settings.RuntimeHostAccessForm.name', 'Name in Orca')}
|
||||
</Label>
|
||||
<Input
|
||||
id="runtime-server-name"
|
||||
value={name}
|
||||
disabled={busy}
|
||||
onChange={(event) => onNameChange(event.target.value)}
|
||||
placeholder={translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.namePlaceholder',
|
||||
'Linux workstation'
|
||||
)}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.nameHelp',
|
||||
'This only changes how the computer appears in Orca.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="runtime-server-access-link">
|
||||
{translate('auto.components.settings.RuntimeHostAccessForm.accessLink', 'Access link')}
|
||||
</Label>
|
||||
<Input
|
||||
id="runtime-server-access-link"
|
||||
aria-invalid={inputError || loopbackBlocked || failure !== null}
|
||||
aria-describedby={describedBy}
|
||||
value={accessLink}
|
||||
disabled={busy}
|
||||
onChange={(event) => {
|
||||
setAllowLoopback(false)
|
||||
onAccessLinkChange(event.target.value)
|
||||
}}
|
||||
placeholder={translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.accessLinkPlaceholder',
|
||||
'orca://pair?code=...'
|
||||
)}
|
||||
className="min-w-0 font-mono"
|
||||
/>
|
||||
<p id="runtime-server-access-link-help" className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.accessLinkHelp',
|
||||
'Orca shows the destination before connecting. Credentials stay hidden.'
|
||||
)}
|
||||
</p>
|
||||
{inputError ? (
|
||||
<p id="runtime-server-access-link-error" className="text-xs text-destructive">
|
||||
{parsed.ok ? null : translateHostAccessLinkError(parsed.kind)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{parsed.ok ? (
|
||||
<div className="space-y-1 rounded-md border border-border/60 bg-background/60 px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs font-medium">
|
||||
<span>
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.destination',
|
||||
'Link destination'
|
||||
)}
|
||||
</span>
|
||||
<Badge variant="outline">
|
||||
{translateRemotePairingEndpointKind(parsed.value.endpointKind)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="font-mono text-sm" aria-live="polite">
|
||||
{parsed.value.displayEndpoint}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loopbackBlocked && parsed.ok ? (
|
||||
<div
|
||||
id="runtime-server-loopback-error"
|
||||
role="alert"
|
||||
className="space-y-1 rounded-md border border-destructive/50 bg-destructive/5 p-3 text-sm"
|
||||
>
|
||||
<div className="font-medium text-destructive">
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.loopbackTitle',
|
||||
'This link points back to this device'
|
||||
)}
|
||||
</div>
|
||||
<p>
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.loopbackDescription',
|
||||
'It uses {{endpoint}}, which points back to the device opening the link—not the other computer that created it.',
|
||||
{ endpoint: parsed.value.displayEndpoint }
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.loopbackRecovery',
|
||||
'On the other computer, create a new link using Another device and choose its Tailscale or LAN address.'
|
||||
)}
|
||||
</p>
|
||||
<details className="pt-1 text-xs">
|
||||
<summary className="cursor-pointer font-medium">
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.connectionDetails',
|
||||
'Connection details'
|
||||
)}
|
||||
</summary>
|
||||
<dl className="mt-2 grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 text-muted-foreground">
|
||||
<dt>
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.destination',
|
||||
'Link destination'
|
||||
)}
|
||||
</dt>
|
||||
<dd className="font-mono text-foreground">{parsed.value.displayEndpoint}</dd>
|
||||
<dt>
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.endpointKind',
|
||||
'Endpoint kind'
|
||||
)}
|
||||
</dt>
|
||||
<dd className="text-foreground">
|
||||
{translateRemotePairingEndpointKind(parsed.value.endpointKind)}
|
||||
</dd>
|
||||
<dt>
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.networkConnection',
|
||||
'Network connection'
|
||||
)}
|
||||
</dt>
|
||||
<dd className="text-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.notAttempted',
|
||||
'Not attempted'
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
</details>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{failure ? (
|
||||
<div
|
||||
id="runtime-server-verification-error"
|
||||
role="alert"
|
||||
className="space-y-1 rounded-md border border-destructive/50 p-3"
|
||||
>
|
||||
<div className="text-sm font-medium text-destructive">
|
||||
{failure.kind === 'host-identity-mismatch'
|
||||
? translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.identityMismatch',
|
||||
'The reached Orca host does not match this access link'
|
||||
)
|
||||
: failure.kind === 'access-link-invalid'
|
||||
? translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.invalidLink',
|
||||
'This access link is no longer valid'
|
||||
)
|
||||
: failure.kind === 'protocol-incompatible'
|
||||
? translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.incompatible',
|
||||
'Orca versions are not compatible'
|
||||
)
|
||||
: failure.kind === 'connection-interrupted'
|
||||
? translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.interrupted',
|
||||
'Connection interrupted'
|
||||
)
|
||||
: failure.kind === 'environment-save-failed'
|
||||
? translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.saveFailed',
|
||||
'Could not save the host'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.unavailable',
|
||||
'Host unavailable'
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{failure.kind === 'environment-save-failed'
|
||||
? failure.message
|
||||
: translateRemotePairingFailureDescription(
|
||||
failure.kind,
|
||||
parsed.ok ? parsed.value.displayEndpoint : null
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<details className="group text-xs">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-1 font-medium text-muted-foreground">
|
||||
{translate('auto.components.settings.RuntimeHostAccessForm.advanced', 'Advanced')}
|
||||
<ChevronDown className="size-3.5 transition-transform group-open:rotate-180" />
|
||||
</summary>
|
||||
{parsed.ok && parsed.value.endpointKind === 'loopback' ? (
|
||||
<label className="mt-3 flex items-start gap-2 rounded-md border border-border/60 p-3">
|
||||
<Checkbox
|
||||
checked={allowLoopback}
|
||||
disabled={busy}
|
||||
onCheckedChange={(checked) => setAllowLoopback(checked === true)}
|
||||
/>
|
||||
<span className="space-y-1">
|
||||
<span className="block font-medium text-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.sshTunnel',
|
||||
'I am using an SSH tunnel to this local address'
|
||||
)}
|
||||
</span>
|
||||
<span className="block text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.sshTunnelHelp',
|
||||
'Keep the tunnel active while using this connection.'
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
) : (
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.headlessHelp',
|
||||
'Using headless orca serve? Run orca serve --pairing-address <reachable-host> on the other computer.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</details>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onCancel} disabled={busy}>
|
||||
{translate('auto.components.settings.RuntimeHostAccessForm.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" size="sm" disabled={!canSubmit}>
|
||||
{busy ? <Loader2 className="animate-spin" /> : <Plus />}
|
||||
{tunnelOverrideEnabled
|
||||
? translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.addWithTunnel',
|
||||
'Add host using tunnel'
|
||||
)
|
||||
: translate('auto.components.settings.RuntimeHostAccessForm.addHost', 'Add host')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { TooltipProvider } from '../ui/tooltip'
|
||||
import {
|
||||
RuntimePairingGeneratorForm,
|
||||
type RuntimePairingIntent
|
||||
} from './RuntimePairingGeneratorForm'
|
||||
|
||||
function renderForm(
|
||||
intent: RuntimePairingIntent,
|
||||
selectedAddress: string,
|
||||
generated?: {
|
||||
address: string
|
||||
runtimePairingUrl: string
|
||||
webClientUrl: string
|
||||
}
|
||||
): string {
|
||||
return renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<RuntimePairingGeneratorForm
|
||||
intent={intent}
|
||||
loopbackAddress="127.0.0.1"
|
||||
networkInterfaces={[{ name: 'tailscale0', address: '100.76.32.125' }]}
|
||||
selectedAddress={selectedAddress}
|
||||
refreshingNetworkInterfaces={false}
|
||||
isGeneratingPairing={false}
|
||||
webClientUrl={generated?.webClientUrl ?? null}
|
||||
runtimePairingUrl={generated?.runtimePairingUrl ?? null}
|
||||
copiedTarget={null}
|
||||
generatedAddress={generated?.address ?? null}
|
||||
onIntentChange={vi.fn()}
|
||||
onSelectedAddressChange={vi.fn()}
|
||||
onRefreshNetworkInterfaces={vi.fn()}
|
||||
onGenerate={vi.fn()}
|
||||
onCopy={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('RuntimePairingGeneratorForm', () => {
|
||||
it('uses detected interfaces for another-device intent', () => {
|
||||
const markup = renderForm('another', '100.76.32.125')
|
||||
expect(markup).toContain('role="combobox"')
|
||||
expect(markup).not.toContain('id="runtime-pairing-custom-address"')
|
||||
})
|
||||
|
||||
it('requires a dedicated value for custom-address intent', () => {
|
||||
const emptyMarkup = renderForm('custom', '')
|
||||
expect(emptyMarkup).toContain('id="runtime-pairing-custom-address"')
|
||||
expect(emptyMarkup).toContain('disabled=""')
|
||||
|
||||
const populatedMarkup = renderForm('custom', 'openclaw.example.ts.net')
|
||||
expect(populatedMarkup).toContain('value="openclaw.example.ts.net"')
|
||||
expect(populatedMarkup).not.toContain('disabled=""')
|
||||
})
|
||||
|
||||
it('hides generated links after the selected address changes', () => {
|
||||
const markup = renderForm('another', '100.76.32.125', {
|
||||
address: '192.168.1.10',
|
||||
runtimePairingUrl: 'orca://pair?code=stale-secret',
|
||||
webClientUrl: 'https://example.test/?pair=stale-secret'
|
||||
})
|
||||
|
||||
expect(markup).toContain('The connection address changed.')
|
||||
expect(markup).not.toContain('stale-secret')
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,18 @@
|
||||
import { Loader2, RefreshCw } from 'lucide-react'
|
||||
import { Button } from '../ui/button'
|
||||
import { Input } from '../ui/input'
|
||||
import { Label } from '../ui/label'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
|
||||
import { AddressPicker, type AddressOption } from '../network/AddressPicker'
|
||||
import { parseServerShareAddress } from '../../../../shared/network/server-share-address'
|
||||
import { GeneratedUrlRow, UnavailableUrlRow } from './RuntimePairingGeneratedUrlRows'
|
||||
import type { RuntimePairingIntent } from './runtime-pairing-link-state'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export type { RuntimePairingIntent } from './runtime-pairing-link-state'
|
||||
|
||||
type RuntimePairingGeneratorFormProps = {
|
||||
intent: RuntimePairingIntent
|
||||
loopbackAddress: string
|
||||
networkInterfaces: { name: string; address: string }[]
|
||||
selectedAddress: string
|
||||
@@ -16,6 +21,8 @@ type RuntimePairingGeneratorFormProps = {
|
||||
webClientUrl: string | null
|
||||
runtimePairingUrl: string | null
|
||||
copiedTarget: 'web' | 'pairing' | null
|
||||
generatedAddress: string | null
|
||||
onIntentChange: (intent: RuntimePairingIntent) => void
|
||||
onSelectedAddressChange: (address: string) => void
|
||||
onRefreshNetworkInterfaces: () => void
|
||||
onGenerate: () => void
|
||||
@@ -23,6 +30,7 @@ type RuntimePairingGeneratorFormProps = {
|
||||
}
|
||||
|
||||
export function RuntimePairingGeneratorForm({
|
||||
intent,
|
||||
loopbackAddress,
|
||||
networkInterfaces,
|
||||
selectedAddress,
|
||||
@@ -31,136 +39,279 @@ export function RuntimePairingGeneratorForm({
|
||||
webClientUrl,
|
||||
runtimePairingUrl,
|
||||
copiedTarget,
|
||||
generatedAddress,
|
||||
onIntentChange,
|
||||
onSelectedAddressChange,
|
||||
onRefreshNetworkInterfaces,
|
||||
onGenerate,
|
||||
onCopy
|
||||
}: RuntimePairingGeneratorFormProps): React.JSX.Element {
|
||||
const options: AddressOption[] = [
|
||||
{
|
||||
value: loopbackAddress,
|
||||
label: `${translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.de6d5cff95',
|
||||
'This computer ('
|
||||
)}${loopbackAddress})`
|
||||
},
|
||||
...networkInterfaces.map((networkInterface) => ({
|
||||
value: networkInterface.address,
|
||||
label: `${networkInterface.name} (${networkInterface.address})`
|
||||
}))
|
||||
]
|
||||
const options: AddressOption[] = networkInterfaces.map((networkInterface) => ({
|
||||
value: networkInterface.address,
|
||||
label: `${networkInterface.name} (${networkInterface.address})`
|
||||
}))
|
||||
const generatedIsCurrent = generatedAddress === selectedAddress
|
||||
const staleGeneratedLink = generatedAddress !== null && !generatedIsCurrent
|
||||
const customAddressResult =
|
||||
intent === 'custom' ? parseServerShareAddress(selectedAddress) : { ok: true as const }
|
||||
const customAddressInvalid = selectedAddress !== '' && !customAddressResult.ok
|
||||
const canGenerate = selectedAddress !== '' && (intent !== 'custom' || customAddressResult.ok)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label id="runtime-pairing-address-label" htmlFor="runtime-pairing-address">
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">
|
||||
{translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.de77eb1b65',
|
||||
'Connection address'
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.intentQuestion',
|
||||
'Where will this link be opened?'
|
||||
)}
|
||||
</Label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<AddressPicker
|
||||
id="runtime-pairing-address"
|
||||
// Why: bounded width so a short value like "This computer
|
||||
// (127.0.0.1)" doesn't stretch the trigger across the whole card;
|
||||
// the value can grow up to the card edge before truncating.
|
||||
className="min-w-[240px] max-w-full"
|
||||
triggerAriaLabel={translate(
|
||||
</legend>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{(
|
||||
[
|
||||
[
|
||||
'another',
|
||||
translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.anotherDevice',
|
||||
'Another device'
|
||||
),
|
||||
translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.anotherDeviceHelp',
|
||||
'Tailscale, LAN, or another reachable address'
|
||||
)
|
||||
],
|
||||
[
|
||||
'local',
|
||||
translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.localOnly',
|
||||
'This computer only'
|
||||
),
|
||||
translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.localOnlyHelp',
|
||||
'A browser or Orca client on this computer'
|
||||
)
|
||||
],
|
||||
[
|
||||
'custom',
|
||||
translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.customAddress',
|
||||
'Custom address'
|
||||
),
|
||||
translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.customAddressHelp',
|
||||
'SSH tunnel, reverse proxy, or custom hostname'
|
||||
)
|
||||
]
|
||||
] as const
|
||||
).map(([value, label, description]) => (
|
||||
<label
|
||||
key={value}
|
||||
className="flex cursor-pointer gap-2 rounded-md border border-border p-3 has-[:checked]:border-ring has-[:checked]:ring-1 has-[:checked]:ring-ring"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="runtime-pairing-intent"
|
||||
value={value}
|
||||
checked={intent === value}
|
||||
onChange={() => onIntentChange(value)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="space-y-1">
|
||||
<span className="block text-xs font-medium">
|
||||
{label}
|
||||
{value === 'another' ? (
|
||||
<span className="ml-1.5 text-[11px] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.recommended',
|
||||
'Recommended'
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="block text-[11px] text-muted-foreground">{description}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{intent === 'local' ? (
|
||||
<div className="rounded-md border border-border/60 bg-muted/30 p-3 text-xs">
|
||||
<div className="font-medium">
|
||||
{translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.localLink',
|
||||
'Local-only link'
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.localLinkHelp',
|
||||
'This link only works in a browser or Orca client running on this computer.'
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-2 font-mono">{loopbackAddress}</div>
|
||||
</div>
|
||||
) : intent === 'custom' ? (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="runtime-pairing-custom-address">
|
||||
{translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.custom-title',
|
||||
'Custom connection address'
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="runtime-pairing-custom-address"
|
||||
value={selectedAddress}
|
||||
onChange={(event) => onSelectedAddressChange(event.target.value)}
|
||||
placeholder={translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.45cf476df3',
|
||||
'host, host:port, or wss://host/path'
|
||||
)}
|
||||
className="font-mono"
|
||||
aria-invalid={customAddressInvalid}
|
||||
aria-describedby="runtime-pairing-custom-address-help"
|
||||
autoFocus
|
||||
/>
|
||||
<p
|
||||
id="runtime-pairing-custom-address-help"
|
||||
className={
|
||||
customAddressInvalid ? 'text-xs text-destructive' : 'text-xs text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{customAddressInvalid
|
||||
? translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.customInvalid',
|
||||
'Enter a valid host, host:port, IPv6 address, or ws(s):// URL.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.custom-hint',
|
||||
'Enter a host, host:port, or a ws(s):// URL.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<Label id="runtime-pairing-address-label" htmlFor="runtime-pairing-address">
|
||||
{translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.de77eb1b65',
|
||||
'Connection address'
|
||||
)}
|
||||
options={options}
|
||||
value={selectedAddress}
|
||||
onValueChange={onSelectedAddressChange}
|
||||
placeholder=""
|
||||
customInputId="runtime-pairing-custom-address"
|
||||
formatCustomLabel={(address) =>
|
||||
translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.custom-option',
|
||||
'{{address}} (custom)',
|
||||
{ address }
|
||||
)
|
||||
}
|
||||
addCustomLabel={translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.add-custom',
|
||||
'Add custom address…'
|
||||
)}
|
||||
validateCustom={parseServerShareAddress}
|
||||
customDialogCopy={{
|
||||
title: translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.custom-title',
|
||||
'Custom connection address'
|
||||
),
|
||||
description: translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.custom-description',
|
||||
'Advertise an address another device can reach — a LAN or Tailscale host, or a full ws(s):// URL.'
|
||||
),
|
||||
inputLabel: translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.4531ea3158',
|
||||
'Custom address'
|
||||
),
|
||||
placeholder: translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.45cf476df3',
|
||||
'host, host:port, or wss://host/path'
|
||||
),
|
||||
hint: translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.custom-hint',
|
||||
'Enter a host, host:port, or a ws(s):// URL.'
|
||||
),
|
||||
cancel: translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.custom-cancel',
|
||||
'Cancel'
|
||||
),
|
||||
confirm: translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.custom-use',
|
||||
'Use address'
|
||||
)
|
||||
}}
|
||||
/>
|
||||
{/* Why: server sharing uses the same interface list as Mobile,
|
||||
</Label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<AddressPicker
|
||||
id="runtime-pairing-address"
|
||||
// Why: bounded width so a short value like "This computer
|
||||
// (127.0.0.1)" doesn't stretch the trigger across the whole card;
|
||||
// the value can grow up to the card edge before truncating.
|
||||
className="min-w-[240px] max-w-full"
|
||||
triggerAriaLabel={translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.de77eb1b65',
|
||||
'Connection address'
|
||||
)}
|
||||
options={options}
|
||||
value={selectedAddress}
|
||||
onValueChange={onSelectedAddressChange}
|
||||
placeholder=""
|
||||
customInputId="runtime-pairing-custom-address"
|
||||
formatCustomLabel={(address) =>
|
||||
translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.custom-option',
|
||||
'{{address}} (custom)',
|
||||
{ address }
|
||||
)
|
||||
}
|
||||
addCustomLabel={translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.add-custom',
|
||||
'Use custom address…'
|
||||
)}
|
||||
validateCustom={parseServerShareAddress}
|
||||
customDialogCopy={{
|
||||
title: translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.custom-title',
|
||||
'Custom connection address'
|
||||
),
|
||||
description: translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.custom-description',
|
||||
'Advertise an address another device can reach — a LAN or Tailscale host, or a full ws(s):// URL.'
|
||||
),
|
||||
inputLabel: translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.4531ea3158',
|
||||
'Custom address'
|
||||
),
|
||||
placeholder: translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.45cf476df3',
|
||||
'host, host:port, or wss://host/path'
|
||||
),
|
||||
hint: translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.custom-hint',
|
||||
'Enter a host, host:port, or a ws(s):// URL.'
|
||||
),
|
||||
cancel: translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.custom-cancel',
|
||||
'Cancel'
|
||||
),
|
||||
confirm: translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.custom-use',
|
||||
'Use address'
|
||||
)
|
||||
}}
|
||||
/>
|
||||
{/* Why: server sharing uses the same interface list as Mobile,
|
||||
and VPN/tailnet addresses can appear after Settings opens. */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onRefreshNetworkInterfaces}
|
||||
disabled={refreshingNetworkInterfaces}
|
||||
aria-label={translate(
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onRefreshNetworkInterfaces}
|
||||
disabled={refreshingNetworkInterfaces}
|
||||
aria-label={translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.360c548cf3',
|
||||
'Refresh connection addresses'
|
||||
)}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<RefreshCw className={refreshingNetworkInterfaces ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.360c548cf3',
|
||||
'Refresh connection addresses'
|
||||
)}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<RefreshCw className={refreshingNetworkInterfaces ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{intent === 'another' &&
|
||||
networkInterfaces.length === 0 &&
|
||||
!refreshingNetworkInterfaces ? (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.360c548cf3',
|
||||
'Refresh connection addresses'
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.noExternalAddress',
|
||||
'No address for another device was found. Connect this computer to a LAN or Tailscale, refresh, or choose Custom address.'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.279e0dcb57',
|
||||
'127.0.0.1 only works on this computer. Use a LAN, Tailscale, or custom address for another device.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{staleGeneratedLink && selectedAddress !== '' ? (
|
||||
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs">
|
||||
{translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.staleAddress',
|
||||
'The connection address changed. Generate a new link for {{address}}.',
|
||||
{ address: selectedAddress }
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={onGenerate}
|
||||
disabled={isGeneratingPairing}
|
||||
disabled={isGeneratingPairing || !canGenerate}
|
||||
>
|
||||
{isGeneratingPairing ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
||||
{translate(
|
||||
@@ -171,7 +322,7 @@ export function RuntimePairingGeneratorForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{webClientUrl ? (
|
||||
{generatedIsCurrent && webClientUrl ? (
|
||||
<GeneratedUrlRow
|
||||
label={translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.6b9ca3e69b',
|
||||
@@ -185,7 +336,7 @@ export function RuntimePairingGeneratorForm({
|
||||
copied={copiedTarget === 'web'}
|
||||
onCopy={() => onCopy('web', webClientUrl)}
|
||||
/>
|
||||
) : runtimePairingUrl ? (
|
||||
) : generatedIsCurrent && runtimePairingUrl ? (
|
||||
<UnavailableUrlRow
|
||||
label={translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.6b9ca3e69b',
|
||||
@@ -198,7 +349,7 @@ export function RuntimePairingGeneratorForm({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{runtimePairingUrl ? (
|
||||
{generatedIsCurrent && runtimePairingUrl ? (
|
||||
<GeneratedUrlRow
|
||||
label={translate(
|
||||
'auto.components.settings.RuntimePairingUrlGenerator.2e5c4e3c93',
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listNetworkInterfaces: vi.fn(),
|
||||
listRuntimeAccessGrants: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
|
||||
vi.mock('./RuntimeAccessGrantList', () => ({ RuntimeAccessGrantList: () => null }))
|
||||
vi.mock('./RuntimePairingGeneratorForm', () => ({
|
||||
RuntimePairingGeneratorForm: (props: { selectedAddress: string }) => (
|
||||
<div data-testid="selected-address">{props.selectedAddress}</div>
|
||||
)
|
||||
}))
|
||||
|
||||
import { RuntimePairingUrlGenerator } from './RuntimePairingUrlGenerator'
|
||||
import { runtimePairingLinkCache } from './runtime-pairing-link-state'
|
||||
|
||||
describe('RuntimePairingUrlGenerator', () => {
|
||||
beforeEach(() => {
|
||||
runtimePairingLinkCache.selectedAddress = '100.76.32.125'
|
||||
runtimePairingLinkCache.customAddress = ''
|
||||
runtimePairingLinkCache.intent = 'another'
|
||||
runtimePairingLinkCache.generatedAddress = null
|
||||
runtimePairingLinkCache.runtimePairingUrl = null
|
||||
runtimePairingLinkCache.webClientUrl = null
|
||||
runtimePairingLinkCache.runtimePairingDeviceId = null
|
||||
mocks.listNetworkInterfaces.mockReset()
|
||||
mocks.listRuntimeAccessGrants.mockReset().mockResolvedValue({ grants: [] })
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
mobile: {
|
||||
listNetworkInterfaces: mocks.listNetworkInterfaces,
|
||||
listRuntimeAccessGrants: mocks.listRuntimeAccessGrants
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('keeps the cached address while interfaces are loading', async () => {
|
||||
let resolveInterfaces!: (value: { interfaces: { name: string; address: string }[] }) => void
|
||||
mocks.listNetworkInterfaces.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveInterfaces = resolve
|
||||
})
|
||||
)
|
||||
|
||||
render(<RuntimePairingUrlGenerator />)
|
||||
await waitFor(() => expect(mocks.listNetworkInterfaces).toHaveBeenCalledOnce())
|
||||
expect(screen.getByTestId('selected-address')).toHaveTextContent('100.76.32.125')
|
||||
|
||||
resolveInterfaces({
|
||||
interfaces: [{ name: 'tailscale0', address: '100.76.32.125' }]
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('selected-address')).toHaveTextContent('100.76.32.125')
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -6,28 +6,15 @@ import { Label } from '../ui/label'
|
||||
import { RuntimeAccessGrantList } from './RuntimeAccessGrantList'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { RuntimePairingGeneratorForm } from './RuntimePairingGeneratorForm'
|
||||
|
||||
const LOOPBACK_ADDRESS = '127.0.0.1'
|
||||
|
||||
// Why: runtime pairing tokens stay valid in the main-process registry; keep the
|
||||
// last displayed URL across settings collapse/navigation without less-protected storage.
|
||||
const runtimePairingUrlCache: {
|
||||
selectedAddress: string
|
||||
runtimePairingUrl: string | null
|
||||
webClientUrl: string | null
|
||||
runtimePairingDeviceId: string | null
|
||||
} = {
|
||||
selectedAddress: LOOPBACK_ADDRESS,
|
||||
runtimePairingUrl: null,
|
||||
webClientUrl: null,
|
||||
runtimePairingDeviceId: null
|
||||
}
|
||||
|
||||
type RuntimePairingUrlGeneratorProps = {
|
||||
framed?: boolean
|
||||
showHeader?: boolean
|
||||
showGeneratorForm?: boolean
|
||||
}
|
||||
import {
|
||||
RUNTIME_PAIRING_LOOPBACK_ADDRESS,
|
||||
cacheGeneratedRuntimePairingLink,
|
||||
clearGeneratedRuntimePairingLink,
|
||||
runtimePairingLinkCache,
|
||||
selectRuntimePairingIntent,
|
||||
type RuntimePairingIntent,
|
||||
type RuntimePairingUrlGeneratorProps
|
||||
} from './runtime-pairing-link-state'
|
||||
|
||||
export function RuntimePairingUrlGenerator({
|
||||
framed = true,
|
||||
@@ -37,15 +24,19 @@ export function RuntimePairingUrlGenerator({
|
||||
const [networkInterfaces, setNetworkInterfaces] = useState<{ name: string; address: string }[]>(
|
||||
[]
|
||||
)
|
||||
const [selectedAddress, setSelectedAddress] = useState(runtimePairingUrlCache.selectedAddress)
|
||||
const [selectedAddress, setSelectedAddress] = useState(runtimePairingLinkCache.selectedAddress)
|
||||
const [intent, setIntent] = useState<RuntimePairingIntent>(runtimePairingLinkCache.intent)
|
||||
const [generatedAddress, setGeneratedAddress] = useState<string | null>(
|
||||
runtimePairingLinkCache.generatedAddress
|
||||
)
|
||||
const [runtimePairingUrl, setRuntimePairingUrl] = useState<string | null>(
|
||||
runtimePairingUrlCache.runtimePairingUrl
|
||||
runtimePairingLinkCache.runtimePairingUrl
|
||||
)
|
||||
const [webClientUrl, setWebClientUrl] = useState<string | null>(
|
||||
runtimePairingUrlCache.webClientUrl
|
||||
runtimePairingLinkCache.webClientUrl
|
||||
)
|
||||
const [runtimePairingDeviceId, setRuntimePairingDeviceId] = useState<string | null>(
|
||||
runtimePairingUrlCache.runtimePairingDeviceId
|
||||
runtimePairingLinkCache.runtimePairingDeviceId
|
||||
)
|
||||
const [runtimeAccessGrants, setRuntimeAccessGrants] = useState<RuntimeAccessGrant[]>([])
|
||||
const [isLoadingAccessGrants, setIsLoadingAccessGrants] = useState(false)
|
||||
@@ -154,6 +145,20 @@ export function RuntimePairingUrlGenerator({
|
||||
}
|
||||
}, [loadNetworkInterfaces])
|
||||
|
||||
useEffect(() => {
|
||||
if (intent !== 'another' || networkInterfaces.length === 0) {
|
||||
return
|
||||
}
|
||||
const addressStillAvailable = networkInterfaces.some(
|
||||
(networkInterface) => networkInterface.address === selectedAddress
|
||||
)
|
||||
if (!addressStillAvailable) {
|
||||
const nextAddress = networkInterfaces[0]?.address ?? ''
|
||||
runtimePairingLinkCache.selectedAddress = nextAddress
|
||||
setSelectedAddress(nextAddress)
|
||||
}
|
||||
}, [intent, networkInterfaces, selectedAddress])
|
||||
|
||||
useEffect(() => {
|
||||
void loadRuntimeAccessGrants()
|
||||
return () => {
|
||||
@@ -162,21 +167,26 @@ export function RuntimePairingUrlGenerator({
|
||||
}, [loadRuntimeAccessGrants])
|
||||
|
||||
const clearGeneratedUrls = (): void => {
|
||||
runtimePairingUrlCache.runtimePairingUrl = null
|
||||
runtimePairingUrlCache.webClientUrl = null
|
||||
runtimePairingUrlCache.runtimePairingDeviceId = null
|
||||
clearGeneratedRuntimePairingLink()
|
||||
if (mountedRef.current) {
|
||||
setRuntimePairingUrl(null)
|
||||
setWebClientUrl(null)
|
||||
setRuntimePairingDeviceId(null)
|
||||
setGeneratedAddress(null)
|
||||
}
|
||||
}
|
||||
|
||||
const generateRuntimePairingUrl = async (): Promise<void> => {
|
||||
const address = selectedAddress.trim()
|
||||
runtimePairingLinkCache.selectedAddress = address
|
||||
setSelectedAddress(address)
|
||||
if (intent === 'custom') {
|
||||
runtimePairingLinkCache.customAddress = address
|
||||
}
|
||||
setIsGeneratingPairing(true)
|
||||
try {
|
||||
const result = await window.api.mobile.getRuntimePairingUrl({
|
||||
address: selectedAddress,
|
||||
address,
|
||||
rotate: true
|
||||
})
|
||||
if (!result.available) {
|
||||
@@ -191,13 +201,17 @@ export function RuntimePairingUrlGenerator({
|
||||
}
|
||||
return
|
||||
}
|
||||
runtimePairingUrlCache.runtimePairingUrl = result.pairingUrl
|
||||
runtimePairingUrlCache.webClientUrl = result.webClientUrl
|
||||
runtimePairingUrlCache.runtimePairingDeviceId = result.deviceId
|
||||
cacheGeneratedRuntimePairingLink({
|
||||
address,
|
||||
pairingUrl: result.pairingUrl,
|
||||
webClientUrl: result.webClientUrl,
|
||||
deviceId: result.deviceId
|
||||
})
|
||||
if (mountedRef.current) {
|
||||
setRuntimePairingUrl(result.pairingUrl)
|
||||
setWebClientUrl(result.webClientUrl)
|
||||
setRuntimePairingDeviceId(result.deviceId)
|
||||
setGeneratedAddress(address)
|
||||
}
|
||||
await loadRuntimeAccessGrants()
|
||||
if (mountedRef.current) {
|
||||
@@ -325,8 +339,29 @@ export function RuntimePairingUrlGenerator({
|
||||
const sharedAccessClassName = showGeneratorForm ? 'border-t border-border/40 pt-3' : ''
|
||||
|
||||
const updateSelectedAddress = (address: string): void => {
|
||||
runtimePairingUrlCache.selectedAddress = address
|
||||
runtimePairingLinkCache.selectedAddress = address
|
||||
setSelectedAddress(address)
|
||||
if (
|
||||
intent === 'another' &&
|
||||
!networkInterfaces.some((networkInterface) => networkInterface.address === address)
|
||||
) {
|
||||
runtimePairingLinkCache.customAddress = address
|
||||
runtimePairingLinkCache.intent = 'custom'
|
||||
setIntent('custom')
|
||||
} else if (intent === 'custom') {
|
||||
runtimePairingLinkCache.customAddress = address
|
||||
}
|
||||
}
|
||||
|
||||
const updateIntent = (nextIntent: RuntimePairingIntent): void => {
|
||||
setIntent(nextIntent)
|
||||
setSelectedAddress(
|
||||
selectRuntimePairingIntent(
|
||||
nextIntent,
|
||||
networkInterfaces,
|
||||
runtimePairingLinkCache.customAddress
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -349,7 +384,8 @@ export function RuntimePairingUrlGenerator({
|
||||
) : null}
|
||||
{showGeneratorForm ? (
|
||||
<RuntimePairingGeneratorForm
|
||||
loopbackAddress={LOOPBACK_ADDRESS}
|
||||
intent={intent}
|
||||
loopbackAddress={RUNTIME_PAIRING_LOOPBACK_ADDRESS}
|
||||
networkInterfaces={networkInterfaces}
|
||||
selectedAddress={selectedAddress}
|
||||
refreshingNetworkInterfaces={refreshingNetworkInterfaces}
|
||||
@@ -357,6 +393,8 @@ export function RuntimePairingUrlGenerator({
|
||||
webClientUrl={webClientUrl}
|
||||
runtimePairingUrl={runtimePairingUrl}
|
||||
copiedTarget={copiedTarget}
|
||||
generatedAddress={generatedAddress}
|
||||
onIntentChange={updateIntent}
|
||||
onSelectedAddressChange={updateSelectedAddress}
|
||||
onRefreshNetworkInterfaces={() => void loadNetworkInterfaces({ showToastOnError: true })}
|
||||
onGenerate={() => void generateRuntimePairingUrl()}
|
||||
|
||||
@@ -5,10 +5,7 @@ import { createLocalizedCatalog } from '@/i18n/localized-catalog'
|
||||
|
||||
export const getEphemeralVmsSearchEntry = createLocalizedCatalog(
|
||||
(): SettingsSearchEntry => ({
|
||||
title: translate(
|
||||
'auto.components.settings.ephemeralVms.search.title',
|
||||
'Per-Workspace Environments'
|
||||
),
|
||||
title: translate('auto.components.settings.ephemeralVms.search.cloudVmTitle', 'Cloud VM'),
|
||||
description: translate(
|
||||
'auto.components.settings.ephemeralVms.search.description',
|
||||
'Learn how repo-owned recipes give each workspace its own on-demand, disposable environment.'
|
||||
|
||||
@@ -275,7 +275,7 @@ export function getExperimentalSearchEntry() {
|
||||
)
|
||||
),
|
||||
ephemeralVms: findEntry(
|
||||
translate('auto.components.settings.ephemeralVms.search.title', 'Per-Workspace Environments')
|
||||
translate('auto.components.settings.ephemeralVms.search.cloudVmTitle', 'Cloud VM')
|
||||
)
|
||||
} as const
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
export const RUNTIME_PAIRING_LOOPBACK_ADDRESS = '127.0.0.1'
|
||||
|
||||
export type RuntimePairingIntent = 'another' | 'local' | 'custom'
|
||||
|
||||
export type RuntimePairingUrlGeneratorProps = {
|
||||
framed?: boolean
|
||||
showHeader?: boolean
|
||||
showGeneratorForm?: boolean
|
||||
}
|
||||
|
||||
// Why: pairing tokens remain in the main-process registry, so the last link can
|
||||
// survive settings navigation without writing credential material to storage.
|
||||
export const runtimePairingLinkCache: {
|
||||
selectedAddress: string
|
||||
customAddress: string
|
||||
intent: RuntimePairingIntent
|
||||
generatedAddress: string | null
|
||||
runtimePairingUrl: string | null
|
||||
webClientUrl: string | null
|
||||
runtimePairingDeviceId: string | null
|
||||
} = {
|
||||
selectedAddress: '',
|
||||
customAddress: '',
|
||||
intent: 'another',
|
||||
generatedAddress: null,
|
||||
runtimePairingUrl: null,
|
||||
webClientUrl: null,
|
||||
runtimePairingDeviceId: null
|
||||
}
|
||||
|
||||
export function clearGeneratedRuntimePairingLink(): void {
|
||||
runtimePairingLinkCache.runtimePairingUrl = null
|
||||
runtimePairingLinkCache.webClientUrl = null
|
||||
runtimePairingLinkCache.runtimePairingDeviceId = null
|
||||
runtimePairingLinkCache.generatedAddress = null
|
||||
}
|
||||
|
||||
export function cacheGeneratedRuntimePairingLink(args: {
|
||||
address: string
|
||||
pairingUrl: string
|
||||
webClientUrl: string | null
|
||||
deviceId: string
|
||||
}): void {
|
||||
runtimePairingLinkCache.runtimePairingUrl = args.pairingUrl
|
||||
runtimePairingLinkCache.webClientUrl = args.webClientUrl
|
||||
runtimePairingLinkCache.runtimePairingDeviceId = args.deviceId
|
||||
runtimePairingLinkCache.generatedAddress = args.address
|
||||
}
|
||||
|
||||
export function selectRuntimePairingIntent(
|
||||
intent: RuntimePairingIntent,
|
||||
networkInterfaces: { address: string }[],
|
||||
customAddress: string
|
||||
): string {
|
||||
runtimePairingLinkCache.intent = intent
|
||||
const selectedAddress =
|
||||
intent === 'local'
|
||||
? RUNTIME_PAIRING_LOOPBACK_ADDRESS
|
||||
: intent === 'another'
|
||||
? (networkInterfaces[0]?.address ?? '')
|
||||
: customAddress
|
||||
runtimePairingLinkCache.selectedAddress = selectedAddress
|
||||
return selectedAddress
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -19,6 +19,11 @@ import {
|
||||
type EditingTarget
|
||||
} from '../settings/ssh-target-draft'
|
||||
import { MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, type SshTarget } from '../../../../shared/ssh-types'
|
||||
import { parseHostAccessLink } from '../../../../shared/remote-pairing-address'
|
||||
import {
|
||||
translateHostAccessLinkError,
|
||||
translateRemotePairingFailureDescription
|
||||
} from '@/lib/remote-pairing-copy'
|
||||
import { RemoteServerFields, SshHostFields } from './AddRemoteHostFields'
|
||||
|
||||
export type AddRemoteHostMode = 'ssh' | 'server'
|
||||
@@ -43,12 +48,18 @@ export function AddRemoteHostDialog({
|
||||
const [sshForm, setSshForm] = useState<EditingTarget>(EMPTY_FORM)
|
||||
const [serverName, setServerName] = useState('')
|
||||
const [pairingCode, setPairingCode] = useState('')
|
||||
const [allowLoopback, setAllowLoopback] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isImporting, setIsImporting] = useState(false)
|
||||
const parsedServerLink = useMemo(() => parseHostAccessLink(pairingCode), [pairingCode])
|
||||
const serverFormCanSubmit =
|
||||
serverName.trim() !== '' &&
|
||||
parsedServerLink.ok &&
|
||||
(parsedServerLink.value.endpointKind !== 'loopback' || allowLoopback)
|
||||
const setSshTargetsMetadata = useAppStore((s) => s.setSshTargetsMetadata)
|
||||
const recordSshRepoReadoptions = useAppStore((s) => s.recordSshRepoReadoptions)
|
||||
const setRuntimeEnvironments = useAppStore((s) => s.setRuntimeEnvironments)
|
||||
const refreshRuntimeEnvironmentStatus = useAppStore((s) => s.refreshRuntimeEnvironmentStatus)
|
||||
const setRuntimeEnvironmentStatus = useAppStore((s) => s.setRuntimeEnvironmentStatus)
|
||||
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
|
||||
|
||||
const close = () => {
|
||||
@@ -62,6 +73,7 @@ export function AddRemoteHostDialog({
|
||||
setSshForm(EMPTY_FORM)
|
||||
setServerName('')
|
||||
setPairingCode('')
|
||||
setAllowLoopback(false)
|
||||
}
|
||||
|
||||
const refreshSshTargetMetadata = async () => {
|
||||
@@ -195,16 +207,44 @@ export function AddRemoteHostDialog({
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!parsedServerLink.ok) {
|
||||
toast.error(translateHostAccessLinkError(parsedServerLink.kind))
|
||||
return
|
||||
}
|
||||
if (parsedServerLink.value.endpointKind === 'loopback' && !allowLoopback) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.sidebar.AddRemoteHostDialog.loopbackBlocked',
|
||||
'Enable the SSH tunnel override or create a new link using the other host’s Tailscale or LAN address.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const result = await window.api.runtimeEnvironments.addFromPairingCode({
|
||||
const result = await window.api.runtimeEnvironments.verifyAndAddFromPairingCode({
|
||||
name: trimmedName,
|
||||
pairingCode: trimmedPairingCode
|
||||
pairingCode: trimmedPairingCode,
|
||||
allowLoopback
|
||||
})
|
||||
if (!result.ok) {
|
||||
toast.error(
|
||||
result.kind === 'environment-save-failed'
|
||||
? result.message
|
||||
: translateRemotePairingFailureDescription(
|
||||
result.kind,
|
||||
parsedServerLink.value.displayEndpoint
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
const environments = await window.api.runtimeEnvironments.list()
|
||||
setRuntimeEnvironments(environments)
|
||||
await refreshRuntimeEnvironmentStatus(result.environment.id)
|
||||
setRuntimeEnvironmentStatus(result.environment.id, {
|
||||
status: result.runtimeStatus,
|
||||
checkedAt: Date.now()
|
||||
})
|
||||
toast.success(
|
||||
translate('auto.components.sidebar.AddRemoteHostDialog.serverSaved', 'Remote server added.')
|
||||
)
|
||||
@@ -260,9 +300,15 @@ export function AddRemoteHostDialog({
|
||||
<RemoteServerFields
|
||||
name={serverName}
|
||||
pairingCode={pairingCode}
|
||||
parsedLink={parsedServerLink}
|
||||
disabled={isSaving}
|
||||
onNameChange={setServerName}
|
||||
onPairingCodeChange={setPairingCode}
|
||||
onPairingCodeChange={(value) => {
|
||||
setPairingCode(value)
|
||||
setAllowLoopback(false)
|
||||
}}
|
||||
allowLoopback={allowLoopback}
|
||||
onAllowLoopbackChange={setAllowLoopback}
|
||||
onSubmit={() => void saveRemoteServer()}
|
||||
/>
|
||||
) : (
|
||||
@@ -306,7 +352,9 @@ export function AddRemoteHostDialog({
|
||||
onClick={
|
||||
renderMode === 'server' ? () => void saveRemoteServer() : () => void saveSshHost()
|
||||
}
|
||||
disabled={isSaving || isImporting}
|
||||
disabled={
|
||||
isSaving || isImporting || (renderMode === 'server' && !serverFormCanSubmit)
|
||||
}
|
||||
>
|
||||
{isSaving
|
||||
? translate('auto.components.sidebar.AddRemoteHostDialog.saving', 'Saving...')
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../../../shared/pairing'
|
||||
import { parseHostAccessLink } from '../../../../shared/remote-pairing-address'
|
||||
import { RemoteServerFields } from './AddRemoteHostFields'
|
||||
|
||||
function loopbackAccessLink(): string {
|
||||
return encodePairingOffer({
|
||||
v: PAIRING_OFFER_VERSION,
|
||||
endpoint: 'ws://127.0.0.1:6768',
|
||||
deviceToken: 'token',
|
||||
publicKeyB64: 'key',
|
||||
scope: 'runtime'
|
||||
})
|
||||
}
|
||||
|
||||
describe('RemoteServerFields', () => {
|
||||
it('associates blocked loopback guidance with the access-link input', () => {
|
||||
const pairingCode = loopbackAccessLink()
|
||||
const markup = renderToStaticMarkup(
|
||||
<RemoteServerFields
|
||||
name="Remote workstation"
|
||||
pairingCode={pairingCode}
|
||||
parsedLink={parseHostAccessLink(pairingCode)}
|
||||
disabled={false}
|
||||
onNameChange={vi.fn()}
|
||||
onPairingCodeChange={vi.fn()}
|
||||
allowLoopback={false}
|
||||
onAllowLoopbackChange={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(markup).toContain('aria-invalid="true"')
|
||||
expect(markup).toContain('aria-describedby="add-server-loopback-blocked"')
|
||||
expect(markup).toContain('id="add-server-loopback-blocked"')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,14 @@
|
||||
import { useState } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
translateHostAccessLinkError,
|
||||
translateRemotePairingEndpointKind
|
||||
} from '@/lib/remote-pairing-copy'
|
||||
import type { ParseHostAccessLinkResult } from '../../../../shared/remote-pairing-address'
|
||||
import { applyParsedSshHostInput, type EditingTarget } from '../settings/ssh-target-draft'
|
||||
import { SshHostAdvancedFields } from '../settings/SshHostAdvancedFields'
|
||||
|
||||
@@ -120,18 +127,32 @@ export function SshHostFields({
|
||||
export function RemoteServerFields({
|
||||
name,
|
||||
pairingCode,
|
||||
parsedLink,
|
||||
disabled,
|
||||
onNameChange,
|
||||
onPairingCodeChange,
|
||||
allowLoopback,
|
||||
onAllowLoopbackChange,
|
||||
onSubmit
|
||||
}: {
|
||||
name: string
|
||||
pairingCode: string
|
||||
parsedLink: ParseHostAccessLinkResult
|
||||
disabled: boolean
|
||||
onNameChange: (value: string) => void
|
||||
onPairingCodeChange: (value: string) => void
|
||||
allowLoopback: boolean
|
||||
onAllowLoopbackChange: (value: boolean) => void
|
||||
onSubmit: () => void
|
||||
}) {
|
||||
const inputError = pairingCode.trim() !== '' && !parsedLink.ok
|
||||
const loopbackBlocked =
|
||||
parsedLink.ok && parsedLink.value.endpointKind === 'loopback' && !allowLoopback
|
||||
const pairingCodeDescriptionId = inputError
|
||||
? 'add-server-pairing-code-error'
|
||||
: loopbackBlocked
|
||||
? 'add-server-loopback-blocked'
|
||||
: 'add-server-pairing-code-help'
|
||||
return (
|
||||
<form
|
||||
className="space-y-3"
|
||||
@@ -142,7 +163,7 @@ export function RemoteServerFields({
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="add-server-name">
|
||||
{translate('auto.components.sidebar.AddRemoteHostDialog.serverName', 'Server name')}
|
||||
{translate('auto.components.sidebar.AddRemoteHostDialog.serverName', 'Name in Orca')}
|
||||
</Label>
|
||||
<Input
|
||||
id="add-server-name"
|
||||
@@ -158,10 +179,12 @@ export function RemoteServerFields({
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="add-server-pairing-code">
|
||||
{translate('auto.components.sidebar.AddRemoteHostDialog.pairingCode', 'Pairing code')}
|
||||
{translate('auto.components.sidebar.AddRemoteHostDialog.pairingCode', 'Access link')}
|
||||
</Label>
|
||||
<Input
|
||||
id="add-server-pairing-code"
|
||||
aria-invalid={inputError || loopbackBlocked}
|
||||
aria-describedby={pairingCodeDescriptionId}
|
||||
value={pairingCode}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onPairingCodeChange(event.target.value)}
|
||||
@@ -171,20 +194,63 @@ export function RemoteServerFields({
|
||||
)}
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate('auto.components.sidebar.AddRemoteHostDialog.pairingHelpPrefix', 'Run')}{' '}
|
||||
<span className="font-mono">
|
||||
{translate(
|
||||
'auto.components.sidebar.AddRemoteHostDialog.pairingCommand',
|
||||
'orca serve --pairing-address <host>'
|
||||
)}
|
||||
</span>{' '}
|
||||
<p id="add-server-pairing-code-help" className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.sidebar.AddRemoteHostDialog.pairingHelpSuffix',
|
||||
'on the server and paste the printed pairing URL.'
|
||||
'Create this under Settings → Remote Orca Servers → Share this host on the other computer.'
|
||||
)}
|
||||
</p>
|
||||
{inputError ? (
|
||||
<p id="add-server-pairing-code-error" role="alert" className="text-xs text-destructive">
|
||||
{parsedLink.ok ? null : translateHostAccessLinkError(parsedLink.kind)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{parsedLink.ok ? (
|
||||
<div className="space-y-1 rounded-md border border-border/60 p-3">
|
||||
<div className="flex items-center gap-2 text-xs font-medium">
|
||||
{translate(
|
||||
'auto.components.sidebar.AddRemoteHostDialog.linkDestination',
|
||||
'Link destination'
|
||||
)}
|
||||
<Badge variant="outline">
|
||||
{translateRemotePairingEndpointKind(parsedLink.value.endpointKind)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="font-mono text-sm">{parsedLink.value.displayEndpoint}</div>
|
||||
{parsedLink.value.endpointKind === 'loopback' ? (
|
||||
<label className="mt-2 flex items-start gap-2 text-xs">
|
||||
<Checkbox
|
||||
checked={allowLoopback}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) => onAllowLoopbackChange(checked === true)}
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-medium">
|
||||
{translate(
|
||||
'auto.components.sidebar.AddRemoteHostDialog.sshTunnel',
|
||||
'I am using an SSH tunnel'
|
||||
)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.sidebar.AddRemoteHostDialog.sshTunnelHelp',
|
||||
'Otherwise, this link points back to this device and cannot identify the other computer.'
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{loopbackBlocked ? (
|
||||
<p id="add-server-loopback-blocked" role="alert" className="text-xs text-destructive">
|
||||
{translate(
|
||||
'auto.components.sidebar.AddRemoteHostDialog.loopbackBlocked',
|
||||
'Enable the SSH tunnel override or create a new link using the other host’s Tailscale or LAN address.'
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ describe('settings navigation metadata', () => {
|
||||
expect(sections.find((section) => section.id === 'voice')?.badge).toBeUndefined()
|
||||
})
|
||||
|
||||
it('places per-workspace environments under Experimental instead of as a beta sidebar item', () => {
|
||||
it('places Cloud VM under Experimental instead of as a beta sidebar item', () => {
|
||||
const sections = buildSettingsNavigationMetadata({
|
||||
isMac: false,
|
||||
isWindows: false,
|
||||
@@ -125,7 +125,7 @@ describe('settings navigation metadata', () => {
|
||||
})
|
||||
const experimental = sections.find((section) => section.id === 'experimental')
|
||||
const entry = experimental?.searchEntries.find(
|
||||
(searchEntry) => searchEntry.title === 'Per-Workspace Environments'
|
||||
(searchEntry) => searchEntry.title === 'Cloud VM'
|
||||
)
|
||||
|
||||
expect(sections.map((section) => section.id)).not.toContain('ephemeral-vms')
|
||||
|
||||
@@ -172,7 +172,11 @@
|
||||
},
|
||||
"webPreloadApi": {
|
||||
"aiVaultUnavailableForHost": "Agent Session History is not available for this execution host.",
|
||||
"runtimeEnvironmentManuallyDisconnected": "Runtime environment is manually disconnected."
|
||||
"runtimeEnvironmentManuallyDisconnected": "Runtime environment is manually disconnected.",
|
||||
"loopbackPairingBlocked": "This access link points back to this device.",
|
||||
"remotePairingUnreachable": "Cannot reach Orca at {{endpoint}}.",
|
||||
"remotePairingInvalidDetails": "This access link contains invalid connection details.",
|
||||
"remotePairingSaveFailed": "Orca verified the host but could not save it. Check browser storage and try again."
|
||||
},
|
||||
"web": {
|
||||
"preload": {
|
||||
@@ -374,6 +378,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"remotePairingCopy": {
|
||||
"invalidInput": "Enter an Orca access link or bare pairing code.",
|
||||
"mobileOnly": "This link grants mobile-only access. Generate a link for another Orca client.",
|
||||
"invalidDestination": "This access link contains an invalid destination.",
|
||||
"unsupportedDestination": "This access link contains an unsupported destination.",
|
||||
"nonConnectableDestination": "This access link contains a non-connectable destination.",
|
||||
"loopback": "Loopback",
|
||||
"tailscale": "Tailscale address",
|
||||
"lan": "Private LAN address",
|
||||
"public": "Public address",
|
||||
"custom": "Custom hostname"
|
||||
},
|
||||
"ensure": {
|
||||
"simulator": {
|
||||
"tab": {
|
||||
@@ -5033,7 +5049,11 @@
|
||||
"sshImportFailed": "Failed to import SSH config.",
|
||||
"importing": "Importing...",
|
||||
"importSshConfig": "Import ~/.ssh/config",
|
||||
"advanced": "Advanced"
|
||||
"advanced": "Advanced",
|
||||
"linkDestination": "Link destination",
|
||||
"sshTunnel": "I am using an SSH tunnel",
|
||||
"sshTunnelHelp": "Otherwise, this link points back to this device and cannot identify the other computer.",
|
||||
"loopbackBlocked": "Enable the SSH tunnel override or create a new link using the other host’s Tailscale or LAN address."
|
||||
},
|
||||
"ForgetSshWorkspaceDialog": {
|
||||
"reconnectFailed": "Reconnection failed",
|
||||
@@ -6740,7 +6760,22 @@
|
||||
"updatingServers": "Updating servers…",
|
||||
"orcaVersion": "Orca v{{value0}}",
|
||||
"removeActiveServerBlocked": "Choose another Active Server in Advanced before removing this server.",
|
||||
"removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone."
|
||||
"removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.",
|
||||
"workflow": "Remote server workflow",
|
||||
"connectWorkflow": "Connect to a host",
|
||||
"connectWorkflowHelp": "This app joins another machine",
|
||||
"shareWorkflow": "Share this host",
|
||||
"shareWorkflowHelp": "Other devices join this machine",
|
||||
"troubleshootWorkflow": "Connection troubleshooting",
|
||||
"sshTunnelRequired": "SSH tunnel required",
|
||||
"troubleshootTitle": "Create a new link on the other host",
|
||||
"troubleshootDescription": "A link that uses 127.0.0.1 points back to the device opening it, not the computer that created it.",
|
||||
"troubleshootStepShare": "On the other computer, open Share this host.",
|
||||
"troubleshootStepAddress": "Choose Another device and select its Tailscale or LAN address.",
|
||||
"troubleshootStepRegenerate": "Generate a new access link and use only the newest link here.",
|
||||
"troubleshootTunnel": "Using an SSH local forward? Return to Connect to a host, paste the loopback link, then enable “I am using an SSH tunnel” under Advanced.",
|
||||
"cloudVmWorkflow": "Cloud VM",
|
||||
"cloudVmWorkflowHelp": "Manage recipe-created cloud machines"
|
||||
},
|
||||
"RuntimePairingGeneratedUrlRows": {
|
||||
"0495f68959": "Copy {{value0}}"
|
||||
@@ -6779,7 +6814,20 @@
|
||||
"custom-description": "Advertise an address another device can reach — a LAN or Tailscale host, or a full ws(s):// URL.",
|
||||
"custom-hint": "Enter a host, host:port, or a ws(s):// URL.",
|
||||
"custom-cancel": "Cancel",
|
||||
"custom-use": "Use address"
|
||||
"custom-use": "Use address",
|
||||
"intentQuestion": "Where will this link be opened?",
|
||||
"anotherDevice": "Another device",
|
||||
"anotherDeviceHelp": "Tailscale, LAN, or another reachable address",
|
||||
"localOnly": "This computer only",
|
||||
"localOnlyHelp": "A browser or Orca client on this computer",
|
||||
"customAddress": "Custom address",
|
||||
"customAddressHelp": "SSH tunnel, reverse proxy, or custom hostname",
|
||||
"recommended": "Recommended",
|
||||
"localLink": "Local-only link",
|
||||
"localLinkHelp": "This link only works in a browser or Orca client running on this computer.",
|
||||
"noExternalAddress": "No address for another device was found. Connect this computer to a LAN or Tailscale, refresh, or choose Custom address.",
|
||||
"staleAddress": "The connection address changed. Generate a new link for {{address}}.",
|
||||
"customInvalid": "Enter a valid host, host:port, IPv6 address, or ws(s):// URL."
|
||||
},
|
||||
"Settings": {
|
||||
"3bf149e873": "Project Settings > {{value0}}",
|
||||
@@ -9403,18 +9451,25 @@
|
||||
"empty": "No temporary VM runtimes need cleanup.",
|
||||
"copyCleanup": "Copy command",
|
||||
"retry": "Retry cleanup",
|
||||
"cleanup": "Cleanup"
|
||||
"cleanup": "Cleanup",
|
||||
"cloudVmLoadFailed": "Couldn’t load Cloud VM runtimes.",
|
||||
"cloudVmCleanupFailedToast": "Couldn’t clean up Cloud VM runtime.",
|
||||
"cloudVmMarkedCleaned": "Marked Cloud VM runtime as cleaned.",
|
||||
"cloudVmCleaned": "Cleaned up Cloud VM runtime.",
|
||||
"cloudVmTitle": "Cloud VM runtimes",
|
||||
"cloudVmRefresh": "Refresh Cloud VM runtimes",
|
||||
"cloudVmLoading": "Checking Cloud VM runtimes…",
|
||||
"cloudVmEmptyWithSetup": "No Cloud VM runtimes yet. Create one from a workspace using an environment recipe."
|
||||
},
|
||||
"ephemeralVms": {
|
||||
"search": {
|
||||
"title": "Per-Workspace Environments",
|
||||
"description": "Learn how repo-owned recipes give each workspace its own on-demand, disposable environment."
|
||||
"description": "Learn how repo-owned recipes give each workspace its own on-demand, disposable environment.",
|
||||
"cloudVmTitle": "Cloud VM"
|
||||
}
|
||||
},
|
||||
"EphemeralVmsPane": {
|
||||
"loadError": "Could not load recipes.",
|
||||
"copyError": "Could not copy the prompt.",
|
||||
"skillTitle": "Per-Workspace Environments skill",
|
||||
"skillDescription": "Sets up, builds, authenticates, and validates repo-owned environment recipes.",
|
||||
"whatTitle": "What the skill does, with you",
|
||||
"whatScaffold": "Writes the recipe & scripts for your provider — connected over an Orca server or SSH.",
|
||||
@@ -9425,13 +9480,16 @@
|
||||
"copied": "Copied",
|
||||
"recipes": "Recipes",
|
||||
"recipesHelp": "Recipes from orca.yaml and enabled plugins show up here, ready to launch a workspace on.",
|
||||
"refresh": "Refresh ephemeral VM recipes",
|
||||
"checking": "Checking recipes...",
|
||||
"none": "No recipes found yet."
|
||||
"none": "No recipes found yet.",
|
||||
"cloudVmSkillTitle": "Cloud VM setup skill",
|
||||
"cloudVmRefresh": "Refresh Cloud VM recipes",
|
||||
"cloudVmTerminalTitle": "Cloud VM setup",
|
||||
"cloudVmTerminalAriaLabel": "Cloud VM skill install terminal"
|
||||
},
|
||||
"ephemeralVmsExperimentalSetting": {
|
||||
"description": "Shows setup controls and workspace run targets for repo-owned, on-demand environments.",
|
||||
"toggleLabel": "Toggle per-workspace environments"
|
||||
"cloudVmToggleLabel": "Toggle Cloud VM"
|
||||
},
|
||||
"SourceControlActionRepoOverrideNote": {
|
||||
"agent": "Agent",
|
||||
@@ -9877,6 +9935,52 @@
|
||||
"logs": "plugin logs",
|
||||
"development": "development plugins"
|
||||
}
|
||||
},
|
||||
"RuntimeHostAccessForm": {
|
||||
"getLink": "Get an access link from the other host",
|
||||
"stepOpenShare": "Open Settings → Remote Orca Servers → Share this host.",
|
||||
"stepChooseAddress": "Choose Another device and select a reachable address.",
|
||||
"stepCopyLink": "Generate the link, then copy the “Pair another Orca client” link.",
|
||||
"name": "Name in Orca",
|
||||
"namePlaceholder": "Linux workstation",
|
||||
"nameHelp": "This only changes how the computer appears in Orca.",
|
||||
"accessLink": "Access link",
|
||||
"accessLinkPlaceholder": "orca://pair?code=...",
|
||||
"accessLinkHelp": "Orca shows the destination before connecting. Credentials stay hidden.",
|
||||
"destination": "Link destination",
|
||||
"loopbackTitle": "This link points back to this device",
|
||||
"loopbackDescription": "It uses {{endpoint}}, which points back to the device opening the link—not the other computer that created it.",
|
||||
"loopbackRecovery": "On the other computer, create a new link using Another device and choose its Tailscale or LAN address.",
|
||||
"identityMismatch": "The reached Orca host does not match this access link",
|
||||
"identityMismatchHelp": "Orca reached {{endpoint}}, but that host does not match this link. Generate a new link on the other host.",
|
||||
"invalidLink": "This access link is no longer valid",
|
||||
"invalidLinkHelp": "Generate a new access link on the other host and try again.",
|
||||
"incompatible": "Orca versions are not compatible",
|
||||
"incompatibleHelp": "Update Orca on this device and the other host, then try again.",
|
||||
"interrupted": "Connection interrupted",
|
||||
"interruptedHelp": "The connection stopped during verification. Check the network or SSH tunnel and try again.",
|
||||
"unavailable": "Host unavailable",
|
||||
"unavailableHelp": "Make sure Orca is running on the other host and that the network or SSH tunnel can reach {{endpoint}}.",
|
||||
"advanced": "Advanced",
|
||||
"sshTunnel": "I am using an SSH tunnel to this local address",
|
||||
"sshTunnelHelp": "Keep the tunnel active while using this connection.",
|
||||
"headlessHelp": "Using headless orca serve? Run orca serve --pairing-address <reachable-host> on the other computer.",
|
||||
"cancel": "Cancel",
|
||||
"addWithTunnel": "Add host using tunnel",
|
||||
"addHost": "Add host",
|
||||
"connectionDetails": "Connection details",
|
||||
"endpointKind": "Endpoint kind",
|
||||
"networkConnection": "Network connection",
|
||||
"notAttempted": "Not attempted",
|
||||
"saveFailed": "Could not save the host",
|
||||
"saveFailedHelp": "The host was verified, but Orca could not save it. Check the name and local settings storage, then try again."
|
||||
},
|
||||
"CloudVmSetupGuide": {
|
||||
"title": "Create a Cloud VM",
|
||||
"description": "Cloud VMs are created from environment recipes when you create a workspace.",
|
||||
"setupRecipe": "Set up an environment recipe for your cloud provider.",
|
||||
"createWorkspace": "Create a workspace and select that recipe under Run on.",
|
||||
"openSetup": "Set up environment recipes"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -149,7 +149,10 @@
|
||||
},
|
||||
"webPreloadApi": {
|
||||
"aiVaultUnavailableForHost": "El historial de sesiones de Agents no está disponible para este host de ejecución.",
|
||||
"runtimeEnvironmentManuallyDisconnected": "El entorno de ejecución se ha desconectado manualmente."
|
||||
"runtimeEnvironmentManuallyDisconnected": "El entorno de ejecución se ha desconectado manualmente.",
|
||||
"loopbackPairingBlocked": "Este enlace de acceso apunta de nuevo a este dispositivo.",
|
||||
"remotePairingUnreachable": "No se puede acceder a Orca en {{endpoint}}.",
|
||||
"remotePairingInvalidDetails": "Este enlace de acceso contiene datos de conexión no válidos."
|
||||
},
|
||||
"web": {
|
||||
"preload": {
|
||||
@@ -351,6 +354,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"remotePairingCopy": {
|
||||
"invalidInput": "Introduce un enlace de acceso de Orca o un código de emparejamiento.",
|
||||
"mobileOnly": "Este enlace concede acceso solo para dispositivos móviles. Genera un enlace para otro cliente de Orca.",
|
||||
"invalidDestination": "Este enlace de acceso contiene un destino no válido.",
|
||||
"unsupportedDestination": "Este enlace de acceso contiene un destino no compatible.",
|
||||
"nonConnectableDestination": "Este enlace de acceso contiene un destino al que no se puede conectar.",
|
||||
"loopback": "Bucle local",
|
||||
"tailscale": "Dirección de Tailscale",
|
||||
"lan": "Dirección LAN privada",
|
||||
"public": "Dirección pública",
|
||||
"custom": "Nombre de host personalizado"
|
||||
},
|
||||
"ensure": {
|
||||
"simulator": {
|
||||
"tab": {
|
||||
@@ -5006,7 +5021,11 @@
|
||||
"importing": "Importando...",
|
||||
"importSshConfig": "Importar ~/.ssh/config",
|
||||
"sshPersistenceDefault": "Los terminales remotos en este host seguirán activos hasta que los cierres o restablezcas el relay.",
|
||||
"advanced": "Advanced"
|
||||
"advanced": "Advanced",
|
||||
"linkDestination": "Destino del enlace",
|
||||
"sshTunnel": "Estoy usando un túnel SSH",
|
||||
"sshTunnelHelp": "De lo contrario, este enlace apunta a este dispositivo y no puede identificar el otro equipo.",
|
||||
"loopbackBlocked": "Activa la opción de túnel SSH o crea un enlace nuevo con la dirección de Tailscale o LAN del otro host."
|
||||
},
|
||||
"ForgetSshWorkspaceDialog": {
|
||||
"reconnectFailed": "Error de reconexión",
|
||||
@@ -6676,7 +6695,22 @@
|
||||
"orcaVersion": "Orca v{{value0}}",
|
||||
"removeActiveServerBlocked": "Choose another Active Server in Advanced before removing this server.",
|
||||
"removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.",
|
||||
"updatingServers": "Updating servers…"
|
||||
"updatingServers": "Updating servers…",
|
||||
"workflow": "Flujo de servidor remoto",
|
||||
"connectWorkflow": "Conectarse a un host",
|
||||
"connectWorkflowHelp": "Esta aplicación se conecta a otra máquina",
|
||||
"shareWorkflow": "Compartir este host",
|
||||
"shareWorkflowHelp": "Otros dispositivos se conectan a esta máquina",
|
||||
"troubleshootWorkflow": "Solución de problemas de conexión",
|
||||
"sshTunnelRequired": "Se requiere un túnel SSH",
|
||||
"troubleshootTitle": "Crear un enlace nuevo en el otro host",
|
||||
"troubleshootDescription": "Un enlace que usa 127.0.0.1 apunta al dispositivo que lo abre, no al equipo que lo creó.",
|
||||
"troubleshootStepShare": "En el otro equipo, abre Compartir este host.",
|
||||
"troubleshootStepAddress": "Elige Otro dispositivo y selecciona su dirección de Tailscale o LAN.",
|
||||
"troubleshootStepRegenerate": "Genera un enlace de acceso nuevo y usa aquí solo el más reciente.",
|
||||
"troubleshootTunnel": "¿Usas un reenvío local SSH? Vuelve a Conectarse a un host, pega el enlace de bucle local y activa «Estoy usando un túnel SSH» en Avanzado.",
|
||||
"cloudVmWorkflow": "VM en la nube",
|
||||
"cloudVmWorkflowHelp": "Gestionar máquinas en la nube creadas mediante recetas"
|
||||
},
|
||||
"RuntimePairingGeneratedUrlRows": {
|
||||
"0495f68959": "Copiar {{value0}}"
|
||||
@@ -6715,7 +6749,20 @@
|
||||
"custom-description": "Anuncia una dirección que otro dispositivo pueda alcanzar: un host de LAN o Tailscale, o una URL ws(s):// completa.",
|
||||
"custom-hint": "Introduce un host, host:puerto o una URL ws(s)://.",
|
||||
"custom-cancel": "Cancelar",
|
||||
"custom-use": "Usar dirección"
|
||||
"custom-use": "Usar dirección",
|
||||
"intentQuestion": "¿Dónde se abrirá este enlace?",
|
||||
"anotherDevice": "Otro dispositivo",
|
||||
"anotherDeviceHelp": "Tailscale, LAN u otra dirección accesible",
|
||||
"localOnly": "Solo este equipo",
|
||||
"localOnlyHelp": "Un navegador o cliente Orca en este equipo",
|
||||
"customAddress": "Dirección personalizada",
|
||||
"customAddressHelp": "Túnel SSH, proxy inverso o nombre de host personalizado",
|
||||
"recommended": "Recomendado",
|
||||
"localLink": "Enlace solo local",
|
||||
"localLinkHelp": "Este enlace solo funciona en un navegador o cliente Orca que se ejecute en este equipo.",
|
||||
"noExternalAddress": "No se encontró una dirección para otro dispositivo. Conecta este equipo a una LAN o a Tailscale, actualiza o elige Dirección personalizada.",
|
||||
"staleAddress": "La dirección de conexión cambió. Genera un enlace nuevo para {{address}}.",
|
||||
"customInvalid": "Introduce un host, host:puerto, dirección IPv6 o URL ws(s):// válidos."
|
||||
},
|
||||
"Settings": {
|
||||
"3bf149e873": "Configuración del proyecto > {{value0}}",
|
||||
@@ -9375,18 +9422,25 @@
|
||||
"empty": "No hay runtimes de VM temporales que limpiar.",
|
||||
"copyCleanup": "Copiar comando",
|
||||
"retry": "Reintentar limpieza",
|
||||
"cleanup": "Limpiar"
|
||||
"cleanup": "Limpiar",
|
||||
"cloudVmLoadFailed": "No se pudieron cargar los runtimes de VM en la nube.",
|
||||
"cloudVmCleanupFailedToast": "No se pudo limpiar el runtime de VM en la nube.",
|
||||
"cloudVmMarkedCleaned": "El runtime de VM en la nube se marcó como limpiado.",
|
||||
"cloudVmCleaned": "Se limpió el runtime de VM en la nube.",
|
||||
"cloudVmTitle": "Runtimes de VM en la nube",
|
||||
"cloudVmRefresh": "Actualizar runtimes de VM en la nube",
|
||||
"cloudVmLoading": "Comprobando runtimes de VM en la nube…",
|
||||
"cloudVmEmptyWithSetup": "Aún no hay runtimes de VM en la nube. Crea uno desde un espacio de trabajo mediante una receta de entorno."
|
||||
},
|
||||
"ephemeralVms": {
|
||||
"search": {
|
||||
"title": "Entornos por espacio de trabajo",
|
||||
"description": "Aprende cómo las recetas del repositorio dan a cada espacio de trabajo su propio entorno desechable bajo demanda."
|
||||
"description": "Aprende cómo las recetas del repositorio dan a cada espacio de trabajo su propio entorno desechable bajo demanda.",
|
||||
"cloudVmTitle": "VM en la nube"
|
||||
}
|
||||
},
|
||||
"EphemeralVmsPane": {
|
||||
"loadError": "No se pudieron cargar las recetas.",
|
||||
"copyError": "No se pudo copiar el prompt.",
|
||||
"skillTitle": "Skill de entornos por espacio de trabajo",
|
||||
"skillDescription": "Configura, construye, autentica y valida recetas de entornos del repositorio.",
|
||||
"whatTitle": "Lo que la skill hace contigo",
|
||||
"whatScaffold": "Escribe la receta y los scripts para tu proveedor, conectados mediante un servidor Orca o SSH.",
|
||||
@@ -9397,13 +9451,16 @@
|
||||
"copied": "Copiado",
|
||||
"recipes": "Recetas",
|
||||
"recipesHelp": "Las recetas de orca.yaml y de los plugins activados aparecen aquí, listas para iniciar un espacio de trabajo.",
|
||||
"refresh": "Actualizar recetas de VMs efímeras",
|
||||
"checking": "Comprobando recetas…",
|
||||
"none": "Aún no se han encontrado recetas."
|
||||
"none": "Aún no se han encontrado recetas.",
|
||||
"cloudVmSkillTitle": "Skill de configuración de VM en la nube",
|
||||
"cloudVmRefresh": "Actualizar recetas de VM en la nube",
|
||||
"cloudVmTerminalTitle": "Configuración de VM en la nube",
|
||||
"cloudVmTerminalAriaLabel": "Terminal de instalación de la skill de VM en la nube"
|
||||
},
|
||||
"ephemeralVmsExperimentalSetting": {
|
||||
"description": "Muestra controles de configuración y destinos de ejecución de espacios de trabajo para entornos bajo demanda del repositorio.",
|
||||
"toggleLabel": "Activar o desactivar entornos por espacio de trabajo"
|
||||
"cloudVmToggleLabel": "Activar o desactivar VM en la nube"
|
||||
},
|
||||
"SourceControlActionRepoOverrideNote": {
|
||||
"agent": "Agente",
|
||||
@@ -9849,6 +9906,50 @@
|
||||
"logs": "registros de plugins",
|
||||
"development": "plugins de desarrollo"
|
||||
}
|
||||
},
|
||||
"RuntimeHostAccessForm": {
|
||||
"getLink": "Obtener un enlace de acceso del otro host",
|
||||
"stepOpenShare": "Abre Ajustes → Servidores remotos de Orca → Compartir este host.",
|
||||
"stepChooseAddress": "Elige Otro dispositivo y selecciona una dirección accesible.",
|
||||
"stepCopyLink": "Genera el enlace y, a continuación, copia el enlace «Emparejar otro cliente de Orca».",
|
||||
"name": "Nombre en Orca",
|
||||
"namePlaceholder": "Estación de trabajo Linux",
|
||||
"nameHelp": "Esto solo cambia cómo aparece el equipo en Orca.",
|
||||
"accessLink": "Enlace de acceso",
|
||||
"accessLinkPlaceholder": "orca://pair?code=...",
|
||||
"accessLinkHelp": "Orca muestra el destino antes de conectarse. Las credenciales permanecen ocultas.",
|
||||
"destination": "Destino del enlace",
|
||||
"loopbackTitle": "Este enlace apunta de nuevo a este dispositivo",
|
||||
"loopbackDescription": "Usa {{endpoint}}, que apunta al dispositivo que abre el enlace, no al otro equipo que lo creó.",
|
||||
"loopbackRecovery": "En el otro equipo, crea un enlace nuevo con Otro dispositivo y elige su dirección de Tailscale o LAN.",
|
||||
"identityMismatch": "El host Orca alcanzado no coincide con este enlace de acceso",
|
||||
"identityMismatchHelp": "Orca llegó a {{endpoint}}, pero ese host no coincide con este enlace. Genera un enlace nuevo en el otro host.",
|
||||
"invalidLink": "Este enlace de acceso ya no es válido",
|
||||
"invalidLinkHelp": "Genera un enlace de acceso nuevo en el otro host e inténtalo de nuevo.",
|
||||
"incompatible": "Las versiones de Orca no son compatibles",
|
||||
"incompatibleHelp": "Actualiza Orca en este dispositivo y en el otro host e inténtalo de nuevo.",
|
||||
"interrupted": "Conexión interrumpida",
|
||||
"interruptedHelp": "La conexión se detuvo durante la verificación. Comprueba la red o el túnel SSH e inténtalo de nuevo.",
|
||||
"unavailable": "Host no disponible",
|
||||
"unavailableHelp": "Asegúrate de que Orca se esté ejecutando en el otro host y de que la red o el túnel SSH puedan acceder a {{endpoint}}.",
|
||||
"advanced": "Avanzado",
|
||||
"sshTunnel": "Estoy usando un túnel SSH hacia esta dirección local",
|
||||
"sshTunnelHelp": "Mantén el túnel activo mientras usas esta conexión.",
|
||||
"headlessHelp": "¿Usas orca serve sin interfaz? Ejecuta orca serve --pairing-address <host-accesible> en el otro equipo.",
|
||||
"cancel": "Cancelar",
|
||||
"addWithTunnel": "Añadir host mediante túnel",
|
||||
"addHost": "Añadir host",
|
||||
"connectionDetails": "Detalles de conexión",
|
||||
"endpointKind": "Tipo de destino",
|
||||
"networkConnection": "Conexión de red",
|
||||
"notAttempted": "No se intentó"
|
||||
},
|
||||
"CloudVmSetupGuide": {
|
||||
"title": "Crear una VM en la nube",
|
||||
"description": "Las VMs en la nube se crean a partir de recetas de entorno al crear un espacio de trabajo.",
|
||||
"setupRecipe": "Configura una receta de entorno para tu proveedor de nube.",
|
||||
"createWorkspace": "Crea un espacio de trabajo y selecciona esa receta en Ejecutar en.",
|
||||
"openSetup": "Configurar recetas de entorno"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -149,7 +149,10 @@
|
||||
},
|
||||
"webPreloadApi": {
|
||||
"aiVaultUnavailableForHost": "この実行ホストでは Agent セッション履歴を使用できません。",
|
||||
"runtimeEnvironmentManuallyDisconnected": "ランタイム環境は手動で切断されています。"
|
||||
"runtimeEnvironmentManuallyDisconnected": "ランタイム環境は手動で切断されています。",
|
||||
"loopbackPairingBlocked": "このアクセスリンクはこのデバイス自身を指しています。",
|
||||
"remotePairingUnreachable": "{{endpoint}} の Orca に接続できません。",
|
||||
"remotePairingInvalidDetails": "このアクセスリンクには無効な接続情報が含まれています。"
|
||||
},
|
||||
"web": {
|
||||
"preload": {
|
||||
@@ -351,6 +354,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"remotePairingCopy": {
|
||||
"invalidInput": "Orca のアクセスリンクまたはペアリングコードを入力してください。",
|
||||
"mobileOnly": "このリンクはモバイル専用アクセスを許可します。別の Orca クライアント用のリンクを生成してください。",
|
||||
"invalidDestination": "このアクセスリンクには無効な接続先が含まれています。",
|
||||
"unsupportedDestination": "このアクセスリンクには未対応の接続先が含まれています。",
|
||||
"nonConnectableDestination": "このアクセスリンクには接続できない接続先が含まれています。",
|
||||
"loopback": "ループバック",
|
||||
"tailscale": "Tailscale アドレス",
|
||||
"lan": "プライベート LAN アドレス",
|
||||
"public": "パブリックアドレス",
|
||||
"custom": "カスタムホスト名"
|
||||
},
|
||||
"ensure": {
|
||||
"simulator": {
|
||||
"tab": {
|
||||
@@ -5006,7 +5021,11 @@
|
||||
"importing": "インポート中...",
|
||||
"importSshConfig": "~/.ssh/config をインポートします",
|
||||
"sshPersistenceDefault": "このホスト上のリモートターミナルは、終了するかリレーをリセットするまで動作し続けます。",
|
||||
"advanced": "Advanced"
|
||||
"advanced": "詳細",
|
||||
"linkDestination": "リンク先",
|
||||
"sshTunnel": "SSH トンネルを使用しています",
|
||||
"sshTunnelHelp": "それ以外の場合、このリンクはこのデバイス自身を指すため、もう一方のコンピューターを識別できません。",
|
||||
"loopbackBlocked": "SSH トンネルの上書きを有効にするか、もう一方のホストの Tailscale または LAN アドレスを使って新しいリンクを作成してください。"
|
||||
},
|
||||
"ForgetSshWorkspaceDialog": {
|
||||
"reconnectFailed": "再接続に失敗しました",
|
||||
@@ -6698,7 +6717,22 @@
|
||||
"orcaVersion": "Orca v{{value0}}",
|
||||
"removeActiveServerBlocked": "Choose another Active Server in Advanced before removing this server.",
|
||||
"removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.",
|
||||
"updatingServers": "Updating servers…"
|
||||
"updatingServers": "Updating servers…",
|
||||
"workflow": "リモートサーバーワークフロー",
|
||||
"connectWorkflow": "ホストに接続",
|
||||
"connectWorkflowHelp": "このアプリを別のマシンに接続します",
|
||||
"shareWorkflow": "このホストを共有",
|
||||
"shareWorkflowHelp": "他のデバイスをこのマシンに接続します",
|
||||
"troubleshootWorkflow": "接続のトラブルシューティング",
|
||||
"sshTunnelRequired": "SSH トンネルが必要です",
|
||||
"troubleshootTitle": "もう一方のホストで新しいリンクを作成",
|
||||
"troubleshootDescription": "127.0.0.1 を使うリンクは、作成したコンピューターではなく、リンクを開いたデバイス自身を指します。",
|
||||
"troubleshootStepShare": "もう一方のコンピューターで「このホストを共有」を開きます。",
|
||||
"troubleshootStepAddress": "「別のデバイス」を選び、Tailscale または LAN アドレスを選択します。",
|
||||
"troubleshootStepRegenerate": "新しいアクセスリンクを生成し、ここでは最新のリンクだけを使います。",
|
||||
"troubleshootTunnel": "SSH ローカルフォワードを使う場合は、「ホストに接続」に戻ってループバックリンクを貼り付け、「詳細」で「SSH トンネルを使用しています」を有効にします。",
|
||||
"cloudVmWorkflow": "クラウド VM",
|
||||
"cloudVmWorkflowHelp": "レシピで作成したクラウドマシンを管理"
|
||||
},
|
||||
"RuntimePairingGeneratedUrlRows": {
|
||||
"0495f68959": "{{value0}}をコピー"
|
||||
@@ -6737,7 +6771,20 @@
|
||||
"custom-description": "他のデバイスから到達できるアドレスを指定します。LAN や Tailscale のホスト、または完全な ws(s):// URL です。",
|
||||
"custom-hint": "ホスト、host:port、または ws(s):// URL を入力してください。",
|
||||
"custom-cancel": "キャンセル",
|
||||
"custom-use": "アドレスを使用"
|
||||
"custom-use": "アドレスを使用",
|
||||
"intentQuestion": "このリンクはどこで開きますか?",
|
||||
"anotherDevice": "別のデバイス",
|
||||
"anotherDeviceHelp": "Tailscale、LAN、または到達可能な別のアドレス",
|
||||
"localOnly": "このコンピューターのみ",
|
||||
"localOnlyHelp": "このコンピューター上のブラウザーまたは Orca クライアント",
|
||||
"customAddress": "カスタムアドレス",
|
||||
"customAddressHelp": "SSH トンネル、リバースプロキシ、またはカスタムホスト名",
|
||||
"recommended": "推奨",
|
||||
"localLink": "ローカル専用リンク",
|
||||
"localLinkHelp": "このリンクは、このコンピューターで実行中のブラウザーまたは Orca クライアントでのみ使えます。",
|
||||
"noExternalAddress": "別のデバイス用のアドレスが見つかりません。このコンピューターを LAN または Tailscale に接続して更新するか、カスタムアドレスを選んでください。",
|
||||
"staleAddress": "接続アドレスが変更されました。{{address}} 用の新しいリンクを生成してください。",
|
||||
"customInvalid": "有効なホスト、ホスト:ポート、IPv6 アドレス、または ws(s):// URL を入力してください。"
|
||||
},
|
||||
"Settings": {
|
||||
"3bf149e873": "プロジェクト設定 > {{value0}}",
|
||||
@@ -9375,18 +9422,25 @@
|
||||
"empty": "一時的な VM ランタイムにはクリーンアップが必要ありません。",
|
||||
"copyCleanup": "コピーコマンド",
|
||||
"retry": "クリーンアップを再試行します",
|
||||
"cleanup": "掃除"
|
||||
"cleanup": "クリーンアップ",
|
||||
"cloudVmLoadFailed": "クラウド VM ランタイムを読み込めませんでした。",
|
||||
"cloudVmCleanupFailedToast": "クラウド VM ランタイムをクリーンアップできませんでした。",
|
||||
"cloudVmMarkedCleaned": "クラウド VM ランタイムをクリーンアップ済みとして記録しました。",
|
||||
"cloudVmCleaned": "クラウド VM ランタイムをクリーンアップしました。",
|
||||
"cloudVmTitle": "クラウド VM ランタイム",
|
||||
"cloudVmRefresh": "クラウド VM ランタイムを更新",
|
||||
"cloudVmLoading": "クラウド VM ランタイムを確認中…",
|
||||
"cloudVmEmptyWithSetup": "クラウド VM ランタイムはまだありません。環境レシピを使用してワークスペースから作成してください。"
|
||||
},
|
||||
"ephemeralVms": {
|
||||
"search": {
|
||||
"title": "ワークスペースごとの環境",
|
||||
"description": "repo 所有のレシピが各ワークスペースに独自のオンデマンドの使い捨て環境を与える方法を学びましょう。"
|
||||
"description": "repo 所有のレシピが各ワークスペースに独自のオンデマンドの使い捨て環境を与える方法を学びましょう。",
|
||||
"cloudVmTitle": "クラウド VM"
|
||||
}
|
||||
},
|
||||
"EphemeralVmsPane": {
|
||||
"loadError": "レシピを読み込めませんでした。",
|
||||
"copyError": "プロンプトをコピーできませんでした。",
|
||||
"skillTitle": "ワークスペースごとの環境スキル",
|
||||
"skillDescription": "repo が所有する環境レシピをセットアップ、構築、認証、および検証します。",
|
||||
"whatTitle": "スキルが何をするのか、あなたと一緒に",
|
||||
"whatScaffold": "Orca サーバーまたは SSH 経由で接続されているプロバイダーのレシピとスクリプトを作成します。",
|
||||
@@ -9397,13 +9451,16 @@
|
||||
"copied": "コピーされました",
|
||||
"recipes": "レシピ",
|
||||
"recipesHelp": "orca.yaml と有効なプラグインのレシピがここに表示され、ワークスペースを起動できます。",
|
||||
"refresh": "一時的な VM レシピを更新する",
|
||||
"checking": "レシピを確認中...",
|
||||
"none": "レシピはまだ見つかりません。"
|
||||
"none": "レシピはまだ見つかりません。",
|
||||
"cloudVmSkillTitle": "クラウド VM セットアップスキル",
|
||||
"cloudVmRefresh": "クラウド VM レシピを更新",
|
||||
"cloudVmTerminalTitle": "クラウド VM のセットアップ",
|
||||
"cloudVmTerminalAriaLabel": "クラウド VM スキルインストールターミナル"
|
||||
},
|
||||
"ephemeralVmsExperimentalSetting": {
|
||||
"description": "repo が所有するオンデマンド環境のセットアップ コントロールとワークスペース実行ターゲットを示します。",
|
||||
"toggleLabel": "ワークスペースごとの環境を切り替える"
|
||||
"cloudVmToggleLabel": "クラウド VM を切り替える"
|
||||
},
|
||||
"SourceControlActionRepoOverrideNote": {
|
||||
"agent": "エージェント",
|
||||
@@ -9849,6 +9906,50 @@
|
||||
"logs": "プラグインログ",
|
||||
"development": "開発プラグイン"
|
||||
}
|
||||
},
|
||||
"RuntimeHostAccessForm": {
|
||||
"getLink": "もう一方のホストからアクセスリンクを取得",
|
||||
"stepOpenShare": "設定 → リモート Orca サーバー → このホストを共有 を開きます。",
|
||||
"stepChooseAddress": "「別のデバイス」を選び、到達可能なアドレスを選択します。",
|
||||
"stepCopyLink": "リンクを生成し、「別の Orca クライアントをペアリング」リンクをコピーします。",
|
||||
"name": "Orca での名前",
|
||||
"namePlaceholder": "Linux ワークステーション",
|
||||
"nameHelp": "これは Orca でのコンピューターの表示名だけを変更します。",
|
||||
"accessLink": "アクセスリンク",
|
||||
"accessLinkPlaceholder": "orca://pair?code=...",
|
||||
"accessLinkHelp": "Orca は接続前に接続先を表示します。認証情報は非表示のままです。",
|
||||
"destination": "リンク先",
|
||||
"loopbackTitle": "このリンクはこのデバイス自身を指しています",
|
||||
"loopbackDescription": "{{endpoint}} はリンクを開いたデバイス自身を指し、リンクを作成したもう一方のコンピューターには接続しません。",
|
||||
"loopbackRecovery": "もう一方のコンピューターで「別のデバイス」を使って新しいリンクを作成し、Tailscale または LAN アドレスを選んでください。",
|
||||
"identityMismatch": "接続した Orca ホストがこのアクセスリンクと一致しません",
|
||||
"identityMismatchHelp": "{{endpoint}} の Orca に接続しましたが、そのホストはこのリンクと一致しません。もう一方のホストで新しいリンクを生成してください。",
|
||||
"invalidLink": "このアクセスリンクは無効になっています",
|
||||
"invalidLinkHelp": "もう一方のホストで新しいアクセスリンクを生成し、もう一度お試しください。",
|
||||
"incompatible": "Orca のバージョンに互換性がありません",
|
||||
"incompatibleHelp": "このデバイスともう一方のホストの Orca を更新し、もう一度お試しください。",
|
||||
"interrupted": "接続が中断されました",
|
||||
"interruptedHelp": "検証中に接続が停止しました。ネットワークまたは SSH トンネルを確認して、もう一度お試しください。",
|
||||
"unavailable": "ホストを利用できません",
|
||||
"unavailableHelp": "もう一方のホストで Orca が実行中であり、ネットワークまたは SSH トンネルから {{endpoint}} に到達できることを確認してください。",
|
||||
"advanced": "詳細",
|
||||
"sshTunnel": "このローカルアドレスへの SSH トンネルを使用しています",
|
||||
"sshTunnelHelp": "この接続を使用している間はトンネルを維持してください。",
|
||||
"headlessHelp": "ヘッドレスの orca serve を使う場合は、もう一方のコンピューターで orca serve --pairing-address <reachable-host> を実行してください。",
|
||||
"cancel": "キャンセル",
|
||||
"addWithTunnel": "トンネルを使ってホストを追加",
|
||||
"addHost": "ホストを追加",
|
||||
"connectionDetails": "接続の詳細",
|
||||
"endpointKind": "接続先の種類",
|
||||
"networkConnection": "ネットワーク接続",
|
||||
"notAttempted": "未試行"
|
||||
},
|
||||
"CloudVmSetupGuide": {
|
||||
"title": "クラウド VM を作成",
|
||||
"description": "クラウド VM は、ワークスペースの作成時に環境レシピから作成されます。",
|
||||
"setupRecipe": "クラウドプロバイダー用の環境レシピをセットアップします。",
|
||||
"createWorkspace": "ワークスペースを作成し、「実行先」でそのレシピを選択します。",
|
||||
"openSetup": "環境レシピをセットアップ"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -149,7 +149,10 @@
|
||||
},
|
||||
"webPreloadApi": {
|
||||
"aiVaultUnavailableForHost": "이 실행 호스트에서는 Agent 세션 기록을 사용할 수 없습니다.",
|
||||
"runtimeEnvironmentManuallyDisconnected": "런타임 환경이 수동으로 연결 해제되었습니다."
|
||||
"runtimeEnvironmentManuallyDisconnected": "런타임 환경이 수동으로 연결 해제되었습니다.",
|
||||
"loopbackPairingBlocked": "이 액세스 링크는 이 기기 자체를 가리킵니다.",
|
||||
"remotePairingUnreachable": "{{endpoint}}의 Orca에 연결할 수 없습니다.",
|
||||
"remotePairingInvalidDetails": "이 액세스 링크에 잘못된 연결 정보가 포함되어 있습니다."
|
||||
},
|
||||
"web": {
|
||||
"preload": {
|
||||
@@ -351,6 +354,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"remotePairingCopy": {
|
||||
"invalidInput": "Orca 액세스 링크 또는 페어링 코드를 입력하세요.",
|
||||
"mobileOnly": "이 링크는 모바일 전용 액세스를 허용합니다. 다른 Orca 클라이언트용 링크를 생성하세요.",
|
||||
"invalidDestination": "이 액세스 링크에 잘못된 대상이 포함되어 있습니다.",
|
||||
"unsupportedDestination": "이 액세스 링크에 지원되지 않는 대상이 포함되어 있습니다.",
|
||||
"nonConnectableDestination": "이 액세스 링크에 연결할 수 없는 대상이 포함되어 있습니다.",
|
||||
"loopback": "루프백",
|
||||
"tailscale": "Tailscale 주소",
|
||||
"lan": "사설 LAN 주소",
|
||||
"public": "공용 주소",
|
||||
"custom": "사용자 지정 호스트 이름"
|
||||
},
|
||||
"ensure": {
|
||||
"simulator": {
|
||||
"tab": {
|
||||
@@ -5006,7 +5021,11 @@
|
||||
"importing": "가져오는 중...",
|
||||
"importSshConfig": "~/.ssh/config 가져오기",
|
||||
"sshPersistenceDefault": "이 호스트의 원격 터미널은 종료하거나 릴레이를 재설정할 때까지 계속 실행됩니다.",
|
||||
"advanced": "Advanced"
|
||||
"advanced": "고급",
|
||||
"linkDestination": "링크 대상",
|
||||
"sshTunnel": "SSH 터널을 사용하고 있습니다",
|
||||
"sshTunnelHelp": "그렇지 않으면 이 링크는 이 기기 자체를 가리키므로 다른 컴퓨터를 식별할 수 없습니다.",
|
||||
"loopbackBlocked": "SSH 터널 재정의를 활성화하거나 다른 호스트의 Tailscale 또는 LAN 주소를 사용해 새 링크를 만드세요."
|
||||
},
|
||||
"ForgetSshWorkspaceDialog": {
|
||||
"reconnectFailed": "재연결 실패",
|
||||
@@ -6661,7 +6680,22 @@
|
||||
"orcaVersion": "Orca v{{value0}}",
|
||||
"removeActiveServerBlocked": "Choose another Active Server in Advanced before removing this server.",
|
||||
"removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.",
|
||||
"updatingServers": "Updating servers…"
|
||||
"updatingServers": "Updating servers…",
|
||||
"workflow": "원격 서버 워크플로",
|
||||
"connectWorkflow": "호스트에 연결",
|
||||
"connectWorkflowHelp": "이 앱을 다른 머신에 연결합니다",
|
||||
"shareWorkflow": "이 호스트 공유",
|
||||
"shareWorkflowHelp": "다른 기기를 이 머신에 연결합니다",
|
||||
"troubleshootWorkflow": "연결 문제 해결",
|
||||
"sshTunnelRequired": "SSH 터널 필요",
|
||||
"troubleshootTitle": "다른 호스트에서 새 링크 만들기",
|
||||
"troubleshootDescription": "127.0.0.1을 사용하는 링크는 링크를 만든 컴퓨터가 아니라 링크를 여는 기기 자체를 가리킵니다.",
|
||||
"troubleshootStepShare": "다른 컴퓨터에서 ‘이 호스트 공유’를 엽니다.",
|
||||
"troubleshootStepAddress": "‘다른 기기’를 선택하고 Tailscale 또는 LAN 주소를 선택합니다.",
|
||||
"troubleshootStepRegenerate": "새 액세스 링크를 생성하고 여기에서는 가장 최신 링크만 사용합니다.",
|
||||
"troubleshootTunnel": "SSH 로컬 포워딩을 사용하나요? ‘호스트에 연결’로 돌아가 루프백 링크를 붙여 넣은 다음 ‘고급’에서 ‘SSH 터널을 사용하고 있습니다’를 활성화하세요.",
|
||||
"cloudVmWorkflow": "클라우드 VM",
|
||||
"cloudVmWorkflowHelp": "레시피로 생성한 클라우드 머신 관리"
|
||||
},
|
||||
"RuntimePairingGeneratedUrlRows": {
|
||||
"0495f68959": "{{value0}} 복사"
|
||||
@@ -6700,7 +6734,20 @@
|
||||
"custom-description": "다른 기기가 연결할 수 있는 주소를 알립니다. LAN 또는 Tailscale 호스트, 또는 전체 ws(s):// URL입니다.",
|
||||
"custom-hint": "호스트, host:port 또는 ws(s):// URL을 입력하세요.",
|
||||
"custom-cancel": "취소",
|
||||
"custom-use": "주소 사용"
|
||||
"custom-use": "주소 사용",
|
||||
"intentQuestion": "이 링크를 어디에서 열 예정인가요?",
|
||||
"anotherDevice": "다른 기기",
|
||||
"anotherDeviceHelp": "Tailscale, LAN 또는 연결 가능한 다른 주소",
|
||||
"localOnly": "이 컴퓨터만",
|
||||
"localOnlyHelp": "이 컴퓨터의 브라우저 또는 Orca 클라이언트",
|
||||
"customAddress": "사용자 지정 주소",
|
||||
"customAddressHelp": "SSH 터널, 리버스 프록시 또는 사용자 지정 호스트 이름",
|
||||
"recommended": "권장",
|
||||
"localLink": "로컬 전용 링크",
|
||||
"localLinkHelp": "이 링크는 이 컴퓨터에서 실행 중인 브라우저 또는 Orca 클라이언트에서만 작동합니다.",
|
||||
"noExternalAddress": "다른 기기용 주소를 찾지 못했습니다. 이 컴퓨터를 LAN 또는 Tailscale에 연결한 후 새로 고치거나 사용자 지정 주소를 선택하세요.",
|
||||
"staleAddress": "연결 주소가 변경되었습니다. {{address}}용 새 링크를 생성하세요.",
|
||||
"customInvalid": "올바른 호스트, 호스트:포트, IPv6 주소 또는 ws(s):// URL을 입력하세요."
|
||||
},
|
||||
"Settings": {
|
||||
"3bf149e873": "프로젝트 설정 > {{value0}}",
|
||||
@@ -9375,18 +9422,25 @@
|
||||
"empty": "임시 VM 런타임은 정리가 필요하지 않습니다.",
|
||||
"copyCleanup": "복사 명령",
|
||||
"retry": "정리 다시 시도",
|
||||
"cleanup": "대청소"
|
||||
"cleanup": "정리",
|
||||
"cloudVmLoadFailed": "클라우드 VM 런타임을 불러올 수 없습니다.",
|
||||
"cloudVmCleanupFailedToast": "클라우드 VM 런타임을 정리할 수 없습니다.",
|
||||
"cloudVmMarkedCleaned": "클라우드 VM 런타임을 정리됨으로 표시했습니다.",
|
||||
"cloudVmCleaned": "클라우드 VM 런타임을 정리했습니다.",
|
||||
"cloudVmTitle": "클라우드 VM 런타임",
|
||||
"cloudVmRefresh": "클라우드 VM 런타임 새로 고침",
|
||||
"cloudVmLoading": "클라우드 VM 런타임 확인 중…",
|
||||
"cloudVmEmptyWithSetup": "아직 클라우드 VM 런타임이 없습니다. 환경 레시피를 사용해 워크스페이스에서 생성하세요."
|
||||
},
|
||||
"ephemeralVms": {
|
||||
"search": {
|
||||
"title": "워크스페이스별 환경",
|
||||
"description": "repo 소유 레시피가 각 워크스페이스에 고유한 주문형 일회용 환경을 제공하는 방법을 알아보세요."
|
||||
"description": "repo 소유 레시피가 각 워크스페이스에 고유한 주문형 일회용 환경을 제공하는 방법을 알아보세요.",
|
||||
"cloudVmTitle": "클라우드 VM"
|
||||
}
|
||||
},
|
||||
"EphemeralVmsPane": {
|
||||
"loadError": "레시피를 로드할 수 없습니다.",
|
||||
"copyError": "프롬프트를 복사할 수 없습니다.",
|
||||
"skillTitle": "워크스페이스별 환경 기술",
|
||||
"skillDescription": "repo 소유 환경 레시피를 설정, 빌드, 인증 및 검증합니다.",
|
||||
"whatTitle": "당신과 함께 기술이 하는 일",
|
||||
"whatScaffold": "Orca 서버 또는 SSH를 통해 연결된 공급자를 위한 레시피 및 스크립트를 작성합니다.",
|
||||
@@ -9397,13 +9451,16 @@
|
||||
"copied": "복사됨",
|
||||
"recipes": "조리법",
|
||||
"recipesHelp": "orca.yaml과 활성화된 플러그인의 레시피가 여기에 표시되어 워크스페이스를 시작할 수 있습니다.",
|
||||
"refresh": "임시 VM 레시피 새로 고침",
|
||||
"checking": "레시피 확인 중...",
|
||||
"none": "아직 레시피를 찾을 수 없습니다."
|
||||
"none": "아직 레시피를 찾을 수 없습니다.",
|
||||
"cloudVmSkillTitle": "클라우드 VM 설정 스킬",
|
||||
"cloudVmRefresh": "클라우드 VM 레시피 새로 고침",
|
||||
"cloudVmTerminalTitle": "클라우드 VM 설정",
|
||||
"cloudVmTerminalAriaLabel": "클라우드 VM 스킬 설치 터미널"
|
||||
},
|
||||
"ephemeralVmsExperimentalSetting": {
|
||||
"description": "repo 소유 온디맨드 환경에 대한 설정 제어 및 워크스페이스 실행 대상을 표시합니다.",
|
||||
"toggleLabel": "워크스페이스별 환경 전환"
|
||||
"cloudVmToggleLabel": "클라우드 VM 전환"
|
||||
},
|
||||
"SourceControlActionRepoOverrideNote": {
|
||||
"agent": "에이전트",
|
||||
@@ -9849,6 +9906,50 @@
|
||||
"logs": "플러그인 로그",
|
||||
"development": "개발 플러그인"
|
||||
}
|
||||
},
|
||||
"RuntimeHostAccessForm": {
|
||||
"getLink": "다른 호스트에서 액세스 링크 가져오기",
|
||||
"stepOpenShare": "설정 → 원격 Orca 서버 → 이 호스트 공유를 엽니다.",
|
||||
"stepChooseAddress": "‘다른 기기’를 선택하고 연결 가능한 주소를 선택합니다.",
|
||||
"stepCopyLink": "링크를 생성한 다음 “다른 Orca 클라이언트 페어링” 링크를 복사하세요.",
|
||||
"name": "Orca에서 사용할 이름",
|
||||
"namePlaceholder": "Linux 워크스테이션",
|
||||
"nameHelp": "Orca에서 이 컴퓨터가 표시되는 이름만 변경합니다.",
|
||||
"accessLink": "액세스 링크",
|
||||
"accessLinkPlaceholder": "orca://pair?code=...",
|
||||
"accessLinkHelp": "Orca는 연결하기 전에 대상을 표시하며 자격 증명은 숨겨 둡니다.",
|
||||
"destination": "링크 대상",
|
||||
"loopbackTitle": "이 링크는 이 기기 자체를 가리킵니다",
|
||||
"loopbackDescription": "{{endpoint}}는 링크를 연 기기 자체를 가리키며, 링크를 만든 다른 컴퓨터에는 연결되지 않습니다.",
|
||||
"loopbackRecovery": "다른 컴퓨터에서 ‘다른 기기’를 사용해 새 링크를 만들고 Tailscale 또는 LAN 주소를 선택하세요.",
|
||||
"identityMismatch": "연결된 Orca 호스트가 이 액세스 링크와 일치하지 않습니다",
|
||||
"identityMismatchHelp": "{{endpoint}}의 Orca에 연결했지만 해당 호스트가 이 링크와 일치하지 않습니다. 다른 호스트에서 새 링크를 생성하세요.",
|
||||
"invalidLink": "이 액세스 링크는 더 이상 유효하지 않습니다",
|
||||
"invalidLinkHelp": "다른 호스트에서 새 액세스 링크를 생성한 후 다시 시도하세요.",
|
||||
"incompatible": "Orca 버전이 호환되지 않습니다",
|
||||
"incompatibleHelp": "이 기기와 다른 호스트의 Orca를 업데이트한 후 다시 시도하세요.",
|
||||
"interrupted": "연결이 중단되었습니다",
|
||||
"interruptedHelp": "확인 중 연결이 중단되었습니다. 네트워크 또는 SSH 터널을 확인한 후 다시 시도하세요.",
|
||||
"unavailable": "호스트를 사용할 수 없습니다",
|
||||
"unavailableHelp": "다른 호스트에서 Orca가 실행 중이고 네트워크 또는 SSH 터널을 통해 {{endpoint}}에 연결할 수 있는지 확인하세요.",
|
||||
"advanced": "고급",
|
||||
"sshTunnel": "이 로컬 주소에 SSH 터널을 사용하고 있습니다",
|
||||
"sshTunnelHelp": "이 연결을 사용하는 동안 터널을 유지하세요.",
|
||||
"headlessHelp": "헤드리스 orca serve를 사용하나요? 다른 컴퓨터에서 orca serve --pairing-address <reachable-host>를 실행하세요.",
|
||||
"cancel": "취소",
|
||||
"addWithTunnel": "터널을 사용해 호스트 추가",
|
||||
"addHost": "호스트 추가",
|
||||
"connectionDetails": "연결 세부 정보",
|
||||
"endpointKind": "대상 유형",
|
||||
"networkConnection": "네트워크 연결",
|
||||
"notAttempted": "시도하지 않음"
|
||||
},
|
||||
"CloudVmSetupGuide": {
|
||||
"title": "클라우드 VM 만들기",
|
||||
"description": "클라우드 VM은 워크스페이스를 만들 때 환경 레시피에서 생성됩니다.",
|
||||
"setupRecipe": "클라우드 공급자를 위한 환경 레시피를 설정하세요.",
|
||||
"createWorkspace": "워크스페이스를 만들고 실행 위치에서 해당 레시피를 선택하세요.",
|
||||
"openSetup": "환경 레시피 설정"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -149,7 +149,10 @@
|
||||
},
|
||||
"webPreloadApi": {
|
||||
"aiVaultUnavailableForHost": "此执行主机不支持 Agent 会话历史记录。",
|
||||
"runtimeEnvironmentManuallyDisconnected": "运行时环境已手动断开连接。"
|
||||
"runtimeEnvironmentManuallyDisconnected": "运行时环境已手动断开连接。",
|
||||
"loopbackPairingBlocked": "此访问链接会指回此设备。",
|
||||
"remotePairingUnreachable": "无法连接 {{endpoint}} 上的 Orca。",
|
||||
"remotePairingInvalidDetails": "此访问链接包含无效的连接信息。"
|
||||
},
|
||||
"web": {
|
||||
"preload": {
|
||||
@@ -351,6 +354,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"remotePairingCopy": {
|
||||
"invalidInput": "请输入 Orca 访问链接或配对代码。",
|
||||
"mobileOnly": "此链接仅授予移动端访问权限。请为另一个 Orca 客户端生成链接。",
|
||||
"invalidDestination": "此访问链接包含无效的目标地址。",
|
||||
"unsupportedDestination": "此访问链接包含不受支持的目标地址。",
|
||||
"nonConnectableDestination": "此访问链接包含无法连接的目标地址。",
|
||||
"loopback": "环回地址",
|
||||
"tailscale": "Tailscale 地址",
|
||||
"lan": "专用 LAN 地址",
|
||||
"public": "公网地址",
|
||||
"custom": "自定义主机名"
|
||||
},
|
||||
"ensure": {
|
||||
"simulator": {
|
||||
"tab": {
|
||||
@@ -5006,7 +5021,11 @@
|
||||
"importing": "正在导入...",
|
||||
"importSshConfig": "导入 ~/.ssh/config",
|
||||
"sshPersistenceDefault": "此主机上的远程终端会保持运行,直到你结束它们或重置中继。",
|
||||
"advanced": "Advanced"
|
||||
"advanced": "Advanced",
|
||||
"linkDestination": "链接目标",
|
||||
"sshTunnel": "我正在使用 SSH 隧道",
|
||||
"sshTunnelHelp": "否则,此链接会指回此设备,无法识别另一台计算机。",
|
||||
"loopbackBlocked": "启用 SSH 隧道选项,或使用另一台主机的 Tailscale 或 LAN 地址创建新链接。"
|
||||
},
|
||||
"ForgetSshWorkspaceDialog": {
|
||||
"reconnectFailed": "重新连接失败",
|
||||
@@ -6653,15 +6672,30 @@
|
||||
"advertiseThisApp": "将此应用作为服务器公布",
|
||||
"advertiseThisAppHelp": "创建访问链接,让浏览器、移动客户端或另一个 Orca 客户端连接回这个正在运行的应用。",
|
||||
"runtimeReachable": "可连接到 {{value0}}。",
|
||||
"updateAvailableOne": "1 update available",
|
||||
"updatesAvailable": "{{value0}} updates available",
|
||||
"versionUnavailable": "Orca version unavailable",
|
||||
"updateServer": "Update",
|
||||
"reviewServerUpdates": "Server updates",
|
||||
"updateAvailableOne": "1 个更新可用",
|
||||
"updatesAvailable": "{{value0}} 个更新可用",
|
||||
"versionUnavailable": "Orca 版本不可用",
|
||||
"updateServer": "更新",
|
||||
"reviewServerUpdates": "服务器更新",
|
||||
"orcaVersion": "Orca v{{value0}}",
|
||||
"removeActiveServerBlocked": "Choose another Active Server in Advanced before removing this server.",
|
||||
"removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.",
|
||||
"updatingServers": "Updating servers…"
|
||||
"removeActiveServerBlocked": "请先在“高级”中选择另一个活动服务器,然后再移除此服务器。",
|
||||
"removeActiveServerDescription": "请先在“高级”中选择另一个活动服务器,然后再移除此服务器。现有的主机会话将保留。",
|
||||
"updatingServers": "正在更新服务器…",
|
||||
"workflow": "远程服务器工作流程",
|
||||
"connectWorkflow": "连接到主机",
|
||||
"connectWorkflowHelp": "此应用加入另一台机器",
|
||||
"shareWorkflow": "共享此主机",
|
||||
"shareWorkflowHelp": "其他设备加入此机器",
|
||||
"troubleshootWorkflow": "连接故障排除",
|
||||
"sshTunnelRequired": "需要 SSH 隧道",
|
||||
"troubleshootTitle": "在另一台主机上创建新链接",
|
||||
"troubleshootDescription": "使用 127.0.0.1 的链接会指向打开它的设备,而不是创建它的计算机。",
|
||||
"troubleshootStepShare": "在另一台计算机上,打开“共享此主机”。",
|
||||
"troubleshootStepAddress": "选择“其他设备”,然后选择其 Tailscale 或 LAN 地址。",
|
||||
"troubleshootStepRegenerate": "生成新的访问链接,并仅在此处使用最新的链接。",
|
||||
"troubleshootTunnel": "正在使用 SSH 本地转发?返回“连接到主机”,粘贴环回链接,然后在“高级”下启用“我正在使用 SSH 隧道”。",
|
||||
"cloudVmWorkflow": "云虚拟机",
|
||||
"cloudVmWorkflowHelp": "管理由配方创建的云端机器"
|
||||
},
|
||||
"RuntimePairingGeneratedUrlRows": {
|
||||
"0495f68959": "复制 {{value0}}"
|
||||
@@ -6700,7 +6734,20 @@
|
||||
"custom-description": "公布其他设备可以访问的地址:LAN 或 Tailscale 主机,或完整的 ws(s):// URL。",
|
||||
"custom-hint": "请输入主机、host:port 或 ws(s):// URL。",
|
||||
"custom-cancel": "取消",
|
||||
"custom-use": "使用地址"
|
||||
"custom-use": "使用地址",
|
||||
"intentQuestion": "此链接将在哪里打开?",
|
||||
"anotherDevice": "其他设备",
|
||||
"anotherDeviceHelp": "Tailscale、LAN 或其他可达地址",
|
||||
"localOnly": "仅此计算机",
|
||||
"localOnlyHelp": "此计算机上的浏览器或 Orca 客户端",
|
||||
"customAddress": "自定义地址",
|
||||
"customAddressHelp": "SSH 隧道、反向代理或自定义主机名",
|
||||
"recommended": "推荐",
|
||||
"localLink": "仅本地链接",
|
||||
"localLinkHelp": "此链接仅在运行于此计算机上的浏览器或 Orca 客户端中有效。",
|
||||
"noExternalAddress": "未找到其他设备的地址。将此计算机连接到 LAN 或 Tailscale,刷新,或选择“自定义地址”。",
|
||||
"staleAddress": "连接地址已更改。为 {{address}} 生成新链接。",
|
||||
"customInvalid": "请输入有效的主机、主机:端口、IPv6 地址或 ws(s):// URL。"
|
||||
},
|
||||
"Settings": {
|
||||
"3bf149e873": "项目设置 > {{value0}}",
|
||||
@@ -6777,7 +6824,7 @@
|
||||
"dev": "开发工具",
|
||||
"devDescription": "仅用于调试 UI 状态的开发工具。",
|
||||
"linearTitle": "Linear",
|
||||
"linearDescription": "Give agents the skill to read and update your linked Linear tickets."
|
||||
"linearDescription": "赋予智能体读取和更新您关联的 Linear 工单的能力。"
|
||||
},
|
||||
"SettingsFormControls": {
|
||||
"42a4d15a30": "没有匹配的字体。",
|
||||
@@ -9375,18 +9422,25 @@
|
||||
"empty": "没有需要清理的临时 VM 运行时。",
|
||||
"copyCleanup": "复制命令",
|
||||
"retry": "重试清理",
|
||||
"cleanup": "清理"
|
||||
"cleanup": "清理",
|
||||
"cloudVmLoadFailed": "无法加载云虚拟机运行时。",
|
||||
"cloudVmCleanupFailedToast": "无法清理云虚拟机运行时。",
|
||||
"cloudVmMarkedCleaned": "已将云虚拟机运行时标记为已清理。",
|
||||
"cloudVmCleaned": "已清理云虚拟机运行时。",
|
||||
"cloudVmTitle": "云虚拟机运行时",
|
||||
"cloudVmRefresh": "刷新云虚拟机运行时",
|
||||
"cloudVmLoading": "正在检查云虚拟机运行时…",
|
||||
"cloudVmEmptyWithSetup": "还没有云虚拟机运行时。请在创建工作区时使用环境模板来创建。"
|
||||
},
|
||||
"ephemeralVms": {
|
||||
"search": {
|
||||
"title": "工作区专属环境",
|
||||
"description": "了解仓库自带的环境模板如何为每个工作区提供按需创建、可丢弃的独立环境。"
|
||||
"description": "了解仓库自带的环境模板如何为每个工作区提供按需创建、可丢弃的独立环境。",
|
||||
"cloudVmTitle": "云虚拟机"
|
||||
}
|
||||
},
|
||||
"EphemeralVmsPane": {
|
||||
"loadError": "无法加载环境模板。",
|
||||
"copyError": "无法复制提示词。",
|
||||
"skillTitle": "工作区专属环境技能",
|
||||
"skillDescription": "设置、构建、认证并验证仓库自带的环境模板。",
|
||||
"whatTitle": "此技能会与你一起完成",
|
||||
"whatScaffold": "为你的提供商编写环境模板和脚本,可通过 Orca 服务器或 SSH 连接。",
|
||||
@@ -9397,13 +9451,16 @@
|
||||
"copied": "已复制",
|
||||
"recipes": "环境模板",
|
||||
"recipesHelp": "orca.yaml 和已启用插件中的配方会显示在这里,可用于启动工作区。",
|
||||
"refresh": "刷新临时 VM 环境模板",
|
||||
"checking": "正在检查环境模板...",
|
||||
"none": "还没有找到环境模板。"
|
||||
"none": "还没有找到环境模板。",
|
||||
"cloudVmSkillTitle": "云虚拟机设置技能",
|
||||
"cloudVmRefresh": "刷新云虚拟机环境模板",
|
||||
"cloudVmTerminalTitle": "云虚拟机设置",
|
||||
"cloudVmTerminalAriaLabel": "云虚拟机技能安装终端"
|
||||
},
|
||||
"ephemeralVmsExperimentalSetting": {
|
||||
"description": "显示仓库自带按需环境的设置控件和工作区运行目标。",
|
||||
"toggleLabel": "切换工作区专属环境"
|
||||
"cloudVmToggleLabel": "切换云虚拟机"
|
||||
},
|
||||
"SourceControlActionRepoOverrideNote": {
|
||||
"agent": "智能体",
|
||||
@@ -9558,25 +9615,25 @@
|
||||
"reviewUpdateOne": "1 update available"
|
||||
},
|
||||
"RemoteServerUpdateDialog": {
|
||||
"versionUnavailable": "Version unavailable",
|
||||
"restartingHelp": "Waiting for the replacement server to reconnect on the new version.",
|
||||
"retry": "Retry",
|
||||
"update": "Update",
|
||||
"title": "Update Remote Orca Servers",
|
||||
"description": "Review paired servers and update supported installs from this Orca client.",
|
||||
"restartWarning": "Updating restarts these servers. {{value0}} live tabs and {{value1}} terminal panes may briefly disconnect.",
|
||||
"checking": "Checking paired servers…",
|
||||
"empty": "No paired Remote Orca Servers.",
|
||||
"checkAgain": "Check again",
|
||||
"updating": "Updating servers…",
|
||||
"updateAll": "Update {{value0}} servers",
|
||||
"versionUnavailable": "版本不可用",
|
||||
"restartingHelp": "正在等待替代服务器在新版本上重新连接。",
|
||||
"retry": "重试",
|
||||
"update": "更新",
|
||||
"title": "更新远程 Orca 服务器",
|
||||
"description": "查看已配对的服务器,并从此 Orca 客户端更新受支持的安装。",
|
||||
"restartWarning": "更新将重启这些服务器。{{value0}} 个活动标签页和 {{value1}} 个终端窗格可能会短暂断开连接。",
|
||||
"checking": "正在检查已配对的服务器…",
|
||||
"empty": "没有已配对的远程 Orca 服务器。",
|
||||
"checkAgain": "再次检查",
|
||||
"updating": "正在更新服务器…",
|
||||
"updateAll": "更新 {{value0}} 台服务器",
|
||||
"noUpdates": "没有可用更新",
|
||||
"updateOne": "Update server",
|
||||
"downloadProgress": "{{value0}} download progress",
|
||||
"liveTabOne": "1 live tab",
|
||||
"liveTabs": "{{value0}} live tabs",
|
||||
"livePaneOne": "1 live pane",
|
||||
"livePanes": "{{value0}} live panes"
|
||||
"updateOne": "更新服务器",
|
||||
"downloadProgress": "{{value0}} 下载进度",
|
||||
"liveTabOne": "1 个活动标签页",
|
||||
"liveTabs": "{{value0}} 个活动标签页",
|
||||
"livePaneOne": "1 个活动窗格",
|
||||
"livePanes": "{{value0}} 个活动窗格"
|
||||
},
|
||||
"RemoteServerUpdateStatus": {
|
||||
"checking": "检查中…",
|
||||
@@ -9586,13 +9643,13 @@
|
||||
"offline": "离线",
|
||||
"queued": "已排队",
|
||||
"checkingUpdate": "正在检查更新…",
|
||||
"downloading": "Downloading…",
|
||||
"restarting": "Restarting…",
|
||||
"updated": "Updated",
|
||||
"failed": "Update failed",
|
||||
"serviceManagerHelp": "Update Orca through the service manager that starts this server.",
|
||||
"unpackedHelp": "Development builds must be updated from their source checkout.",
|
||||
"legacyHelp": "Update this server manually once to enable remote updates."
|
||||
"downloading": "正在下载…",
|
||||
"restarting": "正在重启…",
|
||||
"updated": "已更新",
|
||||
"failed": "更新失败",
|
||||
"serviceManagerHelp": "通过启动此服务器的服务管理器更新 Orca。",
|
||||
"unpackedHelp": "开发版本必须从其源代码检出进行更新。",
|
||||
"legacyHelp": "手动更新此服务器一次以启用远程更新。"
|
||||
},
|
||||
"BrowserLinkRoutingModifierSetting": {
|
||||
"titleSystem": "Hold Shift to open in your web browser",
|
||||
@@ -9849,6 +9906,50 @@
|
||||
"logs": "插件日志",
|
||||
"development": "开发插件"
|
||||
}
|
||||
},
|
||||
"RuntimeHostAccessForm": {
|
||||
"getLink": "从另一台主机获取访问链接",
|
||||
"stepOpenShare": "打开设置 → 远程 Orca 服务器 → 共享此主机。",
|
||||
"stepChooseAddress": "选择“其他设备”,然后选择一个可达地址。",
|
||||
"stepCopyLink": "生成链接,然后复制“配对另一个 Orca 客户端”链接。",
|
||||
"name": "在 Orca 中的名称",
|
||||
"namePlaceholder": "Linux 工作站",
|
||||
"nameHelp": "这仅更改计算机在 Orca 中的显示方式。",
|
||||
"accessLink": "访问链接",
|
||||
"accessLinkPlaceholder": "orca://pair?code=...",
|
||||
"accessLinkHelp": "Orca 会在连接前显示目标。凭据保持隐藏。",
|
||||
"destination": "链接目标",
|
||||
"loopbackTitle": "此链接指向此设备",
|
||||
"loopbackDescription": "{{endpoint}} 会指向打开链接的设备,而不是创建链接的另一台计算机。",
|
||||
"loopbackRecovery": "在另一台计算机上,使用“其他设备”创建新链接,并选择其 Tailscale 或 LAN 地址。",
|
||||
"identityMismatch": "到达的 Orca 主机与此访问链接不匹配",
|
||||
"identityMismatchHelp": "已连接到 {{endpoint}} 上的 Orca,但该主机与此链接不匹配。请在另一台主机上生成新链接。",
|
||||
"invalidLink": "此访问链接不再有效",
|
||||
"invalidLinkHelp": "请在另一台主机上生成新的访问链接,然后重试。",
|
||||
"incompatible": "Orca 版本不兼容",
|
||||
"incompatibleHelp": "请更新此设备和另一台主机上的 Orca,然后重试。",
|
||||
"interrupted": "连接中断",
|
||||
"interruptedHelp": "验证期间连接已中断。请检查网络或 SSH 隧道,然后重试。",
|
||||
"unavailable": "主机不可用",
|
||||
"unavailableHelp": "请确保 Orca 正在另一台主机上运行,并且网络或 SSH 隧道可以连接到 {{endpoint}}。",
|
||||
"advanced": "高级",
|
||||
"sshTunnel": "我正在使用 SSH 隧道连接到此本地地址",
|
||||
"sshTunnelHelp": "使用此连接时保持隧道活跃。",
|
||||
"headlessHelp": "正在使用无头 orca serve?在另一台计算机上运行 orca serve --pairing-address <可达主机>。",
|
||||
"cancel": "取消",
|
||||
"addWithTunnel": "使用隧道添加主机",
|
||||
"addHost": "添加主机",
|
||||
"connectionDetails": "连接详情",
|
||||
"endpointKind": "端点类型",
|
||||
"networkConnection": "网络连接",
|
||||
"notAttempted": "未尝试"
|
||||
},
|
||||
"CloudVmSetupGuide": {
|
||||
"title": "创建云虚拟机",
|
||||
"description": "云虚拟机会在创建工作区时通过环境模板创建。",
|
||||
"setupRecipe": "为你的云提供商设置环境模板。",
|
||||
"createWorkspace": "创建工作区,并在“运行于”中选择该模板。",
|
||||
"openSetup": "设置环境模板"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -58,11 +58,6 @@ const CASES: GuardCase[] = [
|
||||
afterFallback: 'such as',
|
||||
label: 'such as main'
|
||||
},
|
||||
{
|
||||
file: 'components/settings/RuntimeEnvironmentsPane.tsx',
|
||||
afterFallback: 'Run',
|
||||
label: 'Run orca serve'
|
||||
},
|
||||
{
|
||||
file: 'components/settings/AutoRenameBranchFromWorkSetting.tsx',
|
||||
afterFallback: 'Use',
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type {
|
||||
HostAccessLinkErrorKind,
|
||||
RemotePairingEndpointKind
|
||||
} from '../../../shared/remote-pairing-address'
|
||||
import type { RemotePairingFailureKind } from '../../../shared/remote-pairing-verification'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function translateHostAccessLinkError(kind: HostAccessLinkErrorKind): string {
|
||||
switch (kind) {
|
||||
case 'invalid-input':
|
||||
return translate(
|
||||
'auto.lib.remotePairingCopy.invalidInput',
|
||||
'Enter an Orca access link or bare pairing code.'
|
||||
)
|
||||
case 'mobile-only':
|
||||
return translate(
|
||||
'auto.lib.remotePairingCopy.mobileOnly',
|
||||
'This link grants mobile-only access. Generate a link for another Orca client.'
|
||||
)
|
||||
case 'invalid-destination':
|
||||
return translate(
|
||||
'auto.lib.remotePairingCopy.invalidDestination',
|
||||
'This access link contains an invalid destination.'
|
||||
)
|
||||
case 'unsupported-destination':
|
||||
return translate(
|
||||
'auto.lib.remotePairingCopy.unsupportedDestination',
|
||||
'This access link contains an unsupported destination.'
|
||||
)
|
||||
case 'non-connectable-destination':
|
||||
return translate(
|
||||
'auto.lib.remotePairingCopy.nonConnectableDestination',
|
||||
'This access link contains a non-connectable destination.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function translateRemotePairingEndpointKind(kind: RemotePairingEndpointKind): string {
|
||||
switch (kind) {
|
||||
case 'loopback':
|
||||
return translate('auto.lib.remotePairingCopy.loopback', 'Loopback')
|
||||
case 'tailscale':
|
||||
return translate('auto.lib.remotePairingCopy.tailscale', 'Tailscale address')
|
||||
case 'lan':
|
||||
return translate('auto.lib.remotePairingCopy.lan', 'Private LAN address')
|
||||
case 'public':
|
||||
return translate('auto.lib.remotePairingCopy.public', 'Public address')
|
||||
case 'custom':
|
||||
return translate('auto.lib.remotePairingCopy.custom', 'Custom hostname')
|
||||
}
|
||||
}
|
||||
|
||||
export function translateRemotePairingFailureDescription(
|
||||
kind: RemotePairingFailureKind,
|
||||
endpoint: string | null
|
||||
): string {
|
||||
switch (kind) {
|
||||
case 'host-identity-mismatch':
|
||||
return translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.identityMismatchHelp',
|
||||
'Orca reached {{endpoint}}, but that host does not match this link. Generate a new link on the other host.',
|
||||
{ endpoint: endpoint ?? 'Orca' }
|
||||
)
|
||||
case 'access-link-invalid':
|
||||
return translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.invalidLinkHelp',
|
||||
'Generate a new access link on the other host and try again.'
|
||||
)
|
||||
case 'protocol-incompatible':
|
||||
return translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.incompatibleHelp',
|
||||
'Update Orca on this device and the other host, then try again.'
|
||||
)
|
||||
case 'connection-interrupted':
|
||||
return translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.interruptedHelp',
|
||||
'The connection stopped during verification. Check the network or SSH tunnel and try again.'
|
||||
)
|
||||
case 'environment-save-failed':
|
||||
return translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.saveFailedHelp',
|
||||
'The host was verified, but Orca could not save it. Check the name and local settings storage, then try again.'
|
||||
)
|
||||
case 'host-unreachable':
|
||||
return translate(
|
||||
'auto.components.settings.RuntimeHostAccessForm.unavailableHelp',
|
||||
'Make sure Orca is running on the other host and that the network or SSH tunnel can reach {{endpoint}}.',
|
||||
{ endpoint: endpoint ?? 'Orca' }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { PreloadApi } from '../../../preload/api-types'
|
||||
import type { FeatureInteractionState } from '../../../shared/feature-interactions'
|
||||
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
|
||||
import type { TaskSourceContext } from '../../../shared/task-source-context'
|
||||
import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION } from '../../../shared/protocol-version'
|
||||
|
||||
const TEST_COMMIT_OID = '0123456789abcdef0123456789abcdef01234567'
|
||||
|
||||
@@ -200,6 +201,7 @@ describe('web before-unload persistence', () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.doUnmock('./web-runtime-client')
|
||||
})
|
||||
|
||||
it('persists final UI and host-partitioned sessions synchronously', async () => {
|
||||
@@ -531,6 +533,187 @@ describe('web runtime environment identity', () => {
|
||||
)
|
||||
}
|
||||
)
|
||||
it('keeps the current host when verification rejects an incompatible replacement', async () => {
|
||||
vi.doMock('./web-runtime-client', () => ({
|
||||
WebRuntimeClient: class {
|
||||
call(): Promise<RuntimeRpcResponse<unknown>> {
|
||||
return Promise.resolve({
|
||||
id: 'status',
|
||||
ok: true,
|
||||
result: { runtimeProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION - 1 },
|
||||
_meta: { runtimeId: 'runtime-old' }
|
||||
})
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
}
|
||||
}))
|
||||
const globals = installBrowserGlobals('Linux')
|
||||
writeStoredRuntimeEnvironment(globals.storage, 'web-server-a')
|
||||
const previousStored = globals.storage.getItem('orca.web.runtimeEnvironment.v1')
|
||||
const { installWebPreloadApi } = await import('./web-preload-api')
|
||||
installWebPreloadApi()
|
||||
|
||||
await expect(
|
||||
globals.window.api.runtimeEnvironments.verifyAndAddFromPairingCode({
|
||||
name: 'Incompatible server',
|
||||
pairingCode: encodePairingCode()
|
||||
})
|
||||
).resolves.toMatchObject({ ok: false, kind: 'protocol-incompatible' })
|
||||
expect(globals.storage.getItem('orca.web.runtimeEnvironment.v1')).toBe(previousStored)
|
||||
await expect(globals.window.api.runtimeEnvironments.list()).resolves.toMatchObject([
|
||||
{ id: 'web-server-a' }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the current host when browser storage rejects a verified replacement', async () => {
|
||||
vi.doMock('./web-runtime-client', () => ({
|
||||
WebRuntimeClient: class {
|
||||
call(): Promise<RuntimeRpcResponse<unknown>> {
|
||||
return Promise.resolve({
|
||||
id: 'status',
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime-new',
|
||||
rendererGraphEpoch: 1,
|
||||
graphStatus: 'ready',
|
||||
authoritativeWindowId: 1,
|
||||
liveTabCount: 0,
|
||||
liveLeafCount: 0,
|
||||
runtimeProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-new' }
|
||||
})
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
}
|
||||
}))
|
||||
const globals = installBrowserGlobals('Linux')
|
||||
writeStoredRuntimeEnvironment(globals.storage, 'web-server-a')
|
||||
const { installWebPreloadApi } = await import('./web-preload-api')
|
||||
installWebPreloadApi()
|
||||
vi.spyOn(globals.storage, 'setItem').mockImplementation(() => {
|
||||
throw new Error('Browser storage is full.')
|
||||
})
|
||||
|
||||
await expect(
|
||||
globals.window.api.runtimeEnvironments.verifyAndAddFromPairingCode({
|
||||
name: 'Verified replacement',
|
||||
pairingCode: encodePairingCode()
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
kind: 'environment-save-failed',
|
||||
message: 'Orca verified the host but could not save it. Check browser storage and try again.'
|
||||
})
|
||||
await expect(globals.window.api.runtimeEnvironments.list()).resolves.toMatchObject([
|
||||
{ id: 'web-server-a' }
|
||||
])
|
||||
})
|
||||
|
||||
it('requires an explicit loopback override and persists the SSH dependency', async () => {
|
||||
const call = vi.fn().mockResolvedValue({
|
||||
id: 'status',
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime-new',
|
||||
rendererGraphEpoch: 1,
|
||||
graphStatus: 'ready',
|
||||
authoritativeWindowId: 1,
|
||||
liveTabCount: 0,
|
||||
liveLeafCount: 0,
|
||||
runtimeProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-new' }
|
||||
})
|
||||
vi.doMock('./web-runtime-client', () => ({
|
||||
WebRuntimeClient: class {
|
||||
call = call
|
||||
close(): void {}
|
||||
}
|
||||
}))
|
||||
const globals = installBrowserGlobals('Linux')
|
||||
const { installWebPreloadApi } = await import('./web-preload-api')
|
||||
installWebPreloadApi()
|
||||
const pairingCode = encodePairingCode({ endpoint: 'ws://127.0.0.1:6768' })
|
||||
|
||||
await expect(
|
||||
globals.window.api.runtimeEnvironments.verifyAndAddFromPairingCode({
|
||||
name: 'Tunnel server',
|
||||
pairingCode
|
||||
})
|
||||
).resolves.toMatchObject({ ok: false, kind: 'host-unreachable' })
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
|
||||
await expect(
|
||||
globals.window.api.runtimeEnvironments.verifyAndAddFromPairingCode({
|
||||
name: 'Tunnel server',
|
||||
pairingCode,
|
||||
allowLoopback: true
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
environment: { connectionDependency: 'ssh-tunnel' }
|
||||
})
|
||||
expect(call).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
JSON.parse(globals.storage.getItem('orca.web.runtimeEnvironment.v1') ?? '{}')
|
||||
).toMatchObject({ connectionDependency: 'ssh-tunnel' })
|
||||
})
|
||||
|
||||
it('returns a structured failure when the browser client cannot be constructed', async () => {
|
||||
vi.doMock('./web-runtime-client', () => ({
|
||||
WebRuntimeClient: class {
|
||||
constructor() {
|
||||
throw new Error('Invalid public key: expected 32 bytes, got 3')
|
||||
}
|
||||
}
|
||||
}))
|
||||
const globals = installBrowserGlobals('Linux')
|
||||
writeStoredRuntimeEnvironment(globals.storage, 'web-server-a')
|
||||
const { installWebPreloadApi } = await import('./web-preload-api')
|
||||
installWebPreloadApi()
|
||||
|
||||
await expect(
|
||||
globals.window.api.runtimeEnvironments.verifyAndAddFromPairingCode({
|
||||
name: 'Broken server',
|
||||
pairingCode: encodePairingCode()
|
||||
})
|
||||
).resolves.toMatchObject({ ok: false, kind: 'access-link-invalid' })
|
||||
await expect(globals.window.api.runtimeEnvironments.list()).resolves.toMatchObject([
|
||||
{ id: 'web-server-a' }
|
||||
])
|
||||
})
|
||||
|
||||
it('classifies coded browser authorization failures without relying on copy', async () => {
|
||||
vi.doMock('./web-runtime-client', () => ({
|
||||
WebRuntimeClient: class {
|
||||
call(): Promise<RuntimeRpcResponse<unknown>> {
|
||||
return Promise.reject(
|
||||
Object.assign(new Error('Access grant rejected.'), { code: 'unauthorized' })
|
||||
)
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
}
|
||||
}))
|
||||
const globals = installBrowserGlobals('Linux')
|
||||
writeStoredRuntimeEnvironment(globals.storage, 'web-server-a')
|
||||
const { installWebPreloadApi } = await import('./web-preload-api')
|
||||
installWebPreloadApi()
|
||||
|
||||
await expect(
|
||||
globals.window.api.runtimeEnvironments.verifyAndAddFromPairingCode({
|
||||
name: 'Expired server',
|
||||
pairingCode: encodePairingCode()
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
kind: 'access-link-invalid',
|
||||
message: 'Access grant rejected.'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('web browser-local port capability', () => {
|
||||
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
NativeChatAppendedMessages
|
||||
} from '../../../preload/api-types'
|
||||
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
|
||||
import { parseHostAccessLink } from '../../../shared/remote-pairing-address'
|
||||
import { verifyRemotePairingRuntimeStatus } from '../../../shared/remote-pairing-verification'
|
||||
import type { AiVaultListArgs, AiVaultListResult } from '../../../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultPrepareSessionResumeArgs,
|
||||
@@ -113,6 +115,7 @@ import {
|
||||
import { parseWebPairingInput } from './web-pairing'
|
||||
import { copyClipboardTextViaExecCommand } from './web-clipboard-copy-fallback'
|
||||
import { WebRuntimeClient } from './web-runtime-client'
|
||||
import { isWebRuntimeUnauthorizedError } from './web-runtime-client-error'
|
||||
import { RuntimeRpcCallQueuePool } from '../../../shared/runtime-rpc-call-queue'
|
||||
import {
|
||||
assertClipboardTextWriteWithinLimitWithYield,
|
||||
@@ -135,6 +138,7 @@ import {
|
||||
} from '../../../shared/feature-interactions'
|
||||
import { normalizeContextualTourIds, type ContextualTourId } from '../../../shared/contextual-tours'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { translateHostAccessLinkError } from '@/lib/remote-pairing-copy'
|
||||
import { getDefaultCreateProjectParent } from '@/components/sidebar/create-project-defaults'
|
||||
import {
|
||||
parseRuntimeNativeChatReadSessionResult,
|
||||
@@ -1362,6 +1366,106 @@ function createRuntimeEnvironmentsApi(): NonNullable<Partial<PreloadApi>['runtim
|
||||
saveStoredWebRuntimeEnvironment(activeEnvironment)
|
||||
return { environment: redactStoredWebRuntimeEnvironment(activeEnvironment) }
|
||||
},
|
||||
verifyAndAddFromPairingCode: async ({ name, pairingCode, allowLoopback }) => {
|
||||
const parsed = parseHostAccessLink(pairingCode)
|
||||
if (!parsed.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'access-link-invalid',
|
||||
message: translateHostAccessLinkError(parsed.kind)
|
||||
}
|
||||
}
|
||||
if (parsed.value.endpointKind === 'loopback' && !allowLoopback) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'host-unreachable',
|
||||
message: translate(
|
||||
'auto.web.webPreloadApi.loopbackPairingBlocked',
|
||||
'This access link points back to this device.'
|
||||
)
|
||||
}
|
||||
}
|
||||
let client: WebRuntimeClient | null = null
|
||||
let runtimeStatus: RuntimeStatus
|
||||
try {
|
||||
client = new WebRuntimeClient(parsed.value.pairing)
|
||||
const response = (await client.call('status.get', undefined, {
|
||||
timeoutMs: 15_000
|
||||
})) as RuntimeRpcResponse<RuntimeStatus>
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'connection-interrupted',
|
||||
message: response.error.message
|
||||
}
|
||||
}
|
||||
const statusVerification = verifyRemotePairingRuntimeStatus(response.result)
|
||||
if (!statusVerification.ok) {
|
||||
return statusVerification
|
||||
}
|
||||
runtimeStatus = statusVerification.runtimeStatus
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith('Invalid public key')) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'access-link-invalid',
|
||||
message: translate(
|
||||
'auto.web.webPreloadApi.remotePairingInvalidDetails',
|
||||
'This access link contains invalid connection details.'
|
||||
)
|
||||
}
|
||||
}
|
||||
if (
|
||||
isWebRuntimeUnauthorizedError(error) ||
|
||||
(error instanceof Error && error.message.startsWith('Unauthorized.'))
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'access-link-invalid',
|
||||
message: error.message
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'host-unreachable',
|
||||
message: translate(
|
||||
'auto.web.webPreloadApi.remotePairingUnreachable',
|
||||
'Cannot reach Orca at {{endpoint}}.',
|
||||
{ endpoint: parsed.value.displayEndpoint }
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
client?.close()
|
||||
}
|
||||
const usesSshTunnel = parsed.value.endpointKind === 'loopback' && allowLoopback === true
|
||||
const nextEnvironment = createStoredWebRuntimeEnvironment({
|
||||
name,
|
||||
offer: parsed.value.pairing,
|
||||
previousEnvironment: activeEnvironment,
|
||||
...(usesSshTunnel ? { connectionDependency: 'ssh-tunnel' as const } : {})
|
||||
})
|
||||
// Why: a browser storage failure must leave the currently active host usable.
|
||||
try {
|
||||
saveStoredWebRuntimeEnvironment(nextEnvironment)
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'environment-save-failed',
|
||||
message: translate(
|
||||
'auto.web.webPreloadApi.remotePairingSaveFailed',
|
||||
'Orca verified the host but could not save it. Check browser storage and try again.'
|
||||
)
|
||||
}
|
||||
}
|
||||
manuallyDisconnectedEnvironmentIds.clear()
|
||||
closeActiveRuntimeClients()
|
||||
activeEnvironment = nextEnvironment
|
||||
return {
|
||||
ok: true,
|
||||
environment: redactStoredWebRuntimeEnvironment(nextEnvironment),
|
||||
runtimeStatus
|
||||
}
|
||||
},
|
||||
resolve: async ({ selector }) =>
|
||||
redactStoredWebRuntimeEnvironment(resolveEnvironment(selector)),
|
||||
remove: async ({ selector }) => {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
const UNAUTHORIZED_MESSAGE = 'Unauthorized. Pair this web client again.'
|
||||
|
||||
export class WebRuntimeClientError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code: 'unauthorized'
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'WebRuntimeClientError'
|
||||
}
|
||||
}
|
||||
|
||||
export function createWebRuntimeUnauthorizedError(): WebRuntimeClientError {
|
||||
return new WebRuntimeClientError(UNAUTHORIZED_MESSAGE, 'unauthorized')
|
||||
}
|
||||
|
||||
export function isWebRuntimeUnauthorizedError(
|
||||
error: unknown
|
||||
): error is Error & { code: 'unauthorized' } {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
(error as { code?: unknown }).code === 'unauthorized'
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
publicKeyToBase64
|
||||
} from './web-e2ee'
|
||||
import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
|
||||
import { createWebRuntimeUnauthorizedError } from './web-runtime-client-error'
|
||||
|
||||
type WebRuntimeConnectionState =
|
||||
| 'disconnected'
|
||||
@@ -478,7 +479,7 @@ export class WebRuntimeClient {
|
||||
} else if (control.type === 'e2ee_error' || control.error?.code === 'unauthorized') {
|
||||
this.intentionallyClosed = true
|
||||
this.setState('auth-failed')
|
||||
this.rejectAllPending('Unauthorized. Pair this web client again.')
|
||||
this.rejectAllPending(createWebRuntimeUnauthorizedError())
|
||||
this.notifySubscriptionsError('unauthorized', 'Unauthorized. Pair this web client again.')
|
||||
this.ws?.close()
|
||||
}
|
||||
@@ -530,7 +531,7 @@ export class WebRuntimeClient {
|
||||
if (isRuntimeFailureResponse(response) && response.error.code === 'unauthorized') {
|
||||
this.intentionallyClosed = true
|
||||
this.setState('auth-failed')
|
||||
this.rejectAllPending('Unauthorized. Pair this web client again.')
|
||||
this.rejectAllPending(createWebRuntimeUnauthorizedError())
|
||||
this.notifySubscriptionsError('unauthorized', 'Unauthorized. Pair this web client again.')
|
||||
this.ws?.close()
|
||||
return
|
||||
@@ -584,7 +585,7 @@ export class WebRuntimeClient {
|
||||
return Promise.resolve()
|
||||
}
|
||||
if (this.state === 'auth-failed') {
|
||||
return Promise.reject(new Error('Unauthorized. Pair this web client again.'))
|
||||
return Promise.reject(createWebRuntimeUnauthorizedError())
|
||||
}
|
||||
if (this.intentionallyClosed) {
|
||||
return Promise.reject(new Error('Remote Orca runtime connection closed.'))
|
||||
@@ -658,7 +659,7 @@ export class WebRuntimeClient {
|
||||
waiter.resolve()
|
||||
}
|
||||
} else if (next === 'auth-failed') {
|
||||
this.rejectAllWaiters(new Error('Unauthorized. Pair this web client again.'))
|
||||
this.rejectAllWaiters(createWebRuntimeUnauthorizedError())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -667,8 +668,8 @@ export class WebRuntimeClient {
|
||||
return `web-rpc-${this.requestCounter}-${Date.now()}`
|
||||
}
|
||||
|
||||
private rejectAllPending(reason: string): void {
|
||||
const error = new Error(reason)
|
||||
private rejectAllPending(reason: string | Error): void {
|
||||
const error = typeof reason === 'string' ? new Error(reason) : reason
|
||||
for (const [id, pending] of this.pending) {
|
||||
this.pending.delete(id)
|
||||
window.clearTimeout(pending.timeout)
|
||||
|
||||
@@ -59,6 +59,7 @@ export function createStoredWebRuntimeEnvironment(args: {
|
||||
name: string
|
||||
offer: WebPairingOffer
|
||||
previousEnvironment?: StoredWebRuntimeEnvironment | null
|
||||
connectionDependency?: 'ssh-tunnel'
|
||||
}): StoredWebRuntimeEnvironment {
|
||||
const id = `web-${createBrowserUuid()}`
|
||||
const now = Date.now()
|
||||
@@ -70,6 +71,7 @@ export function createStoredWebRuntimeEnvironment(args: {
|
||||
updatedAt: now,
|
||||
lastUsedAt: null,
|
||||
runtimeId: null,
|
||||
...(args.connectionDependency ? { connectionDependency: args.connectionDependency } : {}),
|
||||
...(compatibleEnvironmentIds.length > 0 ? { compatibleEnvironmentIds } : {}),
|
||||
preferredEndpointId: `ws-${id}`,
|
||||
endpoints: [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lstat } from 'node:fs/promises'
|
||||
import { lstat, realpath } from 'node:fs/promises'
|
||||
import type { Stats } from 'node:fs'
|
||||
import { basename, dirname } from 'node:path'
|
||||
|
||||
@@ -18,14 +18,33 @@ function hasSameFilesystemIdentity(oldStat: Stats, newStat: Stats): boolean {
|
||||
return oldStat.dev === newStat.dev && oldStat.ino === newStat.ino
|
||||
}
|
||||
|
||||
function isCaseOnlySameParentRename(oldPath: string, newPath: string): boolean {
|
||||
async function isSameDirectoryEntryRename(
|
||||
oldPath: string,
|
||||
newPath: string,
|
||||
oldStat: Stats,
|
||||
newStat: Stats
|
||||
): Promise<boolean> {
|
||||
const oldBasename = basename(oldPath)
|
||||
const newBasename = basename(newPath)
|
||||
return (
|
||||
dirname(oldPath) === dirname(newPath) &&
|
||||
oldBasename !== newBasename &&
|
||||
caseFoldFileExplorerBasename(oldBasename) === caseFoldFileExplorerBasename(newBasename)
|
||||
)
|
||||
if (!hasSameFilesystemIdentity(oldStat, newStat) || dirname(oldPath) !== dirname(newPath)) {
|
||||
return false
|
||||
}
|
||||
if (oldPath === newPath) {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
const [oldRealPath, newRealPath] = await Promise.all([realpath(oldPath), realpath(newPath)])
|
||||
return oldRealPath === newRealPath
|
||||
} catch (error) {
|
||||
// Preserve case-only renames for dangling symlinks, which realpath cannot resolve.
|
||||
if (!isENOENT(error)) {
|
||||
throw error
|
||||
}
|
||||
return (
|
||||
oldBasename !== newBasename &&
|
||||
caseFoldFileExplorerBasename(oldBasename) === caseFoldFileExplorerBasename(newBasename)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertNoClobberRenameDestinationAvailable(
|
||||
@@ -43,7 +62,7 @@ export async function assertNoClobberRenameDestinationAvailable(
|
||||
}
|
||||
|
||||
const oldStat = await lstat(oldPath)
|
||||
if (hasSameFilesystemIdentity(oldStat, newStat) && isCaseOnlySameParentRename(oldPath, newPath)) {
|
||||
if (await isSameDirectoryEntryRename(oldPath, newPath, oldStat, newStat)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { PAIRING_ENDPOINT_MAX_CHARACTERS } from '../mobile-pairing-protocol-limits'
|
||||
import { parseServerShareAddress } from './server-share-address'
|
||||
|
||||
describe('parseServerShareAddress', () => {
|
||||
@@ -16,19 +17,41 @@ describe('parseServerShareAddress', () => {
|
||||
expect(parseServerShareAddress('my-host:443').ok).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts IPv6 literals with an optional port', () => {
|
||||
expect(parseServerShareAddress('fd7a:115c:a1e0::1').ok).toBe(true)
|
||||
expect(parseServerShareAddress('[fd7a:115c:a1e0::1]:6768').ok).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts ws:// and wss:// URLs', () => {
|
||||
expect(parseServerShareAddress('wss://my-host/path').ok).toBe(true)
|
||||
expect(parseServerShareAddress('ws://192.168.1.50:6768').ok).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects WebSocket URLs with fragments', () => {
|
||||
expect(parseServerShareAddress('wss://my-host/path#fragment').ok).toBe(false)
|
||||
})
|
||||
|
||||
it('trims surrounding whitespace', () => {
|
||||
expect(parseServerShareAddress(' my-host:8080 ')).toEqual({ ok: true, value: 'my-host:8080' })
|
||||
})
|
||||
|
||||
it('rejects empty, whitespace-containing, and malformed input', () => {
|
||||
for (const bad of ['', ' ', 'has space', 'http://my-host', 'wss://', ':6768']) {
|
||||
for (const bad of [
|
||||
'',
|
||||
' ',
|
||||
'has space',
|
||||
'http://my-host',
|
||||
'wss://',
|
||||
':6768',
|
||||
'0.0.0.0',
|
||||
'::',
|
||||
'my-host:0',
|
||||
'999.999.999.999',
|
||||
'wss://user:password@my-host'
|
||||
]) {
|
||||
expect(parseServerShareAddress(bad).ok).toBe(false)
|
||||
}
|
||||
expect(parseServerShareAddress('a'.repeat(PAIRING_ENDPOINT_MAX_CHARACTERS + 1)).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an out-of-range port', () => {
|
||||
|
||||
@@ -1,42 +1,12 @@
|
||||
// Why: shared validator for the "Share this Orca server" custom address. The
|
||||
// field accepts a bare host, host:port, or a full ws(s):// URL — looser than
|
||||
// the mobile pairing grammar because the target is a transport endpoint, not
|
||||
// just an IP. Kept pure so the renderer and any future caller agree.
|
||||
import { parseManualNetworkAddress } from './manual-address'
|
||||
|
||||
export type ParseServerShareAddressResult = { ok: true; value: string } | { ok: false }
|
||||
|
||||
const HOST_LABEL = '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?'
|
||||
const HOSTNAME = `${HOST_LABEL}(?:\\.${HOST_LABEL})*`
|
||||
const IPV4 = '(?:\\d{1,3}\\.){3}\\d{1,3}'
|
||||
const HOST = `(?:${HOSTNAME}|${IPV4})`
|
||||
const PORT = '[0-9]{1,5}'
|
||||
|
||||
const HOST_OR_HOST_PORT = new RegExp(`^${HOST}(?::${PORT})?$`)
|
||||
|
||||
export function parseServerShareAddress(input: string): ParseServerShareAddressResult {
|
||||
const trimmed = input.trim()
|
||||
if (trimmed === '' || /\s/.test(trimmed)) {
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
return { ok: false }
|
||||
}
|
||||
|
||||
// Full ws(s):// URL — defer to the URL parser, which validates host/port/path.
|
||||
if (/^wss?:\/\//i.test(trimmed)) {
|
||||
try {
|
||||
const url = new URL(trimmed)
|
||||
return url.hostname !== '' ? { ok: true, value: trimmed } : { ok: false }
|
||||
} catch {
|
||||
return { ok: false }
|
||||
}
|
||||
}
|
||||
|
||||
// Bare host or host:port. Reject an out-of-range port early.
|
||||
const match = trimmed.match(HOST_OR_HOST_PORT)
|
||||
if (!match) {
|
||||
return { ok: false }
|
||||
}
|
||||
const portPart = trimmed.includes(':') ? trimmed.slice(trimmed.lastIndexOf(':') + 1) : null
|
||||
if (portPart !== null && Number(portPart) > 65535) {
|
||||
return { ok: false }
|
||||
}
|
||||
return { ok: true, value: trimmed }
|
||||
const result = parseManualNetworkAddress(trimmed)
|
||||
return result.ok ? { ok: true, value: result.address } : { ok: false }
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const ORCHESTRATION_RUN_PAGE_LIMIT = 100
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
encodePairingOffer,
|
||||
decodePairingOffer,
|
||||
@@ -22,6 +22,19 @@ describe('pairing offer', () => {
|
||||
expect(decoded).toEqual(offer)
|
||||
})
|
||||
|
||||
it('decodes in browser runtimes without Buffer', () => {
|
||||
const url = encodePairingOffer(offer)
|
||||
const nodeBuffer = Buffer
|
||||
let decoded: PairingOffer | null = null
|
||||
try {
|
||||
vi.stubGlobal('Buffer', undefined)
|
||||
decoded = decodePairingOffer(url)
|
||||
} finally {
|
||||
vi.stubGlobal('Buffer', nodeBuffer)
|
||||
}
|
||||
expect(decoded).toEqual(offer)
|
||||
})
|
||||
|
||||
it('preserves optional device scope metadata', () => {
|
||||
const scopedOffer: PairingOffer = { ...offer, scope: 'mobile' }
|
||||
expect(decodePairingOffer(encodePairingOffer(scopedOffer))).toEqual(scopedOffer)
|
||||
|
||||
@@ -89,6 +89,11 @@ function decodePairingBase64(base64url: string): PairingOffer {
|
||||
throw new Error('Invalid pairing code')
|
||||
}
|
||||
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const json = Buffer.from(base64, 'base64').toString('utf-8')
|
||||
const json =
|
||||
typeof Buffer === 'undefined'
|
||||
? new TextDecoder().decode(
|
||||
Uint8Array.from(atob(base64), (character) => character.charCodeAt(0))
|
||||
)
|
||||
: Buffer.from(base64, 'base64').toString('utf-8')
|
||||
return PairingOfferSchema.parse(JSON.parse(json))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { encodePairingOffer, PAIRING_OFFER_VERSION } from './pairing'
|
||||
import { classifyRemotePairingHostname, parseHostAccessLink } from './remote-pairing-address'
|
||||
|
||||
function accessLink(endpoint: string): string {
|
||||
return encodePairingOffer({
|
||||
v: PAIRING_OFFER_VERSION,
|
||||
endpoint,
|
||||
deviceToken: 'token',
|
||||
publicKeyB64: 'key',
|
||||
scope: 'runtime'
|
||||
})
|
||||
}
|
||||
|
||||
describe('remote pairing address', () => {
|
||||
it.each([
|
||||
['127.0.0.1', 'loopback'],
|
||||
['localhost', 'loopback'],
|
||||
['localhost.', 'loopback'],
|
||||
['api.localhost', 'loopback'],
|
||||
['api.localhost.', 'loopback'],
|
||||
['localhost.localdomain', 'loopback'],
|
||||
['localhost6', 'loopback'],
|
||||
['ip6-localhost', 'loopback'],
|
||||
['::1', 'loopback'],
|
||||
['::ffff:7f00:1', 'loopback'],
|
||||
['100.76.32.125', 'tailscale'],
|
||||
['::ffff:644c:207d', 'tailscale'],
|
||||
['192.168.1.20', 'lan'],
|
||||
['10.0.0.8', 'lan'],
|
||||
['fd7a:115c:a1e0::1', 'lan'],
|
||||
['fe80::1', 'lan'],
|
||||
['orca.example.com', 'public'],
|
||||
['devbox', 'custom']
|
||||
] as const)('classifies %s as %s', (hostname, expected) => {
|
||||
expect(classifyRemotePairingHostname(hostname)).toBe(expected)
|
||||
})
|
||||
|
||||
it('extracts a sanitized display endpoint without credentials', () => {
|
||||
expect(parseHostAccessLink(accessLink('wss://orca.example.com/runtime'))).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
pairing: expect.objectContaining({ endpoint: 'wss://orca.example.com/runtime' }),
|
||||
displayEndpoint: 'orca.example.com',
|
||||
endpointKind: 'public'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps IPv6 brackets in the display endpoint', () => {
|
||||
const result = parseHostAccessLink(accessLink('ws://[fd7a:115c:a1e0::1]:6768'))
|
||||
expect(result.ok && result.value.displayEndpoint).toBe('[fd7a:115c:a1e0::1]:6768')
|
||||
})
|
||||
|
||||
it('rejects invalid and unsupported endpoints', () => {
|
||||
expect(parseHostAccessLink('not-a-link')).toMatchObject({
|
||||
ok: false,
|
||||
kind: 'invalid-input'
|
||||
})
|
||||
expect(parseHostAccessLink(accessLink('https://orca.example.com'))).toMatchObject({
|
||||
ok: false,
|
||||
kind: 'unsupported-destination'
|
||||
})
|
||||
expect(parseHostAccessLink(accessLink('wss://orca.example.com/#fragment'))).toMatchObject({
|
||||
ok: false,
|
||||
kind: 'unsupported-destination'
|
||||
})
|
||||
expect(parseHostAccessLink(accessLink('ws://[::ffff:0.0.0.0]:6768'))).toMatchObject({
|
||||
ok: false,
|
||||
kind: 'non-connectable-destination'
|
||||
})
|
||||
expect(parseHostAccessLink(accessLink('wss://orca.example.com:0'))).toMatchObject({
|
||||
ok: false,
|
||||
kind: 'non-connectable-destination'
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks absolute localhost names used in access links', () => {
|
||||
expect(parseHostAccessLink(accessLink('ws://localhost.:6768'))).toMatchObject({
|
||||
ok: true,
|
||||
value: { endpointKind: 'loopback' }
|
||||
})
|
||||
expect(parseHostAccessLink(accessLink('ws://api.localhost.:6768'))).toMatchObject({
|
||||
ok: true,
|
||||
value: { endpointKind: 'loopback' }
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects mobile-only access grants', () => {
|
||||
const link = encodePairingOffer({
|
||||
v: PAIRING_OFFER_VERSION,
|
||||
endpoint: 'wss://orca.example.com',
|
||||
deviceToken: 'token',
|
||||
publicKeyB64: 'key',
|
||||
scope: 'mobile'
|
||||
})
|
||||
expect(parseHostAccessLink(link)).toMatchObject({ ok: false, kind: 'mobile-only' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
import { parsePairingCode, type PairingOffer } from './pairing'
|
||||
import { isTailnetIPv4Address } from './tailnet-address'
|
||||
|
||||
export type RemotePairingEndpointKind = 'loopback' | 'tailscale' | 'lan' | 'public' | 'custom'
|
||||
|
||||
export type ParsedHostAccessLink = {
|
||||
pairing: PairingOffer
|
||||
displayEndpoint: string
|
||||
endpointKind: RemotePairingEndpointKind
|
||||
}
|
||||
|
||||
export type HostAccessLinkErrorKind =
|
||||
| 'invalid-input'
|
||||
| 'mobile-only'
|
||||
| 'invalid-destination'
|
||||
| 'unsupported-destination'
|
||||
| 'non-connectable-destination'
|
||||
|
||||
export type ParseHostAccessLinkResult =
|
||||
| { ok: true; value: ParsedHostAccessLink }
|
||||
| { ok: false; kind: HostAccessLinkErrorKind; message: string }
|
||||
|
||||
const LOOPBACK_HOSTS = new Set([
|
||||
'localhost',
|
||||
'localhost.localdomain',
|
||||
'localhost6',
|
||||
'localhost6.localdomain6',
|
||||
'ip6-localhost',
|
||||
'ip6-loopback',
|
||||
'127.0.0.1',
|
||||
'::1'
|
||||
])
|
||||
|
||||
function isPrivateIPv4Address(hostname: string): boolean {
|
||||
const octets = hostname.split('.').map(Number)
|
||||
if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet))) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
octets[0] === 10 ||
|
||||
(octets[0] === 172 && octets[1]! >= 16 && octets[1]! <= 31) ||
|
||||
(octets[0] === 192 && octets[1] === 168)
|
||||
)
|
||||
}
|
||||
|
||||
function isPrivateIPv6Address(hostname: string): boolean {
|
||||
const firstHextet = Number.parseInt(hostname.split(':')[0] ?? '', 16)
|
||||
return (
|
||||
Number.isInteger(firstHextet) &&
|
||||
((firstHextet & 0xfe00) === 0xfc00 || (firstHextet & 0xffc0) === 0xfe80)
|
||||
)
|
||||
}
|
||||
|
||||
function getEmbeddedIPv4Address(hostname: string): string | null {
|
||||
const match = hostname.match(/^::(?:ffff:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const high = Number.parseInt(match[1]!, 16)
|
||||
const low = Number.parseInt(match[2]!, 16)
|
||||
return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`
|
||||
}
|
||||
|
||||
export function classifyRemotePairingHostname(hostname: string): RemotePairingEndpointKind {
|
||||
const normalized = hostname
|
||||
.toLowerCase()
|
||||
.replace(/^\[|\]$/g, '')
|
||||
.replace(/\.$/, '')
|
||||
const embeddedIPv4 = getEmbeddedIPv4Address(normalized)
|
||||
if (embeddedIPv4) {
|
||||
return classifyRemotePairingHostname(embeddedIPv4)
|
||||
}
|
||||
if (
|
||||
LOOPBACK_HOSTS.has(normalized) ||
|
||||
normalized.endsWith('.localhost') ||
|
||||
normalized.startsWith('127.')
|
||||
) {
|
||||
return 'loopback'
|
||||
}
|
||||
if (isTailnetIPv4Address(normalized)) {
|
||||
return 'tailscale'
|
||||
}
|
||||
if (isPrivateIPv4Address(normalized) || isPrivateIPv6Address(normalized)) {
|
||||
return 'lan'
|
||||
}
|
||||
return normalized.includes('.') || normalized.includes(':') ? 'public' : 'custom'
|
||||
}
|
||||
|
||||
export function parseHostAccessLink(input: string): ParseHostAccessLinkResult {
|
||||
const pairing = parsePairingCode(input)
|
||||
if (!pairing) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'invalid-input',
|
||||
message: 'Enter an Orca access link or bare pairing code.'
|
||||
}
|
||||
}
|
||||
if (pairing.scope === 'mobile') {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'mobile-only',
|
||||
message: 'This link grants mobile-only access. Generate a link for another Orca client.'
|
||||
}
|
||||
}
|
||||
let endpoint: URL
|
||||
try {
|
||||
endpoint = new URL(pairing.endpoint)
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'invalid-destination',
|
||||
message: 'This access link contains an invalid destination.'
|
||||
}
|
||||
}
|
||||
if (
|
||||
(endpoint.protocol !== 'ws:' && endpoint.protocol !== 'wss:') ||
|
||||
!endpoint.hostname ||
|
||||
endpoint.hash !== ''
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'unsupported-destination',
|
||||
message: 'This access link contains an unsupported destination.'
|
||||
}
|
||||
}
|
||||
const normalizedHostname = endpoint.hostname.toLowerCase().replace(/^\[|\]$/g, '')
|
||||
if (
|
||||
normalizedHostname === '0.0.0.0' ||
|
||||
normalizedHostname === '::' ||
|
||||
getEmbeddedIPv4Address(normalizedHostname) === '0.0.0.0' ||
|
||||
endpoint.port === '0'
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'non-connectable-destination',
|
||||
message: 'This access link contains a non-connectable destination.'
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
pairing,
|
||||
displayEndpoint: endpoint.host,
|
||||
endpointKind: classifyRemotePairingHostname(endpoint.hostname)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, RUNTIME_PROTOCOL_VERSION } from './protocol-version'
|
||||
import { verifyRemotePairingRuntimeStatus } from './remote-pairing-verification'
|
||||
|
||||
function runtimeStatus(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
runtimeId: 'runtime-a',
|
||||
rendererGraphEpoch: 1,
|
||||
graphStatus: 'ready',
|
||||
authoritativeWindowId: 1,
|
||||
liveTabCount: 0,
|
||||
liveLeafCount: 0,
|
||||
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('verifyRemotePairingRuntimeStatus', () => {
|
||||
it('rejects malformed status responses', () => {
|
||||
expect(verifyRemotePairingRuntimeStatus(null)).toMatchObject({
|
||||
ok: false,
|
||||
kind: 'connection-interrupted'
|
||||
})
|
||||
expect(
|
||||
verifyRemotePairingRuntimeStatus(
|
||||
runtimeStatus({
|
||||
runtimeProtocolVersion: Number.NaN
|
||||
})
|
||||
)
|
||||
).toMatchObject({ ok: false, kind: 'connection-interrupted' })
|
||||
expect(
|
||||
verifyRemotePairingRuntimeStatus({ runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION })
|
||||
).toMatchObject({ ok: false, kind: 'connection-interrupted' })
|
||||
expect(
|
||||
verifyRemotePairingRuntimeStatus(runtimeStatus({ capabilities: 'runtime.status.compat.v1' }))
|
||||
).toMatchObject({ ok: false, kind: 'connection-interrupted' })
|
||||
expect(verifyRemotePairingRuntimeStatus(runtimeStatus({ deviceScope: 'admin' }))).toMatchObject(
|
||||
{
|
||||
ok: false,
|
||||
kind: 'connection-interrupted'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects mobile-only access grants', () => {
|
||||
expect(
|
||||
verifyRemotePairingRuntimeStatus(
|
||||
runtimeStatus({
|
||||
deviceScope: 'mobile'
|
||||
})
|
||||
)
|
||||
).toMatchObject({ ok: false, kind: 'access-link-invalid' })
|
||||
})
|
||||
|
||||
it('rejects incompatible hosts', () => {
|
||||
expect(
|
||||
verifyRemotePairingRuntimeStatus(
|
||||
runtimeStatus({
|
||||
runtimeProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION - 1
|
||||
})
|
||||
)
|
||||
).toMatchObject({ ok: false, kind: 'protocol-incompatible' })
|
||||
})
|
||||
|
||||
it('accepts a compatible runtime status', () => {
|
||||
expect(verifyRemotePairingRuntimeStatus(runtimeStatus())).toMatchObject({ ok: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import { evaluateRuntimeCompat } from './protocol-compat'
|
||||
import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, RUNTIME_PROTOCOL_VERSION } from './protocol-version'
|
||||
import type { PublicKnownRuntimeEnvironment } from './runtime-environments'
|
||||
import type { RuntimeStatus } from './runtime-types'
|
||||
|
||||
export type RemotePairingFailureKind =
|
||||
| 'host-unreachable'
|
||||
| 'host-identity-mismatch'
|
||||
| 'access-link-invalid'
|
||||
| 'protocol-incompatible'
|
||||
| 'connection-interrupted'
|
||||
| 'environment-save-failed'
|
||||
|
||||
export type RemotePairingFailure = {
|
||||
ok: false
|
||||
kind: RemotePairingFailureKind
|
||||
message: string
|
||||
}
|
||||
|
||||
export type VerifyAndAddRuntimeEnvironmentResult =
|
||||
| {
|
||||
ok: true
|
||||
environment: PublicKnownRuntimeEnvironment
|
||||
runtimeStatus: RuntimeStatus
|
||||
}
|
||||
| RemotePairingFailure
|
||||
|
||||
const RUNTIME_GRAPH_STATUSES = new Set(['ready', 'reloading', 'unavailable'])
|
||||
|
||||
function isNonNegativeSafeInteger(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && Number(value) >= 0
|
||||
}
|
||||
|
||||
function hasValidRuntimeStatusShape(status: Record<string, unknown>): boolean {
|
||||
return (
|
||||
typeof status.runtimeId === 'string' &&
|
||||
status.runtimeId.length > 0 &&
|
||||
isNonNegativeSafeInteger(status.rendererGraphEpoch) &&
|
||||
typeof status.graphStatus === 'string' &&
|
||||
RUNTIME_GRAPH_STATUSES.has(status.graphStatus) &&
|
||||
(status.authoritativeWindowId === null ||
|
||||
isNonNegativeSafeInteger(status.authoritativeWindowId)) &&
|
||||
isNonNegativeSafeInteger(status.liveTabCount) &&
|
||||
isNonNegativeSafeInteger(status.liveLeafCount) &&
|
||||
(status.deviceScope === undefined ||
|
||||
status.deviceScope === 'mobile' ||
|
||||
status.deviceScope === 'runtime') &&
|
||||
(status.capabilities === undefined ||
|
||||
(Array.isArray(status.capabilities) &&
|
||||
status.capabilities.every((capability) => typeof capability === 'string')))
|
||||
)
|
||||
}
|
||||
|
||||
export function verifyRemotePairingRuntimeStatus(
|
||||
value: unknown
|
||||
): { ok: true; runtimeStatus: RuntimeStatus } | RemotePairingFailure {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'connection-interrupted',
|
||||
message: 'The remote host returned an invalid status response.'
|
||||
}
|
||||
}
|
||||
const status = value as Partial<RuntimeStatus> & Record<string, unknown>
|
||||
if (status.deviceScope === 'mobile') {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'access-link-invalid',
|
||||
message: 'This link grants mobile-only access. Generate a link for another Orca client.'
|
||||
}
|
||||
}
|
||||
const versionFields = [
|
||||
status.runtimeProtocolVersion,
|
||||
status.protocolVersion,
|
||||
status.minCompatibleRuntimeClientVersion,
|
||||
status.minCompatibleMobileVersion
|
||||
]
|
||||
if (
|
||||
versionFields.some(
|
||||
(version) => version !== undefined && (!Number.isSafeInteger(version) || Number(version) < 0)
|
||||
)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'connection-interrupted',
|
||||
message: 'The remote host returned an invalid protocol version.'
|
||||
}
|
||||
}
|
||||
const compatibility = evaluateRuntimeCompat({
|
||||
clientProtocolVersion: RUNTIME_PROTOCOL_VERSION,
|
||||
minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION,
|
||||
serverProtocolVersion: status.runtimeProtocolVersion ?? status.protocolVersion,
|
||||
serverMinCompatibleClientProtocolVersion:
|
||||
status.minCompatibleRuntimeClientVersion ?? status.minCompatibleMobileVersion
|
||||
})
|
||||
if (compatibility.kind === 'blocked') {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'protocol-incompatible',
|
||||
message:
|
||||
compatibility.reason === 'client-too-old'
|
||||
? 'Update this Orca client before adding the remote host.'
|
||||
: 'Update Orca on the remote host before adding it.'
|
||||
}
|
||||
}
|
||||
if (!hasValidRuntimeStatusShape(status)) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'connection-interrupted',
|
||||
message: 'The remote host returned an invalid status response.'
|
||||
}
|
||||
}
|
||||
return { ok: true, runtimeStatus: value as RuntimeStatus }
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
export type RemoteRuntimePairingStage = 'connect' | 'host-identity' | 'access-grant' | 'runtime'
|
||||
|
||||
/**
|
||||
* Error type for the remote-runtime client, split out from
|
||||
* `remote-runtime-client.ts` so type-only consumers can reference it without
|
||||
@@ -7,10 +9,21 @@
|
||||
*/
|
||||
export class RemoteRuntimeClientError extends Error {
|
||||
readonly code: string
|
||||
readonly pairingStage?: RemoteRuntimePairingStage
|
||||
readonly closeCode?: number
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
constructor(
|
||||
code: string,
|
||||
message: string,
|
||||
details?: {
|
||||
pairingStage?: RemoteRuntimePairingStage
|
||||
closeCode?: number
|
||||
}
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'RemoteRuntimeClientError'
|
||||
this.code = code
|
||||
this.pairingStage = details?.pairingStage
|
||||
this.closeCode = details?.closeCode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +197,28 @@ describe('sendRemoteRuntimeRequest', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('classifies a non-Orca handshake as a host identity mismatch', async () => {
|
||||
const server = await createInvalidHandshakeServer()
|
||||
|
||||
await expect(
|
||||
sendRemoteRuntimeRequest(server.pairing, 'status.get', {}, 1000)
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_runtime_response',
|
||||
pairingStage: 'host-identity'
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies an undecryptable post-auth frame as a runtime failure', async () => {
|
||||
const server = await createOneShotServer({ sendUndecryptableResponse: true })
|
||||
|
||||
await expect(
|
||||
sendRemoteRuntimeRequest(server.pairing, 'status.get', {}, 1000)
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_runtime_response',
|
||||
pairingStage: 'runtime'
|
||||
})
|
||||
})
|
||||
|
||||
it('refreshes the per-call timeout when the runtime sends keepalive frames', async () => {
|
||||
const server = await createOneShotServer()
|
||||
|
||||
@@ -425,10 +447,35 @@ async function createClosingServer(
|
||||
return { pairing }
|
||||
}
|
||||
|
||||
async function createInvalidHandshakeServer(): Promise<{ pairing: PairingOffer }> {
|
||||
const serverKeyPair = generateKeyPair()
|
||||
const wss = new WebSocketServer({ port: 0 })
|
||||
servers.push(wss)
|
||||
wss.on('connection', (ws) => {
|
||||
ws.once('message', () => ws.send(JSON.stringify({ type: 'not_orca' })))
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve) => wss.once('listening', resolve))
|
||||
const address = wss.address() as AddressInfo
|
||||
const pairing = parsePairingCode(
|
||||
encodePairingOffer({
|
||||
v: 2,
|
||||
endpoint: `ws://127.0.0.1:${address.port}`,
|
||||
deviceToken: 'device-token',
|
||||
publicKeyB64: publicKeyToBase64(serverKeyPair.publicKey)
|
||||
})
|
||||
)
|
||||
if (!pairing) {
|
||||
throw new Error('Failed to create test pairing')
|
||||
}
|
||||
return { pairing }
|
||||
}
|
||||
|
||||
async function createOneShotServer(
|
||||
options: {
|
||||
response?: (requestId: string) => unknown
|
||||
onRequest?: (request: Record<string, unknown>) => void
|
||||
sendUndecryptableResponse?: boolean
|
||||
} = {}
|
||||
): Promise<{ pairing: PairingOffer }> {
|
||||
const serverKeyPair = generateKeyPair()
|
||||
@@ -466,6 +513,10 @@ async function createOneShotServer(
|
||||
|
||||
const request = JSON.parse(plaintext) as { id: string } & Record<string, unknown>
|
||||
options.onRequest?.(request)
|
||||
if (options.sendUndecryptableResponse) {
|
||||
ws.send('not-an-encrypted-frame')
|
||||
return
|
||||
}
|
||||
const key = sharedKey
|
||||
const keepalive = setInterval(() => {
|
||||
sendEncrypted(ws, key, { _keepalive: true })
|
||||
|
||||
@@ -121,6 +121,12 @@ export async function sendRemoteRuntimeRequest<TResult>(
|
||||
let state: HandshakeState = 'awaiting_ready'
|
||||
let settled = false
|
||||
let ws: WebSocket | null = null
|
||||
const getPairingStage = (): 'connect' | 'host-identity' | 'runtime' =>
|
||||
state === 'awaiting_ready'
|
||||
? 'connect'
|
||||
: state === 'awaiting_authenticated'
|
||||
? 'host-identity'
|
||||
: 'runtime'
|
||||
|
||||
const cleanupSocketListeners = (): void => {
|
||||
const socket = ws
|
||||
@@ -145,7 +151,8 @@ export async function sendRemoteRuntimeRequest<TResult>(
|
||||
ok: false,
|
||||
error: new RemoteRuntimeClientError(
|
||||
'runtime_timeout',
|
||||
'Timed out waiting for the remote Orca runtime to respond.'
|
||||
'Timed out waiting for the remote Orca runtime to respond.',
|
||||
{ pairingStage: getPairingStage() }
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -210,7 +217,8 @@ export async function sendRemoteRuntimeRequest<TResult>(
|
||||
ok: false,
|
||||
error: new RemoteRuntimeClientError(
|
||||
'remote_runtime_unavailable',
|
||||
'Could not connect to the remote Orca runtime.'
|
||||
'Could not connect to the remote Orca runtime.',
|
||||
{ pairingStage: getPairingStage() }
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -221,7 +229,11 @@ export async function sendRemoteRuntimeRequest<TResult>(
|
||||
ok: false,
|
||||
error: new RemoteRuntimeClientError(
|
||||
'remote_runtime_unavailable',
|
||||
formatRemoteRuntimeCloseMessage(code, reason)
|
||||
formatRemoteRuntimeCloseMessage(code, reason),
|
||||
{
|
||||
pairingStage: getPairingStage(),
|
||||
closeCode: code
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -236,7 +248,10 @@ export async function sendRemoteRuntimeRequest<TResult>(
|
||||
ok: false,
|
||||
error: new RemoteRuntimeClientError(
|
||||
'invalid_runtime_response',
|
||||
'Remote Orca runtime returned an unexpected binary frame.'
|
||||
'Remote Orca runtime returned an unexpected binary frame.',
|
||||
{
|
||||
pairingStage: state === 'awaiting_ready' ? 'host-identity' : getPairingStage()
|
||||
}
|
||||
)
|
||||
})
|
||||
return
|
||||
@@ -254,7 +269,10 @@ export async function sendRemoteRuntimeRequest<TResult>(
|
||||
ok: false,
|
||||
error: new RemoteRuntimeClientError(
|
||||
'invalid_runtime_response',
|
||||
'Remote Orca runtime returned an undecryptable frame.'
|
||||
'Remote Orca runtime returned an undecryptable frame.',
|
||||
{
|
||||
pairingStage: state === 'awaiting_authenticated' ? 'host-identity' : getPairingStage()
|
||||
}
|
||||
)
|
||||
})
|
||||
return
|
||||
@@ -282,7 +300,8 @@ export async function sendRemoteRuntimeRequest<TResult>(
|
||||
ok: false,
|
||||
error: new RemoteRuntimeClientError(
|
||||
'invalid_runtime_response',
|
||||
'Remote Orca runtime returned an invalid E2EE handshake frame.'
|
||||
'Remote Orca runtime returned an invalid E2EE handshake frame.',
|
||||
{ pairingStage: 'host-identity' }
|
||||
)
|
||||
})
|
||||
return
|
||||
@@ -296,7 +315,8 @@ export async function sendRemoteRuntimeRequest<TResult>(
|
||||
ok: false,
|
||||
error: new RemoteRuntimeClientError(
|
||||
'invalid_runtime_response',
|
||||
'Remote Orca runtime returned an unexpected E2EE handshake frame.'
|
||||
'Remote Orca runtime returned an unexpected E2EE handshake frame.',
|
||||
{ pairingStage: 'host-identity' }
|
||||
)
|
||||
})
|
||||
return
|
||||
@@ -314,7 +334,8 @@ export async function sendRemoteRuntimeRequest<TResult>(
|
||||
ok: false,
|
||||
error: new RemoteRuntimeClientError(
|
||||
'invalid_runtime_response',
|
||||
'Remote Orca runtime returned an invalid E2EE auth frame.'
|
||||
'Remote Orca runtime returned an invalid E2EE auth frame.',
|
||||
{ pairingStage: 'host-identity' }
|
||||
)
|
||||
})
|
||||
return
|
||||
@@ -331,7 +352,8 @@ export async function sendRemoteRuntimeRequest<TResult>(
|
||||
ok: false,
|
||||
error: new RemoteRuntimeClientError(
|
||||
code,
|
||||
'Remote Orca runtime rejected the pairing token.'
|
||||
'Remote Orca runtime rejected the pairing token.',
|
||||
{ pairingStage: code === 'unauthorized' ? 'access-grant' : 'host-identity' }
|
||||
)
|
||||
})
|
||||
return
|
||||
@@ -361,7 +383,8 @@ export async function sendRemoteRuntimeRequest<TResult>(
|
||||
ok: false,
|
||||
error: new RemoteRuntimeClientError(
|
||||
'invalid_runtime_response',
|
||||
'Remote Orca runtime returned an invalid response frame.'
|
||||
'Remote Orca runtime returned an invalid response frame.',
|
||||
{ pairingStage: 'runtime' }
|
||||
)
|
||||
})
|
||||
return
|
||||
@@ -376,7 +399,8 @@ export async function sendRemoteRuntimeRequest<TResult>(
|
||||
ok: false,
|
||||
error: new RemoteRuntimeClientError(
|
||||
'invalid_runtime_response',
|
||||
'Remote Orca runtime returned an invalid response frame.'
|
||||
'Remote Orca runtime returned an invalid response frame.',
|
||||
{ pairingStage: 'runtime' }
|
||||
)
|
||||
})
|
||||
return
|
||||
@@ -387,7 +411,8 @@ export async function sendRemoteRuntimeRequest<TResult>(
|
||||
ok: false,
|
||||
error: new RemoteRuntimeClientError(
|
||||
'invalid_runtime_response',
|
||||
'Remote Orca runtime returned a mismatched response id.'
|
||||
'Remote Orca runtime returned a mismatched response id.',
|
||||
{ pairingStage: 'runtime' }
|
||||
)
|
||||
})
|
||||
return
|
||||
|
||||
@@ -87,6 +87,29 @@ describe('runtime environment store', () => {
|
||||
]).toEqual([101, 102, 200])
|
||||
})
|
||||
|
||||
it('keeps SSH-tunnel metadata only while the pairing endpoint is loopback', () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-env-store-'))
|
||||
tempDirs.push(userDataPath)
|
||||
const environment = addEnvironmentFromPairingCode(userDataPath, {
|
||||
name: 'tunneled box',
|
||||
pairingCode: pairingCode(),
|
||||
connectionDependency: 'ssh-tunnel'
|
||||
})
|
||||
expect(environment.connectionDependency).toBe('ssh-tunnel')
|
||||
|
||||
const updated = updateEnvironmentFromPairingCode(userDataPath, environment.id, {
|
||||
pairingCode: pairingCode('ws://192.0.2.10:6768')
|
||||
})
|
||||
expect(updated).not.toHaveProperty('connectionDependency')
|
||||
|
||||
const direct = addEnvironmentFromPairingCode(userDataPath, {
|
||||
name: 'direct box',
|
||||
pairingCode: pairingCode('ws://192.0.2.11:6768'),
|
||||
connectionDependency: 'ssh-tunnel'
|
||||
})
|
||||
expect(direct).not.toHaveProperty('connectionDependency')
|
||||
})
|
||||
|
||||
it('throttles lastUsedAt writes so it does not rewrite the store on every runtime call', () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-env-store-'))
|
||||
tempDirs.push(userDataPath)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from 'node:path'
|
||||
import { JsonStringifyByteLimitError } from './node-bounded-json-stringify'
|
||||
import { readNodeFileSyncWithinLimit } from './node-bounded-file-reader'
|
||||
import { parsePairingCode, type PairingOffer } from './pairing'
|
||||
import { classifyRemotePairingHostname } from './remote-pairing-address'
|
||||
import { writeSecureJsonFileWithinLimit } from './bounded-secure-json-file'
|
||||
import { hardenExistingSecureFile } from './secure-file'
|
||||
import {
|
||||
@@ -41,7 +42,13 @@ export function listEnvironments(userDataPath: string): KnownRuntimeEnvironment[
|
||||
|
||||
export function addEnvironmentFromPairingCode(
|
||||
userDataPath: string,
|
||||
args: { name: string; pairingCode: string; now?: number; source?: RuntimeEnvironmentSource }
|
||||
args: {
|
||||
name: string
|
||||
pairingCode: string
|
||||
now?: number
|
||||
source?: RuntimeEnvironmentSource
|
||||
connectionDependency?: 'ssh-tunnel'
|
||||
}
|
||||
): KnownRuntimeEnvironment {
|
||||
const offer = parsePairingCode(args.pairingCode)
|
||||
if (!offer) {
|
||||
@@ -65,7 +72,8 @@ export function addEnvironmentFromPairingCode(
|
||||
now,
|
||||
offer,
|
||||
runtimeId: null,
|
||||
...(args.source ? { source: args.source } : {})
|
||||
...(args.source ? { source: args.source } : {}),
|
||||
...getPairingConnectionDependency(args.connectionDependency, offer)
|
||||
})
|
||||
const next = {
|
||||
version: 1 as const,
|
||||
@@ -110,7 +118,8 @@ export function updateEnvironmentFromPairingCode(
|
||||
now: existing.createdAt,
|
||||
offer,
|
||||
runtimeId: existing.runtimeId,
|
||||
...(existing.source ? { source: existing.source } : {})
|
||||
...(existing.source ? { source: existing.source } : {}),
|
||||
...getPairingConnectionDependency(existing.connectionDependency, offer)
|
||||
})
|
||||
const next = {
|
||||
...environment,
|
||||
@@ -128,6 +137,23 @@ export function updateEnvironmentFromPairingCode(
|
||||
return next
|
||||
}
|
||||
|
||||
function getPairingConnectionDependency(
|
||||
dependency: 'ssh-tunnel' | undefined,
|
||||
offer: PairingOffer
|
||||
): { connectionDependency?: 'ssh-tunnel' } {
|
||||
if (!dependency) {
|
||||
return {}
|
||||
}
|
||||
try {
|
||||
const endpoint = new URL(offer.endpoint)
|
||||
return classifyRemotePairingHostname(endpoint.hostname) === 'loopback'
|
||||
? { connectionDependency: dependency }
|
||||
: {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveEnvironment(
|
||||
userDataPath: string,
|
||||
selector: string
|
||||
|
||||
@@ -31,6 +31,7 @@ export const KnownRuntimeEnvironmentSchema = z.object({
|
||||
lastUsedAt: z.number().finite().nullable(),
|
||||
runtimeId: z.string().min(1).nullable(),
|
||||
source: RuntimeEnvironmentSourceSchema.optional(),
|
||||
connectionDependency: z.literal('ssh-tunnel').optional(),
|
||||
endpoints: z.array(RuntimeAccessEndpointSchema).min(1),
|
||||
preferredEndpointId: z.string().min(1)
|
||||
})
|
||||
@@ -66,6 +67,7 @@ export function createEnvironmentFromPairingOffer(args: {
|
||||
offer: PairingOffer
|
||||
runtimeId?: string | null
|
||||
source?: RuntimeEnvironmentSource
|
||||
connectionDependency?: 'ssh-tunnel'
|
||||
}): KnownRuntimeEnvironment {
|
||||
const endpointId = `ws-${args.id}`
|
||||
return KnownRuntimeEnvironmentSchema.parse({
|
||||
@@ -77,6 +79,7 @@ export function createEnvironmentFromPairingOffer(args: {
|
||||
lastUsedAt: null,
|
||||
runtimeId: args.runtimeId ?? null,
|
||||
...(args.source ? { source: args.source } : {}),
|
||||
...(args.connectionDependency ? { connectionDependency: args.connectionDependency } : {}),
|
||||
endpoints: [
|
||||
{
|
||||
id: endpointId,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import path from 'node:path'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
|
||||
test.describe('floating Markdown filesystem aliases', () => {
|
||||
test.skip(process.platform !== 'darwin', 'Requires native APFS alias behavior')
|
||||
|
||||
test('renames one APFS entry through its Unicode alias', async ({ orcaPage }) => {
|
||||
const directory = await orcaPage.evaluate(() => window.api.app.getFloatingMarkdownDirectory())
|
||||
const suffix = Date.now().toString(36)
|
||||
const originalPath = path.join(directory, `floating-alias-${suffix}-straße.md`)
|
||||
const renamedPath = path.join(directory, `floating-alias-${suffix}-STRASSE.MD`)
|
||||
const renamedName = path.basename(renamedPath)
|
||||
|
||||
const result = await orcaPage.evaluate(
|
||||
async ({ directory, originalPath, renamedPath, renamedName }) => {
|
||||
await window.api.fs.createFile({ filePath: originalPath })
|
||||
await window.api.fs.writeFile({ filePath: originalPath, content: 'same entry\n' })
|
||||
const settled = await Promise.allSettled([
|
||||
window.api.fs.rename({ oldPath: originalPath, newPath: renamedPath })
|
||||
])
|
||||
return {
|
||||
status: settled[0].status,
|
||||
reason: settled[0].status === 'rejected' ? String(settled[0].reason) : null,
|
||||
content: (await window.api.fs.readFile({ filePath: renamedPath })).content,
|
||||
renamedEntryExists: (await window.api.fs.readDir({ dirPath: directory })).some(
|
||||
({ name }) => name === renamedName
|
||||
)
|
||||
}
|
||||
},
|
||||
{ directory, originalPath, renamedPath, renamedName }
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'fulfilled',
|
||||
reason: null,
|
||||
content: 'same entry\n',
|
||||
renamedEntryExists: true
|
||||
})
|
||||
})
|
||||
|
||||
test('keeps dotless and ASCII I destinations distinct through IPC', async ({ orcaPage }) => {
|
||||
const directory = await orcaPage.evaluate(() => window.api.app.getFloatingMarkdownDirectory())
|
||||
const suffix = Date.now().toString(36)
|
||||
const firstPath = path.join(directory, `floating-dotless-first-${suffix}.md`)
|
||||
const secondPath = path.join(directory, `floating-dotless-second-${suffix}.md`)
|
||||
const dotlessDestination = path.join(directory, `floating-destination-${suffix}-ı.md`)
|
||||
const asciiDestination = path.join(directory, `floating-destination-${suffix}-I.md`)
|
||||
|
||||
const result = await orcaPage.evaluate(
|
||||
async ({ firstPath, secondPath, dotlessDestination, asciiDestination }) => {
|
||||
await window.api.fs.createFile({ filePath: firstPath })
|
||||
await window.api.fs.createFile({ filePath: secondPath })
|
||||
await window.api.fs.writeFile({ filePath: firstPath, content: 'dotless\n' })
|
||||
await window.api.fs.writeFile({ filePath: secondPath, content: 'ascii\n' })
|
||||
|
||||
const settled = await Promise.allSettled([
|
||||
window.api.fs.rename({ oldPath: firstPath, newPath: dotlessDestination }),
|
||||
window.api.fs.rename({ oldPath: secondPath, newPath: asciiDestination })
|
||||
])
|
||||
return {
|
||||
statuses: settled.map(({ status }) => status),
|
||||
dotlessContent: (await window.api.fs.readFile({ filePath: dotlessDestination })).content,
|
||||
asciiContent: (await window.api.fs.readFile({ filePath: asciiDestination })).content
|
||||
}
|
||||
},
|
||||
{ firstPath, secondPath, dotlessDestination, asciiDestination }
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
statuses: ['fulfilled', 'fulfilled'],
|
||||
dotlessContent: 'dotless\n',
|
||||
asciiContent: 'ascii\n'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,295 @@
|
||||
import path from 'node:path'
|
||||
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { waitForSessionReady } from './helpers/store'
|
||||
import { createRestartSession } from './helpers/orca-restart'
|
||||
|
||||
// Why: mirrors FLOATING_TERMINAL_WORKTREE_ID in src/shared/constants.ts.
|
||||
// E2E specs avoid importing renderer/shared modules into the Playwright runner.
|
||||
const FLOATING_WORKTREE_ID = 'global-floating-terminal'
|
||||
const OPEN_PANEL_SELECTOR = '[data-floating-terminal-panel][aria-hidden="false"]'
|
||||
const PANEL_SELECTOR = '[data-floating-terminal-panel]'
|
||||
|
||||
async function seedFloatingMarkdownFile(page: Page): Promise<{
|
||||
originalName: string
|
||||
originalPath: string
|
||||
intermediateName: string
|
||||
intermediatePath: string
|
||||
renamedName: string
|
||||
renamedPath: string
|
||||
tabId: string
|
||||
}> {
|
||||
const directory = await page.evaluate(() => window.api.app.getFloatingMarkdownDirectory())
|
||||
const suffix = Date.now().toString(36)
|
||||
const originalName = `floating-rename-${suffix}.md`
|
||||
const intermediateName = `floating-entered-${suffix}.md`
|
||||
const renamedName = `floating-renamed-${suffix}.md`
|
||||
const originalPath = path.join(directory, originalName)
|
||||
const intermediatePath = path.join(directory, intermediateName)
|
||||
const renamedPath = path.join(directory, renamedName)
|
||||
const tabId = await page.evaluate(
|
||||
async ({ filePath, originalName, worktreeId }) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('Store unavailable')
|
||||
}
|
||||
|
||||
await store.getState().updateSettings({ floatingTerminalEnabled: true })
|
||||
await window.api.fs.createFile({ filePath })
|
||||
await window.api.fs.writeFile({ filePath, content: '# Floating rename\n' })
|
||||
store.getState().openFile(
|
||||
{
|
||||
filePath,
|
||||
relativePath: originalName,
|
||||
worktreeId,
|
||||
language: 'markdown',
|
||||
mode: 'edit',
|
||||
runtimeEnvironmentId: null
|
||||
},
|
||||
{ preview: false, suppressActiveRuntimeFallback: true }
|
||||
)
|
||||
|
||||
const state = store.getState()
|
||||
const file = state.openFiles.find(
|
||||
(candidate) => candidate.filePath === filePath && candidate.worktreeId === worktreeId
|
||||
)
|
||||
const tab = state.unifiedTabsByWorktree[worktreeId]?.find(
|
||||
(candidate) => candidate.contentType === 'editor' && candidate.entityId === file?.id
|
||||
)
|
||||
if (!file || !tab) {
|
||||
throw new Error('Floating Markdown tab unavailable')
|
||||
}
|
||||
return tab.id
|
||||
},
|
||||
{ filePath: originalPath, originalName, worktreeId: FLOATING_WORKTREE_ID }
|
||||
)
|
||||
return {
|
||||
originalName,
|
||||
originalPath,
|
||||
intermediateName,
|
||||
intermediatePath,
|
||||
renamedName,
|
||||
renamedPath,
|
||||
tabId
|
||||
}
|
||||
}
|
||||
|
||||
async function openFloatingPanel(page: Page): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
(selector) => Boolean(document.querySelector(selector)),
|
||||
PANEL_SELECTOR,
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
await page.evaluate(() => window.dispatchEvent(new Event('orca-toggle-floating-terminal')))
|
||||
await expect(page.locator(OPEN_PANEL_SELECTOR)).toBeVisible()
|
||||
}
|
||||
|
||||
test('concurrent floating Markdown renames do not clobber the destination', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
const directory = await orcaPage.evaluate(() => window.api.app.getFloatingMarkdownDirectory())
|
||||
const suffix = Date.now().toString(36)
|
||||
const firstPath = path.join(directory, `floating-first-${suffix}.md`)
|
||||
const secondPath = path.join(directory, `floating-second-${suffix}.md`)
|
||||
const destinationPath = path.join(directory, `floating-destination-${suffix}.md`)
|
||||
|
||||
const result = await orcaPage.evaluate(
|
||||
async ({ firstPath, secondPath, destinationPath }) => {
|
||||
await window.api.fs.createFile({ filePath: firstPath })
|
||||
await window.api.fs.createFile({ filePath: secondPath })
|
||||
await window.api.fs.writeFile({ filePath: firstPath, content: 'first\n' })
|
||||
await window.api.fs.writeFile({ filePath: secondPath, content: 'second\n' })
|
||||
|
||||
const settled = await Promise.allSettled([
|
||||
window.api.fs.rename({ oldPath: firstPath, newPath: destinationPath }),
|
||||
window.api.fs.rename({ oldPath: secondPath, newPath: destinationPath })
|
||||
])
|
||||
const firstExists = await window.api.fs.pathExists({ filePath: firstPath })
|
||||
const secondExists = await window.api.fs.pathExists({ filePath: secondPath })
|
||||
return {
|
||||
statuses: settled.map(({ status }) => status).sort(),
|
||||
rejectionMessages: settled.flatMap((outcome) =>
|
||||
outcome.status === 'rejected' ? [String(outcome.reason)] : []
|
||||
),
|
||||
destinationContent: (await window.api.fs.readFile({ filePath: destinationPath })).content,
|
||||
firstExists,
|
||||
secondExists,
|
||||
firstContent: firstExists
|
||||
? (await window.api.fs.readFile({ filePath: firstPath })).content
|
||||
: null,
|
||||
secondContent: secondExists
|
||||
? (await window.api.fs.readFile({ filePath: secondPath })).content
|
||||
: null
|
||||
}
|
||||
},
|
||||
{ firstPath, secondPath, destinationPath }
|
||||
)
|
||||
|
||||
expect(result.statuses).toEqual(['fulfilled', 'rejected'])
|
||||
expect(result.rejectionMessages[0]).toContain('already exists')
|
||||
expect(Number(result.firstExists) + Number(result.secondExists)).toBe(1)
|
||||
expect(
|
||||
[result.destinationContent, result.firstContent ?? result.secondContent].toSorted()
|
||||
).toEqual(['first\n', 'second\n'])
|
||||
})
|
||||
|
||||
test('Electron serializes native Unicode rename aliases', async ({ orcaPage }) => {
|
||||
test.skip(process.platform !== 'darwin', 'Requires native Unicode aliasing')
|
||||
const directory = await orcaPage.evaluate(() => window.api.app.getFloatingMarkdownDirectory())
|
||||
const suffix = Date.now().toString(36)
|
||||
const firstPath = path.join(directory, `floating-unicode-first-${suffix}.md`)
|
||||
const secondPath = path.join(directory, `floating-unicode-second-${suffix}.md`)
|
||||
const sharpSDestination = path.join(directory, `floating-destination-${suffix}-straße.md`)
|
||||
const expandedDestination = path.join(directory, `floating-destination-${suffix}-STRASSE.MD`)
|
||||
|
||||
const result = await orcaPage.evaluate(
|
||||
async ({ firstPath, secondPath, sharpSDestination, expandedDestination }) => {
|
||||
await window.api.fs.createFile({ filePath: firstPath })
|
||||
await window.api.fs.createFile({ filePath: secondPath })
|
||||
await window.api.fs.writeFile({ filePath: firstPath, content: 'first\n' })
|
||||
await window.api.fs.writeFile({ filePath: secondPath, content: 'second\n' })
|
||||
|
||||
const settled = await Promise.allSettled([
|
||||
window.api.fs.rename({ oldPath: firstPath, newPath: sharpSDestination }),
|
||||
window.api.fs.rename({ oldPath: secondPath, newPath: expandedDestination })
|
||||
])
|
||||
const firstExists = await window.api.fs.pathExists({ filePath: firstPath })
|
||||
const secondExists = await window.api.fs.pathExists({ filePath: secondPath })
|
||||
return {
|
||||
statuses: settled.map(({ status }) => status).sort(),
|
||||
destinationContent: (await window.api.fs.readFile({ filePath: sharpSDestination })).content,
|
||||
firstExists,
|
||||
secondExists,
|
||||
remainingContent: firstExists
|
||||
? (await window.api.fs.readFile({ filePath: firstPath })).content
|
||||
: (await window.api.fs.readFile({ filePath: secondPath })).content
|
||||
}
|
||||
},
|
||||
{ firstPath, secondPath, sharpSDestination, expandedDestination }
|
||||
)
|
||||
|
||||
expect(result.statuses).toEqual(['fulfilled', 'rejected'])
|
||||
expect(Number(result.firstExists) + Number(result.secondExists)).toBe(1)
|
||||
expect([result.destinationContent, result.remainingContent].toSorted()).toEqual([
|
||||
'first\n',
|
||||
'second\n'
|
||||
])
|
||||
})
|
||||
|
||||
test('floating workspace Markdown renames survive an app restart', async (// oxlint-disable-next-line no-empty-pattern -- This persistence test owns both Electron launches.
|
||||
{}, testInfo) => {
|
||||
test.setTimeout(300_000)
|
||||
const session = createRestartSession(testInfo)
|
||||
let firstApp: ElectronApplication | null = null
|
||||
let secondApp: ElectronApplication | null = null
|
||||
|
||||
try {
|
||||
const first = await session.launch()
|
||||
firstApp = first.app
|
||||
await waitForSessionReady(first.page)
|
||||
const seeded = await seedFloatingMarkdownFile(first.page)
|
||||
await openFloatingPanel(first.page)
|
||||
|
||||
const panel = first.page.locator(OPEN_PANEL_SELECTOR)
|
||||
const tab = panel.locator(`[data-tab-id="${seeded.tabId}"]`)
|
||||
await expect(tab).toContainText(seeded.originalName)
|
||||
await tab.click({ button: 'right' })
|
||||
const renameMenuItem = first.page.getByRole('menuitem').filter({ hasText: 'Rename' }).first()
|
||||
await expect(renameMenuItem).toBeVisible()
|
||||
await renameMenuItem.click()
|
||||
|
||||
const enterInput = panel.getByRole('textbox', {
|
||||
name: `Rename file ${seeded.originalName}`,
|
||||
exact: true
|
||||
})
|
||||
await enterInput.fill(seeded.intermediateName)
|
||||
await enterInput.press('Enter')
|
||||
await expect(tab).toContainText(seeded.intermediateName)
|
||||
|
||||
await tab.getByText(seeded.intermediateName, { exact: true }).dispatchEvent('dblclick')
|
||||
const blurInput = panel.getByRole('textbox', {
|
||||
name: `Rename file ${seeded.intermediateName}`,
|
||||
exact: true
|
||||
})
|
||||
await blurInput.fill(seeded.renamedName)
|
||||
await panel.getByRole('radio', { name: 'Rich Editor' }).click()
|
||||
await expect(tab).toContainText(seeded.renamedName)
|
||||
await expect
|
||||
.poll(() =>
|
||||
first.page.evaluate(
|
||||
async ({ renamedPath, originalPath, intermediatePath }) => ({
|
||||
content: (await window.api.fs.readFile({ filePath: renamedPath })).content,
|
||||
originalExists: await window.api.fs.pathExists({ filePath: originalPath }),
|
||||
intermediateExists: await window.api.fs.pathExists({ filePath: intermediatePath })
|
||||
}),
|
||||
{
|
||||
renamedPath: seeded.renamedPath,
|
||||
originalPath: seeded.originalPath,
|
||||
intermediatePath: seeded.intermediatePath
|
||||
}
|
||||
)
|
||||
)
|
||||
.toEqual({
|
||||
content: '# Floating rename\n',
|
||||
originalExists: false,
|
||||
intermediateExists: false
|
||||
})
|
||||
|
||||
await session.close(firstApp)
|
||||
firstApp = null
|
||||
|
||||
const second = await session.launch()
|
||||
secondApp = second.app
|
||||
await waitForSessionReady(second.page)
|
||||
await openFloatingPanel(second.page)
|
||||
|
||||
const restoredPanel = second.page.locator(OPEN_PANEL_SELECTOR)
|
||||
const restoredTab = restoredPanel.locator('[data-tab-id]').filter({
|
||||
hasText: seeded.renamedName
|
||||
})
|
||||
await expect(restoredTab).toContainText(seeded.renamedName)
|
||||
await expect
|
||||
.poll(() =>
|
||||
second.page.evaluate(
|
||||
async ({ renamedPath, originalPath, intermediatePath, worktreeId }) => {
|
||||
const file = window.__store
|
||||
?.getState()
|
||||
.openFiles.find(
|
||||
(candidate) =>
|
||||
candidate.worktreeId === worktreeId && candidate.filePath === renamedPath
|
||||
)
|
||||
return {
|
||||
restoredPath: file?.filePath ?? null,
|
||||
content: (await window.api.fs.readFile({ filePath: renamedPath })).content,
|
||||
originalExists: await window.api.fs.pathExists({ filePath: originalPath }),
|
||||
intermediateExists: await window.api.fs.pathExists({ filePath: intermediatePath })
|
||||
}
|
||||
},
|
||||
{
|
||||
renamedPath: seeded.renamedPath,
|
||||
originalPath: seeded.originalPath,
|
||||
intermediatePath: seeded.intermediatePath,
|
||||
worktreeId: FLOATING_WORKTREE_ID
|
||||
}
|
||||
)
|
||||
)
|
||||
.toEqual({
|
||||
restoredPath: seeded.renamedPath,
|
||||
content: '# Floating rename\n',
|
||||
originalExists: false,
|
||||
intermediateExists: false
|
||||
})
|
||||
} finally {
|
||||
for (const app of [secondApp, firstApp]) {
|
||||
if (!app) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await session.close(app)
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
await session.dispose()
|
||||
}
|
||||
})
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from '../../src/main/runtime/orchestration/db'
|
||||
import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles'
|
||||
import type { RuntimeTerminalListResult, RuntimeTerminalRead } from '../../src/shared/runtime-types'
|
||||
import { listAllOrchestrationRuns } from './orchestration-run-pages'
|
||||
|
||||
const PROVIDER_SESSION_ID = 'e2e-legacy-orchestration-worker'
|
||||
const fakeCliDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-legacy-worker-'))
|
||||
@@ -622,10 +623,8 @@ for (const contractVersion of [LEGACY_CONTRACT_VERSION, CURRENT_CONTRACT_VERSION
|
||||
|
||||
let assignmentRunId = run.result.run.id
|
||||
if (contractVersion === LEGACY_CONTRACT_VERSION) {
|
||||
const runs = await secondClient.call<{
|
||||
runs: { id: string; objective: string }[]
|
||||
}>('orchestration.runList')
|
||||
assignmentRunId = runs.result.runs.find(
|
||||
const runs = await listAllOrchestrationRuns(secondClient)
|
||||
assignmentRunId = runs.find(
|
||||
(candidate) =>
|
||||
candidate.objective === 'Recovered orchestration work from a contract update'
|
||||
)!.id
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../src/shared/orchestration-run-pagination'
|
||||
|
||||
export type OrchestrationRunSummary = {
|
||||
id: string
|
||||
objective: string
|
||||
}
|
||||
|
||||
type RunListClient = {
|
||||
call<TResult>(method: string, params?: unknown): Promise<{ result: TResult }>
|
||||
}
|
||||
|
||||
export async function listAllOrchestrationRuns(
|
||||
client: RunListClient
|
||||
): Promise<OrchestrationRunSummary[]> {
|
||||
const runs: OrchestrationRunSummary[] = []
|
||||
let cursor: string | undefined
|
||||
do {
|
||||
const page = await client.call<{
|
||||
runs: OrchestrationRunSummary[]
|
||||
nextCursor?: string | null
|
||||
}>('orchestration.runList', {
|
||||
limit: ORCHESTRATION_RUN_PAGE_LIMIT,
|
||||
...(cursor ? { cursor } : {})
|
||||
})
|
||||
runs.push(...page.result.runs)
|
||||
cursor = page.result.nextCursor ?? undefined
|
||||
} while (cursor)
|
||||
return runs
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { listAllOrchestrationRuns } from './orchestration-run-pages'
|
||||
|
||||
describe('orchestration Run pagination compatibility', () => {
|
||||
it('stops after an old server response without nextCursor', async () => {
|
||||
const call = vi.fn().mockResolvedValue({
|
||||
result: { runs: [{ id: 'run_old', objective: 'Old server Run' }] }
|
||||
})
|
||||
|
||||
await expect(listAllOrchestrationRuns({ call })).resolves.toEqual([
|
||||
{ id: 'run_old', objective: 'Old server Run' }
|
||||
])
|
||||
expect(call).toHaveBeenCalledTimes(1)
|
||||
expect(call).toHaveBeenCalledWith('orchestration.runList', { limit: 100 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user