mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 00:03:55 +00:00
Merge remote-tracking branch 'origin/main' into fix14974d-work
# Conflicts: # src/main/providers/ssh-pty-provider.test.ts
This commit is contained in:
@@ -76,6 +76,7 @@ import {
|
||||
resolveGrokSessionsDir
|
||||
} from './grok-session-paths'
|
||||
import { sweepStaleAgentHookEndpointTemps } from './agent-hook-endpoint-temp-cleanup'
|
||||
import { classifyTruncatedHookRequest } from './agent-hook-transport-interference'
|
||||
import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit'
|
||||
|
||||
/** Maximum request body size accepted by the listener (1 MB). */
|
||||
@@ -352,6 +353,8 @@ export type AgentHookEventPayload = {
|
||||
toolUseId?: string
|
||||
/** Claude agent/subagent identifier when the hook source exposes one. */
|
||||
toolAgentId?: string
|
||||
/** Claude teammate name carried by TeammateIdle. */
|
||||
teammateName?: string
|
||||
/** Agent/subagent type from the source hook payload, when present. */
|
||||
toolAgentType?: string
|
||||
/** Provider-owned conversation/session id needed to resume a sleeping agent. */
|
||||
@@ -532,12 +535,20 @@ export function readRequestBody(req: IncomingMessage): Promise<unknown> {
|
||||
settleReject(error)
|
||||
}
|
||||
}
|
||||
// Why (#11217): a body cut short of its own Content-Length is the fingerprint of an IDS
|
||||
// resetting the connection mid-inspection. Classify on every path that ends the request without
|
||||
// 'end' — a peer RST surfaces as 'error' (ECONNRESET) and only a local destroy reaches 'close' first.
|
||||
const settleUnfinished = (fallback: Error): void => {
|
||||
settleReject(
|
||||
classifyTruncatedHookRequest(req.headers['content-length'], byteLength) ?? fallback
|
||||
)
|
||||
}
|
||||
const onError = (err: Error): void => {
|
||||
settleReject(err)
|
||||
settleUnfinished(err)
|
||||
}
|
||||
// Why: req.destroy() (slowloris timer) emits 'close' but not 'end'/'error'; without this the promise never settles and buffers leak.
|
||||
const onClose = (): void => {
|
||||
settleReject(new Error('aborted'))
|
||||
settleUnfinished(new Error('aborted'))
|
||||
}
|
||||
req.on('data', onData)
|
||||
req.on('end', onEnd)
|
||||
@@ -1776,6 +1787,17 @@ function extractAmpToolFields(
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retires any cached tool fields. PermissionRequest is the only OpenCode-family event that
|
||||
* carries them, and isNewTurnEvent is false for this family, so nothing else ever resets the
|
||||
* cache — without an explicit retire, resolveToolState inherits one answered permission onto
|
||||
* every later frame in the pane and the row reads a resolved command as the live tool.
|
||||
*/
|
||||
const OPENCODE_TOOL_FIELDS_RETIRED: ToolSnapshot = {
|
||||
hasToolUpdate: true,
|
||||
hasToolInputField: true
|
||||
}
|
||||
|
||||
function extractOpenCodeToolFields(
|
||||
eventName: unknown,
|
||||
hookPayload: Record<string, unknown>
|
||||
@@ -1783,7 +1805,7 @@ function extractOpenCodeToolFields(
|
||||
if (eventName === 'MessagePart' && hookPayload.role === 'assistant') {
|
||||
const text = readString(hookPayload, 'text')
|
||||
if (text) {
|
||||
return { lastAssistantMessage: capOpenCodeHookText(text) }
|
||||
return { ...OPENCODE_TOOL_FIELDS_RETIRED, lastAssistantMessage: capOpenCodeHookText(text) }
|
||||
}
|
||||
}
|
||||
if (eventName === 'AskUserQuestion') {
|
||||
@@ -1792,11 +1814,43 @@ function extractOpenCodeToolFields(
|
||||
? hookPayload.tool_input
|
||||
: stripHookEnvelopeKeys(hookPayload)
|
||||
return {
|
||||
hasToolUpdate: true,
|
||||
...OPENCODE_TOOL_FIELDS_RETIRED,
|
||||
interactivePrompt: deriveInteractivePrompt('AskUserQuestion', toolInputSource)
|
||||
}
|
||||
}
|
||||
return {}
|
||||
if (eventName === 'PermissionRequest') {
|
||||
// Why: the payload is permission.asked's event.properties — `permission` names what is
|
||||
// being requested and `metadata`/`patterns` say which command or path it covers. Without
|
||||
// them the row is a bare 'waiting' that cannot tell the user what to approve.
|
||||
// The SDK types `metadata` as Record<string, unknown>, so these keys come from the tools
|
||||
// themselves: bash sends `command`, edit sends `filepath`, webfetch sends `url` (verified
|
||||
// against opencode 1.18.18). `file_path`/`path` cover tools that spell it the common way.
|
||||
// `diff` is deliberately absent — edit ships the whole patch and it would swamp the row.
|
||||
const metadata = hookPayload.metadata
|
||||
const metadataInput =
|
||||
metadata !== null && typeof metadata === 'object' && !Array.isArray(metadata)
|
||||
? readFirstString(metadata as Record<string, unknown>, [
|
||||
'command',
|
||||
'filepath',
|
||||
'file_path',
|
||||
'path',
|
||||
'url'
|
||||
])
|
||||
: undefined
|
||||
const patterns = Array.isArray(hookPayload.patterns)
|
||||
? hookPayload.patterns.filter(
|
||||
(pattern): pattern is string => typeof pattern === 'string' && pattern.length > 0
|
||||
)
|
||||
: []
|
||||
return toolUpdate(
|
||||
{
|
||||
toolName: readString(hookPayload, 'permission'),
|
||||
toolInput: metadataInput ?? (patterns.length > 0 ? patterns.join(', ') : undefined)
|
||||
},
|
||||
{ hasToolInputField: hasAnyOwnField(hookPayload, ['metadata', 'patterns']) }
|
||||
)
|
||||
}
|
||||
return OPENCODE_TOOL_FIELDS_RETIRED
|
||||
}
|
||||
|
||||
function extractCursorToolFields(
|
||||
@@ -2420,7 +2474,9 @@ function isGrokIdleNotification(message: string | undefined): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean {
|
||||
/** The per-provider answer to "is this event a user-initiated new turn?". Exported so the
|
||||
* observation stamp reuses it instead of minting a second list of event-name literals. */
|
||||
export function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean {
|
||||
// Why: exhaustive switch so a new AgentHookSource fails typecheck here instead of falling through to false.
|
||||
switch (source) {
|
||||
case 'claude':
|
||||
@@ -2665,14 +2721,20 @@ function normalizeClaudeSubagentLifecycleEvent(
|
||||
}
|
||||
const workingChildEvidence = claudeRosterHasRuntimeWorkingSubagent(roster)
|
||||
const hasUnconfirmedChild = claudeRosterHasRestoredSnapshotSubagent(roster)
|
||||
const hasConfirmedDoneGate =
|
||||
cachedLead?.state === 'done' &&
|
||||
cachedLead.interrupted !== true &&
|
||||
(state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) ||
|
||||
state.claudeActiveSessionCronPaneKeys.has(paneKey))
|
||||
const restoredOnlyDoneGate =
|
||||
cachedLead?.state === 'done' && !hasConfirmedDoneGate && hasUnconfirmedChild
|
||||
if (roster?.size === 0) {
|
||||
state.claudeSubagentRosterByPaneKey.delete(paneKey)
|
||||
}
|
||||
if (
|
||||
!hasCachedLeadEvidence &&
|
||||
endedChildWork &&
|
||||
!workingChildEvidence &&
|
||||
(hasUnconfirmedChild || !endedRuntimeChildWork)
|
||||
(restoredOnlyDoneGate ||
|
||||
(endedChildWork && !hasCachedLeadEvidence && (hasUnconfirmedChild || !endedRuntimeChildWork)))
|
||||
) {
|
||||
// Why: a restored-only ending proves no lead boundary, and an unmatched restored sibling proves no current liveness; persist the roster transition without publishing fresh work or completion.
|
||||
state.claudeUnconfirmedRestoredStatusPaneKeys.add(paneKey)
|
||||
@@ -2719,6 +2781,31 @@ export function seedClaudeSubagentRosterFromSnapshots(
|
||||
}
|
||||
}
|
||||
|
||||
export function seedClaudeLeadTurnFromPersistedStatus(
|
||||
state: HookListenerState,
|
||||
paneKey: string,
|
||||
status: Pick<AgentHookEventPayload, 'payload'>,
|
||||
options: { childOnlyBoundary: boolean }
|
||||
): void {
|
||||
if (options.childOnlyBoundary && status.payload.agentType === 'claude') {
|
||||
state.claudeLeadStateByPaneKey.set(paneKey, {
|
||||
state: 'done',
|
||||
...(status.payload.interrupted === true ? { interrupted: true } : {}),
|
||||
...(status.payload.turnCompletedAt !== undefined
|
||||
? { turnCompletedAt: status.payload.turnCompletedAt }
|
||||
: {})
|
||||
})
|
||||
if (status.payload.prompt) {
|
||||
state.lastPromptByPaneKey.set(paneKey, status.payload.prompt)
|
||||
}
|
||||
if (status.payload.lastAssistantMessage) {
|
||||
state.lastToolByPaneKey.set(paneKey, {
|
||||
lastAssistantMessage: status.payload.lastAssistantMessage
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reap this pane's unconfirmed restored seeds because no live agent process backs
|
||||
* the pane any more (its PTY died while Orca was down, so no finish hook could
|
||||
* arrive). Callers must have proven the pane is LOCAL-launched — a remote/SSH
|
||||
@@ -2749,6 +2836,13 @@ function clearClaudePendingWaitForAgent(
|
||||
return
|
||||
}
|
||||
state.claudeLeadStateByPaneKey.set(paneKey, lead.stateBeforeWait ?? { state: 'working' })
|
||||
const previousTool = state.lastToolByPaneKey.get(paneKey)
|
||||
state.lastToolByPaneKey.set(
|
||||
paneKey,
|
||||
previousTool?.lastAssistantMessage
|
||||
? { lastAssistantMessage: previousTool.lastAssistantMessage }
|
||||
: {}
|
||||
)
|
||||
}
|
||||
|
||||
/** Clear an AskUserQuestion wait after the answer is typed (answering emits no hook event; the caller infers it from the submit keystroke). Restores the stashed pre-wait lead state or 'working', drops the cached card, and returns the pane state to emit (gated up to 'working' while children run). */
|
||||
@@ -4530,6 +4624,10 @@ export function normalizeHookPayload(
|
||||
compactTrigger,
|
||||
toolUseId: readFirstString(hookPayloadRecord, ['tool_use_id', 'toolUseId']),
|
||||
toolAgentId: readFirstString(hookPayloadRecord, ['agent_id', 'agentId']),
|
||||
teammateName:
|
||||
source === 'claude' && eventName === 'TeammateIdle'
|
||||
? readString(hookPayloadRecord, 'teammate_name')
|
||||
: undefined,
|
||||
toolAgentType: readString(hookPayloadRecord, 'agent_type'),
|
||||
...(source === 'claude'
|
||||
? {
|
||||
|
||||
@@ -93,6 +93,8 @@ export type AgentHookRelayEnvelope = {
|
||||
toolUseId?: string
|
||||
/** Claude subagent identity, when the source hook provides one. */
|
||||
toolAgentId?: string
|
||||
/** Claude teammate name carried by TeammateIdle. */
|
||||
teammateName?: string
|
||||
/** Claude agent type, used only as a lower-confidence identity fallback. */
|
||||
toolAgentType?: string
|
||||
/** Provider-owned conversation/session id needed to resume a sleeping agent. */
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
classifyTruncatedHookRequest,
|
||||
createHookTransportInterferenceTracker,
|
||||
describeHookTransportInterference,
|
||||
HookRequestTruncatedError,
|
||||
isHookRequestTruncatedError
|
||||
} from './agent-hook-transport-interference'
|
||||
|
||||
describe('classifyTruncatedHookRequest', () => {
|
||||
it('reports truncation when fewer bytes arrived than Content-Length promised', () => {
|
||||
const error = classifyTruncatedHookRequest('4096', 512)
|
||||
expect(isHookRequestTruncatedError(error)).toBe(true)
|
||||
expect(error).toMatchObject({ bytesRead: 512, contentLength: 4096 })
|
||||
})
|
||||
|
||||
it('reports truncation when the connection died before any body byte', () => {
|
||||
expect(classifyTruncatedHookRequest('4096', 0)).toBeInstanceOf(HookRequestTruncatedError)
|
||||
})
|
||||
|
||||
it('stays silent for a complete body', () => {
|
||||
expect(classifyTruncatedHookRequest('512', 512)).toBeNull()
|
||||
})
|
||||
|
||||
it('stays silent without a usable Content-Length', () => {
|
||||
// Why: chunked bodies and malformed headers prove nothing — reporting them would drown the signal.
|
||||
expect(classifyTruncatedHookRequest(undefined, 10)).toBeNull()
|
||||
expect(classifyTruncatedHookRequest('', 10)).toBeNull()
|
||||
expect(classifyTruncatedHookRequest('not-a-number', 10)).toBeNull()
|
||||
})
|
||||
|
||||
it('reads the first value when the header arrives duplicated', () => {
|
||||
expect(classifyTruncatedHookRequest(['4096', '4096'], 12)).toBeInstanceOf(
|
||||
HookRequestTruncatedError
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createHookTransportInterferenceTracker', () => {
|
||||
const truncation = { source: 'claude', error: new HookRequestTruncatedError(10, 900) }
|
||||
|
||||
it('stays quiet below the threshold so a single crashed writer is not an alarm', () => {
|
||||
const onThreshold = vi.fn()
|
||||
const tracker = createHookTransportInterferenceTracker(onThreshold, 3)
|
||||
tracker.record(truncation)
|
||||
tracker.record(truncation)
|
||||
expect(onThreshold).not.toHaveBeenCalled()
|
||||
expect(tracker.getCount()).toBe(2)
|
||||
})
|
||||
|
||||
it('reports exactly once at the threshold and keeps counting after', () => {
|
||||
const onThreshold = vi.fn()
|
||||
const tracker = createHookTransportInterferenceTracker(onThreshold, 3)
|
||||
for (let i = 0; i < 6; i++) {
|
||||
tracker.record(truncation)
|
||||
}
|
||||
expect(onThreshold).toHaveBeenCalledTimes(1)
|
||||
expect(onThreshold).toHaveBeenCalledWith({
|
||||
count: 3,
|
||||
source: 'claude',
|
||||
bytesRead: 10,
|
||||
contentLength: 900
|
||||
})
|
||||
expect(tracker.getCount()).toBe(6)
|
||||
})
|
||||
|
||||
it('re-arms after reset', () => {
|
||||
const onThreshold = vi.fn()
|
||||
const tracker = createHookTransportInterferenceTracker(onThreshold, 1)
|
||||
tracker.record(truncation)
|
||||
tracker.reset()
|
||||
tracker.record(truncation)
|
||||
expect(onThreshold).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeHookTransportInterference', () => {
|
||||
it('names the cause and the consequence so the log line is actionable', () => {
|
||||
const message = describeHookTransportInterference({
|
||||
count: 3,
|
||||
source: 'codex',
|
||||
bytesRead: 10,
|
||||
contentLength: 900
|
||||
})
|
||||
expect(message).toContain('/hook/codex')
|
||||
expect(message).toContain('10/900 bytes')
|
||||
expect(message).toContain('security software')
|
||||
// Why: the client's own --max-time can truncate too; a single-cause message would misdiagnose a stall.
|
||||
expect(message).toContain('stalled past the hook client timeout')
|
||||
})
|
||||
|
||||
it('omits the route when the truncation happened before it resolved', () => {
|
||||
const message = describeHookTransportInterference({
|
||||
count: 3,
|
||||
source: null,
|
||||
bytesRead: 0,
|
||||
contentLength: 900
|
||||
})
|
||||
expect(message).not.toContain('/hook/')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
// Transport background and the allowlisting answer for security teams: docs/reference/agent-hook-transport.md
|
||||
//
|
||||
// Why (#11217): enterprise IDS/AV products inspect loopback HTTP bodies and reset the hook POST
|
||||
// mid-flight when the agent's own tool I/O happens to match an LFI/command-injection signature.
|
||||
// The listener fails open on every error, so a reset arrives as a swallowed exception and agent
|
||||
// status stops with no diagnostic anywhere. Classifying the truncation is what makes it reportable.
|
||||
|
||||
/** Thrown by the listener when a request closed before `end` with fewer bytes than `Content-Length` promised. */
|
||||
export class HookRequestTruncatedError extends Error {
|
||||
readonly bytesRead: number
|
||||
readonly contentLength: number
|
||||
|
||||
constructor(bytesRead: number, contentLength: number) {
|
||||
super(`hook request truncated after ${bytesRead} of ${contentLength} bytes`)
|
||||
this.name = 'HookRequestTruncatedError'
|
||||
this.bytesRead = bytesRead
|
||||
this.contentLength = contentLength
|
||||
}
|
||||
}
|
||||
|
||||
export function isHookRequestTruncatedError(error: unknown): error is HookRequestTruncatedError {
|
||||
return error instanceof HookRequestTruncatedError
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncation is only interference when Orca did not cause it. `Content-Length` must be present and
|
||||
* unmet: a chunked body or a completed one proves nothing, and reporting those would make the
|
||||
* signal useless.
|
||||
*/
|
||||
export function classifyTruncatedHookRequest(
|
||||
contentLengthHeader: string | string[] | undefined,
|
||||
bytesRead: number
|
||||
): HookRequestTruncatedError | null {
|
||||
const raw = Array.isArray(contentLengthHeader) ? contentLengthHeader[0] : contentLengthHeader
|
||||
if (!raw || !/^\d+$/.test(raw.trim())) {
|
||||
return null
|
||||
}
|
||||
const contentLength = Number(raw.trim())
|
||||
return bytesRead < contentLength ? new HookRequestTruncatedError(bytesRead, contentLength) : null
|
||||
}
|
||||
|
||||
/** Why: one truncation can be a crashed agent mid-write; a repeat is a device on the loopback path. */
|
||||
export const HOOK_TRANSPORT_INTERFERENCE_THRESHOLD = 3
|
||||
|
||||
export type HookTransportInterferenceReport = {
|
||||
/** Total authenticated-but-truncated hook POSTs observed since start. */
|
||||
count: number
|
||||
/** The hook route of the most recent truncation, when the URL resolved to one. */
|
||||
source: string | null
|
||||
bytesRead: number
|
||||
contentLength: number
|
||||
}
|
||||
|
||||
export type HookTransportInterferenceTracker = {
|
||||
record: (detail: { source: string | null; error: HookRequestTruncatedError }) => void
|
||||
getCount: () => number
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts truncated hook POSTs and reports exactly once, at `threshold`. Counting continues after
|
||||
* the report so a diagnostics reader can still see the magnitude without re-notifying per event.
|
||||
*/
|
||||
export function createHookTransportInterferenceTracker(
|
||||
onThresholdReached: (report: HookTransportInterferenceReport) => void,
|
||||
threshold: number = HOOK_TRANSPORT_INTERFERENCE_THRESHOLD
|
||||
): HookTransportInterferenceTracker {
|
||||
let count = 0
|
||||
let reported = false
|
||||
return {
|
||||
record: ({ source, error }) => {
|
||||
count += 1
|
||||
if (reported || count < threshold) {
|
||||
return
|
||||
}
|
||||
reported = true
|
||||
onThresholdReached({
|
||||
count,
|
||||
source,
|
||||
bytesRead: error.bytesRead,
|
||||
contentLength: error.contentLength
|
||||
})
|
||||
},
|
||||
getCount: () => count,
|
||||
reset: () => {
|
||||
count = 0
|
||||
reported = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared operator-facing text. Names the likely cause first but not exclusively: the hook client's
|
||||
* own `--max-time` can also cut a body if this process stalls, and claiming certainty would send a
|
||||
* user to their IT department over a main-thread hang.
|
||||
*/
|
||||
export function describeHookTransportInterference(report: HookTransportInterferenceReport): string {
|
||||
return (
|
||||
`[agent-hooks] ${report.count} agent-status POSTs were cut off mid-body on loopback ` +
|
||||
`(last: ${report.bytesRead}/${report.contentLength} bytes${report.source ? `, /hook/${report.source}` : ''}). ` +
|
||||
'Agent status will be missing for every runtime until this stops. Most likely local network ' +
|
||||
'security software is inspecting and blocking loopback HTTP; less likely, this process stalled ' +
|
||||
'past the hook client timeout. See docs/reference/agent-hook-transport.md.'
|
||||
)
|
||||
}
|
||||
@@ -37,6 +37,34 @@ describe('getAgentRowConversationName', () => {
|
||||
expect(getAgentRowConversationName(tab, 'claude', false)).toBe('Investigate replay bug')
|
||||
})
|
||||
|
||||
it('names a split pane from its own live title, not the tab title', () => {
|
||||
// Why: the tab title is the FOCUSED pane's, so the sibling must not read it.
|
||||
const tab = makeTab({ title: '\u2733 Linear work log' })
|
||||
expect(getAgentRowConversationName(tab, 'claude', false, '\u2733 Redis cache strategy')).toBe(
|
||||
'Redis cache strategy'
|
||||
)
|
||||
// OpenCode's semantic title is a live title too, so it follows the pane.
|
||||
expect(
|
||||
getAgentRowConversationName(tab, 'opencode', false, 'OC | build the release pipeline')
|
||||
).toBe('OC | build the release pipeline')
|
||||
// No resolvable pane title: no live title at all, never the sibling's.
|
||||
expect(getAgentRowConversationName(tab, 'claude', false, null)).toBeNull()
|
||||
// A single-pane tab passes undefined and is untouched.
|
||||
expect(getAgentRowConversationName(tab, 'claude', false)).toBe('Linear work log')
|
||||
})
|
||||
|
||||
it('keeps tab-owned names above the pane title', () => {
|
||||
// Why: the user gave these to the whole tab, and none of them flip on focus.
|
||||
const custom = makeTab({ customTitle: 'Patient sync spike' })
|
||||
expect(getAgentRowConversationName(custom, 'claude', false, 'Redis cache strategy')).toBe(
|
||||
'Patient sync spike'
|
||||
)
|
||||
const quick = makeTab({ quickCommandLabel: 'Run tests' })
|
||||
expect(getAgentRowConversationName(quick, 'claude', false, null)).toBe('Run tests')
|
||||
const generated = makeTab({ generatedTitle: 'Fix intake flow' })
|
||||
expect(getAgentRowConversationName(generated, 'claude', true, null)).toBe('Fix intake flow')
|
||||
})
|
||||
|
||||
it('strips leading status decoration from agent-set titles', () => {
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: '✳ Fix patient intake flow' }), 'claude', false)
|
||||
|
||||
@@ -113,7 +113,13 @@ function conversationNameFromLiveTitle(
|
||||
export function getAgentRowConversationName(
|
||||
tab: ConversationNameTab,
|
||||
agentType: AgentType | null | undefined,
|
||||
generatedTitlesEnabled: boolean
|
||||
generatedTitlesEnabled: boolean,
|
||||
// Why: `tab.title` carries only the FOCUSED pane's title, so in a split tab it
|
||||
// names one pane and mislabels its siblings. Callers on a multi-pane tab pass
|
||||
// this row's own pane title, or `null` when none resolves; `undefined` (a
|
||||
// single-pane tab) keeps the tab title. Tab-owned names above are unaffected:
|
||||
// the user gave those to the whole tab and they do not flip on focus.
|
||||
paneLiveTitle?: string | null
|
||||
): string | null {
|
||||
const customTitle = tab.customTitle?.trim()
|
||||
if (customTitle) {
|
||||
@@ -123,7 +129,8 @@ export function getAgentRowConversationName(
|
||||
if (quickCommandLabel) {
|
||||
return quickCommandLabel
|
||||
}
|
||||
const liveTitle = tab.title?.trim() ?? ''
|
||||
const liveTitle =
|
||||
paneLiveTitle === undefined ? (tab.title?.trim() ?? '') : (paneLiveTitle?.trim() ?? '')
|
||||
if (isMeaningfulOpenCodeTerminalTitle(liveTitle)) {
|
||||
return liveTitle
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AgentStatusObservationSequencer,
|
||||
createAgentStatusAuthorityId
|
||||
} from './agent-status-observation'
|
||||
|
||||
const PANE_A = 'tab-a:11111111-1111-4111-8111-111111111111'
|
||||
const PANE_B = 'tab-b:22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function observe(
|
||||
sequencer: AgentStatusObservationSequencer,
|
||||
paneKey: string
|
||||
): ReturnType<AgentStatusObservationSequencer['observe']> {
|
||||
return sequencer.observe(paneKey, { origin: 'hook', observedAt: 1_000 })
|
||||
}
|
||||
|
||||
describe('AgentStatusObservationSequencer', () => {
|
||||
it('advances revision strictly for every observation of a pane', () => {
|
||||
const sequencer = new AgentStatusObservationSequencer('authority-1')
|
||||
|
||||
const first = observe(sequencer, PANE_A)
|
||||
const second = observe(sequencer, PANE_A)
|
||||
const third = observe(sequencer, PANE_A)
|
||||
|
||||
expect(first.revision).toBeLessThan(second.revision)
|
||||
expect(second.revision).toBeLessThan(third.revision)
|
||||
expect(first.authorityId).toBe('authority-1')
|
||||
})
|
||||
|
||||
it('keeps each pane strictly increasing while panes interleave', () => {
|
||||
const sequencer = new AgentStatusObservationSequencer('authority-1')
|
||||
|
||||
const a1 = observe(sequencer, PANE_A)
|
||||
const b1 = observe(sequencer, PANE_B)
|
||||
const a2 = observe(sequencer, PANE_A)
|
||||
const b2 = observe(sequencer, PANE_B)
|
||||
|
||||
expect(a2.revision).toBeGreaterThan(a1.revision)
|
||||
expect(b2.revision).toBeGreaterThan(b1.revision)
|
||||
})
|
||||
|
||||
it('bumps incarnation on rebind and leaves it alone otherwise', () => {
|
||||
const sequencer = new AgentStatusObservationSequencer('authority-1')
|
||||
|
||||
const before = observe(sequencer, PANE_A)
|
||||
const stillBefore = observe(sequencer, PANE_A)
|
||||
sequencer.rebind(PANE_A)
|
||||
const after = observe(sequencer, PANE_A)
|
||||
|
||||
expect(stillBefore.incarnation).toBe(before.incarnation)
|
||||
expect(after.incarnation).toBeGreaterThan(before.incarnation)
|
||||
// Why: rebind marks a new session behind the key; it must not rewind the order of what came before.
|
||||
expect(after.revision).toBeGreaterThan(stillBefore.revision)
|
||||
})
|
||||
|
||||
it('rebinding one pane does not move another pane already being tracked', () => {
|
||||
const sequencer = new AgentStatusObservationSequencer('authority-1')
|
||||
|
||||
const otherBefore = observe(sequencer, PANE_B)
|
||||
sequencer.rebind(PANE_A)
|
||||
const otherAfter = observe(sequencer, PANE_B)
|
||||
|
||||
expect(otherAfter.incarnation).toBe(otherBefore.incarnation)
|
||||
})
|
||||
|
||||
it('never lowers a pane incarnation after its state is forgotten', () => {
|
||||
const sequencer = new AgentStatusObservationSequencer('authority-1')
|
||||
|
||||
observe(sequencer, PANE_A)
|
||||
sequencer.rebind(PANE_A)
|
||||
const rebound = observe(sequencer, PANE_A)
|
||||
sequencer.forget(PANE_A)
|
||||
const reobserved = observe(sequencer, PANE_A)
|
||||
|
||||
expect(reobserved.incarnation).toBeGreaterThanOrEqual(rebound.incarnation)
|
||||
expect(reobserved.revision).toBeGreaterThan(rebound.revision)
|
||||
})
|
||||
|
||||
it('keeps revision increasing for a pane whose per-pane state was evicted by the cap', () => {
|
||||
const sequencer = new AgentStatusObservationSequencer('authority-1')
|
||||
|
||||
const first = observe(sequencer, PANE_A)
|
||||
// Why: overflow the bounded per-pane map so PANE_A's entry is definitely evicted.
|
||||
for (let i = 0; i < 2_000; i++) {
|
||||
observe(sequencer, `tab-flood:${i}`)
|
||||
}
|
||||
const afterEviction = observe(sequencer, PANE_A)
|
||||
|
||||
expect(afterEviction.revision).toBeGreaterThan(first.revision)
|
||||
expect(afterEviction.incarnation).toBeGreaterThanOrEqual(first.incarnation)
|
||||
})
|
||||
|
||||
it('stamps the requested facets and omits absent optional ones', () => {
|
||||
const sequencer = new AgentStatusObservationSequencer('authority-1')
|
||||
|
||||
const boundary = sequencer.observe(PANE_A, {
|
||||
origin: 'osc',
|
||||
observedAt: 42,
|
||||
boundary: true,
|
||||
kind: 'snapshot'
|
||||
})
|
||||
const plain = sequencer.observe(PANE_A, { origin: 'title', observedAt: 43, boundary: false })
|
||||
|
||||
expect(boundary).toMatchObject({
|
||||
origin: 'osc',
|
||||
observedAt: 42,
|
||||
boundary: true,
|
||||
kind: 'snapshot'
|
||||
})
|
||||
expect(plain.origin).toBe('title')
|
||||
expect('boundary' in plain).toBe(false)
|
||||
expect('kind' in plain).toBe(false)
|
||||
})
|
||||
|
||||
it('gives each sequencer instance a distinct authority id', () => {
|
||||
// Why: revision counters live in memory, so a restarted authority must not be
|
||||
// comparable with the observations it emitted before.
|
||||
const first = createAgentStatusAuthorityId('main-agent-hooks')
|
||||
const second = createAgentStatusAuthorityId('main-agent-hooks')
|
||||
|
||||
expect(first).not.toBe(second)
|
||||
expect(first.startsWith('main-agent-hooks:')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
// ─── Observation provenance for agent status (STA-4293, step 1) ─────────────
|
||||
// An AgentStatusEntry today cannot say who observed it, on whose clock, or in what
|
||||
// order relative to the pane's other observations. Hook rows carry main's
|
||||
// watermark-forced monotonic clock while byte-derived rows carry the renderer's
|
||||
// Date.now(), and both are arbitrated by one strict `<` on updatedAt. This facet
|
||||
// records the missing facts at every ingress.
|
||||
//
|
||||
// NOTHING READS IT YET. It is stamped so consumers can be migrated one at a time.
|
||||
|
||||
/** Where the evidence for a status row came from — the ingress, not the transport.
|
||||
* A hook event relayed over SSH is still `hook`; the relay is a carrier. */
|
||||
export const AGENT_STATUS_OBSERVATION_ORIGINS = [
|
||||
/** Provider hook event (loopback HTTP or relayed), run through a provider normalizer. */
|
||||
'hook',
|
||||
/** OSC 9999 structured payload parsed out of PTY bytes. Canonical payload, no provider normalizer. */
|
||||
'osc',
|
||||
/** Inferred from a terminal title. The weakest evidence Orca acts on. */
|
||||
'title',
|
||||
/** Derived from the pane's own process/output evidence (e.g. Command Code output seeds). */
|
||||
'process',
|
||||
/** Seeded when Orca launched the agent itself, before any provider signal. */
|
||||
'launch',
|
||||
/** Stamped by orchestration dispatch rather than by the agent. */
|
||||
'orchestration'
|
||||
] as const
|
||||
export type AgentStatusObservationOrigin = (typeof AGENT_STATUS_OBSERVATION_ORIGINS)[number]
|
||||
|
||||
/**
|
||||
* What the observation claims, independent of `state`.
|
||||
* - `transition` — an event asserting the pane just changed state.
|
||||
* - `snapshot` — a repaint/replay of the state already believed true (OSC repaints, relay
|
||||
* replay, title-derived rows re-derived every render). Never proof of a new turn.
|
||||
* - `identity-only` — a resume-identity refresh whose status-shaped fields are transport
|
||||
* placeholders (`providerSessionOnly`, agent-status-types.ts). PR #14657 had to
|
||||
* special-case exactly this; the facet exists so the next consumer need not rediscover it.
|
||||
*/
|
||||
export type AgentStatusObservationKind = 'transition' | 'snapshot' | 'identity-only'
|
||||
|
||||
export type AgentStatusObservation = {
|
||||
origin: AgentStatusObservationOrigin
|
||||
/** Which component sequenced this observation. Opaque; see the ordering rule below. */
|
||||
authorityId: string
|
||||
/** Bumped when the pane/PTY behind this key is rebound. Non-decreasing per pane. */
|
||||
incarnation: number
|
||||
/** Monotonic per pane within one authority. Compare it; never count with it (values are shared
|
||||
* across the authority's panes, so consecutive observations of one pane leave gaps). */
|
||||
revision: number
|
||||
/** The AUTHORITY's clock when it sequenced this observation — not the reader's. */
|
||||
observedAt: number
|
||||
/** This observation is a user-initiated new turn. Stamped from the hook listener's
|
||||
* own per-provider `isNewTurnEvent`, never from a fresh list of event-name literals:
|
||||
* the retired-pane gate (server.ts) matched two raw literals and stranded providers
|
||||
* whose boundary event is named anything else — the defect PR #14626 fixed one instance of. */
|
||||
boundary?: true
|
||||
kind?: AgentStatusObservationKind
|
||||
}
|
||||
|
||||
/** The observation facet, mixed into every row shape that carries one. Optional and read by
|
||||
* nothing yet (STA-4293): rows from old hosts, persisted rehydration, and any ingress not yet
|
||||
* stamped carry none, so consumers must keep working without it. Deliberately NOT mixed into
|
||||
* `AgentStatusPayload` — the ingress stamps it and it is never read back out of the reported
|
||||
* body, so a hook or OSC writer cannot declare its own provenance. */
|
||||
export type WithAgentStatusObservation = { observation?: AgentStatusObservation }
|
||||
|
||||
// ─── THE ORDERING RULE ──────────────────────────────────────────────────────
|
||||
// `(authorityId, incarnation, revision)` is a total order ONLY within one authorityId.
|
||||
// A different authorityId means "incomparable", not "older" — the id is regenerated per
|
||||
// authority instance, so a restarted main, a second machine, and a renderer that parsed
|
||||
// bytes itself all sequence into disjoint spaces. Consumers must fall back to today's
|
||||
// timestamp rule across authorities, never mix the two orders.
|
||||
//
|
||||
// ─── THE DECAY RULE (neither original review caught this) ───────────────────
|
||||
// Staleness must be computed against the SAME authority clock that stamped `observedAt`,
|
||||
// or replicas must decay on LOCAL RECEIPT time instead.
|
||||
//
|
||||
// `observedAt` is the authority's wall clock. Today `isExplicitAgentStatusFresh`
|
||||
// (renderer/src/lib/pane-agent-evidence.ts) computes `rendererNow - entry.updatedAt`, and
|
||||
// for a MIRRORED REMOTE entry `updatedAt` is the HOST's clock. A host running minutes fast
|
||||
// makes every remote row look permanently fresh; a host running slow makes them decay on
|
||||
// arrival. Declaring `observedAt` display-only does NOT fix that — the skew is in the
|
||||
// subtraction, not in the tiebreak. A replica must either receive the authority's own
|
||||
// freshness verdict, or stamp its own receipt time and decay against that.
|
||||
//
|
||||
// This PR does not fix it. It records the contract at the type so the PR that moves the
|
||||
// first consumer has something to be correct against.
|
||||
|
||||
/** Bounds the per-pane incarnation map. Panes are created for the life of the process;
|
||||
* eviction is safe because `incarnation` is floored by an authority-wide counter (below). */
|
||||
const OBSERVATION_PANE_STATE_MAX = 1024
|
||||
|
||||
/**
|
||||
* Assigns ordering metadata for one authority (one main process, one renderer).
|
||||
*
|
||||
* `revision` is a single authority-wide counter rather than one per pane: it is then
|
||||
* strictly increasing for every pane by construction, including a pane whose per-pane
|
||||
* state was evicted, so a future consumer can never read a restarted counter as "older".
|
||||
*
|
||||
* `incarnation` is per pane, floored by an authority-wide counter that `rebind` advances.
|
||||
* A pane re-observed after eviction therefore resumes at a value no lower than its last.
|
||||
*/
|
||||
export class AgentStatusObservationSequencer {
|
||||
private revision = 0
|
||||
private incarnationFloor = 0
|
||||
private readonly incarnationByPaneKey = new Map<string, number>()
|
||||
|
||||
constructor(private readonly authorityId: string) {}
|
||||
|
||||
getAuthorityId(): string {
|
||||
return this.authorityId
|
||||
}
|
||||
|
||||
observe(
|
||||
paneKey: string,
|
||||
args: {
|
||||
origin: AgentStatusObservationOrigin
|
||||
observedAt: number
|
||||
boundary?: boolean
|
||||
kind?: AgentStatusObservationKind
|
||||
}
|
||||
): AgentStatusObservation {
|
||||
this.revision += 1
|
||||
return {
|
||||
origin: args.origin,
|
||||
authorityId: this.authorityId,
|
||||
incarnation: this.resolveIncarnation(paneKey),
|
||||
revision: this.revision,
|
||||
observedAt: args.observedAt,
|
||||
...(args.boundary ? { boundary: true as const } : {}),
|
||||
...(args.kind ? { kind: args.kind } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/** The pane's PTY was rebound (relaunch, reattach, pane reuse): later observations
|
||||
* describe a different session behind the same key. */
|
||||
rebind(paneKey: string): void {
|
||||
this.incarnationFloor += 1
|
||||
this.incarnationByPaneKey.delete(paneKey)
|
||||
this.setIncarnation(paneKey, this.incarnationFloor)
|
||||
}
|
||||
|
||||
/** Pane is gone; drop its state. Re-observation after this reads the current floor. */
|
||||
forget(paneKey: string): void {
|
||||
this.incarnationByPaneKey.delete(paneKey)
|
||||
}
|
||||
|
||||
private resolveIncarnation(paneKey: string): number {
|
||||
const known = this.incarnationByPaneKey.get(paneKey)
|
||||
if (known !== undefined) {
|
||||
return known
|
||||
}
|
||||
this.setIncarnation(paneKey, this.incarnationFloor)
|
||||
return this.incarnationFloor
|
||||
}
|
||||
|
||||
private setIncarnation(paneKey: string, incarnation: number): void {
|
||||
this.incarnationByPaneKey.set(paneKey, incarnation)
|
||||
while (this.incarnationByPaneKey.size > OBSERVATION_PANE_STATE_MAX) {
|
||||
const oldest = this.incarnationByPaneKey.keys().next().value
|
||||
if (oldest === undefined) {
|
||||
return
|
||||
}
|
||||
this.incarnationByPaneKey.delete(oldest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-instance authority id. Regenerated every process start on purpose: a restarted
|
||||
* authority's revision counter starts over, so its observations must not be comparable
|
||||
* with the ones it emitted before (including any rehydrated from disk). */
|
||||
export function createAgentStatusAuthorityId(role: string): string {
|
||||
return `${role}:${globalThis.crypto.randomUUID()}`
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
// a narrow interrupt fallback synthesizes a final `done` when an agent misses its cancellation hook.
|
||||
|
||||
import type { AgentProviderSessionMetadata } from './agent-session-resume'
|
||||
import type { WithAgentStatusObservation } from './agent-status-observation'
|
||||
import {
|
||||
normalizeInteractivePromptField,
|
||||
normalizeOptionalField,
|
||||
@@ -151,7 +152,7 @@ export type AgentStatusEntry = {
|
||||
* the transition may have been missed while no receiver was up, so freshness gates
|
||||
* treat the row as stale immediately. Cleared by any accepted live event. */
|
||||
restoredUnconfirmed?: boolean
|
||||
}
|
||||
} & WithAgentStatusObservation
|
||||
|
||||
export type MigrationUnsupportedPtyEntry = {
|
||||
ptyId: string
|
||||
@@ -252,7 +253,7 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & {
|
||||
promptInteractionKey?: string
|
||||
/** See AgentStatusEntry.restoredUnconfirmed — hydrated nonterminal provenance. */
|
||||
restoredUnconfirmed?: boolean
|
||||
}
|
||||
} & WithAgentStatusObservation
|
||||
|
||||
/** Wire shape for ordinary pane teardown or a stamped SSH disconnect batch. */
|
||||
export type AgentStatusClearIpcPayload =
|
||||
|
||||
@@ -142,6 +142,9 @@ export type BrowserCookieImportSummary = {
|
||||
importedCookies: number
|
||||
skippedCookies: number
|
||||
googleCookiesSkipped?: number
|
||||
// Why (STA-4300): cookies whose source partition identity could not be read faithfully are
|
||||
// skipped rather than written unpartitioned, and a skip is only honest if it is reported.
|
||||
partitionSkippedCookies?: number
|
||||
domains: string[]
|
||||
warning?:
|
||||
| {
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
// Why: the managed runtime config keeps serving the last good settings while the
|
||||
// source is unusable, so a stall is "working but not picking up your edits" —
|
||||
// the reason is what makes it actionable, and the path is what the user fixes.
|
||||
export type CodexConfigSyncStallReason = 'missing-source' | 'blank-source' | 'unreadable-source'
|
||||
// Why: the first three describe the SYSTEM config source. 'managed-home-unavailable'
|
||||
// describes the managed home itself being temporarily unreadable (e.g. an antivirus
|
||||
// lock on Windows) — a different file and a different remedy, so it needs its own
|
||||
// reason rather than borrowing 'unreadable-source' and blaming the wrong path.
|
||||
export type CodexConfigSyncStallReason =
|
||||
| 'missing-source'
|
||||
| 'blank-source'
|
||||
| 'unreadable-source'
|
||||
| 'managed-home-unavailable'
|
||||
|
||||
export type CodexConfigSyncStatus =
|
||||
| { state: 'synced'; reason: null; systemConfigPath: string }
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import {
|
||||
codexRosterToSnapshots,
|
||||
finishCodexSubagent,
|
||||
setCodexSubagentModel,
|
||||
upsertCodexSubagent,
|
||||
type CodexSubagentRoster
|
||||
} from './codex-subagent-roster'
|
||||
@@ -61,4 +62,54 @@ describe('Codex subagent roster', () => {
|
||||
expect(roster.size).toBe(AGENT_STATUS_MAX_SUBAGENTS)
|
||||
expect(roster.has('replacement')).toBe(true)
|
||||
})
|
||||
|
||||
describe('setCodexSubagentModel', () => {
|
||||
it('records the model without disturbing the child lifecycle or label', () => {
|
||||
const roster: CodexSubagentRoster = new Map()
|
||||
upsertCodexSubagent(roster, 'child-1', { description: '/root/audit', state: 'waiting' }, 10)
|
||||
|
||||
setCodexSubagentModel(roster, 'child-1', ' gpt-5.6-terra ')
|
||||
|
||||
expect(codexRosterToSnapshots(roster)).toEqual([
|
||||
{
|
||||
id: 'child-1',
|
||||
agentType: undefined,
|
||||
description: '/root/audit',
|
||||
model: 'gpt-5.6-terra',
|
||||
state: 'waiting',
|
||||
startedAt: 10
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('never creates a row for a child that is no longer tracked', () => {
|
||||
const roster: CodexSubagentRoster = new Map()
|
||||
upsertCodexSubagent(roster, 'child-1', { state: 'working' }, 10)
|
||||
finishCodexSubagent(roster, 'child-1')
|
||||
|
||||
// A model read racing a completed child must not resurrect its row.
|
||||
setCodexSubagentModel(roster, 'child-1', 'gpt-5.6-terra')
|
||||
|
||||
expect(roster.size).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps a known model when the new value is empty', () => {
|
||||
const roster: CodexSubagentRoster = new Map()
|
||||
upsertCodexSubagent(roster, 'child-1', { model: 'gpt-5.6-sol', state: 'working' }, 10)
|
||||
|
||||
setCodexSubagentModel(roster, 'child-1', ' ')
|
||||
setCodexSubagentModel(roster, 'child-1', undefined)
|
||||
|
||||
expect(roster.get('child-1')?.model).toBe('gpt-5.6-sol')
|
||||
})
|
||||
|
||||
it('bounds an oversized model to the shared cap', () => {
|
||||
const roster: CodexSubagentRoster = new Map()
|
||||
upsertCodexSubagent(roster, 'child-1', { state: 'working' }, 10)
|
||||
|
||||
setCodexSubagentModel(roster, 'child-1', 'x'.repeat(AGENT_MODEL_MAX_LENGTH + 50))
|
||||
|
||||
expect(roster.get('child-1')?.model).toHaveLength(AGENT_MODEL_MAX_LENGTH)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -61,6 +61,28 @@ export function finishCodexSubagent(roster: CodexSubagentRoster, id: string): vo
|
||||
roster.delete(id.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the model a already-tracked child is running. Deliberately narrower
|
||||
* than `upsertCodexSubagent`: it never creates a roster entry and never touches
|
||||
* `state`, so late model discovery from a child rollout cannot resurrect a
|
||||
* finished child nor move any child's lifecycle.
|
||||
*/
|
||||
export function setCodexSubagentModel(
|
||||
roster: CodexSubagentRoster,
|
||||
id: string,
|
||||
model: string | undefined
|
||||
): void {
|
||||
const normalizedModel = normalizeOptionalField(model, AGENT_MODEL_MAX_LENGTH)
|
||||
if (!normalizedModel) {
|
||||
return
|
||||
}
|
||||
const existing = roster.get(id.trim())
|
||||
if (!existing) {
|
||||
return
|
||||
}
|
||||
existing.model = normalizedModel
|
||||
}
|
||||
|
||||
export function seedCodexSubagentRoster(
|
||||
roster: CodexSubagentRoster,
|
||||
snapshots: readonly AgentSubagentSnapshot[]
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import type * as NodeFs from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
// Why: ESM namespaces are not configurable, so counting rollout opens needs a
|
||||
// delegating mock rather than vi.spyOn. Every other export stays real.
|
||||
const openSyncCalls = vi.hoisted(() => vi.fn())
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeFs>()
|
||||
return {
|
||||
...actual,
|
||||
openSync: (...args: Parameters<typeof actual.openSync>) => {
|
||||
openSyncCalls(...args)
|
||||
return actual.openSync(...args)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
createCodexSubagentTranscriptState,
|
||||
hasTrackedCodexTranscriptSubagents,
|
||||
@@ -155,4 +170,122 @@ describe('Codex subagent transcript reconciliation', () => {
|
||||
expect(hasTrackedCodexTranscriptSubagents(state)).toBe(false)
|
||||
expect(roster.size).toBe(0)
|
||||
})
|
||||
|
||||
describe('child model identity', () => {
|
||||
function turnContext(model: string): unknown {
|
||||
return { type: 'turn_context', payload: { turn_id: 'turn-1', model } }
|
||||
}
|
||||
|
||||
function started(): unknown {
|
||||
return { type: 'event_msg', payload: { type: 'task_started' } }
|
||||
}
|
||||
|
||||
/** Parent rollout with one live child, plus that child's own rollout. */
|
||||
function seedPair(childRecords: unknown[]): {
|
||||
parentPath: string
|
||||
childPath: string
|
||||
} {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'codex-subagent-model-'))
|
||||
dirs.push(dir)
|
||||
const parentPath = join(dir, 'rollout-parent.jsonl')
|
||||
const childPath = join(dir, `rollout-child-${CHILD_ID}.jsonl`)
|
||||
writeFileSync(parentPath, jsonl([turnContext('gpt-5.6-sol'), activity('started')]))
|
||||
writeFileSync(childPath, jsonl(childRecords))
|
||||
return { parentPath, childPath }
|
||||
}
|
||||
|
||||
it('reports the model from the child rollout, not the parent model', () => {
|
||||
const { parentPath } = seedPair([started(), turnContext('gpt-5.6-terra')])
|
||||
const state = createCodexSubagentTranscriptState()
|
||||
const roster: CodexSubagentRoster = new Map()
|
||||
|
||||
reconcileCodexSubagentTranscript(state, roster, parentPath)
|
||||
|
||||
// The parent runs sol; only the child's own turn_context may set its model.
|
||||
expect(codexRosterToSnapshots(roster)?.[0]?.model).toBe('gpt-5.6-terra')
|
||||
})
|
||||
|
||||
it('keeps the discovered model when a later poll carries no turn_context', () => {
|
||||
const { parentPath, childPath } = seedPair([started(), turnContext('gpt-5.6-terra')])
|
||||
const state = createCodexSubagentTranscriptState()
|
||||
const roster: CodexSubagentRoster = new Map()
|
||||
reconcileCodexSubagentTranscript(state, roster, parentPath)
|
||||
|
||||
// The cursor is incremental: this appended line is all the next read sees.
|
||||
writeFileSync(
|
||||
childPath,
|
||||
jsonl([
|
||||
started(),
|
||||
turnContext('gpt-5.6-terra'),
|
||||
{ type: 'event_msg', payload: { type: 'agent_message' } }
|
||||
])
|
||||
)
|
||||
reconcileCodexSubagentTranscript(state, roster, parentPath)
|
||||
|
||||
expect(codexRosterToSnapshots(roster)?.[0]?.model).toBe('gpt-5.6-terra')
|
||||
})
|
||||
|
||||
it('tracks the newest model when the child switches mid-session', () => {
|
||||
const { parentPath } = seedPair([
|
||||
started(),
|
||||
turnContext('gpt-5.6-terra'),
|
||||
turnContext('gpt-5.6-sol')
|
||||
])
|
||||
const state = createCodexSubagentTranscriptState()
|
||||
const roster: CodexSubagentRoster = new Map()
|
||||
|
||||
reconcileCodexSubagentTranscript(state, roster, parentPath)
|
||||
|
||||
expect(codexRosterToSnapshots(roster)?.[0]?.model).toBe('gpt-5.6-sol')
|
||||
})
|
||||
|
||||
it('leaves the child working and keeps its identity while reading the model', () => {
|
||||
const { parentPath } = seedPair([started(), turnContext('gpt-5.6-terra')])
|
||||
const state = createCodexSubagentTranscriptState()
|
||||
const roster: CodexSubagentRoster = new Map()
|
||||
|
||||
reconcileCodexSubagentTranscript(state, roster, parentPath)
|
||||
|
||||
// Model discovery must not move lifecycle or overwrite the child's label.
|
||||
expect(codexRosterToSnapshots(roster)).toEqual([
|
||||
{
|
||||
id: CHILD_ID,
|
||||
description: '/root/sidebar_repro',
|
||||
state: 'working',
|
||||
startedAt: 1234,
|
||||
agentType: undefined,
|
||||
model: 'gpt-5.6-terra'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('does not resurrect a child that completed in the same read', () => {
|
||||
const { parentPath } = seedPair([
|
||||
started(),
|
||||
turnContext('gpt-5.6-terra'),
|
||||
{ type: 'event_msg', payload: { type: 'task_complete' } }
|
||||
])
|
||||
const state = createCodexSubagentTranscriptState()
|
||||
const roster: CodexSubagentRoster = new Map()
|
||||
|
||||
reconcileCodexSubagentTranscript(state, roster, parentPath)
|
||||
|
||||
expect(roster.size).toBe(0)
|
||||
expect(hasTrackedCodexTranscriptSubagents(state)).toBe(false)
|
||||
})
|
||||
|
||||
it('reads the model without opening any file beyond the parent and child rollouts', () => {
|
||||
const { parentPath } = seedPair([started(), turnContext('gpt-5.6-terra')])
|
||||
const state = createCodexSubagentTranscriptState()
|
||||
const roster: CodexSubagentRoster = new Map()
|
||||
openSyncCalls.mockClear()
|
||||
|
||||
reconcileCodexSubagentTranscript(state, roster, parentPath)
|
||||
|
||||
// Why: model extraction reuses the records already read for completion
|
||||
// detection, so it must add no file I/O of its own.
|
||||
expect(openSyncCalls).toHaveBeenCalledTimes(2)
|
||||
expect(codexRosterToSnapshots(roster)?.[0]?.model).toBe('gpt-5.6-terra')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { basename, dirname, extname, isAbsolute, join } from 'node:path'
|
||||
|
||||
import {
|
||||
finishCodexSubagent,
|
||||
setCodexSubagentModel,
|
||||
upsertCodexSubagent,
|
||||
type CodexSubagentRoster
|
||||
} from './codex-subagent-roster'
|
||||
@@ -22,6 +23,10 @@ type JsonlCursor = {
|
||||
|
||||
type TrackedTranscriptSubagent = JsonlCursor & {
|
||||
description?: string
|
||||
/** Latest model seen in the child's own rollout. Retained across polls
|
||||
* because the cursor is incremental: `turn_context` is emitted once per
|
||||
* turn, so a later read usually carries no model at all. */
|
||||
model?: string
|
||||
startedAt: number
|
||||
unresolvedSince?: number
|
||||
}
|
||||
@@ -199,6 +204,24 @@ function readActivity(recordValue: JsonRecord):
|
||||
}
|
||||
}
|
||||
|
||||
/** Latest model from the child's own `turn_context` records. A child can be
|
||||
* launched on a different model than its parent, so this is read from the
|
||||
* child rollout rather than inherited. */
|
||||
function readChildModel(records: JsonRecord[]): string | undefined {
|
||||
let model: string | undefined
|
||||
for (const recordValue of records) {
|
||||
if (recordValue.type !== 'turn_context') {
|
||||
continue
|
||||
}
|
||||
const payload = record(recordValue.payload)
|
||||
const value = typeof payload?.model === 'string' ? payload.model.trim() : ''
|
||||
if (value) {
|
||||
model = value
|
||||
}
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
function childIsComplete(records: JsonRecord[]): boolean {
|
||||
let complete = false
|
||||
for (const recordValue of records) {
|
||||
@@ -289,6 +312,11 @@ export function reconcileCodexSubagentTranscript(
|
||||
}
|
||||
} else {
|
||||
tracked.unresolvedSince = undefined
|
||||
tracked.model = readChildModel(records) ?? tracked.model
|
||||
// Why: re-applied every reconcile, not just on discovery — the parent's
|
||||
// own activity upsert can rebuild this child's roster entry, which would
|
||||
// otherwise drop a model found on an earlier poll.
|
||||
setCodexSubagentModel(roster, id, tracked.model)
|
||||
if (!childIsComplete(records)) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -106,7 +106,9 @@ export type ComputerActionVerification =
|
||||
reason:
|
||||
| 'synthetic_input'
|
||||
| 'clipboard_paste'
|
||||
| 'accessibility_action_unasserted'
|
||||
| 'provider_unavailable'
|
||||
| 'readback_unsupported'
|
||||
| 'window_changed'
|
||||
| 'value_mismatch'
|
||||
expected?: string | null
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* The single errno allowlist for "this path is definitively not there".
|
||||
*
|
||||
* `existsSync` returns `false` for `ENOENT` and for `EPERM`, `EACCES`, `EBUSY`,
|
||||
* `EIO`, `UNKNOWN` and every unrecognised code alike, and a `catch` returning a
|
||||
* default does the same. Callers that act on absence — deleting a mirror,
|
||||
* overwriting a config, clearing a credential — must be able to tell the two
|
||||
* apart, and they must all agree on where the line is, so this lives in one
|
||||
* place rather than being re-derived per lane.
|
||||
*
|
||||
* An unknown code is never absence. Mapping the unknown to a verdict is the
|
||||
* category error this predicate exists to prevent.
|
||||
*/
|
||||
export function isDefinitiveAbsence(error: unknown): boolean {
|
||||
const code = (error as NodeJS.ErrnoException | null)?.code
|
||||
return code === 'ENOENT' || code === 'ENOTDIR'
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isHandledWireDiscriminant } from './handled-wire-discriminant'
|
||||
|
||||
const HANDLED: Record<'missing' | 'unavailable', true> = {
|
||||
missing: true,
|
||||
unavailable: true
|
||||
}
|
||||
|
||||
describe('isHandledWireDiscriminant', () => {
|
||||
it('admits the handled members', () => {
|
||||
expect(isHandledWireDiscriminant('missing', HANDLED)).toBe(true)
|
||||
expect(isHandledWireDiscriminant('unavailable', HANDLED)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a member this build has never heard of', () => {
|
||||
expect(isHandledWireDiscriminant('permission-denied', HANDLED)).toBe(false)
|
||||
})
|
||||
|
||||
// Why: Object.hasOwn coerces its key, so a guard typed (x: string) admits ['missing'] — whose
|
||||
// toString matches a handled key — and the value then falls straight back out of the switch.
|
||||
// This is the P1 found in review on #15002; it is the reason the parameter is `unknown`.
|
||||
it('rejects a non-string the wire can carry', () => {
|
||||
for (const value of [['missing'], null, undefined, 0, true, { missing: true }, Symbol('x')]) {
|
||||
expect(isHandledWireDiscriminant(value, HANDLED), String(value)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
// Why: hasOwn, not `in` — inherited keys are not handled members.
|
||||
it('does not admit inherited object keys', () => {
|
||||
expect(isHandledWireDiscriminant('toString', HANDLED)).toBe(false)
|
||||
expect(isHandledWireDiscriminant('constructor', HANDLED)).toBe(false)
|
||||
expect(isHandledWireDiscriminant('__proto__', HANDLED)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
// Why: runtime RPC results are cast, not decoded — `runtime-rpc-envelope` declares `result:
|
||||
// z.unknown()` — so a host on a newer build routinely puts a discriminant on the wire that this
|
||||
// build's union has never heard of, and a switch on it falls through to `undefined`.
|
||||
//
|
||||
// Callers pass a `Record<TheUnion, true>` so a new union member fails typecheck twice: on the Record
|
||||
// (TS2741) and on the switch (`switch-exhaustiveness-check`, which forbids an absorbing `default:`).
|
||||
//
|
||||
// typeof first: `Object.hasOwn` coerces its key, so a host that widened the field to an array sends
|
||||
// `['missing']`, which a hasOwn-only guard admits before the switch drops it straight back out.
|
||||
export function isHandledWireDiscriminant<T extends string>(
|
||||
value: unknown,
|
||||
handled: Record<T, true>
|
||||
): value is T {
|
||||
return typeof value === 'string' && Object.hasOwn(handled, value)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createHookListenerState, normalizeHookPayload } from './agent-hook-listener'
|
||||
import { makePaneKey } from './stable-pane-id'
|
||||
|
||||
const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111')
|
||||
|
||||
/**
|
||||
* OpenCode reports an approval prompt as `permission.asked`; the status plugin forwards
|
||||
* that event's `properties` under `hook_event_name: 'PermissionRequest'`. The shape is
|
||||
* fixed by @opencode-ai/sdk 1.18.18 (`EventPermissionAsked`): `permission` names what is
|
||||
* being requested, `patterns` carries the concrete commands/paths it applies to, and
|
||||
* `metadata` holds tool-specific detail.
|
||||
*
|
||||
* Only those outer names are typed — `metadata` is `Record<string, unknown>`, so the keys
|
||||
* inside it come from each tool. The payloads below were captured from a live opencode
|
||||
* 1.18.18 rather than invented, because the first version of this suite guessed a shape
|
||||
* (`metadata: {}`) that OpenCode never emits and so never exercised the real `edit` path.
|
||||
*
|
||||
* `normalizeOpenCodeFamilyEvent` serves opencode and mimo-code from one path, so every
|
||||
* case here runs for both.
|
||||
*/
|
||||
describe('OpenCode-family permission request status', () => {
|
||||
let state: ReturnType<typeof createHookListenerState>
|
||||
|
||||
beforeEach(() => {
|
||||
state = createHookListenerState()
|
||||
})
|
||||
|
||||
const SOURCES = ['opencode', 'mimo-code'] as const
|
||||
|
||||
function permissionEvent(
|
||||
source: (typeof SOURCES)[number],
|
||||
properties: Record<string, unknown>
|
||||
): ReturnType<typeof normalizeHookPayload> {
|
||||
return normalizeHookPayload(
|
||||
state,
|
||||
source,
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: { hook_event_name: 'PermissionRequest', ...properties }
|
||||
},
|
||||
'production'
|
||||
)
|
||||
}
|
||||
|
||||
function lifecycleEvent(
|
||||
source: (typeof SOURCES)[number],
|
||||
eventName: string,
|
||||
properties: Record<string, unknown> = {}
|
||||
): ReturnType<typeof normalizeHookPayload> {
|
||||
return normalizeHookPayload(
|
||||
state,
|
||||
source,
|
||||
{ paneKey: PANE_KEY, payload: { hook_event_name: eventName, ...properties } },
|
||||
'production'
|
||||
)
|
||||
}
|
||||
|
||||
const BASH_PERMISSION = {
|
||||
id: 'per_01',
|
||||
sessionID: 'ses_root',
|
||||
permission: 'bash',
|
||||
patterns: ['rm -rf build/'],
|
||||
metadata: { command: 'rm -rf build/' },
|
||||
always: []
|
||||
}
|
||||
|
||||
it.each(SOURCES)('reports a permission request as waiting for %s', (source) => {
|
||||
const event = permissionEvent(source, BASH_PERMISSION)
|
||||
|
||||
expect(event?.payload.state).toBe('waiting')
|
||||
expect(event?.payload.agentType).toBe(source)
|
||||
})
|
||||
|
||||
it.each(SOURCES)('names the requested permission as the tool for %s', (source) => {
|
||||
const event = permissionEvent(source, BASH_PERMISSION)
|
||||
|
||||
// Why: a bare `waiting` row cannot tell the user what is blocked; the permission
|
||||
// name is the only field that always identifies it (STA-3160).
|
||||
expect(event?.payload.toolName).toBe('bash')
|
||||
})
|
||||
|
||||
it.each(SOURCES)('surfaces the blocked command as tool input for %s', (source) => {
|
||||
const event = permissionEvent(source, BASH_PERMISSION)
|
||||
|
||||
expect(event?.payload.toolInput).toBe('rm -rf build/')
|
||||
})
|
||||
|
||||
it.each(SOURCES)('names the edited file for %s', (source) => {
|
||||
// Captured verbatim from opencode 1.18.18 — an `edit` request keys its path as
|
||||
// `filepath` (one word) and ships the whole patch alongside it.
|
||||
const event = permissionEvent(source, {
|
||||
id: 'per_02',
|
||||
sessionID: 'ses_root',
|
||||
permission: 'edit',
|
||||
patterns: ['private/tmp/oc-perm-probe/notes.md'],
|
||||
metadata: {
|
||||
filepath: '/private/tmp/oc-perm-probe/notes.md',
|
||||
diff: '@@ -0,0 +1,1 @@\n+hello world\n\\ No newline at end of file\n'
|
||||
},
|
||||
always: ['*']
|
||||
})
|
||||
|
||||
expect(event?.payload.toolName).toBe('edit')
|
||||
expect(event?.payload.toolInput).toBe('/private/tmp/oc-perm-probe/notes.md')
|
||||
})
|
||||
|
||||
it.each(SOURCES)('never previews a diff as the blocked target for %s', (source) => {
|
||||
const event = permissionEvent(source, {
|
||||
id: 'per_03',
|
||||
sessionID: 'ses_root',
|
||||
permission: 'edit',
|
||||
patterns: ['src/a.ts'],
|
||||
metadata: { diff: '@@ -1 +1 @@\n-before\n+after\n' },
|
||||
always: []
|
||||
})
|
||||
|
||||
// Why: `diff` is the largest field OpenCode sends and would swamp a one-line row.
|
||||
expect(event?.payload.toolInput).not.toContain('@@')
|
||||
})
|
||||
|
||||
it.each(SOURCES)('names the fetched url for %s', (source) => {
|
||||
// Captured verbatim from opencode 1.18.18.
|
||||
const event = permissionEvent(source, {
|
||||
id: 'per_04',
|
||||
sessionID: 'ses_root',
|
||||
permission: 'webfetch',
|
||||
patterns: ['https://example.com'],
|
||||
metadata: { url: 'https://example.com', format: 'markdown' },
|
||||
always: ['*']
|
||||
})
|
||||
|
||||
expect(event?.payload.toolName).toBe('webfetch')
|
||||
expect(event?.payload.toolInput).toBe('https://example.com')
|
||||
})
|
||||
|
||||
it.each(SOURCES)('falls back to patterns when metadata is unrecognized for %s', (source) => {
|
||||
// Why: `metadata` is typed `Record<string, unknown>` by the SDK, so a tool Orca has
|
||||
// never seen (an MCP server's, say) can key its detail anything — patterns is the one
|
||||
// field every permission carries.
|
||||
const event = permissionEvent(source, {
|
||||
id: 'per_05',
|
||||
sessionID: 'ses_root',
|
||||
permission: 'some_mcp_tool',
|
||||
patterns: ['src/a.ts', 'src/b.ts'],
|
||||
metadata: { unrecognizedKey: 'ignored' },
|
||||
always: []
|
||||
})
|
||||
|
||||
expect(event?.payload.toolName).toBe('some_mcp_tool')
|
||||
expect(event?.payload.toolInput).toBe('src/a.ts, src/b.ts')
|
||||
})
|
||||
|
||||
it.each(SOURCES)(
|
||||
'still reports waiting when the payload names no permission for %s',
|
||||
(source) => {
|
||||
// Why: an unrecognized/absent permission must not downgrade the blocked state — the
|
||||
// user still has to answer, they just get less detail.
|
||||
const event = permissionEvent(source, { id: 'per_06', sessionID: 'ses_root' })
|
||||
|
||||
expect(event?.payload.state).toBe('waiting')
|
||||
expect(event?.payload.toolName).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it.each(SOURCES)('leaves the AskUserQuestion route untouched for %s', (source) => {
|
||||
const properties = { questions: [{ question: 'Choose', options: ['x', 'y'] }] }
|
||||
const event = normalizeHookPayload(
|
||||
state,
|
||||
source,
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: { hook_event_name: 'AskUserQuestion', ...properties }
|
||||
},
|
||||
'production'
|
||||
)
|
||||
|
||||
expect(event?.payload.state).toBe('waiting')
|
||||
expect(event?.payload.interactivePrompt).toBe(JSON.stringify(properties))
|
||||
})
|
||||
|
||||
it.each(SOURCES)('clears permission metadata once the turn resumes for %s', (source) => {
|
||||
permissionEvent(source, BASH_PERMISSION)
|
||||
const resumed = lifecycleEvent(source, 'SessionBusy')
|
||||
|
||||
// Why: a stale approval card must not outlive the approval — once work resumes the
|
||||
// row is working again and the blocked command is no longer what the pane is on.
|
||||
expect(resumed?.payload.state).toBe('working')
|
||||
expect(resumed?.payload.toolName).toBeUndefined()
|
||||
expect(resumed?.payload.toolInput).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each(SOURCES)('retires the permission when the session goes idle for %s', (source) => {
|
||||
permissionEvent(source, BASH_PERMISSION)
|
||||
const idle = lifecycleEvent(source, 'SessionIdle')
|
||||
|
||||
expect(idle?.payload.state).toBe('done')
|
||||
expect(idle?.payload.toolName).toBeUndefined()
|
||||
expect(idle?.payload.toolInput).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each(SOURCES)(
|
||||
'does not label a later question with the answered permission for %s',
|
||||
(source) => {
|
||||
permissionEvent(source, BASH_PERMISSION)
|
||||
const question = lifecycleEvent(source, 'AskUserQuestion', {
|
||||
questions: [{ question: 'Choose', options: ['x', 'y'] }]
|
||||
})
|
||||
|
||||
expect(question?.payload.state).toBe('waiting')
|
||||
expect(question?.payload.toolName).toBeUndefined()
|
||||
expect(question?.payload.toolInput).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it.each(SOURCES)('does not attribute the answered permission to a reply for %s', (source) => {
|
||||
permissionEvent(source, BASH_PERMISSION)
|
||||
const part = lifecycleEvent(source, 'MessagePart', { role: 'assistant', text: 'Ran it.' })
|
||||
|
||||
expect(part?.payload.lastAssistantMessage).toBe('Ran it.')
|
||||
expect(part?.payload.toolName).toBeUndefined()
|
||||
expect(part?.payload.toolInput).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each(SOURCES)('does not carry an answered permission into a later wait for %s', (source) => {
|
||||
// Why: the row now shows tool fields on 'waiting' as well as 'working' (that is the whole
|
||||
// point of STA-3160), so a later non-permission wait must arrive with none — otherwise
|
||||
// relaxing the row gate turns an answered command into the question's caption.
|
||||
permissionEvent(source, BASH_PERMISSION)
|
||||
lifecycleEvent(source, 'SessionBusy')
|
||||
lifecycleEvent(source, 'SessionIdle')
|
||||
const laterQuestion = lifecycleEvent(source, 'AskUserQuestion', {
|
||||
questions: [{ question: 'Which branch?', options: ['main', 'dev'] }]
|
||||
})
|
||||
|
||||
expect(laterQuestion?.payload.state).toBe('waiting')
|
||||
expect(laterQuestion?.payload.toolInput).toBeUndefined()
|
||||
expect(laterQuestion?.payload.toolName).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each(SOURCES)('does not carry an answered permission into a later turn for %s', (source) => {
|
||||
// Why: isNewTurnEvent is false for this family, so nothing else ever resets the cached
|
||||
// tool. Without an explicit retire, one permission pins its command to every later
|
||||
// working frame in the pane — the exact stale-tool-line the row gate guards against.
|
||||
permissionEvent(source, BASH_PERMISSION)
|
||||
lifecycleEvent(source, 'SessionBusy')
|
||||
lifecycleEvent(source, 'SessionIdle')
|
||||
const laterTurn = lifecycleEvent(source, 'SessionBusy')
|
||||
|
||||
expect(laterTurn?.payload.state).toBe('working')
|
||||
// Why: assert the command first — it is the string a user would misread as live work.
|
||||
expect(laterTurn?.payload.toolInput).toBeUndefined()
|
||||
expect(laterTurn?.payload.toolName).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -14,7 +14,7 @@ import { tmpdir } from 'node:os'
|
||||
import { basename, delimiter, dirname, isAbsolute, join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildPosixCommandPathLookupScript } from './posix-command-path-lookup'
|
||||
import { buildWslLoginShellCommand, escapeWslShCommandForWindows } from './wsl-login-shell-command'
|
||||
import { buildWslCapturedLoginShellCommand, buildWslExecArgs } from './wsl-login-shell-command'
|
||||
|
||||
type ShellCase = {
|
||||
name: string
|
||||
@@ -233,7 +233,9 @@ describe('buildPosixCommandPathLookupScript', () => {
|
||||
'resolves through the Windows-to-WSL login-shell boundary with inline masks',
|
||||
() => {
|
||||
const lookup = buildPosixCommandPathLookupScript({ kind: 'literal', value: 'sh' })
|
||||
const command = buildWslLoginShellCommand(
|
||||
// Why the captured form: an interactive login shell also prints the distro's
|
||||
// rc/motd to stdout, which would land in front of the resolved path.
|
||||
const captured = buildWslCapturedLoginShellCommand(
|
||||
[
|
||||
`sh() { printf '%s\\n' masked-function; }`,
|
||||
`alias sh='printf masked-alias'`,
|
||||
@@ -241,13 +243,13 @@ describe('buildPosixCommandPathLookupScript', () => {
|
||||
`printf '%s' "$resolved"`
|
||||
].join('\n')
|
||||
)
|
||||
const resolved = execFileSync(
|
||||
const stdout = execFileSync(
|
||||
'wsl.exe',
|
||||
['--', 'sh', '-lc', escapeWslShCommandForWindows(command)],
|
||||
buildWslExecArgs(undefined, ['sh', '-lc', captured.command]),
|
||||
{ encoding: 'utf8', timeout: WSL_TEST_COMMAND_TIMEOUT_MS }
|
||||
).trim()
|
||||
)
|
||||
|
||||
expect(resolved).toMatch(/^\/.+\/sh$/)
|
||||
expect(captured.readStdout(stdout)?.trim()).toMatch(/^\/.+\/sh$/)
|
||||
},
|
||||
30_000
|
||||
)
|
||||
@@ -266,7 +268,7 @@ function canRunWslSh(): boolean {
|
||||
return wslShAvailable
|
||||
}
|
||||
try {
|
||||
execFileSync('wsl.exe', ['--', 'sh', '-lc', 'true'], {
|
||||
execFileSync('wsl.exe', ['--exec', 'sh', '-lc', 'true'], {
|
||||
timeout: WSL_TEST_COMMAND_TIMEOUT_MS
|
||||
})
|
||||
wslShAvailable = true
|
||||
|
||||
@@ -87,3 +87,11 @@ export async function buildInstallRgMessage(
|
||||
`Install ripgrep ${location} to enable fast, gitignore-aware listing: ${cmd}`
|
||||
)
|
||||
}
|
||||
|
||||
export async function buildRipgrepRequiredMessage(
|
||||
host: 'local' | 'remote' = 'local'
|
||||
): Promise<string> {
|
||||
const cmd = await detectInstallCommand()
|
||||
const location = host === 'local' ? 'on the host running Quick Open' : 'on the remote'
|
||||
return `Quick Open search requires ripgrep ${location} to stay resource-bounded. Install it with: ${cmd}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { isClipboardTextByteLengthOverLimit } from './clipboard-text'
|
||||
import { compareFileNames } from './file-name-sort'
|
||||
|
||||
export const QUICK_OPEN_RESULT_LIMIT = 50
|
||||
export const QUICK_OPEN_QUERY_MAX_BYTES = 2 * 1024
|
||||
export const QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS = 256
|
||||
export const QUICK_OPEN_SEARCH_VERSION = 1
|
||||
|
||||
export type QuickOpenIndexedFile = {
|
||||
path: string
|
||||
lowerPath: string
|
||||
lowerFilename: string
|
||||
inputIndex: number
|
||||
}
|
||||
|
||||
export type QuickOpenSearchResult = {
|
||||
path: string
|
||||
score: number
|
||||
}
|
||||
|
||||
export function prepareQuickOpenFiles(files: readonly string[]): QuickOpenIndexedFile[] {
|
||||
return files.map((path, inputIndex) => prepareQuickOpenFile(path, inputIndex))
|
||||
}
|
||||
|
||||
const preparedQuickOpenFiles = new WeakMap<readonly string[], QuickOpenIndexedFile[]>()
|
||||
|
||||
export function getPreparedQuickOpenFiles(
|
||||
files: readonly string[]
|
||||
): readonly QuickOpenIndexedFile[] {
|
||||
const cached = preparedQuickOpenFiles.get(files)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const prepared = prepareQuickOpenFiles(files)
|
||||
preparedQuickOpenFiles.set(files, prepared)
|
||||
return prepared
|
||||
}
|
||||
|
||||
export function isQuickOpenQueryTooLarge(
|
||||
query: string,
|
||||
maxBytes = QUICK_OPEN_QUERY_MAX_BYTES
|
||||
): boolean {
|
||||
return isClipboardTextByteLengthOverLimit(query, maxBytes)
|
||||
}
|
||||
|
||||
export function isQuickOpenRemoteQueryTooLarge(query: string): boolean {
|
||||
return query.length > QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS || isQuickOpenQueryTooLarge(query)
|
||||
}
|
||||
|
||||
export function rankQuickOpenFiles(
|
||||
query: string,
|
||||
files: readonly QuickOpenIndexedFile[],
|
||||
limit = QUICK_OPEN_RESULT_LIMIT
|
||||
): QuickOpenSearchResult[] {
|
||||
if (limit <= 0 || isQuickOpenQueryTooLarge(query)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const normalizedQuery = normalizeQuickOpenQuery(query)
|
||||
const results: QuickOpenRankedResult[] = []
|
||||
for (const file of files) {
|
||||
const score = normalizedQuery ? fuzzyMatchIndexedFile(normalizedQuery, file) : 0
|
||||
if (score !== -1) {
|
||||
retainTopResult(results, { path: file.path, score, inputIndex: file.inputIndex }, limit)
|
||||
}
|
||||
}
|
||||
return finalizeResults(results)
|
||||
}
|
||||
|
||||
export class QuickOpenPathRanker {
|
||||
private readonly normalizedQuery: string | null
|
||||
private readonly retained: QuickOpenRankedResult[] = []
|
||||
private inputIndex = 0
|
||||
private matchCount = 0
|
||||
|
||||
constructor(
|
||||
query: string,
|
||||
private readonly limit: number
|
||||
) {
|
||||
this.normalizedQuery =
|
||||
limit <= 0 || isQuickOpenQueryTooLarge(query) ? null : normalizeQuickOpenQuery(query)
|
||||
}
|
||||
|
||||
consider(path: string): void {
|
||||
const file = prepareQuickOpenFile(path, this.inputIndex++)
|
||||
if (this.normalizedQuery === null) {
|
||||
return
|
||||
}
|
||||
const score = this.normalizedQuery ? fuzzyMatchIndexedFile(this.normalizedQuery, file) : 0
|
||||
if (score === -1) {
|
||||
return
|
||||
}
|
||||
this.matchCount++
|
||||
retainTopResult(
|
||||
this.retained,
|
||||
{ path: file.path, score, inputIndex: file.inputIndex },
|
||||
this.limit
|
||||
)
|
||||
}
|
||||
|
||||
result(): { paths: string[]; totalCount: number } {
|
||||
return {
|
||||
paths: finalizeResults(this.retained).map((result) => result.path),
|
||||
totalCount: this.matchCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeQuickOpenQuery(query: string): string {
|
||||
return query.trim().replace(/\\/g, '/').toLowerCase()
|
||||
}
|
||||
|
||||
function prepareQuickOpenFile(path: string, inputIndex: number): QuickOpenIndexedFile {
|
||||
const searchPath = path.replace(/\\/g, '/')
|
||||
const lastSlash = searchPath.lastIndexOf('/')
|
||||
return {
|
||||
path,
|
||||
lowerPath: searchPath.toLowerCase(),
|
||||
lowerFilename: searchPath.slice(lastSlash + 1).toLowerCase(),
|
||||
inputIndex
|
||||
}
|
||||
}
|
||||
|
||||
function fuzzyMatchIndexedFile(query: string, file: QuickOpenIndexedFile): number {
|
||||
let qi = 0
|
||||
let score = 0
|
||||
let lastMatchIdx = -1
|
||||
|
||||
for (let ti = 0; ti < file.lowerPath.length && qi < query.length; ti++) {
|
||||
if (file.lowerPath[ti] !== query[qi]) {
|
||||
continue
|
||||
}
|
||||
const gap = lastMatchIdx === -1 ? 0 : ti - lastMatchIdx - 1
|
||||
score += gap
|
||||
if (
|
||||
ti > 0 &&
|
||||
(file.lowerPath[ti - 1] === '/' ||
|
||||
file.lowerPath[ti - 1] === '.' ||
|
||||
file.lowerPath[ti - 1] === '-')
|
||||
) {
|
||||
score -= 5
|
||||
}
|
||||
lastMatchIdx = ti
|
||||
qi++
|
||||
}
|
||||
|
||||
if (qi < query.length) {
|
||||
return -1
|
||||
}
|
||||
if (file.lowerFilename.includes(query)) {
|
||||
score -= 100
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
type QuickOpenRankedResult = QuickOpenSearchResult & {
|
||||
inputIndex: number
|
||||
}
|
||||
|
||||
function retainTopResult(
|
||||
heap: QuickOpenRankedResult[],
|
||||
candidate: QuickOpenRankedResult,
|
||||
limit: number
|
||||
): void {
|
||||
if (heap.length === limit && compareRankedResult(candidate, heap[0]) >= 0) {
|
||||
return
|
||||
}
|
||||
if (heap.length < limit) {
|
||||
heap.push(candidate)
|
||||
siftResultUp(heap, heap.length - 1)
|
||||
return
|
||||
}
|
||||
heap[0] = candidate
|
||||
siftResultDown(heap)
|
||||
}
|
||||
|
||||
function siftResultUp(heap: QuickOpenRankedResult[], startIndex: number): void {
|
||||
let index = startIndex
|
||||
while (index > 0) {
|
||||
const parentIndex = Math.floor((index - 1) / 2)
|
||||
if (compareRankedResult(heap[index], heap[parentIndex]) <= 0) {
|
||||
return
|
||||
}
|
||||
;[heap[index], heap[parentIndex]] = [heap[parentIndex], heap[index]]
|
||||
index = parentIndex
|
||||
}
|
||||
}
|
||||
|
||||
function siftResultDown(heap: QuickOpenRankedResult[]): void {
|
||||
let index = 0
|
||||
while (true) {
|
||||
const leftIndex = index * 2 + 1
|
||||
if (leftIndex >= heap.length) {
|
||||
return
|
||||
}
|
||||
const rightIndex = leftIndex + 1
|
||||
const worseChildIndex =
|
||||
rightIndex < heap.length && compareRankedResult(heap[rightIndex], heap[leftIndex]) > 0
|
||||
? rightIndex
|
||||
: leftIndex
|
||||
if (compareRankedResult(heap[worseChildIndex], heap[index]) <= 0) {
|
||||
return
|
||||
}
|
||||
;[heap[index], heap[worseChildIndex]] = [heap[worseChildIndex], heap[index]]
|
||||
index = worseChildIndex
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeResults(results: QuickOpenRankedResult[]): QuickOpenSearchResult[] {
|
||||
return results
|
||||
.sort(compareRankedResult)
|
||||
.map(({ path, score }): QuickOpenSearchResult => ({ path, score }))
|
||||
}
|
||||
|
||||
function compareRankedResult(a: QuickOpenRankedResult, b: QuickOpenRankedResult): number {
|
||||
return a.score - b.score || compareFileNames(a.path, b.path) || a.inputIndex - b.inputIndex
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
limitQuickOpenFilesBySerializedBytes,
|
||||
limitQuickOpenSearchReplyBySerializedBytes,
|
||||
serializedQuickOpenPathBytes
|
||||
} from './quick-open-transport-budget'
|
||||
|
||||
describe('Quick Open transport byte budget', () => {
|
||||
it('matches JSON.stringify byte accounting, including escaped paths', () => {
|
||||
const path = 'src/line\nquote".ts'
|
||||
expect(serializedQuickOpenPathBytes(path)).toBe(Buffer.byteLength(JSON.stringify(path), 'utf8'))
|
||||
})
|
||||
|
||||
it('returns the largest prefix that fits as a JSON array', () => {
|
||||
const files = ['src/a.ts', 'data/ignored\nfile.bin', 'src/b.ts']
|
||||
const budget = Buffer.byteLength(JSON.stringify(files.slice(0, 2)), 'utf8')
|
||||
expect(limitQuickOpenFilesBySerializedBytes(files, budget)).toEqual(files.slice(0, 2))
|
||||
})
|
||||
|
||||
it('drops the lowest-ranked reply entries until the complete result fits', () => {
|
||||
const result = {
|
||||
worktree: 'wt-1',
|
||||
rootPath: '/repo',
|
||||
files: [
|
||||
{ relativePath: 'src/b.ts', basename: 'b.ts', kind: 'text' as const },
|
||||
{ relativePath: 'src/a.ts', basename: 'a.ts', kind: 'text' as const }
|
||||
],
|
||||
totalCount: 2,
|
||||
truncated: false
|
||||
}
|
||||
const budget = Buffer.byteLength(
|
||||
JSON.stringify({ ...result, files: result.files.slice(0, 1) }),
|
||||
'utf8'
|
||||
)
|
||||
expect(limitQuickOpenSearchReplyBySerializedBytes(result, budget).files).toHaveLength(1)
|
||||
expect(limitQuickOpenSearchReplyBySerializedBytes(result, budget).truncated).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
/** Byte accounting for the JSON array returned by files.listAll. */
|
||||
export function serializedQuickOpenPathBytes(path: string): number {
|
||||
return Buffer.byteLength(JSON.stringify(path), 'utf8')
|
||||
}
|
||||
|
||||
type QuickOpenSearchReply = {
|
||||
files: readonly unknown[]
|
||||
totalCount: number
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/** Keep a ranked Quick Open RPC reply below the request-scoped content ceiling. */
|
||||
export function limitQuickOpenSearchReplyBySerializedBytes<T extends QuickOpenSearchReply>(
|
||||
result: T,
|
||||
maxBytes: number
|
||||
): T {
|
||||
if (Buffer.byteLength(JSON.stringify(result), 'utf8') <= maxBytes) {
|
||||
return result
|
||||
}
|
||||
|
||||
for (let length = result.files.length - 1; length >= 0; length -= 1) {
|
||||
const bounded = { ...result, files: result.files.slice(0, length), truncated: true } as T
|
||||
if (Buffer.byteLength(JSON.stringify(bounded), 'utf8') <= maxBytes) {
|
||||
return bounded
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Quick Open search result exceeds the remote transport budget')
|
||||
}
|
||||
|
||||
export function limitQuickOpenFilesBySerializedBytes(
|
||||
files: readonly string[],
|
||||
maxBytes: number
|
||||
): string[] {
|
||||
const bounded: string[] = []
|
||||
let serializedBytes = 2 // []
|
||||
for (const path of files) {
|
||||
const nextBytes = serializedQuickOpenPathBytes(path) + (bounded.length === 0 ? 0 : 1)
|
||||
if (serializedBytes + nextBytes > maxBytes) {
|
||||
break
|
||||
}
|
||||
bounded.push(path)
|
||||
serializedBytes += nextBytes
|
||||
}
|
||||
return bounded
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import type { RemoteServerUpdateSupport } from './remote-server-update'
|
||||
import type { ExecutionHostId } from './execution-host'
|
||||
import type { PtyIncarnationId } from './pty-incarnation'
|
||||
import type { RasterImageDimensions } from './raster-image-dimensions'
|
||||
import type { TerminalExitCause } from './terminal-exit-cause'
|
||||
|
||||
export type { RuntimeMarkdownReadTabResult, RuntimeMarkdownSaveTabResult }
|
||||
|
||||
@@ -380,6 +381,7 @@ export type RuntimeFileListResult = {
|
||||
files: RuntimeFileListEntry[]
|
||||
totalCount: number
|
||||
truncated: boolean
|
||||
quickOpenSearchVersion?: number
|
||||
}
|
||||
|
||||
export type RuntimeFileOpenResult = {
|
||||
@@ -465,6 +467,10 @@ export type RuntimeTerminalSummary = {
|
||||
writable: boolean
|
||||
lastOutputAt: number | null
|
||||
preview: string
|
||||
/** Why this terminal's process is gone. Absent while it is still running, and
|
||||
* absent from a host predating the field — never read an absent value as a
|
||||
* clean finish (STA-4536). */
|
||||
exitCause?: TerminalExitCause
|
||||
/** Where this terminal actually runs. Absent when the host predates the field
|
||||
* or could not name the host — never read an absent value as local. */
|
||||
executionHostId?: ExecutionHostId
|
||||
@@ -603,10 +609,29 @@ export type RuntimeWorktreeTerminalSleepResult = {
|
||||
}
|
||||
)
|
||||
|
||||
/** Evidence class that proved a pane is parked on a prompt only a human can answer.
|
||||
* Never inferred from silence: `hook` is an agent-reported blocked/waiting state,
|
||||
* `prompt-text` is a matched prompt in the retained tail, `title` is a live OSC title. */
|
||||
export type RuntimeTerminalInteractiveWaitSource = 'hook' | 'prompt-text' | 'title'
|
||||
|
||||
/** Present only while the wait is live. A `null` field means the pane was evaluated and nothing
|
||||
* proves a wait; an absent field means it was never evaluated — an older host, an unverifiable
|
||||
* worker identity, an unreadable pane, or an agent probe that did not answer in time. Absence
|
||||
* is never "not waiting". */
|
||||
export type RuntimeTerminalInteractiveWait = {
|
||||
source: RuntimeTerminalInteractiveWaitSource
|
||||
/** Named prompt when the tail identified one. Absent for hook and title evidence. */
|
||||
reason?: RuntimeTerminalWaitBlockedReason
|
||||
/** ms epoch the wait was first observed, when the evidence carries a timestamp. */
|
||||
since?: number
|
||||
}
|
||||
|
||||
export type RuntimeTerminalShow = RuntimeTerminalSummary & {
|
||||
paneRuntimeId: number
|
||||
ptyId: string | null
|
||||
rendererGraphEpoch: number
|
||||
/** Null when nothing proves an interactive wait. Absent on hosts that predate the field. */
|
||||
agentWait?: RuntimeTerminalInteractiveWait | null
|
||||
}
|
||||
|
||||
export type RuntimeTerminalState = 'running' | 'exited' | 'unknown'
|
||||
@@ -745,6 +770,8 @@ export type RuntimeTerminalClose = {
|
||||
}
|
||||
|
||||
export type RuntimeTerminalWaitCondition = 'exit' | 'tui-idle'
|
||||
/** The `codex-` prefixes are historical: these startup prompts are matched by shape, not by
|
||||
* vendor, so they also fire for Claude and Cursor. Renaming them would break paired hosts. */
|
||||
export type RuntimeTerminalWaitBlockedReason =
|
||||
| 'codex-update-prompt'
|
||||
| 'codex-trust-workspace'
|
||||
@@ -752,6 +779,7 @@ export type RuntimeTerminalWaitBlockedReason =
|
||||
| 'codex-model-migration-prompt'
|
||||
| 'codex-hooks-review-prompt'
|
||||
| 'codex-interactive-prompt'
|
||||
| 'agent-approval-prompt'
|
||||
|
||||
export type RuntimeTerminalWait = {
|
||||
handle: string
|
||||
@@ -759,6 +787,8 @@ export type RuntimeTerminalWait = {
|
||||
satisfied: boolean
|
||||
status: RuntimeTerminalState
|
||||
exitCode: number | null
|
||||
/** Why it exited. `exitCode` alone cannot answer that — see {@link TerminalExitCause}. */
|
||||
exitCause?: TerminalExitCause
|
||||
blockedReason?: RuntimeTerminalWaitBlockedReason
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export const SESSION_TAB_CLOSE_CANCELED_ERROR = 'session_tab_close_canceled'
|
||||
export const SESSION_TAB_CLOSE_FAILED_ERROR = 'session_tab_close_failed'
|
||||
export const SESSION_TAB_NOT_FOUND_ERROR = 'session_tab_not_found'
|
||||
export const SESSION_TAB_CLOSE_TIMEOUT_ERROR = 'session_tab_close_timeout'
|
||||
|
||||
export type SessionTabCloseRequest = {
|
||||
requestId: string
|
||||
tabId: string
|
||||
worktreeId: string
|
||||
expiresAt?: number
|
||||
}
|
||||
|
||||
export type SessionTabCloseResponse = {
|
||||
requestId: string
|
||||
error?: string
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export const SKILL_INSTALL_CANCEL_CAPABILITY = 'skills.install-cancel.v1' as con
|
||||
export const SKILL_UPLOAD_CAPABILITY = 'skills.upload.v1' as const
|
||||
export const SKILL_MANAGEMENT_CAPABILITY = 'skills.manage.v1' as const
|
||||
export const SKILL_BUNDLE_INSTALL_CAPABILITY = 'skills.install.bundle.v1' as const
|
||||
export const SKILL_BUNDLE_PREVIEW_CAPABILITY = 'skills.preview.bundle.v1' as const
|
||||
export const SKILL_INSTALL_PROGRESS_CAPABILITY = 'skills.install-progress.v1' as const
|
||||
/** Why: the install request schema is strict, so a host that predates the agent
|
||||
* picker rejects the whole request rather than ignoring the new field. */
|
||||
|
||||
@@ -4,11 +4,15 @@ import {
|
||||
SkillInstallRequestSchema,
|
||||
SkillRemoveRequestSchema
|
||||
} from './skill-install-contract'
|
||||
import { SkillBundleInstallRequestSchema } from './skill-bundle-install-contract'
|
||||
import {
|
||||
SkillBundleInstallPreviewRequestSchema,
|
||||
SkillBundleInstallRequestSchema
|
||||
} from './skill-bundle-install-contract'
|
||||
|
||||
export const SKILL_SSH_RELAY_INSTALL_METHOD = 'skills.install' as const
|
||||
export const SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD = 'skills.installBundle' as const
|
||||
export const SKILL_SSH_RELAY_PREVIEW_METHOD = 'skills.previewInstall' as const
|
||||
export const SKILL_SSH_RELAY_PREVIEW_BUNDLE_METHOD = 'skills.previewBundleInstall' as const
|
||||
export const SKILL_SSH_RELAY_REMOVE_METHOD = 'skills.removeInstall' as const
|
||||
export const SKILL_SSH_RELAY_LIST_METHOD = 'skills.listManagedInstalls' as const
|
||||
export const SKILL_SSH_RELAY_BEGIN_UPLOAD_METHOD = 'skills.beginUpload' as const
|
||||
@@ -49,6 +53,13 @@ export const SkillSshPreviewParamsSchema = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const SkillSshPreviewBundleParamsSchema = z
|
||||
.object({
|
||||
request: SkillBundleInstallPreviewRequestSchema,
|
||||
workspace: SkillSshWorkspaceAuthoritySchema.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const SkillSshRemoveParamsSchema = z
|
||||
.object({
|
||||
request: SkillRemoveRequestSchema,
|
||||
@@ -64,4 +75,5 @@ export type SkillSshWorkspaceAuthority = z.infer<typeof SkillSshWorkspaceAuthori
|
||||
export type SkillSshInstallParams = z.infer<typeof SkillSshInstallParamsSchema>
|
||||
export type SkillSshInstallBundleParams = z.infer<typeof SkillSshInstallBundleParamsSchema>
|
||||
export type SkillSshPreviewParams = z.infer<typeof SkillSshPreviewParamsSchema>
|
||||
export type SkillSshPreviewBundleParams = z.infer<typeof SkillSshPreviewBundleParamsSchema>
|
||||
export type SkillSshRemoveParams = z.infer<typeof SkillSshRemoveParamsSchema>
|
||||
|
||||
@@ -781,6 +781,11 @@ const agentHookUnattributedSchema = z
|
||||
.object({ reason: z.enum(['empty_pane_key', 'unknown_tab_id']) })
|
||||
.strict()
|
||||
|
||||
// Why (#11217): loopback hook POSTs reset mid-body by local security software kill agent status for
|
||||
// every runtime at once. Count only — the truncated bodies carry user prompts and tool I/O, so
|
||||
// nothing derived from them may reach the wire.
|
||||
const agentHookTransportBlockedSchema = z.object({ count: z.number().int().nonnegative() }).strict()
|
||||
|
||||
// ── Onboarding ──────────────────────────────────────────────────────────
|
||||
// Closed enums only — no raw paths/repo names/URLs/error strings (measures activation, not repo debugging).
|
||||
// Why: event names still carry legacy seven-step payloads; keep validation backward-compatible for old rows.
|
||||
@@ -1444,6 +1449,7 @@ export const eventSchemas = {
|
||||
agent_error: agentErrorSchema,
|
||||
agent_hook_install_failed: agentHookInstallFailedSchema,
|
||||
agent_hook_unattributed: agentHookUnattributedSchema,
|
||||
agent_hook_transport_blocked: agentHookTransportBlockedSchema,
|
||||
|
||||
daemon_start_failed: daemonStartFailedSchema,
|
||||
main_thread_hang_detected: mainThreadHangDetectedSchema,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
describeTerminalExitCause,
|
||||
isDeliberateTerminalExit,
|
||||
resolveProcessExitCause,
|
||||
resolveUnreportedExitCause
|
||||
} from './terminal-exit-cause'
|
||||
|
||||
describe('resolveProcessExitCause', () => {
|
||||
it('reports a signalled death as a signal, not as the zero node-pty pairs with it', () => {
|
||||
expect(resolveProcessExitCause({ exitCode: 0, signal: 9 })).toEqual({
|
||||
kind: 'signaled',
|
||||
signal: 9
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses to read a status the host cannot report', () => {
|
||||
// macOS wraps every PTY in `login -flpq`, which returns 0 for a shell that
|
||||
// exited 42 and 0 for one that was SIGKILLed.
|
||||
expect(
|
||||
resolveProcessExitCause({ exitCode: 0, signal: 0, hostReportsChildExitStatus: false })
|
||||
).toEqual({ kind: 'unknown', reason: 'host_status_unavailable' })
|
||||
expect(
|
||||
resolveProcessExitCause({ exitCode: 42, signal: 9, hostReportsChildExitStatus: false })
|
||||
).toEqual({ kind: 'unknown', reason: 'host_status_unavailable' })
|
||||
})
|
||||
|
||||
it('treats the stop paths’ negative sentinel as absence of evidence', () => {
|
||||
expect(resolveProcessExitCause({ exitCode: -1 })).toEqual({
|
||||
kind: 'unknown',
|
||||
reason: 'stop_unverified'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a real status when the host vouches for it', () => {
|
||||
expect(resolveProcessExitCause({ exitCode: 42, signal: 0 })).toEqual({
|
||||
kind: 'exited',
|
||||
exitCode: 42
|
||||
})
|
||||
expect(resolveProcessExitCause({ exitCode: 0 })).toEqual({ kind: 'exited', exitCode: 0 })
|
||||
})
|
||||
|
||||
it('treats a missing signal as no signal rather than as unknown', () => {
|
||||
expect(resolveProcessExitCause({ exitCode: 7, signal: null })).toEqual({
|
||||
kind: 'exited',
|
||||
exitCode: 7
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveUnreportedExitCause', () => {
|
||||
it('refuses to turn a bare zero into a clean finish', () => {
|
||||
// An older daemon, or the SSH relay, forwards a code and no cause. Its 0 may
|
||||
// be a clean finish or a SIGKILL; nothing downstream can tell them apart.
|
||||
expect(resolveUnreportedExitCause(0)).toEqual({ kind: 'unknown', reason: 'cause_unreported' })
|
||||
})
|
||||
|
||||
it('keeps a bare nonzero status, which nothing fabricates', () => {
|
||||
// node-pty only ever pairs a signal with 0, and a wrapper only ever returns
|
||||
// 0, so a nonzero code really is the child's wait status.
|
||||
expect(resolveUnreportedExitCause(137)).toEqual({ kind: 'exited', exitCode: 137 })
|
||||
})
|
||||
|
||||
it('keeps the more specific reason for the stop sentinel', () => {
|
||||
expect(resolveUnreportedExitCause(-1)).toEqual({ kind: 'unknown', reason: 'stop_unverified' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeTerminalExitCause', () => {
|
||||
it('gives every cause a sentence that does not need a number decoded', () => {
|
||||
expect(describeTerminalExitCause({ kind: 'operator_close' })).toBe(
|
||||
'Terminal closed by operator request'
|
||||
)
|
||||
expect(describeTerminalExitCause({ kind: 'signaled', signal: 9 })).toBe(
|
||||
'Agent process killed by signal 9'
|
||||
)
|
||||
expect(describeTerminalExitCause({ kind: 'exited', exitCode: 3 })).toBe(
|
||||
'Agent process exited with code 3'
|
||||
)
|
||||
expect(describeTerminalExitCause({ kind: 'unknown', reason: 'stop_unverified' })).toBe(
|
||||
'Agent process stop was requested but never confirmed'
|
||||
)
|
||||
expect(describeTerminalExitCause({ kind: 'unknown', reason: 'host_status_unavailable' })).toBe(
|
||||
'Agent process ended; this host cannot report why'
|
||||
)
|
||||
expect(describeTerminalExitCause({ kind: 'unknown', reason: 'cause_unreported' })).toBe(
|
||||
'Agent process ended; the reporting host did not say why'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDeliberateTerminalExit', () => {
|
||||
it('counts only an operator close as deliberate', () => {
|
||||
expect(isDeliberateTerminalExit({ kind: 'operator_close' })).toBe(true)
|
||||
expect(isDeliberateTerminalExit({ kind: 'exited', exitCode: 0 })).toBe(false)
|
||||
expect(isDeliberateTerminalExit({ kind: 'signaled', signal: 9 })).toBe(false)
|
||||
expect(isDeliberateTerminalExit({ kind: 'unknown', reason: 'stop_unverified' })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Why a terminal's process is gone.
|
||||
*
|
||||
* Orca used to record one number and let every reader guess. That number is not
|
||||
* evidence: the stop paths synthesize it, node-pty reports 0 for a signalled
|
||||
* death, and macOS's TCC `login(1)` wrapper returns its own status instead of
|
||||
* the shell's. A clean finish, an OOM kill and an operator close all arrived as
|
||||
* "code 0" (STA-4536, STA-4603).
|
||||
*
|
||||
* So a cause is only ever built from evidence someone actually holds, and the
|
||||
* absence of evidence is spelled `unknown` rather than guessed.
|
||||
*/
|
||||
export type TerminalExitCause =
|
||||
/** Teardown was requested through Orca — a close, a stop, a worktree removal. Not a failure. */
|
||||
| { kind: 'operator_close' }
|
||||
/** The host reported a signal. The agent did not choose to stop. */
|
||||
| { kind: 'signaled'; signal: number }
|
||||
/** The process ended on its own and the host vouched for the status. */
|
||||
| { kind: 'exited'; exitCode: number }
|
||||
/** Nothing here is provable. Never narrow this by guessing. */
|
||||
| { kind: 'unknown'; reason: TerminalExitUnknownReason }
|
||||
|
||||
export type TerminalExitUnknownReason =
|
||||
/** A stop was issued and no exit was ever observed, so the process may still be alive. */
|
||||
| 'stop_unverified'
|
||||
/** The host cannot report its child's status at all (see {@link hostReportsChildExitStatus}). */
|
||||
| 'host_status_unavailable'
|
||||
/**
|
||||
* The exit arrived from a source that reports a code but no cause — an SSH
|
||||
* relay, or a daemon older than this field. Its `0` may be a clean finish or
|
||||
* a signal; nothing here can tell, so nothing here claims to.
|
||||
*/
|
||||
| 'cause_unreported'
|
||||
|
||||
export const OPERATOR_CLOSE_EXIT_CAUSE: TerminalExitCause = { kind: 'operator_close' }
|
||||
|
||||
/**
|
||||
* Build a cause from what the host actually observed.
|
||||
*
|
||||
* `hostReportsChildExitStatus: false` means the number and signal below describe
|
||||
* a wrapper process, not the agent — so they are dropped rather than reported.
|
||||
*/
|
||||
export function resolveProcessExitCause(observation: {
|
||||
exitCode: number
|
||||
signal?: number | null
|
||||
hostReportsChildExitStatus?: boolean
|
||||
}): TerminalExitCause {
|
||||
if (observation.hostReportsChildExitStatus === false) {
|
||||
return { kind: 'unknown', reason: 'host_status_unavailable' }
|
||||
}
|
||||
if (typeof observation.signal === 'number' && observation.signal > 0) {
|
||||
return { kind: 'signaled', signal: observation.signal }
|
||||
}
|
||||
// Why: the stop paths pass a negative code to mean "we asked it to stop and
|
||||
// never saw it die". That is an absence of evidence, not an exit status.
|
||||
if (observation.exitCode < 0) {
|
||||
return { kind: 'unknown', reason: 'stop_unverified' }
|
||||
}
|
||||
return { kind: 'exited', exitCode: observation.exitCode }
|
||||
}
|
||||
|
||||
/**
|
||||
* The cause for an exit delivered without one — an older daemon, or the SSH
|
||||
* relay, which forwards a code and nothing else.
|
||||
*
|
||||
* Zero is the ambiguous one, and the only one worth refusing: node-pty pairs it
|
||||
* with every signalled death, and a wrapper spawn returns it for any outcome at
|
||||
* all. A nonzero status is never fabricated that way, so it is still reported.
|
||||
*/
|
||||
export function resolveUnreportedExitCause(exitCode: number): TerminalExitCause {
|
||||
if (exitCode < 0) {
|
||||
return { kind: 'unknown', reason: 'stop_unverified' }
|
||||
}
|
||||
return exitCode === 0
|
||||
? { kind: 'unknown', reason: 'cause_unreported' }
|
||||
: { kind: 'exited', exitCode }
|
||||
}
|
||||
|
||||
/** One line an operator or a coordinating agent can read without decoding a number. */
|
||||
export function describeTerminalExitCause(cause: TerminalExitCause): string {
|
||||
switch (cause.kind) {
|
||||
case 'operator_close':
|
||||
return 'Terminal closed by operator request'
|
||||
case 'signaled':
|
||||
return `Agent process killed by signal ${cause.signal}`
|
||||
case 'exited':
|
||||
return `Agent process exited with code ${cause.exitCode}`
|
||||
case 'unknown':
|
||||
switch (cause.reason) {
|
||||
case 'stop_unverified':
|
||||
return 'Agent process stop was requested but never confirmed'
|
||||
case 'host_status_unavailable':
|
||||
return 'Agent process ended; this host cannot report why'
|
||||
case 'cause_unreported':
|
||||
return 'Agent process ended; the reporting host did not say why'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** True when a dispatch that ended this way was torn down deliberately rather than lost. */
|
||||
export function isDeliberateTerminalExit(cause: TerminalExitCause): boolean {
|
||||
return cause.kind === 'operator_close'
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeTerminalPadding } from './terminal-padding-settings'
|
||||
|
||||
describe('normalizeTerminalPadding', () => {
|
||||
it('rounds fractional padding to the nearest fitted pixel', () => {
|
||||
expect(normalizeTerminalPadding(1.5)).toBe(2)
|
||||
expect(normalizeTerminalPadding(2.49)).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps padding inside the settings range', () => {
|
||||
expect(normalizeTerminalPadding(-1)).toBe(0)
|
||||
expect(normalizeTerminalPadding(513)).toBe(512)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +0,0 @@
|
||||
const MIN_TERMINAL_PADDING = 0
|
||||
const MAX_TERMINAL_PADDING = 512
|
||||
|
||||
export function normalizeTerminalPadding(value: number): number {
|
||||
return Math.min(MAX_TERMINAL_PADDING, Math.max(MIN_TERMINAL_PADDING, Math.round(value)))
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
normalizeExecutionHostId,
|
||||
toSshExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from './execution-host'
|
||||
import type { WorkspaceCleanupCandidate } from './workspace-cleanup'
|
||||
|
||||
/** Host evidence a cleanup row carries; `worktreeId` alone repeats across hosts. */
|
||||
export type WorkspaceCleanupHostFacts = {
|
||||
@@ -10,6 +12,22 @@ export type WorkspaceCleanupHostFacts = {
|
||||
executionHostId?: ExecutionHostId
|
||||
}
|
||||
|
||||
export type WorkspaceCleanupIdentityFacts = WorkspaceCleanupHostFacts &
|
||||
Pick<WorkspaceCleanupCandidate, 'worktreeId'>
|
||||
|
||||
const WORKSPACE_CLEANUP_IDENTITY_SEPARATOR = '\0'
|
||||
|
||||
export function getWorkspaceCleanupCandidateHostId(
|
||||
candidate: WorkspaceCleanupHostFacts
|
||||
): ExecutionHostId {
|
||||
return (
|
||||
resolveWorkspaceCleanupRemovalHostId(candidate) ??
|
||||
// Why: display/index surfaces still need a bucket for a pre-host row; only
|
||||
// the destructive path may not guess (see resolveWorkspaceCleanupRemovalHostId).
|
||||
LOCAL_EXECUTION_HOST_ID
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The owner a destructive removal is allowed to target, or null when the row
|
||||
* carries no host evidence at all (a snapshot or publisher that predates
|
||||
@@ -26,3 +44,24 @@ export function resolveWorkspaceCleanupRemovalHostId(
|
||||
const connectionId = candidate.connectionId?.trim()
|
||||
return connectionId ? toSshExecutionHostId(connectionId) : null
|
||||
}
|
||||
|
||||
export function getWorkspaceCleanupHostIdentity(hostId: string, id: string): string {
|
||||
return `${hostId}${WORKSPACE_CLEANUP_IDENTITY_SEPARATOR}${id}`
|
||||
}
|
||||
|
||||
/** Stable key for one row of one host — the identity every cleanup surface keys on. */
|
||||
export function getWorkspaceCleanupCandidateIdentity(
|
||||
candidate: WorkspaceCleanupIdentityFacts
|
||||
): string {
|
||||
return getWorkspaceCleanupHostIdentity(
|
||||
getWorkspaceCleanupCandidateHostId(candidate),
|
||||
candidate.worktreeId
|
||||
)
|
||||
}
|
||||
|
||||
export function getWorkspaceCleanupIdentityWorktreeId(identity: string): string {
|
||||
const separatorIndex = identity.indexOf(WORKSPACE_CLEANUP_IDENTITY_SEPARATOR)
|
||||
return separatorIndex === -1
|
||||
? identity
|
||||
: identity.slice(separatorIndex + WORKSPACE_CLEANUP_IDENTITY_SEPARATOR.length)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Type-only, so the cycle back through workspace-cleanup-filter-model erases at build.
|
||||
import type { WorkspaceCleanupBrowseState } from './workspace-cleanup-browse-state'
|
||||
import type { ExecutionHostId } from './execution-host'
|
||||
import { getWorkspaceCleanupCandidateHostId } from './workspace-cleanup-host-identity'
|
||||
|
||||
export const WORKSPACE_CLEANUP_CLASSIFIER_VERSION = 2
|
||||
export const WORKSPACE_CLEANUP_ARCHIVED_IDLE_MS = 7 * 24 * 60 * 60 * 1000
|
||||
@@ -38,6 +39,10 @@ export type WorkspaceCleanupDismissal = {
|
||||
dismissedAt: number
|
||||
fingerprint: string
|
||||
classifierVersion: number
|
||||
// Why (STA-4343): `repoId::path` ids repeat across hosts, so ignoring one
|
||||
// host's row must not hide another host's. Optional: a dismissal persisted
|
||||
// before this field keeps its legacy id-only match.
|
||||
executionHostId?: ExecutionHostId
|
||||
}
|
||||
|
||||
export type WorkspaceCleanupUIState = {
|
||||
@@ -266,12 +271,19 @@ export function isWorkspaceOldForCleanup(
|
||||
}
|
||||
|
||||
export function shouldHideWorkspaceCleanupCandidate(
|
||||
candidate: Pick<WorkspaceCleanupCandidate, 'worktreeId' | 'fingerprint'>,
|
||||
candidate: Pick<
|
||||
WorkspaceCleanupCandidate,
|
||||
'worktreeId' | 'fingerprint' | 'connectionId' | 'executionHostId'
|
||||
>,
|
||||
dismissal: WorkspaceCleanupDismissal | undefined
|
||||
): boolean {
|
||||
return (
|
||||
dismissal?.worktreeId === candidate.worktreeId &&
|
||||
dismissal.fingerprint === candidate.fingerprint &&
|
||||
dismissal.classifierVersion === WORKSPACE_CLEANUP_CLASSIFIER_VERSION
|
||||
dismissal.classifierVersion === WORKSPACE_CLEANUP_CLASSIFIER_VERSION &&
|
||||
// A host-qualified dismissal hides only its own host's row; a legacy one
|
||||
// (no host recorded) keeps hiding every row that matches the fingerprint.
|
||||
(dismissal.executionHostId === undefined ||
|
||||
dismissal.executionHostId === getWorkspaceCleanupCandidateHostId(candidate))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../execution-host'
|
||||
import {
|
||||
composeWorktreeHostIdentity,
|
||||
getWorktreeHostIdentity,
|
||||
getWorktreeIdFromHostIdentity
|
||||
} from './host-qualified-identity'
|
||||
|
||||
const WORKTREE_ID = 'repo-1::/work/orca'
|
||||
|
||||
describe('worktree host identity', () => {
|
||||
it('separates two hosts that publish the same id', () => {
|
||||
expect(getWorktreeHostIdentity({ id: WORKTREE_ID, hostId: 'local' })).not.toBe(
|
||||
getWorktreeHostIdentity({ id: WORKTREE_ID, hostId: 'ssh:build-box' })
|
||||
)
|
||||
})
|
||||
|
||||
it('gives an unqualified row its own bucket', () => {
|
||||
expect(getWorktreeHostIdentity({ id: WORKTREE_ID, hostId: undefined })).not.toBe(
|
||||
getWorktreeHostIdentity({ id: WORKTREE_ID, hostId: 'local' })
|
||||
)
|
||||
})
|
||||
|
||||
// Why this matters: the store index recovers the workspace id from its own key
|
||||
// rather than re-reading `worktree.id`, so a host id containing the separator
|
||||
// would silently truncate every id in the index.
|
||||
it('round-trips the id back out for every host shape', () => {
|
||||
const hostIds = [
|
||||
undefined,
|
||||
'local',
|
||||
toSshExecutionHostId('build-box'),
|
||||
// Real SSH aliases carry '@' and ':'; runtime ids are uuids.
|
||||
toSshExecutionHostId('deploy@10.0.0.4:2222'),
|
||||
toRuntimeExecutionHostId('03ef704c-b180-4b10-998d-e28fbd5de9a3')
|
||||
] as const
|
||||
|
||||
for (const hostId of hostIds) {
|
||||
expect(getWorktreeIdFromHostIdentity(composeWorktreeHostIdentity(hostId, WORKTREE_ID))).toBe(
|
||||
WORKTREE_ID
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('round-trips a workspace id that itself contains the separator', () => {
|
||||
// A workspace id is a repo id plus a filesystem path, so it may contain
|
||||
// anything; only the host half has to be separator-free.
|
||||
const awkwardId = 'repo-1::/work/a|b/orca'
|
||||
|
||||
expect(
|
||||
getWorktreeIdFromHostIdentity(
|
||||
composeWorktreeHostIdentity(toSshExecutionHostId('build-box'), awkwardId)
|
||||
)
|
||||
).toBe(awkwardId)
|
||||
})
|
||||
|
||||
it('keeps a host id free of the separator by construction', () => {
|
||||
// encodeURIComponent escapes '|' as %7C, which is what makes the split exact.
|
||||
expect(toSshExecutionHostId('we|rd')).not.toContain('|')
|
||||
expect(toRuntimeExecutionHostId('we|rd')).not.toContain('|')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ExecutionHostId } from '../execution-host'
|
||||
import type { Worktree } from './types'
|
||||
|
||||
/**
|
||||
* Separator between the host and the workspace id.
|
||||
*
|
||||
* Printable on purpose: these identities become React keys and DOM attribute
|
||||
* values, and HTML attribute parsing replaces U+0000 with U+FFFD — a NUL
|
||||
* separator would not survive the round trip an anchor comparison depends on.
|
||||
*
|
||||
* `|` is safe as a delimiter because an execution host id cannot contain one:
|
||||
* it is `local`, or a fixed `ssh:`/`runtime:` prefix followed by an
|
||||
* `encodeURIComponent`-escaped part (see `toSshExecutionHostId`), which encodes
|
||||
* `|` as `%7C`. Putting the host first therefore makes the split unambiguous
|
||||
* even though the workspace id — a repo id and a filesystem path — may contain
|
||||
* anything.
|
||||
*/
|
||||
const HOST_SEPARATOR = '|'
|
||||
|
||||
/**
|
||||
* Stable key for one workspace on one host (STA-4343).
|
||||
*
|
||||
* `worktreeId` is `repoId::path` with no host component, so a repo registered on
|
||||
* two execution hosts publishes the same id twice for two different workspaces.
|
||||
* Any map, set or React key that must keep them apart keys on this instead.
|
||||
*
|
||||
* An unqualified row gets its own bucket rather than being folded into a host:
|
||||
* it may well BE one of them, but nothing here can prove which.
|
||||
*/
|
||||
export function getWorktreeHostIdentity(worktree: Pick<Worktree, 'id' | 'hostId'>): string {
|
||||
return composeWorktreeHostIdentity(worktree.hostId, worktree.id)
|
||||
}
|
||||
|
||||
export function composeWorktreeHostIdentity(
|
||||
hostId: ExecutionHostId | undefined,
|
||||
worktreeId: string
|
||||
): string {
|
||||
return `${hostId ?? ''}${HOST_SEPARATOR}${worktreeId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace id back out of an identity.
|
||||
*
|
||||
* Exact, not best-effort: the host cannot contain the separator (see above), so
|
||||
* everything after the first one is the id. That lets an index recover the id
|
||||
* from its own key instead of re-reading `worktree.id`, which matters because
|
||||
* retained selectors assert that getter is read exactly once per snapshot.
|
||||
*/
|
||||
export function getWorktreeIdFromHostIdentity(identity: string): string {
|
||||
return identity.slice(identity.indexOf(HOST_SEPARATOR) + 1)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GitWorktreeInfo } from './types'
|
||||
import type { ExecutionHostId } from '../execution-host'
|
||||
import type { GitWorktreeInfo, Worktree } from './types'
|
||||
|
||||
export const LOCKED_WORKTREE_REMOVAL_PREFIX = 'Worktree is locked by Git.'
|
||||
|
||||
@@ -122,3 +123,33 @@ export function classifyWorktreeForceDeleteReason(
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Host qualification (STA-4343) ───────────────────────────────────
|
||||
//
|
||||
// A workspace id is `repoId::path` with no host component, so the local host, an
|
||||
// SSH host and a paired runtime can all publish the SAME id. The same repo at the
|
||||
// same path on two hosts is TWO workspaces, never one — removing by that id alone
|
||||
// destroys whichever checkout routing happens to pick, and routing prefers the
|
||||
// ACTIVE workspace's host, which is usually not the row the user confirmed.
|
||||
//
|
||||
// So a destructive removal travels as a host-qualified target and is ROUTED to
|
||||
// the confirmed host. Making the field required (not optional) is the point: a
|
||||
// future delete entry point cannot be written unguarded without a type error.
|
||||
|
||||
/**
|
||||
* Identity for a destructive workspace removal.
|
||||
*
|
||||
* `executionHostId` is required but nullable on purpose: `null` states that the
|
||||
* confirmed row itself declares no host (a pre-host-qualified snapshot row), so
|
||||
* a caller has to make that claim deliberately instead of omitting the field.
|
||||
*/
|
||||
export type WorktreeRemovalTarget = {
|
||||
id: string
|
||||
executionHostId: ExecutionHostId | null
|
||||
}
|
||||
|
||||
export function toWorktreeRemovalTarget(
|
||||
worktree: Pick<Worktree, 'id' | 'hostId'>
|
||||
): WorktreeRemovalTarget {
|
||||
return { id: worktree.id, executionHostId: worktree.hostId ?? null }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
||||
import { join, relative, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// Why: `wsl.exe <...> -- <argv>` expands $name in every argument against the guest
|
||||
// environment before the guest runs, silently rewriting scripts (an `awk '{print $2}'`
|
||||
// loses its field reference). `--exec` passes argv through untouched. Nothing may
|
||||
// reintroduce the `--` mode separator, so guard the tree instead of each call site.
|
||||
//
|
||||
// Two spellings reach wsl.exe, and both have shipped a regression: an argv array, and
|
||||
// a command line built as a string (PowerShell setup commands). Comments describing
|
||||
// the old form are allowed — only code is scanned.
|
||||
// Guest programs Orca hands to wsl.exe. An allowlist rather than "any quoted
|
||||
// token", because git's own `--` pathspec separator is followed by one too.
|
||||
const GUEST_PROGRAM = String.raw`(?:\/[\w./-]+\/)?(?:sh|bash|zsh|dash|ash|ksh|mksh|env|rm|cat|printf|node|python3?)`
|
||||
// `\s*` has to cross newlines: the formatter puts each argv element on its own
|
||||
// line, which is the exact shape this guard exists to catch.
|
||||
const ARGV_FORM = new RegExp(String.raw`'--',\s*'${GUEST_PROGRAM}'`)
|
||||
const STRING_FORM = new RegExp(String.raw`wsl(?:\.exe)?\b[^\n]*?[^-]--\s+${GUEST_PROGRAM}\b`)
|
||||
|
||||
const SCANNED_ROOTS = ['src', 'config', 'tests']
|
||||
const SCANNED_EXTENSIONS = ['.ts', '.tsx', '.mjs', '.js']
|
||||
const IGNORED_DIRECTORIES = new Set(['node_modules', 'dist', 'out', 'build', '.git'])
|
||||
|
||||
function collectSourceFiles(root: string): string[] {
|
||||
let found: string[] = []
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = readdirSync(root)
|
||||
} catch {
|
||||
return found
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (IGNORED_DIRECTORIES.has(entry)) {
|
||||
continue
|
||||
}
|
||||
const full = join(root, entry)
|
||||
if (statSync(full).isDirectory()) {
|
||||
found = found.concat(collectSourceFiles(full))
|
||||
continue
|
||||
}
|
||||
if (SCANNED_EXTENSIONS.some((extension) => full.endsWith(extension))) {
|
||||
found.push(full)
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop comment-only lines, then rejoin.
|
||||
*
|
||||
* Why rejoin: an argv array is formatted one element per line, so matching each
|
||||
* line in isolation cannot see `'--',\n 'bash'` — the shape the guard is for.
|
||||
*/
|
||||
function codeText(contents: string): string {
|
||||
return contents
|
||||
.split('\n')
|
||||
.filter((line) => !/^\s*(?:\/\/|\/\*|\*)/.test(line))
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
describe('wsl.exe mode separator', () => {
|
||||
const repoRoot = resolve(__dirname, '..', '..')
|
||||
const files = SCANNED_ROOTS.flatMap((root) => collectSourceFiles(join(repoRoot, root)))
|
||||
|
||||
it('scans a plausible number of files', () => {
|
||||
// A broken root or extension list would make the guard silently vacuous.
|
||||
expect(files.length).toBeGreaterThan(500)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['argv array', ARGV_FORM],
|
||||
['command string', STRING_FORM]
|
||||
])('never hands the guest shell to wsl.exe through `--` (%s)', (_form, pattern) => {
|
||||
const offenders = files.filter((file) => {
|
||||
const text = codeText(readFileSync(file, 'utf8'))
|
||||
// Why scoped to WSL files: other tools take a `--` separator followed by a
|
||||
// program too (tmux `split-window … -- cat`), and those are correct.
|
||||
return /wsl/i.test(text) && pattern.test(text)
|
||||
})
|
||||
|
||||
expect(offenders.map((file) => relative(repoRoot, file))).toEqual([])
|
||||
})
|
||||
|
||||
it('sees a `--` separator split across lines by the formatter', () => {
|
||||
// Why: the guard once matched line-by-line and was blind to this exact shape,
|
||||
// which is how every multi-element argv array in this repo is formatted.
|
||||
const formatted = ['args: [', " '-d',", ' distro,', " '--',", " 'bash'", ']'].join('\n')
|
||||
|
||||
expect(ARGV_FORM.test(codeText(formatted))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,13 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildWslCapturedLoginShellCommand,
|
||||
buildWslExecArgs,
|
||||
buildWslInteractiveLoginShellCommand,
|
||||
buildWslLoginShellCommand,
|
||||
escapeWslShCommandForWindows,
|
||||
quotePosixShell
|
||||
} from './wsl-login-shell-command'
|
||||
|
||||
@@ -21,7 +22,7 @@ function canRunWslSh(): boolean {
|
||||
return wslShAvailable
|
||||
}
|
||||
try {
|
||||
execFileSync('wsl.exe', ['--', 'sh', '-lc', 'true'], {
|
||||
execFileSync('wsl.exe', ['--exec', 'sh', '-lc', 'true'], {
|
||||
timeout: WSL_TEST_COMMAND_TIMEOUT_MS
|
||||
})
|
||||
wslShAvailable = true
|
||||
@@ -46,7 +47,7 @@ function expectValidShSyntax(command: string): void {
|
||||
if (!canRunWslSh()) {
|
||||
return
|
||||
}
|
||||
execFileSync('wsl.exe', ['--', 'sh', '-n'], {
|
||||
execFileSync('wsl.exe', ['--exec', 'sh', '-n'], {
|
||||
input: command,
|
||||
timeout: WSL_TEST_COMMAND_TIMEOUT_MS
|
||||
})
|
||||
@@ -122,54 +123,174 @@ describe('wsl login shell command helpers', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves command-scoped environment variables through the outer WSL shell', () => {
|
||||
it('keeps command-scoped environment variables in the quoted payload', () => {
|
||||
const command = buildWslLoginShellCommand('HISTFILE=/tmp/orca-history printf "$HISTFILE"')
|
||||
const escaped = escapeWslShCommandForWindows(command)
|
||||
|
||||
expect(command).toContain('\'HISTFILE=/tmp/orca-history printf "$HISTFILE"\'')
|
||||
expect(escaped).toContain('\\$_orca_wsl_shell')
|
||||
expect(escaped).toContain('\\${SHELL:-/bin/bash}')
|
||||
expect(escaped).toContain('\\$(getent passwd "\\$(id -un)"')
|
||||
expect(escaped).toContain('\\$HISTFILE')
|
||||
expectValidShSyntax(command)
|
||||
}, 30_000)
|
||||
|
||||
it('does not double-escape wrapper shell variables', () => {
|
||||
const command = 'echo \\$_orca_wsl_shell "$_orca_wsl_shell"'
|
||||
|
||||
expect(escapeWslShCommandForWindows(command)).toBe(
|
||||
'echo \\$_orca_wsl_shell "\\$_orca_wsl_shell"'
|
||||
)
|
||||
it('routes through --exec so wsl.exe cannot preprocess argv', () => {
|
||||
expect(buildWslExecArgs('Ubuntu', ['sh', '-lc', 'printf "$HOME"'])).toEqual([
|
||||
'-d',
|
||||
'Ubuntu',
|
||||
'--exec',
|
||||
'sh',
|
||||
'-lc',
|
||||
'printf "$HOME"'
|
||||
])
|
||||
// A distro-less target still has to bypass the `--` preprocessor.
|
||||
expect(buildWslExecArgs(undefined, ['sh', '-c', 'true'])).toEqual([
|
||||
'--exec',
|
||||
'sh',
|
||||
'-c',
|
||||
'true'
|
||||
])
|
||||
})
|
||||
|
||||
it('escapes user command dollars inside POSIX-quoted payloads for WSL argv', () => {
|
||||
const command = buildWslLoginShellCommand(
|
||||
'HISTFILE=/tmp/orca-history printf "$HISTFILE"; printf \'%s\' "$SHELL"'
|
||||
)
|
||||
const escaped = escapeWslShCommandForWindows(command)
|
||||
|
||||
expect(escaped).toContain(
|
||||
"'HISTFILE=/tmp/orca-history printf \"\\$HISTFILE\"; printf '\\''%s'\\'' \"\\$SHELL\"'"
|
||||
)
|
||||
expectValidShSyntax(command)
|
||||
}, 30_000)
|
||||
|
||||
it('preserves user command variables across the Windows-to-WSL argv boundary', () => {
|
||||
if (!canRunWslSh()) {
|
||||
return
|
||||
}
|
||||
|
||||
const command = buildWslLoginShellCommand('orca_value=ok; printf "<%s>" "$orca_value"')
|
||||
const escaped = escapeWslShCommandForWindows(command)
|
||||
// Why the captured form: an interactive login shell also prints the distro's
|
||||
// rc/motd to stdout (stock Ubuntu ships a sudo hint), so a raw login-shell
|
||||
// read cannot be compared byte-for-byte on a real distro.
|
||||
const captured = buildWslCapturedLoginShellCommand('orca_value=ok; printf "<%s>" "$orca_value"')
|
||||
|
||||
expect(
|
||||
execFileSync('wsl.exe', ['--', 'sh', '-lc', escaped], {
|
||||
encoding: 'utf8',
|
||||
timeout: WSL_TEST_COMMAND_TIMEOUT_MS
|
||||
})
|
||||
captured.readStdout(
|
||||
execFileSync('wsl.exe', buildWslExecArgs(undefined, ['sh', '-lc', captured.command]), {
|
||||
encoding: 'utf8',
|
||||
timeout: WSL_TEST_COMMAND_TIMEOUT_MS
|
||||
})
|
||||
)
|
||||
).toBe('<ok>')
|
||||
}, 30_000)
|
||||
|
||||
// Why: `--` expands $name in argv against the guest env before the guest runs,
|
||||
// so these scripts reached the shell already rewritten. Each case below was
|
||||
// measured to return DIFFERENT bytes under `--` than under `--exec`; cases
|
||||
// that merely look risky but are unaffected (a sed backreference has no `$`)
|
||||
// are deliberately not here, because they would pass either way.
|
||||
it.each([
|
||||
['awk field reference', ['sh', '-c', `echo 'a b' | awk '{print $2}'`], 'b\n'],
|
||||
['literal escaped dollar', ['sh', '-c', `printf '[%s]' "\\$HOME"`], '[$HOME]'],
|
||||
['single-quoted dollar', ['sh', '-c', `printf '[%s]' '$PATH'`], '[$PATH]'],
|
||||
// The shape wslUncDirectoryExists uses: `--` blanked $1, so every existing
|
||||
// directory probed as missing.
|
||||
['positional argument', ['sh', '-c', 'printf "[%s]" "$1"', 'sh', 'ARG'], '[ARG]'],
|
||||
['shell local', ['sh', '-c', 'x=hi; printf "[%s]" "$x"'], '[hi]']
|
||||
])(
|
||||
'passes %s to the guest byte-for-byte',
|
||||
(_name, shellArgs, expected) => {
|
||||
if (!canRunWslSh()) {
|
||||
return
|
||||
}
|
||||
|
||||
expect(
|
||||
execFileSync('wsl.exe', buildWslExecArgs(undefined, shellArgs), {
|
||||
encoding: 'utf8',
|
||||
timeout: WSL_TEST_COMMAND_TIMEOUT_MS
|
||||
})
|
||||
).toBe(expected)
|
||||
},
|
||||
30_000
|
||||
)
|
||||
|
||||
describe('captured login shell', () => {
|
||||
it('returns only the fenced payload, dropping rc banner noise', () => {
|
||||
const captured = buildWslCapturedLoginShellCommand('printf directory', 'nonce1')
|
||||
|
||||
expect(
|
||||
captured.readStdout(
|
||||
'To run a command as administrator (user "root"), use "sudo <command>".\n\n' +
|
||||
'__ORCA_WSL_CAPTURE_BEGIN_nonce1__directory__ORCA_WSL_CAPTURE_END_nonce1__'
|
||||
)
|
||||
).toBe('directory')
|
||||
})
|
||||
|
||||
it.skipIf(!canRunWslSh())(
|
||||
'propagates the payload exit status to the caller',
|
||||
() => {
|
||||
// Why a real shell: asserting the script merely *contains* `exit $?` passes
|
||||
// for any input. statPath maps this exit 2 to ENOENT, so it has to survive
|
||||
// the fence for real.
|
||||
const captured = buildWslCapturedLoginShellCommand('printf partial; exit 2')
|
||||
|
||||
let status: number | undefined
|
||||
try {
|
||||
execFileSync('wsl.exe', buildWslExecArgs(undefined, ['sh', '-lc', captured.command]), {
|
||||
encoding: 'utf8',
|
||||
timeout: WSL_TEST_COMMAND_TIMEOUT_MS,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
} catch (error) {
|
||||
status = (error as { status?: number }).status
|
||||
}
|
||||
|
||||
expect(status).toBe(2)
|
||||
},
|
||||
30_000
|
||||
)
|
||||
|
||||
it('emits the status plumbing the payload needs', () => {
|
||||
const captured = buildWslCapturedLoginShellCommand('exit 2', 'nonce1')
|
||||
|
||||
expect(captured.command).toContain('_orca_capture_status=$?')
|
||||
expect(captured.command).toContain('exit $_orca_capture_status')
|
||||
})
|
||||
|
||||
it('keeps payload bytes that themselves contain a fence', () => {
|
||||
// Why a per-call nonce: `cat` of a file quoting a fixed marker would
|
||||
// otherwise be truncated at the quote.
|
||||
const captured = buildWslCapturedLoginShellCommand('cat -- /f', 'nonce2')
|
||||
const payload = 'see __ORCA_WSL_CAPTURE_BEGIN_nonce1__ and __ORCA_WSL_CAPTURE_END_nonce1__\n'
|
||||
|
||||
expect(
|
||||
captured.readStdout(
|
||||
`banner\n__ORCA_WSL_CAPTURE_BEGIN_nonce2__${payload}__ORCA_WSL_CAPTURE_END_nonce2__`
|
||||
)
|
||||
).toBe(payload)
|
||||
})
|
||||
|
||||
it('returns null when the fence never appeared', () => {
|
||||
const captured = buildWslCapturedLoginShellCommand('printf hi', 'nonce1')
|
||||
|
||||
expect(captured.readStdout('bash: line 1: command not found\n')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the tail when a payload exits before the closing fence', () => {
|
||||
const captured = buildWslCapturedLoginShellCommand('printf partial; exit 2', 'nonce1')
|
||||
|
||||
expect(captured.readStdout('banner\n__ORCA_WSL_CAPTURE_BEGIN_nonce1__partial')).toBe(
|
||||
'partial'
|
||||
)
|
||||
})
|
||||
|
||||
it('gives each invocation a distinct fence', () => {
|
||||
const first = buildWslCapturedLoginShellCommand('printf hi')
|
||||
const second = buildWslCapturedLoginShellCommand('printf hi')
|
||||
|
||||
expect(first.command).not.toBe(second.command)
|
||||
})
|
||||
|
||||
it.skipIf(!canRunWslSh())(
|
||||
'reads a clean payload back from a real distro login shell',
|
||||
() => {
|
||||
const captured = buildWslCapturedLoginShellCommand('printf directory')
|
||||
const stdout = execFileSync(
|
||||
'wsl.exe',
|
||||
buildWslExecArgs(undefined, ['sh', '-lc', captured.command]),
|
||||
{ encoding: 'utf8', timeout: WSL_TEST_COMMAND_TIMEOUT_MS }
|
||||
)
|
||||
|
||||
expect(captured.readStdout(stdout)).toBe('directory')
|
||||
},
|
||||
30_000
|
||||
)
|
||||
})
|
||||
|
||||
it('starts an interactive login shell without assuming bash', () => {
|
||||
const command = buildWslInteractiveLoginShellCommand()
|
||||
|
||||
@@ -186,3 +307,35 @@ describe('wsl login shell command helpers', () => {
|
||||
expectValidShSyntax(command)
|
||||
})
|
||||
})
|
||||
|
||||
describe('in-guest wrapper root resolution', () => {
|
||||
// Why this test exists: the wrapper tree is content-addressed, so its path
|
||||
// carries a hash the guest cannot derive. A previous revision of this script
|
||||
// rebuilt the root as `${ORCA_USER_DATA_PATH}/shell-ready`, which stopped
|
||||
// matching -- every WSL pane then fell through to an unwrapped `exec $shell -l`
|
||||
// and silently lost the ready marker, OSC 133, and the launch preflight.
|
||||
it('prefers the host-published root over the legacy user-data guess', () => {
|
||||
const script = buildWslInteractiveLoginShellCommand()
|
||||
expect(script).toContain('if [ -n "${ORCA_SHELL_READY_ROOT:-}" ]; then')
|
||||
expect(script).toContain('_orca_shell_ready_root="${ORCA_SHELL_READY_ROOT%/}"')
|
||||
// The legacy branch must remain reachable only as a fallback, so an older
|
||||
// host that exports just ORCA_USER_DATA_PATH still wraps its shells.
|
||||
expect(script).toContain('elif [ -n "${ORCA_USER_DATA_PATH:-}" ]; then')
|
||||
})
|
||||
|
||||
it('resolves the published root ahead of the legacy path under a real shell', () => {
|
||||
const script = buildWslInteractiveLoginShellCommand()
|
||||
// Run only the root-resolution prologue, then report what it picked.
|
||||
const prologue = script.split('_orca_wsl_shell_name=')[0] as string
|
||||
const probe = [
|
||||
'ORCA_SHELL_READY_ROOT=/mnt/c/ud/shell-wrappers/deadbeefdeadbeef/shell-ready',
|
||||
'ORCA_USER_DATA_PATH=/mnt/c/ud',
|
||||
'export ORCA_SHELL_READY_ROOT ORCA_USER_DATA_PATH',
|
||||
prologue,
|
||||
'printf "%s" "$_orca_shell_ready_root"'
|
||||
].join('\n')
|
||||
const result = spawnSync('sh', ['-c', probe], { encoding: 'utf8' })
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toBe('/mnt/c/ud/shell-wrappers/deadbeefdeadbeef/shell-ready')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,19 +2,21 @@ export function quotePosixShell(value: string): string {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`
|
||||
}
|
||||
|
||||
export function escapeWslShCommandForWindows(command: string): string {
|
||||
// WSL preprocesses unescaped $ in Windows argv before the WSL-side shell
|
||||
// sees it, even when the POSIX script text would single-quote the dollar.
|
||||
let escaped = ''
|
||||
for (let index = 0; index < command.length; index += 1) {
|
||||
const char = command[index]
|
||||
if (char === '$' && command[index - 1] !== '\\') {
|
||||
escaped += '\\$'
|
||||
continue
|
||||
}
|
||||
escaped += char
|
||||
}
|
||||
return escaped
|
||||
/**
|
||||
* Build `wsl.exe` argv that hands a POSIX script to the guest byte-for-byte.
|
||||
*
|
||||
* Why: `wsl.exe -d <distro> -- <argv>` expands `$name` in every argument against
|
||||
* the guest environment before the guest ever runs — it does this even when no
|
||||
* shell is in the command, so `-- /usr/bin/printf %s '$HOME'` prints /home/you.
|
||||
* That silently rewrites scripts (`awk '{print $2}'` loses its field ref) and no
|
||||
* amount of escaping on our side is reliable. `--exec` skips that preprocessing
|
||||
* and passes argv through untouched, so scripts mean what they say.
|
||||
*/
|
||||
export function buildWslExecArgs(
|
||||
distro: string | undefined,
|
||||
shellArgs: readonly string[]
|
||||
): string[] {
|
||||
return [...(distro ? ['-d', distro] : []), '--exec', ...shellArgs]
|
||||
}
|
||||
|
||||
export function buildWslLoginShellCommand(command: string): string {
|
||||
@@ -36,6 +38,72 @@ export function buildWslLoginShellCommand(command: string): string {
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export type WslCapturedLoginShellCommand = {
|
||||
command: string
|
||||
/** Payload the command wrote, or null when the fence never appeared. */
|
||||
readStdout: (stdout: string) => string | null
|
||||
// Why exposed: binary reads (`git show` blob content) must slice bytes rather
|
||||
// than decode to a string first, and this module is bundled for the renderer,
|
||||
// so it cannot reference Buffer itself.
|
||||
beginMarker: string
|
||||
endMarker: string
|
||||
}
|
||||
|
||||
// Why: the fence has to be absent from both the rc output ahead of it and the
|
||||
// payload behind it. A per-call nonce is the only spelling that guarantees
|
||||
// both -- `cat`-ing a file that happens to quote a fixed marker would otherwise
|
||||
// truncate the file. Not security-sensitive, so Math.random is sufficient.
|
||||
function nextWslCaptureNonce(): string {
|
||||
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `command` through the distro login shell and fence its stdout.
|
||||
*
|
||||
* Why: the login shell has to be interactive for bash/zsh so PATH matches what
|
||||
* the user sees in their own terminal (nvm, mise and asdf all install into rc
|
||||
* files that only interactive shells read). But an interactive shell also runs
|
||||
* the distro's rc/motd, and stock Ubuntu writes its "run a command as
|
||||
* administrator" hint to *stdout*. Anything parsing that stream reads the
|
||||
* banner as data. The fence marks where the payload starts and ends so callers
|
||||
* keep the PATH benefit without the noise.
|
||||
*/
|
||||
export function buildWslCapturedLoginShellCommand(
|
||||
command: string,
|
||||
nonce: string = nextWslCaptureNonce()
|
||||
): WslCapturedLoginShellCommand {
|
||||
const begin = `__ORCA_WSL_CAPTURE_BEGIN_${nonce}__`
|
||||
const end = `__ORCA_WSL_CAPTURE_END_${nonce}__`
|
||||
return {
|
||||
beginMarker: begin,
|
||||
endMarker: end,
|
||||
command: buildWslLoginShellCommand(
|
||||
[
|
||||
`printf %s ${quotePosixShell(begin)}`,
|
||||
command,
|
||||
'_orca_capture_status=$?',
|
||||
`printf %s ${quotePosixShell(end)}`,
|
||||
'exit $_orca_capture_status'
|
||||
].join('\n')
|
||||
),
|
||||
readStdout: (stdout) => {
|
||||
// Why lastIndexOf: a login shell can echo the command text before running
|
||||
// it (`set -x` in an rc file), which repeats the opening fence verbatim.
|
||||
// The real payload always follows the last one. The nonce keeps a payload
|
||||
// that happens to quote a marker from colliding.
|
||||
const beginIndex = stdout.lastIndexOf(begin)
|
||||
if (beginIndex === -1) {
|
||||
return null
|
||||
}
|
||||
const payloadStart = beginIndex + begin.length
|
||||
const endIndex = stdout.indexOf(end, payloadStart)
|
||||
// A payload that exited early never prints the closing fence; the rest of
|
||||
// the stream is still its output, and login shells run exit hooks after it.
|
||||
return endIndex === -1 ? stdout.slice(payloadStart) : stdout.slice(payloadStart, endIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWslInteractiveLoginShellCommand(): string {
|
||||
return [
|
||||
'_orca_wsl_shell=$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f7)',
|
||||
@@ -46,7 +114,13 @@ export function buildWslInteractiveLoginShellCommand(): string {
|
||||
' _orca_wsl_shell=/bin/sh',
|
||||
'fi',
|
||||
'_orca_shell_ready_root=""',
|
||||
'if [ -n "${ORCA_USER_DATA_PATH:-}" ]; then',
|
||||
// Why the explicit root first: the wrapper tree is content-addressed, so its
|
||||
// path carries a hash the guest cannot derive. The host publishes the
|
||||
// resolved root and WSLENV /p-translates it. The ORCA_USER_DATA_PATH branch
|
||||
// stays as the fallback for an older host that exports only that.
|
||||
'if [ -n "${ORCA_SHELL_READY_ROOT:-}" ]; then',
|
||||
' _orca_shell_ready_root="${ORCA_SHELL_READY_ROOT%/}"',
|
||||
'elif [ -n "${ORCA_USER_DATA_PATH:-}" ]; then',
|
||||
' _orca_shell_ready_root="${ORCA_USER_DATA_PATH%/}/shell-ready"',
|
||||
'fi',
|
||||
'_orca_wsl_shell_name=$(basename "$_orca_wsl_shell" | tr "[:upper:]" "[:lower:]")',
|
||||
|
||||
@@ -20,6 +20,33 @@ export function isWslUncPath(path: string): boolean {
|
||||
return parseWslUncPath(path) !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Windows path to a Linux path for commands that will execute inside WSL.
|
||||
* Returns the path unchanged if it is already POSIX-style.
|
||||
*
|
||||
* Why: WSL hook/setup environments may need both the worktree UNC path
|
||||
* (\\wsl.localhost\...) and regular Windows install paths (C:\Users\...)
|
||||
* translated before passing them to bash. Leaving drive paths untouched
|
||||
* breaks scripts that read ORCA_ROOT_PATH or similar env vars inside WSL.
|
||||
*/
|
||||
export function toLinuxPath(windowsPath: string): string {
|
||||
// Why the platform guard: on a POSIX host a literal `//wsl$/x` path is an
|
||||
// ordinary directory, not a distro mount, so it must survive unchanged.
|
||||
const info = process.platform === 'win32' ? parseWslUncPath(windowsPath) : null
|
||||
if (info) {
|
||||
return info.linuxPath
|
||||
}
|
||||
|
||||
const driveMatch = windowsPath.match(/^([A-Za-z]):[/\\](.*)$/)
|
||||
if (!driveMatch) {
|
||||
return windowsPath
|
||||
}
|
||||
|
||||
const driveLetter = driveMatch[1].toLowerCase()
|
||||
const rest = driveMatch[2].replace(/\\/g, '/')
|
||||
return `/mnt/${driveLetter}/${rest}`
|
||||
}
|
||||
|
||||
/** Convert an absolute Linux path in a known WSL distro to its Windows form. */
|
||||
export function toWindowsWslPath(linuxPath: string, distro: string): string {
|
||||
const mntMatch = linuxPath.match(/^\/mnt\/([a-z])(\/.*)?$/)
|
||||
|
||||
Reference in New Issue
Block a user