feat(agent-status): gate OSC 9999 with a pane-bound nonce

Orca parses OSC 9999 off the PTY master unconditionally, so any text a pane
prints becomes an agent-status assertion for that pane — a fetched page, a
cat'd log, a dependency's build output. Stamp a per-pane nonce into pane env
next to ORCA_PANE_KEY and grade every payload against it inside the shared
processor all four parser call sites already funnel through.

Ships in observe mode: a wrong nonce is dropped everywhere, but an un-nonced
payload is still accepted and tagged pane-unattested, because every OSC 9999
payload Orca receives today comes from an external process and the format has
never been documented. ORCA_AGENT_STATUS_OSC_NONCE=enforce opts in early.

The nonce proves possession of pane context, not agent identity — code already
running in the pane can read the env like any descendant.

Refs #14033 (STA-4003)
This commit is contained in:
Brennan Benson
2026-08-18 00:49:33 -07:00
parent 7ba336099c
commit 8b34c88004
25 changed files with 801 additions and 17 deletions
+1
View File
@@ -99,6 +99,7 @@ docs/**
!docs/ai-vault-process-isolation-plan.md
!docs/mobile-terminal-shortcut-bar.md
!docs/reference/
!docs/reference/agent-status-osc-nonce.md
!docs/reference/git-compatibility.md
!docs/reference/headless-linux-server.md
!docs/reference/linux-glibc-compatibility.md
+6
View File
@@ -48,6 +48,12 @@ All changes must consider folder workspaces as well as git worktrees. Don't assu
Clients and remote Orca servers update independently, so mixed versions are the normal state. Before changing anything a paired client and host exchange — RPC params, stream frames, or the content either side publishes over them — follow [`docs/reference/remote-wire-compatibility.md`](./docs/reference/remote-wire-compatibility.md). A new optional field is safe; a new stream opcode must be capability-negotiated because decoders drop unknown opcodes silently; and changing what the host publishes reaches old clients even with no wire change.
## Agent Status OSC Surface
A pane can publish agent status in-band with `OSC 9999`. Orca parses it off the PTY master, so **any text the pane prints** reaches the parser — including a page an agent fetched or a file it `cat`'d. The payload is gated by a pane-inherited nonce.
Before changing the parser, the payload, or what the gate accepts, read [`docs/reference/agent-status-osc-nonce.md`](./docs/reference/agent-status-osc-nonce.md). Two things it is easy to get wrong: the nonce proves possession of pane context, **not** agent identity, and un-nonced payloads are still accepted by default so existing external integrations do not break.
## Git Binary Compatibility
Orca runs the user's Git binary on native, WSL, and SSH hosts, which may all have different versions. Treat Git 2.25 as the core-workflow baseline and follow [`docs/reference/git-compatibility.md`](./docs/reference/git-compatibility.md).
+98
View File
@@ -0,0 +1,98 @@
# Agent-status OSC 9999 nonce gate
`OSC 9999 ; <json> (BEL | ST)` lets a process publish agent status for its pane.
Orca reads it off the PTY master, so **anything the pane prints** reaches the
parser — including text the pane's process did not author.
This page is the contract for the nonce that gates it.
## What the nonce proves — and what it does not
The nonce is a per-pane random value Orca stamps into the pane's environment as
`ORCA_AGENT_STATUS_NONCE`, next to `ORCA_PANE_KEY`. Child processes inherit it
the same way.
It authenticates **possession of pane context**: the emitter is the pane's own
process or a descendant of it. It is not an identity, a capability, or a
signature over the payload.
| Threat | Killed? |
|---|---|
| **T1 — accidental replay.** A log, transcript, or fixture containing a recorded payload is `cat`'d into a pane. | **Yes.** The recorded text cannot carry this pane's nonce. |
| **T2 — content-mediated injection.** An agent echoes attacker-controlled text (a fetched page, a dependency's build output, another agent's relayed output) that embeds a valid payload. | **Yes**, once enforcing. The attacker's text cannot contain the nonce. |
| **T3 — pane-resident malicious code.** Code already executing in the pane reads the env like any descendant and emits a correct nonce. | **No. Nothing here helps.** A pane-inherited token cannot distinguish the pane's processes from each other. |
Do not describe this gate as making agent status trustworthy. It makes agent
status *attributable to the pane*, which is a strictly weaker claim.
## Trust tiers
`gradeAgentStatusOscNonce` grades every parsed payload:
- `pane-verified` — nonce present and matched.
- `pane-unattested` — the pane carries a nonce but the payload omitted it, or
presented a different one.
- `pane-unstamped` — the pane was never stamped (a PTY that predates the
feature, or a host that does not stamp), so no nonce could be expected.
The tier rides `ProcessedAgentStatusChunk.attestation`, which is in-process
only. It is never persisted, sent over IPC, or published to paired clients — the
nonce itself least of all, since parsed payloads are republished widely.
## Compatibility: why this ships in observe mode
Nothing in this repo emits OSC 9999. **Every payload Orca receives today comes
from an external process**, and the format has never been documented, so the
population of existing self-reporting integrations is unknown rather than known
to be empty. A hard cutover would break all of them silently, and silently is
the operative word: a dropped status row looks like an agent that stopped
reporting, not like a rejected message.
So the default enforcement mode is `observe`:
| Case | `observe` (default) | `enforce` |
|---|---|---|
| Correct nonce | accept, `pane-verified` | accept, `pane-verified` |
| No nonce, pane never stamped | accept, `pane-unstamped` | accept, `pane-unstamped` |
| No nonce, pane **is** stamped | accept, `pane-unattested`, logged | **drop** |
| Wrong or malformed nonce | **drop** | **drop** |
A wrong nonce is dropped in both modes. No integration sends one today, so the
only sources are a replayed capture from a different pane and a deliberate
forgery; there is no compatible behavior to preserve.
`ORCA_AGENT_STATUS_OSC_NONCE=enforce` on the Orca process opts in early.
**Criteria to flip the default to `enforce`:** the
`dropped unattested agent-status OSC payload` warning and its `observe`-mode
counterpart show no sustained `pane-unattested` traffic from real integrations
across a release, and the payload format — including the nonce field — is
documented publicly so integrators have a migration target. Publishing the
format before the gate bites is what turns "an agent could accidentally echo
this" into "anyone can forge this on purpose", so the two must land together.
## Mixed versions
The only wire-visible change is a new optional `nonce` key in the OSC 9999 JSON.
Older parsers drop unknown keys (`normalizeAgentStatusObject` builds an explicit
object), so this is Rule 1 in
[remote-wire-compatibility.md](./remote-wire-compatibility.md) — safe, and safe
only for as long as every reader treats it as optional.
Stamping and verification always happen on the same side: whichever runtime
spawns the PTY stamps its env, and that same runtime's parser checks it. There
is no split where one version stamps and another verifies. A host that does not
stamp reports every payload `pane-unstamped` and behaves exactly as it does
today.
## Where it is wired
- Stamped into pane env wherever `ORCA_PANE_KEY` is (`pty-connection.ts`,
`launch-worktree-background-terminals.ts`,
`adopt-agent-background-session-tab.ts`, `codex-detached-pane-restart.ts`), and
forwarded across the WSL boundary in `wsl-orca-env.ts`.
- Recorded per PTY in `src/main/pty/agent-status-osc-nonce-registry.ts` from the
spawn env, and dropped with the PTY.
- Checked inside `createAgentStatusOscProcessor`, the one function all four
parser call sites already share. The call sites keep their separate merge
points; each supplies its own pane's nonce.
+11
View File
@@ -172,6 +172,11 @@ import { parseWslPath, wslUncDirectoryExistsAsync } from '../wsl'
import { mergePersistedWindowsPath, resolvePathEnvKey } from '../pty/windows-environment-path'
import { addOrcaWslInteropEnv, stampWslOrchestrationCompatibilityHost } from '../pty/wsl-orca-env'
import { resolveCodexShellLaunchPreflightCommand } from '../pty/codex-shell-launch-preflight'
import {
forgetAgentStatusOscNonceForPty,
recordAgentStatusOscNonceForPty
} from '../pty/agent-status-osc-nonce-registry'
import { AGENT_STATUS_OSC_NONCE_ENV_VAR } from '../../shared/agent-status-osc-nonce'
import { PtyProducerFlowController } from './pty-producer-flow-control'
import { beginTerminalInstall } from './watcher-removal-gate'
import {
@@ -2201,6 +2206,8 @@ export function clearProviderPtyState(
return !paneKey || (stillOwnsPaneKey && stablePaneKey === paneKey)
}
})
// Why: the pane secret outlives nothing — drop it with the PTY, unconditionally, so a reused id can never inherit a dead pane's nonce.
forgetAgentStatusOscNonceForPty(id)
// Why: clear the hook server's per-paneKey caches (via the spawn-time paneKey mapping, its only ptyId→paneKey correlation) so dead panes don't accumulate over process lifetime.
if (paneKey) {
if (stillOwnsPaneKey) {
@@ -5480,6 +5487,7 @@ export function registerPtyHandlers(
worktreeId: args.worktreeId
})
}
recordAgentStatusOscNonceForPty(result.id, env?.[AGENT_STATUS_OSC_NONCE_ENV_VAR])
const pendingSerializer = paneKey ? pendingByPaneKey.get(paneKey) : undefined
const inheritRendererReadiness =
result.isReattach === true &&
@@ -7166,6 +7174,9 @@ export function registerPtyHandlers(
worktreeId: args.worktreeId
})
}
// Why: read from args.env, not the mutated spawn env — this is the value
// the pane's process actually inherited, and the only one it can present.
recordAgentStatusOscNonceForPty(result.id, args.env?.[AGENT_STATUS_OSC_NONCE_ENV_VAR])
if (legacySpawnPaneKey && migrationUnsupportedPaneKey) {
agentHookServer.registerPaneKeyAlias(
legacySpawnPaneKey.paneKey,
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
clearAgentStatusOscNonceRegistry,
forgetAgentStatusOscNonceForPty,
getAgentStatusOscNonceForPty,
recordAgentStatusOscNonceForPty
} from './agent-status-osc-nonce-registry'
const NONCE = 'a1b2c3d4e5f60718293a4b5c6d7e8f90'
describe('agent status OSC nonce registry', () => {
beforeEach(() => {
clearAgentStatusOscNonceRegistry()
})
it('records a spawn-env nonce under its PTY', () => {
recordAgentStatusOscNonceForPty('pty-1', NONCE)
expect(getAgentStatusOscNonceForPty('pty-1')).toBe(NONCE)
expect(getAgentStatusOscNonceForPty('pty-2')).toBeNull()
})
it('reports an unstamped PTY as null rather than a falsy string', () => {
expect(getAgentStatusOscNonceForPty('never-spawned')).toBeNull()
})
it.each([undefined, null, '', 42, {}, 'x'.repeat(129)])(
'ignores the malformed env value %p',
(value) => {
recordAgentStatusOscNonceForPty('pty-1', value)
expect(getAgentStatusOscNonceForPty('pty-1')).toBeNull()
}
)
it('keeps the original nonce when a reattach arrives with no env', () => {
// Why: a reattach whose env we cannot see must not silently downgrade a
// pane whose live agent is still presenting the original nonce.
recordAgentStatusOscNonceForPty('pty-1', NONCE)
expect(recordAgentStatusOscNonceForPty('pty-1', undefined)).toBe(NONCE)
expect(getAgentStatusOscNonceForPty('pty-1')).toBe(NONCE)
})
it('drops the nonce with the PTY so a reused id cannot inherit it', () => {
recordAgentStatusOscNonceForPty('pty-1', NONCE)
forgetAgentStatusOscNonceForPty('pty-1')
expect(getAgentStatusOscNonceForPty('pty-1')).toBeNull()
})
})
@@ -0,0 +1,49 @@
import {
isWellFormedAgentStatusOscNonce,
resolveAgentStatusOscNonceEnforcement,
type AgentStatusOscNonceEnforcement
} from '../../shared/agent-status-osc-nonce'
/**
* Per-PTY record of the agent-status nonce Orca stamped into that pane's env,
* so main's OSC 9999 gate can verify what a payload presents.
*
* Recorded from the spawn env rather than minted here: the pane identity env
* (ORCA_PANE_KEY and friends) is assembled by the caller that owns the pane, and
* the nonce must match the value that pane's descendants actually inherited.
* Lives in its own module so both the spawn path and the runtime can read it
* without importing each other.
*/
const nonceByPtyId = new Map<string, string>()
/** Returns the recorded nonce, or null when the pane was spawned without one. */
export function recordAgentStatusOscNonceForPty(ptyId: string, nonce: unknown): string | null {
if (!isWellFormedAgentStatusOscNonce(nonce)) {
// Why: a reattach whose env we cannot see must keep the nonce the original
// spawn recorded — dropping it would silently downgrade the pane.
return nonceByPtyId.get(ptyId) ?? null
}
nonceByPtyId.set(ptyId, nonce)
return nonce
}
export function getAgentStatusOscNonceForPty(ptyId: string): string | null {
return nonceByPtyId.get(ptyId) ?? null
}
export function forgetAgentStatusOscNonceForPty(ptyId: string): void {
nonceByPtyId.delete(ptyId)
}
/** Test-only reset; production teardown goes through forgetAgentStatusOscNonceForPty. */
export function clearAgentStatusOscNonceRegistry(): void {
nonceByPtyId.clear()
}
/**
* Undocumented opt-in for users who want the gate to bite before the default
* flips: `ORCA_AGENT_STATUS_OSC_NONCE=enforce`.
*/
export function getAgentStatusOscNonceEnforcement(): AgentStatusOscNonceEnforcement {
return resolveAgentStatusOscNonceEnforcement(process.env.ORCA_AGENT_STATUS_OSC_NONCE)
}
+4
View File
@@ -8,6 +8,7 @@ import {
SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV,
SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV
} from '../../shared/setup-agent-sequencing'
import { AGENT_STATUS_OSC_NONCE_ENV_VAR } from '../../shared/agent-status-osc-nonce'
const WSLENV_ENTRY_SEPARATOR = ':'
@@ -78,6 +79,9 @@ export function addOrcaWslInteropEnv(env: Record<string, string>): void {
'ORCA_USER_DATA_PATH/p',
'ORCA_CLI_COMMAND/u',
'ORCA_PANE_KEY/u',
// Why: without this the guest process cannot attest its OSC 9999 payloads,
// and every WSL pane would report as unattested.
`${AGENT_STATUS_OSC_NONCE_ENV_VAR}/u`,
'ORCA_TAB_ID/u',
'ORCA_WORKTREE_ID/u',
'ORCA_AGENT_LAUNCH_TOKEN/u',
+19 -2
View File
@@ -89,6 +89,10 @@ import {
createAgentStatusOscProcessor,
type ProcessedAgentStatusChunk
} from '../../shared/agent-status-osc'
import {
getAgentStatusOscNonceEnforcement,
getAgentStatusOscNonceForPty
} from '../pty/agent-status-osc-nonce-registry'
import { buildOrchestrationTaskDisplayMetadata } from '../../shared/orchestration-task-display'
import {
isTerminalInputTooLargeWithYield,
@@ -11237,10 +11241,23 @@ export class OrcaRuntimeService {
private processAgentStatusOscForPty(ptyId: string, data: string): ProcessedAgentStatusChunk {
let processor = this.agentStatusOscProcessorsByPtyId.get(ptyId)
if (!processor) {
processor = createAgentStatusOscProcessor()
processor = createAgentStatusOscProcessor({
// Why late-bound: the processor is created by the first data chunk, which
// can beat the spawn path's nonce record on a reattach.
getExpectedNonce: () => getAgentStatusOscNonceForPty(ptyId),
enforcement: getAgentStatusOscNonceEnforcement()
})
this.agentStatusOscProcessorsByPtyId.set(ptyId, processor)
}
return processor(data)
const chunk = processor(data)
if (chunk.attestation.rejected > 0) {
console.warn('[runtime] dropped unattested agent-status OSC payload', {
ptyId,
paneKey: this.ptysById.get(ptyId)?.paneKey ?? null,
rejected: chunk.attestation.rejected
})
}
return chunk
}
/** Emit the facts batched while applying one chunk/frame as a single
@@ -10,6 +10,8 @@
*/
import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { AGENT_STATUS_OSC_NONCE_ENV_VAR } from '../../../../shared/agent-status-osc-nonce'
import { getOrCreatePaneAgentStatusOscNonce } from './pane-agent-status-osc-nonce'
import type { TerminalPaneLayoutNode, TerminalTab } from '../../../../shared/terminal-tab-types'
import type { AppState } from '@/store'
import { useAppStore } from '@/store'
@@ -156,6 +158,9 @@ function buildPaneIdentityEnv(
}
: {}),
ORCA_PANE_KEY: makePaneKey(tabId, leafId),
[AGENT_STATUS_OSC_NONCE_ENV_VAR]: getOrCreatePaneAgentStatusOscNonce(
makePaneKey(tabId, leafId)
),
ORCA_TAB_ID: tabId,
ORCA_WORKTREE_ID: worktreeId
}
@@ -0,0 +1,36 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
getOrCreatePaneAgentStatusOscNonce,
getPaneAgentStatusOscNonce,
resetPaneAgentStatusOscNonces
} from './pane-agent-status-osc-nonce'
describe('pane agent status OSC nonce', () => {
beforeEach(() => {
resetPaneAgentStatusOscNonces()
})
it('mints 128 bits of hex entropy', () => {
expect(getOrCreatePaneAgentStatusOscNonce('tab-1:leaf-1')).toMatch(/^[0-9a-f]{32}$/)
})
it('gives different panes different nonces', () => {
expect(getOrCreatePaneAgentStatusOscNonce('tab-1:leaf-1')).not.toBe(
getOrCreatePaneAgentStatusOscNonce('tab-1:leaf-2')
)
})
it('returns the same nonce when a pane respawns, so a live agent keeps verifying', () => {
const first = getOrCreatePaneAgentStatusOscNonce('tab-1:leaf-1')
expect(getOrCreatePaneAgentStatusOscNonce('tab-1:leaf-1')).toBe(first)
})
it('reports a pane that never spawned as unstamped', () => {
expect(getPaneAgentStatusOscNonce('tab-1:leaf-1')).toBeNull()
getOrCreatePaneAgentStatusOscNonce('tab-1:leaf-1')
expect(getPaneAgentStatusOscNonce('tab-1:leaf-1')).toMatch(/^[0-9a-f]{32}$/)
})
})
@@ -0,0 +1,44 @@
import { AGENT_STATUS_OSC_NONCE_BYTES } from '../../../../shared/agent-status-osc-nonce'
/**
* Per-pane secret stamped into pane env beside ORCA_PANE_KEY, so the pane's
* process and its descendants can attest OSC 9999 agent-status payloads.
*
* Memoized per paneKey for the life of the app run, deliberately: a restart,
* reattach, or detached-pane adoption re-enters the same pane and must present
* the same nonce, or a still-running agent's payloads would start failing the
* gate. Entries are therefore NOT evicted on pane close — one short string per
* pane created this run, the same lifetime class as the other paneKey-keyed
* renderer maps (cache timers, agent status). Main drops its copy with the PTY.
*/
const nonceByPaneKey = new Map<string, string>()
function mintNonce(): string {
const bytes = new Uint8Array(AGENT_STATUS_OSC_NONCE_BYTES)
crypto.getRandomValues(bytes)
let hex = ''
for (const byte of bytes) {
hex += byte.toString(16).padStart(2, '0')
}
return hex
}
export function getOrCreatePaneAgentStatusOscNonce(paneKey: string): string {
const existing = nonceByPaneKey.get(paneKey)
if (existing) {
return existing
}
const nonce = mintNonce()
nonceByPaneKey.set(paneKey, nonce)
return nonce
}
/** Null when this pane never spawned through a nonce-stamping path. */
export function getPaneAgentStatusOscNonce(paneKey: string): string | null {
return nonceByPaneKey.get(paneKey) ?? null
}
/** Test-only: production entries intentionally live for the app run (see above). */
export function resetPaneAgentStatusOscNonces(): void {
nonceByPaneKey.clear()
}
@@ -215,6 +215,7 @@ export function startParkedTerminalByteWatcher(
? null
: createPtyOutputProcessor({
...(options.initialTitle !== undefined ? { initialAgentTitle: options.initialTitle } : {}),
agentStatusPaneKey: paneKey,
...sideEffectCallbacks
})
// Why (byte-parser mode only): under main authority, byte-scanning PR links too would observe every link twice (facts already carry them).
@@ -223,6 +223,9 @@ describe('connectPanePty', () => {
startupCommandDelivery: 'shell-ready',
env: expect.objectContaining({
ORCA_PANE_KEY: paneKey,
// Why: the OSC 9999 gate is only as good as this stamp reaching the
// pane's process — assert it on the live spawn, not just the minter.
ORCA_AGENT_STATUS_NONCE: expect.stringMatching(/^[0-9a-f]{32}$/),
ORCA_TAB_ID: 'tab-1',
ORCA_WORKTREE_ID: 'wt-1',
ORCA_WORKSPACE_ID: 'wt-1',
@@ -14,6 +14,8 @@ import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import { useAppStore } from '@/store'
import { getWorktreeMapFromState } from '@/store/selectors'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { AGENT_STATUS_OSC_NONCE_ENV_VAR } from '../../../../shared/agent-status-osc-nonce'
import { getOrCreatePaneAgentStatusOscNonce } from './pane-agent-status-osc-nonce'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { isEphemeralSetupTerminalWorktreeId } from '../../../../shared/ephemeral-setup-terminal-worktree-id'
import { TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
@@ -3433,6 +3435,9 @@ export function connectPanePty(
const paneIdentityEnv = {
...workspaceEnv,
ORCA_PANE_KEY: cacheKey,
// Why: rides the same pane env as ORCA_PANE_KEY so descendants inherit it;
// gates OSC 9999 so text merely passing through the pane can't assert status.
[AGENT_STATUS_OSC_NONCE_ENV_VAR]: getOrCreatePaneAgentStatusOscNonce(cacheKey),
ORCA_TAB_ID: deps.tabId,
ORCA_WORKTREE_ID: deps.worktreeId,
...(launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: launchToken } : {})
@@ -44,6 +44,8 @@ import {
createAgentStatusOscProcessor,
type ProcessedAgentStatusChunk
} from '../../../../shared/agent-status-osc'
import { tryMakePaneKey } from '../../../../shared/stable-pane-id'
import { getPaneAgentStatusOscNonce } from './pane-agent-status-osc-nonce'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
import {
registerPtySideEffectPendingGauge,
@@ -97,6 +99,8 @@ type PtyOutputProcessorOptions = Pick<
> & {
/** Seed for mid-session processors (parked-tab watchers): pane's last title, so an agent finishing mid-stream still yields a working→idle transition. */
initialAgentTitle?: string
/** Pane whose agent-status nonce gates OSC 9999. Omitted for panes with no pane identity. */
agentStatusPaneKey?: string
}
type ProcessPtyOutputOptions = {
@@ -149,7 +153,8 @@ export function createPtyOutputProcessor({
onAgentBecameWorking,
onAgentExited,
onAgentStatus,
initialAgentTitle
initialAgentTitle,
agentStatusPaneKey
}: PtyOutputProcessorOptions): {
processData: (
data: string,
@@ -166,8 +171,12 @@ export function createPtyOutputProcessor({
disposePendingSideEffectGauge: () => void
} {
const bellDetector = createBellDetector()
const agentStatusOscGate = {
getExpectedNonce: (): string | null =>
agentStatusPaneKey ? getPaneAgentStatusOscNonce(agentStatusPaneKey) : null
}
// Why let: a model-restore marker drops bytes; recreating the parser stops a partial OSC-9999 carry from swallowing the next chunk's head.
let processAgentStatusChunk = createAgentStatusOscProcessor()
let processAgentStatusChunk = createAgentStatusOscProcessor(agentStatusOscGate)
// Why: seed emitted-title memory and the agent tracker so a mid-session processor behaves as if it had observed the pane's last live title.
let lastEmittedTitle: string | null =
initialAgentTitle !== undefined ? normalizeTerminalTitle(initialAgentTitle) : null
@@ -547,7 +556,7 @@ export function createPtyOutputProcessor({
flushPendingSideEffects,
resetBellDetector: () => bellDetector.reset(),
resetAgentStatusCarry: () => {
processAgentStatusChunk = createAgentStatusOscProcessor()
processAgentStatusChunk = createAgentStatusOscProcessor(agentStatusOscGate)
},
disposePendingSideEffectGauge
}
@@ -600,6 +609,9 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
}
}
})
// Why: the gate's pane identity must be the same paneKey whose env carries the
// nonce; synthetic panes (floating/setup terminals) have none and stay ungated.
const agentStatusPaneKey = tryMakePaneKey(tabId, leafId)
const outputProcessor = createPtyOutputProcessor({
onTitleChange,
onBell,
@@ -610,7 +622,10 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
},
onAgentBecameWorking,
onAgentExited,
onAgentStatus
onAgentStatus,
// Why: the same env that stamps the pane's nonce — using it keeps the gate's
// pane identity in lock-step with what this PTY's process actually inherited.
...(agentStatusPaneKey ? { agentStatusPaneKey } : {})
})
// Why: a new pane can attach to the same ptyId before the old instance's detach() runs; track owned handlers so unregister never deletes the live one.
const ownedDataAndReplayHandlers = new Map<
@@ -10,6 +10,7 @@ import type {
RuntimeEnsureAgentSessionResult
} from '../../../../shared/agent-session-host-authority'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import { tryMakePaneKey } from '../../../../shared/stable-pane-id'
import type { TabActivationIntent } from '../../../../shared/tab-activation-intent'
import type {
RuntimeMobileSessionTerminalClientTab,
@@ -358,13 +359,15 @@ export function createRemoteRuntimePtyTransport(
// Why: reconnect retries must replay one host operation instead of creating
// another fresh agent when the first response was lost.
const agentCreateOperation = createAgentSessionCreateOperation()
const agentStatusPaneKey = tryMakePaneKey(tabId ?? undefined, leafId ?? undefined)
const outputProcessor = createPtyOutputProcessor({
onTitleChange,
onBell,
onAgentBecameIdle,
onAgentBecameWorking,
onAgentExited,
onAgentStatus
onAgentStatus,
...(agentStatusPaneKey ? { agentStatusPaneKey } : {})
})
const shutdownDataHandler = (
data: string,
@@ -1,6 +1,8 @@
import { useAppStore } from '@/store'
import { makePaneKey, type PaneKey } from '../../../shared/stable-pane-id'
import type { AgentType } from '../../../shared/agent-status-types'
import { AGENT_STATUS_OSC_NONCE_ENV_VAR } from '../../../shared/agent-status-osc-nonce'
import { getOrCreatePaneAgentStatusOscNonce } from '@/components/terminal-pane/pane-agent-status-osc-nonce'
import { bindAutomationTerminal } from '@/lib/automation-terminal-ownership'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { retireProvider, retireUnownedTerminal } from '@/lib/retire-unowned-background-terminal'
@@ -45,6 +47,7 @@ export function reserveAgentBackgroundSessionIdentity(args: {
paneEnv: {
...args.env,
ORCA_PANE_KEY: paneKey,
[AGENT_STATUS_OSC_NONCE_ENV_VAR]: getOrCreatePaneAgentStatusOscNonce(paneKey),
ORCA_TAB_ID: reservedTabId,
ORCA_WORKTREE_ID: args.worktreeId,
ORCA_AGENT_LAUNCH_TOKEN: launchToken
@@ -13,6 +13,7 @@ import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-type
import { isMainTerminalSideEffectAuthorityForPty } from '@/components/terminal-pane/terminal-side-effect-facts-handler'
import { resolveLiveAgentStatusConnectionRouting } from '@/lib/agent-status-connection-ownership'
import { rendererAgentStatusObservations } from '@/lib/renderer-agent-status-observations'
import { getPaneAgentStatusOscNonce } from '@/components/terminal-pane/pane-agent-status-osc-nonce'
export async function observeExistingAutomationSession(args: {
ptyId: string
@@ -34,7 +35,9 @@ export async function observeExistingAutomationSession(args: {
settings: useAppStore.getState().settings,
runtimeEnvironmentId: null
})
const processAgentStatus = createAgentStatusOscProcessor()
const processAgentStatus = createAgentStatusOscProcessor({
getExpectedNonce: () => getPaneAgentStatusOscNonce(paneKey)
})
const handleData = (data: string): void => {
onData(data)
const processed = processAgentStatus(data)
@@ -1,6 +1,7 @@
import { useAppStore } from '@/store'
import { createAgentStatusOscProcessor } from '../../../shared/agent-status-osc'
import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types'
import { getPaneAgentStatusOscNonce } from '@/components/terminal-pane/pane-agent-status-osc-nonce'
import {
resolveLiveAgentStatusConnectionRouting,
type AgentStatusConnectionRouting
@@ -24,7 +25,9 @@ export function createBackgroundAgentStatusConsumer(args: {
* same authority the byte path writes under. */
observeLaunchIngress: () => AgentStatusObservation
} {
const processAgentStatus = createAgentStatusOscProcessor()
const processAgentStatus = createAgentStatusOscProcessor({
getExpectedNonce: () => getPaneAgentStatusOscNonce(args.paneKey)
})
const resolveRouting = (): AgentStatusConnectionRouting | undefined => {
const ptyId = args.getPtyId()
const state = useAppStore.getState()
@@ -10,6 +10,8 @@ import { retireUnownedTerminal } from '@/lib/retire-unowned-background-terminal'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { makePaneKey } from '../../../shared/stable-pane-id'
import { AGENT_STATUS_OSC_NONCE_ENV_VAR } from '../../../shared/agent-status-osc-nonce'
import { getOrCreatePaneAgentStatusOscNonce } from '@/components/terminal-pane/pane-agent-status-osc-nonce'
import {
buildSetupRunnerCommand,
getSetupRunnerCommandPlatformForPath
@@ -54,9 +56,11 @@ function buildPaneEnv(
leafId: string,
env: Record<string, string> | undefined
): Record<string, string> {
const paneKey = makePaneKey(tabId, leafId)
return {
...env,
ORCA_PANE_KEY: makePaneKey(tabId, leafId),
ORCA_PANE_KEY: paneKey,
[AGENT_STATUS_OSC_NONCE_ENV_VAR]: getOrCreatePaneAgentStatusOscNonce(paneKey),
ORCA_TAB_ID: tabId,
ORCA_WORKTREE_ID: worktreeId
}
+41
View File
@@ -0,0 +1,41 @@
import {
AGENT_STATUS_JSON_STRUCTURE_LIMITS,
normalizeAgentStatusPayload,
type ParsedAgentStatusPayload
} from './agent-status-types'
import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit'
import { AGENT_STATUS_OSC_NONCE_FIELD } from './agent-status-osc-nonce'
export type AgentStatusOscFrame = {
payload: ParsedAgentStatusPayload
/** Raw, unvalidated — only the gate reads it, and only to compare. */
nonce: unknown
}
/**
* Parse an OSC 9999 frame, surfacing its pane nonce separately from the status
* fields.
*
* Why separate: the nonce is a pane secret and `ParsedAgentStatusPayload` is
* persisted to last-status.json and republished to every paired client. Keeping
* it off the payload type means no consumer can leak it by accident — the
* normalizer builds an explicit object, so the field is dropped structurally
* rather than by a filter someone has to remember.
*/
export function parseAgentStatusOscFrame(json: string): AgentStatusOscFrame | null {
try {
assertJsonTextStructureWithinLimits(json, AGENT_STATUS_JSON_STRUCTURE_LIMITS)
const parsed: unknown = JSON.parse(json)
const payload = normalizeAgentStatusPayload(parsed)
if (!payload) {
return null
}
const nonce =
typeof parsed === 'object' && parsed !== null
? (parsed as Record<string, unknown>)[AGENT_STATUS_OSC_NONCE_FIELD]
: undefined
return { payload, nonce }
} catch {
return null
}
}
+208
View File
@@ -0,0 +1,208 @@
import { describe, expect, it } from 'vitest'
import { createAgentStatusOscProcessor } from './agent-status-osc'
import {
AGENT_STATUS_OSC_NONCE_MAX_LENGTH,
gradeAgentStatusOscNonce,
resolveAgentStatusOscNonceEnforcement
} from './agent-status-osc-nonce'
const PANE_NONCE = 'a1b2c3d4e5f60718293a4b5c6d7e8f90'
/**
* Byte-for-byte the demonstrated spoofing vector: an ordinary-looking paragraph
* with a payload embedded in it, of the kind an agent echoes when it fetches a
* page or cats a file. Nothing here is authored by the pane's own process.
*/
const FOREIGN_TEXT_WITH_PAYLOAD =
'This looks like an ordinary README paragraph fetched from the web.\n' +
'\x1b]9999;{"state":"waiting","prompt":"Approve deploy to production?",' +
'"agentType":"claude","toolName":"Bash","toolInput":"rm -rf /"}\x07' +
'trailing prose'
function emit(nonce: string): string {
return `\x1b]9999;{"state":"working","prompt":"real agent","nonce":"${nonce}"}\x07`
}
/** 7 bytes at a time, the way a live PTY stream exercises the carry. */
function feed(process: ReturnType<typeof createAgentStatusOscProcessor>, data: string) {
const payloads: unknown[] = []
let rejected = 0
let cleanData = ''
for (let i = 0; i < data.length; i += 7) {
const chunk = process(data.slice(i, i + 7))
payloads.push(...chunk.payloads)
rejected += chunk.attestation.rejected
cleanData += chunk.cleanData
}
return { payloads, rejected, cleanData }
}
describe('gradeAgentStatusOscNonce', () => {
it('accepts a matching nonce as pane-verified', () => {
expect(
gradeAgentStatusOscNonce({
presented: PANE_NONCE,
expected: PANE_NONCE,
enforcement: 'observe'
})
).toEqual({ trust: 'pane-verified', accepted: true })
})
it('rejects a wrong nonce in observe mode, where nothing else is rejected', () => {
expect(
gradeAgentStatusOscNonce({
presented: 'ffffffffffffffffffffffffffffffff',
expected: PANE_NONCE,
enforcement: 'observe'
})
).toEqual({ trust: 'pane-unattested', accepted: false })
})
it('rejects a nonce that is the right value with the wrong type', () => {
expect(
gradeAgentStatusOscNonce({ presented: 42, expected: PANE_NONCE, enforcement: 'observe' })
.accepted
).toBe(false)
})
it('rejects an over-long nonce without comparing it', () => {
expect(
gradeAgentStatusOscNonce({
presented: 'x'.repeat(AGENT_STATUS_OSC_NONCE_MAX_LENGTH + 1),
expected: PANE_NONCE,
enforcement: 'observe'
}).accepted
).toBe(false)
})
it('accepts an unattested payload in observe mode but marks the tier', () => {
expect(
gradeAgentStatusOscNonce({
presented: undefined,
expected: PANE_NONCE,
enforcement: 'observe'
})
).toEqual({ trust: 'pane-unattested', accepted: true })
})
it('drops the same unattested payload when enforcing', () => {
expect(
gradeAgentStatusOscNonce({
presented: undefined,
expected: PANE_NONCE,
enforcement: 'enforce'
})
).toEqual({ trust: 'pane-unattested', accepted: false })
})
it('accepts un-nonced payloads on an unstamped pane even when enforcing', () => {
// Why: a host that predates the nonce cannot stamp; enforcing there would
// break every integration on that host with no migration path.
expect(
gradeAgentStatusOscNonce({ presented: undefined, expected: null, enforcement: 'enforce' })
).toEqual({ trust: 'pane-unstamped', accepted: true })
})
it('ignores a nonce presented to an unstamped pane rather than trusting it', () => {
expect(
gradeAgentStatusOscNonce({ presented: PANE_NONCE, expected: null, enforcement: 'enforce' })
).toEqual({ trust: 'pane-unstamped', accepted: true })
})
})
describe('resolveAgentStatusOscNonceEnforcement', () => {
it('defaults to observe so no existing integration is cut over silently', () => {
expect(resolveAgentStatusOscNonceEnforcement(undefined)).toBe('observe')
expect(resolveAgentStatusOscNonceEnforcement('')).toBe('observe')
expect(resolveAgentStatusOscNonceEnforcement('yes')).toBe('observe')
})
it('opts in on an explicit enforce', () => {
expect(resolveAgentStatusOscNonceEnforcement(' Enforce ')).toBe('enforce')
})
})
describe('createAgentStatusOscProcessor nonce gate', () => {
it('accepts a payload carrying the pane nonce', () => {
const process = createAgentStatusOscProcessor({ getExpectedNonce: () => PANE_NONCE })
const result = feed(process, emit(PANE_NONCE))
expect(result.rejected).toBe(0)
expect(result.payloads).toEqual([{ state: 'working', prompt: 'real agent' }])
})
it('never leaks the nonce into the parsed payload', () => {
// Why: parsed payloads are persisted and republished to paired clients.
const process = createAgentStatusOscProcessor({ getExpectedNonce: () => PANE_NONCE })
const [payload] = feed(process, emit(PANE_NONCE)).payloads
expect(JSON.stringify(payload)).not.toContain(PANE_NONCE)
expect(Object.keys(payload as object)).not.toContain('nonce')
})
it('rejects a payload bearing another pane nonce, in observe mode', () => {
// T1: a transcript recorded in one pane, replayed in another.
const process = createAgentStatusOscProcessor({ getExpectedNonce: () => PANE_NONCE })
const result = feed(process, emit('deadbeefdeadbeefdeadbeefdeadbeef'))
expect(result.payloads).toEqual([])
expect(result.rejected).toBe(1)
})
it('still strips the OSC bytes of a rejected payload from the rendered stream', () => {
const process = createAgentStatusOscProcessor({ getExpectedNonce: () => PANE_NONCE })
const result = feed(process, `head${emit('deadbeefdeadbeefdeadbeefdeadbeef')}tail`)
expect(result.cleanData).toBe('headtail')
})
it('T2: drops foreign text embedding a payload, in a stamped pane, when enforcing', () => {
const process = createAgentStatusOscProcessor({
getExpectedNonce: () => PANE_NONCE,
enforcement: 'enforce'
})
const result = feed(process, FOREIGN_TEXT_WITH_PAYLOAD)
expect(result.payloads).toEqual([])
expect(result.rejected).toBe(1)
expect(result.cleanData).toContain('trailing prose')
})
it('T2: reports the same foreign text as unattested in observe mode', () => {
const process = createAgentStatusOscProcessor({ getExpectedNonce: () => PANE_NONCE })
const chunk = process(FOREIGN_TEXT_WITH_PAYLOAD)
expect(chunk.attestation.rejected).toBe(0)
expect(chunk.attestation.accepted).toHaveLength(1)
expect(chunk.attestation.accepted[0]?.trust).toBe('pane-unattested')
})
it('leaves an unstamped pane behaving exactly as it did before the gate', () => {
const process = createAgentStatusOscProcessor({ enforcement: 'enforce' })
const chunk = process(FOREIGN_TEXT_WITH_PAYLOAD)
expect(chunk.payloads).toHaveLength(1)
expect(chunk.attestation.accepted[0]?.trust).toBe('pane-unstamped')
})
it('reads the pane nonce late, so a stamp recorded after the first byte still gates', () => {
// Why: the runtime creates the processor on first data, which can beat the
// spawn path's nonce record on a reattach.
let expected: string | null = null
const process = createAgentStatusOscProcessor({
getExpectedNonce: () => expected,
enforcement: 'enforce'
})
expect(process(FOREIGN_TEXT_WITH_PAYLOAD).payloads).toHaveLength(1)
expected = PANE_NONCE
expect(process(FOREIGN_TEXT_WITH_PAYLOAD).payloads).toHaveLength(0)
})
})
+107
View File
@@ -0,0 +1,107 @@
/**
* Pane-bound nonce for the OSC 9999 agent-status channel.
*
* What the nonce proves: the emitter could read the pane's environment, i.e. it
* is the pane's own process or a descendant of it. It does NOT prove the
* emitter is an agent, and it does NOT stop code already running inside the
* pane from reading the value and forging a payload. It exists to stop text
* that merely *passes through* the pane (a fetched page, a cat'd log, relayed
* output from another agent) from asserting agent status for that pane.
*/
/** Injected into pane env next to ORCA_PANE_KEY, so descendants inherit it. */
export const AGENT_STATUS_OSC_NONCE_ENV_VAR = 'ORCA_AGENT_STATUS_NONCE'
/** Payload field carrying the nonce. Additive: older parsers drop unknown keys. */
export const AGENT_STATUS_OSC_NONCE_FIELD = 'nonce'
/** Bound before any comparison so an oversized JSON string can't be used as a work amplifier. */
export const AGENT_STATUS_OSC_NONCE_MAX_LENGTH = 128
/** Bytes of entropy per pane. 128 bits is unguessable and keeps the payload short. */
export const AGENT_STATUS_OSC_NONCE_BYTES = 16
export type AgentStatusOscTrust =
/** Nonce present and matched the pane's. */
| 'pane-verified'
/** Pane carries a nonce, payload omitted it — a pre-nonce integration, or injected text. */
| 'pane-unattested'
/** Pane was never stamped (older host, pre-feature PTY), so no nonce could be expected. */
| 'pane-unstamped'
export type AgentStatusOscNonceEnforcement =
/** Accept unattested payloads, record the tier. Wrong nonces are still dropped. */
| 'observe'
/** Additionally drop unattested payloads in stamped panes. */
| 'enforce'
/**
* Ships as `observe`: OSC 9999 is an undocumented surface every one of whose
* payloads today comes from an external process (nothing in this repo emits
* it), so a hard cutover would silently break integrations we cannot enumerate.
* See docs/reference/agent-status-osc-nonce.md for the flip criteria.
*/
export const DEFAULT_AGENT_STATUS_OSC_NONCE_ENFORCEMENT: AgentStatusOscNonceEnforcement = 'observe'
export type AgentStatusOscNonceVerdict = {
trust: AgentStatusOscTrust
accepted: boolean
}
export function isWellFormedAgentStatusOscNonce(value: unknown): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
value.length <= AGENT_STATUS_OSC_NONCE_MAX_LENGTH
)
}
/** Length-first, then full-width compare: no early exit on the first differing char. */
function constantTimeEquals(left: string, right: string): boolean {
if (left.length !== right.length) {
return false
}
let diff = 0
for (let i = 0; i < left.length; i++) {
diff |= left.charCodeAt(i) ^ right.charCodeAt(i)
}
return diff === 0
}
/**
* Grade one OSC 9999 payload against the pane's nonce.
*
* A *wrong* nonce is dropped in every mode: no integration sends one today, so
* the only sources are a replayed capture from another pane and a forgery.
*/
export function gradeAgentStatusOscNonce(args: {
presented: unknown
expected: string | null | undefined
enforcement: AgentStatusOscNonceEnforcement
}): AgentStatusOscNonceVerdict {
const expected = isWellFormedAgentStatusOscNonce(args.expected) ? args.expected : null
if (!expected) {
return { trust: 'pane-unstamped', accepted: true }
}
if (args.presented === undefined || args.presented === null) {
return { trust: 'pane-unattested', accepted: args.enforcement !== 'enforce' }
}
if (!isWellFormedAgentStatusOscNonce(args.presented)) {
return { trust: 'pane-unattested', accepted: false }
}
if (constantTimeEquals(args.presented, expected)) {
return { trust: 'pane-verified', accepted: true }
}
return { trust: 'pane-unattested', accepted: false }
}
/** Read the enforcement mode from an env bag (main resolves it from process.env). */
export function resolveAgentStatusOscNonceEnforcement(
raw: string | undefined
): AgentStatusOscNonceEnforcement {
const normalized = raw?.trim().toLowerCase()
if (normalized === 'enforce' || normalized === 'observe') {
return normalized
}
return DEFAULT_AGENT_STATUS_OSC_NONCE_ENFORCEMENT
}
+60 -7
View File
@@ -1,12 +1,46 @@
import type { ParsedAgentStatusPayload } from './agent-status-types'
import { parseAgentStatusPayload } from './agent-status-types'
import { parseAgentStatusOscFrame } from './agent-status-osc-frame'
import type { AgentStatusOscNonceEnforcement, AgentStatusOscTrust } from './agent-status-osc-nonce'
import {
DEFAULT_AGENT_STATUS_OSC_NONCE_ENFORCEMENT,
gradeAgentStatusOscNonce
} from './agent-status-osc-nonce'
const OSC_AGENT_STATUS_PREFIX = '\x1b]9999;'
export type AttestedAgentStatusPayload = {
payload: ParsedAgentStatusPayload
trust: AgentStatusOscTrust
}
/**
* Nonce-gate outcome for one chunk. In-process only — it is never persisted,
* sent over IPC, or published to paired clients.
*/
export type AgentStatusOscChunkAttestation = {
/** Index-aligned with `payloads`. */
accepted: AttestedAgentStatusPayload[]
/** Well-formed payloads the gate refused (wrong nonce, or unattested while enforcing). */
rejected: number
}
export type ProcessedAgentStatusChunk = {
cleanData: string
/** Payloads that passed the gate, in byte order. */
payloads: ParsedAgentStatusPayload[]
/** Offset into `cleanData` of the last payload the gate accepted. */
lastPayloadCleanOffset: number | null
attestation: AgentStatusOscChunkAttestation
}
export type AgentStatusOscProcessorOptions = {
/**
* The pane's nonce, read late so a processor created before the PTY's env is
* recorded still gates correctly. Null/absent means the pane was never
* stamped, and every payload is accepted as `pane-unstamped`.
*/
getExpectedNonce?: () => string | null
enforcement?: AgentStatusOscNonceEnforcement
}
function findAgentStatusTerminator(
@@ -32,8 +66,11 @@ function findAgentStatusTerminator(
* Why: hidden/model-owned terminal output needs the same agent-status parsing
* as mounted terminal panes, even when no terminal view is rendered.
*/
export function createAgentStatusOscProcessor(): (data: string) => ProcessedAgentStatusChunk {
export function createAgentStatusOscProcessor(
options: AgentStatusOscProcessorOptions = {}
): (data: string) => ProcessedAgentStatusChunk {
const MAX_PENDING = 64 * 1024
const enforcement = options.enforcement ?? DEFAULT_AGENT_STATUS_OSC_NONCE_ENFORCEMENT
let pending = ''
return (data: string): ProcessedAgentStatusChunk => {
@@ -42,6 +79,8 @@ export function createAgentStatusOscProcessor(): (data: string) => ProcessedAgen
const payloads: ParsedAgentStatusPayload[] = []
let lastPayloadCleanOffset: number | null = null
const accepted: AttestedAgentStatusPayload[] = []
let rejected = 0
let cleanData = ''
let cursor = 0
@@ -76,14 +115,28 @@ export function createAgentStatusOscProcessor(): (data: string) => ProcessedAgen
break
}
const parsed = parseAgentStatusPayload(combined.slice(payloadStart, terminator.index))
if (parsed) {
payloads.push(parsed)
lastPayloadCleanOffset = cleanData.length
const frame = parseAgentStatusOscFrame(combined.slice(payloadStart, terminator.index))
if (frame) {
const verdict = gradeAgentStatusOscNonce({
presented: frame.nonce,
expected: options.getExpectedNonce?.() ?? null,
enforcement
})
if (verdict.accepted) {
payloads.push(frame.payload)
accepted.push({ payload: frame.payload, trust: verdict.trust })
// Stays aligned with `payloads`: a rejected payload emits no status
// event, so prompt-lifecycle byte order must not anchor to it.
lastPayloadCleanOffset = cleanData.length
} else {
rejected += 1
}
}
cursor = terminator.index + terminator.length
}
return { cleanData, payloads, lastPayloadCleanOffset }
// The OSC bytes are stripped from cleanData whether or not the gate accepted
// the payload: a rejected sequence must not be re-rendered into the pane.
return { cleanData, payloads, lastPayloadCleanOffset, attestation: { accepted, rejected } }
}
}
+12
View File
@@ -29,6 +29,18 @@ export function makePaneKey(tabId: string, stableLeafId: string): PaneKey {
return `${tabId}:${stableLeafId}` as PaneKey
}
/** Non-throwing makePaneKey for call sites holding ids that may be synthetic
* (floating/setup terminals) — those panes have no paneKey at all. */
export function tryMakePaneKey(
tabId: string | undefined,
stableLeafId: string | undefined
): PaneKey | null {
if (!tabId || tabId.includes(':') || !stableLeafId || !isTerminalLeafId(stableLeafId)) {
return null
}
return `${tabId}:${stableLeafId}` as PaneKey
}
export function parsePaneKey(
paneKey: string
): { tabId: string; leafId: TerminalLeafId; stablePaneId: StablePaneId } | null {