Merge remote-tracking branch 'origin/main' into fix14974d-work

# Conflicts:
#	src/main/providers/ssh-pty-provider.test.ts
This commit is contained in:
Brennan Benson
2026-08-18 16:40:38 -07:00
1514 changed files with 97551 additions and 24642 deletions
+107 -9
View File
@@ -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'
? {
+2
View File
@@ -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)
+9 -2
View File
@@ -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
}
+124
View File
@@ -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)
})
})
+172
View File
@@ -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 -2
View File
@@ -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 =
+3
View File
@@ -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?:
| {
+9 -1
View File
@@ -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 }
+51
View File
@@ -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)
})
})
})
+22
View File
@@ -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')
})
})
})
+28
View File
@@ -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
}
+2
View File
@@ -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)
})
})
+15
View File
@@ -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()
})
})
+9 -7
View File
@@ -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
+8
View File
@@ -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}`
}
+217
View File
@@ -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)
})
})
+46
View File
@@ -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
}
+30
View File
@@ -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
}
+16
View File
@@ -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
}
+1
View File
@@ -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. */
+13 -1
View File
@@ -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>
+6
View File
@@ -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,
+99
View File
@@ -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)
})
})
+103
View File
@@ -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)
})
})
-6
View File
@@ -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)
}
+14 -2
View File
@@ -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)
}
+32 -1
View File
@@ -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)
})
})
+187 -34
View File
@@ -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')
})
})
+88 -14
View File
@@ -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:]")',
+27
View File
@@ -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])(\/.*)?$/)