feat(telemetry): measure macOS stale-daemon adoption and cwd denials (#18043)

* feat(telemetry): measure macOS stale-daemon adoption and cwd denials

Adds two enum-only PostHog events so #17696 can be sized instead of guessed at:

- daemon_adopted: once per macOS launch that keeps a daemon an earlier app
  launch forked (invisible to daemon_lifecycle, which only sees replacements).
  Carries app-version match, spawner-path class (installed app / Squirrel
  ShipIt cache / other / missing), the existing TCC attribution verdict, and
  the bucketed live-session count.
- daemon_pty_cwd_denied: the symptom itself. The daemon probes the requested
  cwd in its own process (only its TCC context counts) and returns an additive
  cwdReadableByDaemon field; the app emits only when the daemon was denied AND
  the app can read the same path, so a missing or genuinely unreadable cwd
  never counts. Non-permission errors read as readable on purpose.

Both emitters swallow every failure; nothing here can delay or fail daemon
startup or a PTY spawn. Off macOS neither event fires. The new wire field is
optional, so older daemons and clients are unaffected.

* fix(telemetry): keep cwd-denial classification inside the swallow guard

Read the pid record at emit time (inside the try) rather than passing the
adapter's startup snapshot: a throwing app-environment read can no longer
escape spawn(), and a denial after a respawn is billed to the daemon that
actually spawned the PTY.
This commit is contained in:
Jinwoo Hong
2026-09-01 22:33:39 -04:00
committed by GitHub
parent 0352c239c2
commit a7fda48fe3
21 changed files with 642 additions and 4 deletions
@@ -0,0 +1,168 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ParsedDaemonPid } from './daemon-pid-file-parse'
import { validate } from '../telemetry/validator'
const { trackMock, accessSyncMock, existsSyncMock, readFileSyncMock, getVersionMock } = vi.hoisted(
() => ({
trackMock: vi.fn(),
accessSyncMock: vi.fn(),
existsSyncMock: vi.fn(() => true),
readFileSyncMock: vi.fn(),
getVersionMock: vi.fn(() => '1.4.191')
})
)
vi.mock('../telemetry/client', () => ({ track: trackMock }))
vi.mock('node:fs', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
accessSync: accessSyncMock,
existsSync: existsSyncMock,
readFileSync: readFileSyncMock
}))
vi.mock('node:os', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
homedir: () => '/Users/alice'
}))
vi.mock('../../shared/app-environment', () => ({
getAppEnvironment: () => ({ getVersion: getVersionMock })
}))
import {
classifyDaemonAdoptionOrigin,
trackDaemonAdopted,
trackDaemonPtyCwdDeniedIfDiverged
} from './daemon-adoption-telemetry-event'
const stalePidRecord: ParsedDaemonPid = {
pid: 1530,
startedAtMs: 1,
entryPath: '/x/daemon-entry.js',
appVersion: '1.4.187',
launchNonce: 'n',
linuxStartTicks: null,
bootId: null,
spawnerExecPath:
'/Users/alice/Library/Caches/com.stablyai.orca.ShipIt/u/Orca.app/Contents/MacOS/Orca'
}
const origin = { app_version_match: 'different', spawner_path_class: 'updater-cache' } as const
const PID_PATH = '/fake/daemon.pid'
beforeEach(() => {
trackMock.mockReset()
accessSyncMock.mockReset()
existsSyncMock.mockReset().mockReturnValue(true)
readFileSyncMock.mockReset().mockReturnValue(JSON.stringify(stalePidRecord))
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('classifyDaemonAdoptionOrigin', () => {
it('compares the recorded app version and classifies the spawner path', () => {
expect(classifyDaemonAdoptionOrigin(stalePidRecord)).toEqual(origin)
expect(classifyDaemonAdoptionOrigin({ ...stalePidRecord, appVersion: '1.4.191' })).toEqual({
app_version_match: 'same',
spawner_path_class: 'updater-cache'
})
expect(classifyDaemonAdoptionOrigin(null)).toEqual({
app_version_match: 'unknown',
spawner_path_class: 'unknown'
})
})
})
describe('trackDaemonAdopted', () => {
it('emits a validator-accepted payload', () => {
trackDaemonAdopted(stalePidRecord, 'intact', 7)
expect(trackMock).toHaveBeenCalledTimes(1)
const [name, props] = trackMock.mock.calls[0]
expect(name).toBe('daemon_adopted')
expect(props).toEqual({
...origin,
tcc_attribution: 'intact',
live_session_count_bucket: '6+'
})
expect(validate('daemon_adopted', props).ok).toBe(true)
})
it('swallows a throwing telemetry client', () => {
trackMock.mockImplementationOnce(() => {
throw new Error('posthog exploded')
})
expect(() => trackDaemonAdopted(null, 'unknown', null)).not.toThrow()
})
})
describe('trackDaemonPtyCwdDeniedIfDiverged', () => {
it('emits only when the daemon was denied and the app can read the same cwd', () => {
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH)
expect(accessSyncMock).toHaveBeenCalledWith('/Users/alice/Documents/repo', expect.any(Number))
expect(trackMock).toHaveBeenCalledTimes(1)
const [name, props] = trackMock.mock.calls[0]
expect(name).toBe('daemon_pty_cwd_denied')
expect(props).toEqual({ cwd_class: 'documents', ...origin })
expect(validate('daemon_pty_cwd_denied', props).ok).toBe(true)
})
// False positives would drown the signal this event exists to measure, so every
// non-divergent shape must stay silent.
it('stays silent when the daemon could read the cwd or did not report', () => {
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', true, PID_PATH)
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', undefined, PID_PATH)
trackDaemonPtyCwdDeniedIfDiverged(undefined, false, PID_PATH)
expect(accessSyncMock).not.toHaveBeenCalled()
expect(trackMock).not.toHaveBeenCalled()
})
it('stays silent when the app cannot read the cwd either (no divergence)', () => {
accessSyncMock.mockImplementation(() => {
throw Object.assign(new Error('EACCES'), { code: 'EACCES' })
})
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH)
expect(trackMock).not.toHaveBeenCalled()
})
it('attributes the denial to the daemon recorded right now, not a startup snapshot', () => {
readFileSyncMock.mockReturnValue(
JSON.stringify({
...stalePidRecord,
appVersion: '1.4.191',
spawnerExecPath: '/Applications/Orca.app/Contents/MacOS/Orca'
})
)
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH)
expect(readFileSyncMock).toHaveBeenCalledWith(PID_PATH, 'utf8')
expect(trackMock.mock.calls[0][1]).toEqual({
cwd_class: 'documents',
app_version_match: 'same',
spawner_path_class: 'applications'
})
})
it('swallows a throwing app environment or pid-record read instead of failing the spawn', () => {
getVersionMock.mockImplementationOnce(() => {
throw new Error('AppEnvironment not initialized')
})
expect(() =>
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH)
).not.toThrow()
expect(trackMock).not.toHaveBeenCalled()
})
it('stays silent off macOS', () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
trackDaemonPtyCwdDeniedIfDiverged('/home/alice/Documents/repo', false, PID_PATH)
expect(accessSyncMock).not.toHaveBeenCalled()
expect(trackMock).not.toHaveBeenCalled()
})
it('swallows a throwing telemetry client', () => {
trackMock.mockImplementationOnce(() => {
throw new Error('posthog exploded')
})
expect(() =>
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH)
).not.toThrow()
})
})
@@ -0,0 +1,81 @@
// App-side emitters for `daemon_adopted` and `daemon_pty_cwd_denied` (#17696). Both sit on the
// daemon launch / PTY spawn path, so every failure dies here — telemetry can never cost a terminal.
import { accessSync, constants as fsConstants, existsSync } from 'node:fs'
import { homedir } from 'node:os'
import { getAppEnvironment } from '../../shared/app-environment'
import {
classifyDaemonPtyCwd,
classifyDaemonSpawnerPath,
type DaemonAdoptedAppVersionMatch,
type DaemonSpawnerPathClass
} from '../../shared/daemon-adoption-telemetry'
import { bucketDaemonLiveSessionCount } from '../../shared/daemon-lifecycle-telemetry'
import type { EventProps } from '../../shared/telemetry-events'
import { track } from '../telemetry/client'
import { readDaemonPidRecord } from './daemon-endpoint-incarnation'
import type { ParsedDaemonPid } from './daemon-pid-file-parse'
import type { MacDaemonTccAttributionHealth } from './daemon-tcc-attribution'
export type DaemonAdoptionOrigin = Pick<
EventProps<'daemon_pty_cwd_denied'>,
'app_version_match' | 'spawner_path_class'
>
/** Classifies the adopted daemon's pid record against the running app; enum-only by construction. */
export function classifyDaemonAdoptionOrigin(
pidRecord: ParsedDaemonPid | null
): DaemonAdoptionOrigin {
const appVersionMatch: DaemonAdoptedAppVersionMatch = !pidRecord?.appVersion
? 'unknown'
: pidRecord.appVersion === getAppEnvironment().getVersion()
? 'same'
: 'different'
const spawnerPathClass: DaemonSpawnerPathClass = classifyDaemonSpawnerPath(
pidRecord?.spawnerExecPath ?? null,
existsSync
)
return { app_version_match: appVersionMatch, spawner_path_class: spawnerPathClass }
}
// Adopted a daemon that a previous app launch forked (macOS only; that is where attribution matters).
export function trackDaemonAdopted(
pidRecord: ParsedDaemonPid | null,
tccAttribution: MacDaemonTccAttributionHealth,
liveSessionCount: number | null
): void {
try {
track('daemon_adopted', {
...classifyDaemonAdoptionOrigin(pidRecord),
tcc_attribution: tccAttribution,
live_session_count_bucket: bucketDaemonLiveSessionCount(liveSessionCount)
})
} catch {
// Telemetry is best-effort; a dropped event must not fail daemon adoption.
}
}
/**
* Emits only on proven divergence: the daemon reported the cwd unreadable AND this process can
* read it. A cwd neither can read (chmod, ENOENT, unmounted volume) is not the #17696 shape.
*/
export function trackDaemonPtyCwdDeniedIfDiverged(
cwd: string | undefined,
cwdReadableByDaemon: boolean | undefined,
pidPath: string | null
): void {
try {
if (process.platform !== 'darwin' || !cwd || cwdReadableByDaemon !== false) {
return
}
accessSync(cwd, fsConstants.R_OK | fsConstants.X_OK)
// Why read now, not the adapter's startup snapshot: a respawn swaps the daemon under a
// long-lived adapter, and the denial must be attributed to the daemon that just spawned.
track('daemon_pty_cwd_denied', {
cwd_class: classifyDaemonPtyCwd(cwd, homedir()),
...classifyDaemonAdoptionOrigin(readDaemonPidRecord(pidPath))
})
} catch {
// Either the app cannot read it (no divergence) or telemetry failed; neither may reach the caller.
}
}
@@ -14,6 +14,12 @@ export type DaemonCreateOrAttachResult = {
wslDistro?: string | null
agentSessionEnsure?: AgentSessionClaimedSpawnResult
incarnationId?: PtyIncarnationId
/**
* Whether the daemon process itself could read the requested cwd at spawn. Only the daemon's own
* verdict counts: macOS TCC scopes folder access per process tree, so the app's view of the same
* path proves nothing about the daemon's (#17696). Omitted by daemons predating this field.
*/
cwdReadableByDaemon?: boolean
}
export function getDaemonSessionResultMetadata(session: {
@@ -50,7 +50,8 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) {
unbindLocalProviderListenersMock,
rebindLocalProviderListenersMock,
trackDaemonReplacedMock,
trackDaemonRetiredMock
trackDaemonRetiredMock,
trackDaemonAdoptedMock
} = state
// Why: both fakes are annotated with constructor types so the exported factories widen to
@@ -82,6 +83,9 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) {
if (result.mode) {
this.handle.mode = result.mode
}
if (result.adopted) {
this.handle.adopted = true
}
return {
socketPath: result.socketPath,
tokenPath: result.tokenPath
@@ -199,6 +203,9 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) {
trackDaemonReplaced: trackDaemonReplacedMock,
trackDaemonRetired: trackDaemonRetiredMock
}),
daemonAdoptionTelemetryEvent: () => ({
trackDaemonAdopted: trackDaemonAdoptedMock
}),
daemonSpawner: () => ({
DaemonSpawner: MockDaemonSpawner,
getDaemonSocketPath: (_dir: string, version?: number) =>
+3 -1
View File
@@ -41,7 +41,8 @@ export async function importFreshDaemonInit(state: DaemonInitMockState) {
unbindLocalProviderListenersMock,
rebindLocalProviderListenersMock,
trackDaemonReplacedMock,
trackDaemonRetiredMock
trackDaemonRetiredMock,
trackDaemonAdoptedMock
} = state
vi.resetModules()
@@ -64,6 +65,7 @@ export async function importFreshDaemonInit(state: DaemonInitMockState) {
rebindLocalProviderListenersMock.mockClear()
trackDaemonReplacedMock.mockClear()
trackDaemonRetiredMock.mockClear()
trackDaemonAdoptedMock.mockClear()
checkDaemonHealthMock.mockClear()
checkDaemonHealthMock.mockResolvedValue('healthy')
healthCheckDaemonMock.mockClear()
@@ -47,6 +47,7 @@ export type MockAdapterConstructor = new (opts: MockAdapter['options']) => MockA
/** Handle the fake spawner hands back from ensureRunning/getHandle. */
export type MockSpawnerHandle = {
mode?: 'degraded-new-pty-fallback'
adopted?: true
releaseAdoptionLease?: () => void
shutdown: () => Promise<void>
}
@@ -95,6 +96,7 @@ export type EnsureRunningOverride = () => Promise<{
socketPath: string
tokenPath: string
mode?: 'degraded-new-pty-fallback'
adopted?: true
}>
/** Every stub daemon-init's suites share, plus the control knobs they mutate per test. */
@@ -143,6 +145,7 @@ export type DaemonInitMockState = {
rebindLocalProviderListenersMock: Mock<(...args: unknown[]) => void>
trackDaemonReplacedMock: Mock<(...args: unknown[]) => void>
trackDaemonRetiredMock: Mock<(...args: unknown[]) => void>
trackDaemonAdoptedMock: Mock<(...args: unknown[]) => void>
}
/** net.connect stubs the suites install in beforeEach. */
@@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
isPackagedMock,
getMacDaemonTccAttributionHealthMock,
trackDaemonAdoptedMock,
probeSocketExistsMock,
readFileSyncMock,
unlinkSyncMock,
@@ -42,6 +44,7 @@ vi.mock('./daemon-process-start-time', () => moduleFactories.daemonProcessStartT
vi.mock('./daemon-pid-file-parse', () => moduleFactories.daemonPidFileParse())
vi.mock('./client', () => moduleFactories.client())
vi.mock('./daemon-lifecycle-event', () => moduleFactories.daemonLifecycleEvent())
vi.mock('./daemon-adoption-telemetry-event', () => moduleFactories.daemonAdoptionTelemetryEvent())
vi.mock('./daemon-spawner', () => moduleFactories.daemonSpawner())
vi.mock('./daemon-pty-adapter', () => moduleFactories.daemonPtyAdapter())
vi.mock('../ipc/pty', () => moduleFactories.ipcPty())
@@ -228,6 +231,48 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
expect(adapterInstances[1].disconnectOnly).toHaveBeenCalledOnce()
})
// #17696: adopting a daemon from an earlier app launch is invisible to daemon_lifecycle, so
// it gets its own event — macOS only, and only for adopted (not freshly forked) daemons.
it('reports a macOS daemon adoption with its TCC attribution and live session bucket', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
const mod = await importFresh()
ensureRunningOverrides.push(async () => ({
socketPath: '/fake/adopted-socket',
tokenPath: '/fake/adopted-token',
adopted: true
}))
getMacDaemonTccAttributionHealthMock.mockResolvedValueOnce('severed')
defaultListSessionsSessions.push({ sessionId: 'wt-1@@a' }, { sessionId: 'wt-1@@b' })
await mod.initDaemonPtyProvider()
await vi.waitFor(() => expect(trackDaemonAdoptedMock).toHaveBeenCalledOnce())
// null pid record: the harness has no pid file, which the emitter classifies as 'unknown'.
expect(trackDaemonAdoptedMock).toHaveBeenCalledWith(null, 'severed', 2)
vi.restoreAllMocks()
})
it('does not report adoption for a freshly forked daemon or off macOS', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
const mod = await importFresh()
await mod.initDaemonPtyProvider()
await new Promise((resolve) => setImmediate(resolve))
expect(trackDaemonAdoptedMock).not.toHaveBeenCalled()
vi.restoreAllMocks()
vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
const linuxMod = await importFresh()
ensureRunningOverrides.push(async () => ({
socketPath: '/fake/adopted-socket',
tokenPath: '/fake/adopted-token',
adopted: true
}))
await linuxMod.initDaemonPtyProvider()
await new Promise((resolve) => setImmediate(resolve))
expect(trackDaemonAdoptedMock).not.toHaveBeenCalled()
vi.restoreAllMocks()
})
it('routes fresh PTYs to the local fallback when a preserved daemon cannot spawn new PTYs', async () => {
const mod = await importFresh()
ensureRunningOverrides.push(async () => ({
+3 -1
View File
@@ -156,6 +156,7 @@ function createDaemonInitMockState(): DaemonInitMockState {
const rebindLocalProviderListenersMock = vi.fn()
const trackDaemonReplacedMock = vi.fn()
const trackDaemonRetiredMock = vi.fn()
const trackDaemonAdoptedMock = vi.fn()
return {
getPathMock,
@@ -197,7 +198,8 @@ function createDaemonInitMockState(): DaemonInitMockState {
unbindLocalProviderListenersMock,
rebindLocalProviderListenersMock,
trackDaemonReplacedMock,
trackDaemonRetiredMock
trackDaemonRetiredMock,
trackDaemonAdoptedMock
}
}
@@ -41,6 +41,7 @@ function createPreservedDaemonHandle(
mode?: 'degraded-new-pty-fallback'
): DaemonProcessHandle {
const handle: DaemonProcessHandle = {
adopted: true,
shutdown: async () => {
await cleanupDaemonForProtocol(runtimeDir, protocolVersion)
}
+31
View File
@@ -24,7 +24,10 @@ import {
import type { DaemonProvider } from './daemon-provider-routing'
import { installDaemonProvider } from './daemon-provider-state'
import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider'
import { trackDaemonAdopted } from './daemon-adoption-telemetry-event'
import { readDaemonPidRecord } from './daemon-endpoint-incarnation'
import { trackDaemonRetired } from './daemon-lifecycle-event'
import { getMacDaemonTccAttributionHealth } from './daemon-tcc-attribution'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import type { DaemonRespawnReason } from './daemon-pty-runtime-state'
import { DaemonPtyRouter } from './daemon-pty-router'
@@ -156,9 +159,37 @@ export async function initDaemonPtyProvider(
logDaemonMilestone('daemon-init-done', {
legacyAdapters: legacyAdapters.length
})
if (process.platform === 'darwin' && newSpawner.getHandle()?.adopted) {
void reportDaemonAdoption(runtimeDir, info.socketPath, info.tokenPath, newAdapter)
}
await reconcileSeededClaudeLivePtys(routedAdapter)
}
// Why off the init path: this is measurement of an adopted daemon (#17696), and neither its probes nor their failure may delay or fail startup.
async function reportDaemonAdoption(
runtimeDir: string,
socketPath: string,
tokenPath: string,
adapter: DaemonPtyAdapter
): Promise<void> {
try {
const [tccAttribution, liveSessionCount] = await Promise.all([
getMacDaemonTccAttributionHealth(runtimeDir, socketPath, tokenPath),
adapter.listSessions().then(
(sessions) => sessions.length,
() => null
)
])
trackDaemonAdopted(
readDaemonPidRecord(getDaemonPidPath(runtimeDir)),
tccAttribution,
liveSessionCount
)
} catch {
// Best-effort measurement only.
}
}
// Why: release gate ids only for daemon-confirmed-dead sessions; keep seeds on listing failure since releasing early can rotate a live CLI's refresh token.
async function reconcileSeededClaudeLivePtys(provider: DaemonProvider): Promise<void> {
if (!hasSeededUnconfirmedClaudePtys()) {
@@ -4,6 +4,7 @@ import type {
HistoryRecoveryContext,
PendingDaemonSpawnOperation
} from './daemon-pty-runtime-state'
import { trackDaemonPtyCwdDeniedIfDiverged } from './daemon-adoption-telemetry-event'
import { STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version'
import { TerminalKilledError } from './daemon-pty-lifecycle-errors'
import { DaemonPtySpawnResult } from './daemon-pty-spawn-result'
@@ -246,6 +247,9 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult {
}
activeSpawnContext = context
const result = await this.createOrAttachSpawn(context, context.historySeedSegments)
if (result.isNew && !attachOnly) {
trackDaemonPtyCwdDeniedIfDiverged(effectiveCwd, result.cwdReadableByDaemon, this.pidPath)
}
return this.finishSpawn(context, result)
}
+2
View File
@@ -31,6 +31,8 @@ export type DaemonPidFile = {
export type DaemonProcessHandle = {
mode?: 'degraded-new-pty-fallback'
/** Set when the launcher kept a daemon some earlier app launch forked, rather than forking one. */
adopted?: true
releaseAdoptionLease?(): void
shutdown(): Promise<void>
}
+4 -1
View File
@@ -161,7 +161,10 @@ export class DaemonTerminalAdmission {
...(result.launchAgent ? { launchAgent: result.launchAgent } : {}),
wslDistro: result.wslDistro,
...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}),
...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {})
...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}),
...(result.cwdReadableByDaemon !== undefined
? { cwdReadableByDaemon: result.cwdReadableByDaemon }
: {})
}
}
@@ -54,4 +54,6 @@ export type CreateOrAttachResult = {
attachToken: symbol
incarnationId: PtyIncarnationId
agentSessionEnsure?: AgentSessionClaimedSpawnResult
/** Daemon-process verdict on the spawn cwd; only set on a fresh spawn that was given a cwd. */
cwdReadableByDaemon?: boolean
}
@@ -0,0 +1,75 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SubprocessHandle } from './session-subprocess-handle'
import { TerminalHost, type TerminalHostOptions } from './terminal-host'
vi.mock('../pty-descendant-termination', () => ({ killWithDescendantSweep: vi.fn() }))
function createMockSubprocess(): SubprocessHandle {
let onExitCb: ((code: number) => void) | null = null
return {
pid: 99999,
getForegroundProcess: vi.fn(() => null),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => {
setTimeout(() => onExitCb?.(0), 5)
}),
terminateOwnedTree: () => 'unavailable' as const,
forceKill: vi.fn(() => onExitCb?.(137)),
signal: vi.fn(),
onData() {},
onExit(cb) {
onExitCb = cb
},
dispose: vi.fn()
}
}
// #17696: only the daemon process can say whether TCC lets it read the cwd, so its verdict
// rides on the create result. A non-permission failure must never read as denial.
describe('TerminalHost cwd readability verdict', () => {
let host: TerminalHost
let platformDescriptor: PropertyDescriptor | undefined
beforeEach(() => {
platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
const spawnSubprocess: TerminalHostOptions['spawnSubprocess'] = () => createMockSubprocess()
host = new TerminalHost({ spawnSubprocess })
})
afterEach(async () => {
await host.dispose()
if (platformDescriptor) {
Object.defineProperty(process, 'platform', platformDescriptor)
}
})
const create = (sessionId: string, cwd?: string) =>
host.createOrAttach({
sessionId,
cols: 80,
rows: 24,
...(cwd ? { cwd } : {}),
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
it('reports a readable cwd as readable', async () => {
expect((await create('readable', process.cwd())).cwdReadableByDaemon).toBe(true)
})
it('reports a missing cwd as readable — absence is not a permission denial', async () => {
expect((await create('missing', '/definitely/not/a/real/dir')).cwdReadableByDaemon).toBe(true)
})
it('omits the verdict when no cwd was requested', async () => {
expect((await create('no-cwd')).cwdReadableByDaemon).toBeUndefined()
})
it('omits the verdict on attach to an existing session', async () => {
await create('attach', process.cwd())
const attached = await create('attach', process.cwd())
expect(attached.isNew).toBe(false)
expect(attached.cwdReadableByDaemon).toBeUndefined()
})
})
@@ -1,3 +1,4 @@
import { accessSync, constants as fsConstants } from 'node:fs'
import { buildStartupCommandSubmission } from '../../shared/startup-command-submission'
import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend'
import { getDaemonSessionResultMetadata } from './daemon-create-or-attach-result'
@@ -88,6 +89,8 @@ async function spawnAndPublishSession(
ctx: { size: { cols: number; rows: number }; wslDistro: string | undefined }
): Promise<CreateOrAttachResult> {
const { size, wslDistro } = ctx
// Why before the fork: the shell's own cwd may already have fallen back, so probe the requested path.
const cwdReadableByDaemon = opts.cwd && !wslDistro ? isCwdReadableByThisProcess(opts.cwd) : null
const subprocess = await deps.spawnSubprocess({
sessionId: opts.sessionId,
cols: size.cols,
@@ -184,6 +187,20 @@ async function spawnAndPublishSession(
shellState: session.shellState,
incarnationId: session.incarnationId,
...getDaemonSessionResultMetadata(session),
...(cwdReadableByDaemon !== null ? { cwdReadableByDaemon } : {}),
attachToken: token
}
}
// Why R_OK|X_OK: listing a directory needs read, and entering it needs search — both are what
// TCC withholds. A non-permission failure (ENOENT, ENOTDIR) reads as readable so it can never
// masquerade as a permission denial.
function isCwdReadableByThisProcess(cwd: string): boolean {
try {
accessSync(cwd, fsConstants.R_OK | fsConstants.X_OK)
return true
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
return code !== 'EACCES' && code !== 'EPERM'
}
}
+2
View File
@@ -24,7 +24,9 @@ let storeRef: Store | null = null
const MAIN_OWNED_TELEMETRY_EVENTS = new Set<EventName>([
'app_starred_orca',
'daemon_adopted',
'daemon_audit_eligibility',
'daemon_pty_cwd_denied',
'star_nag_outcome',
'feature_interaction_usage_bucket_reached'
])
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'
import { classifyDaemonPtyCwd, classifyDaemonSpawnerPath } from './daemon-adoption-telemetry'
import { eventSchemas } from './telemetry-event-registry'
describe('classifyDaemonSpawnerPath', () => {
const alwaysExists = () => true
it('classifies the installed app, the ShipIt staging area, and everything else', () => {
expect(
classifyDaemonSpawnerPath('/Applications/Orca.app/Contents/MacOS/Orca', alwaysExists)
).toBe('applications')
expect(
classifyDaemonSpawnerPath('/private/Applications/Orca.app/Contents/MacOS/Orca', alwaysExists)
).toBe('applications')
expect(
classifyDaemonSpawnerPath(
'/Users/a/Library/Caches/com.stablyai.orca.ShipIt/update.abc/Orca.app/Contents/MacOS/Orca',
alwaysExists
)
).toBe('updater-cache')
expect(
classifyDaemonSpawnerPath('/Users/a/Applications/Orca.app/Contents/MacOS/Orca', alwaysExists)
).toBe('other')
expect(classifyDaemonSpawnerPath('/tmp/OrcaA.app/Contents/MacOS/Orca', alwaysExists)).toBe(
'other'
)
})
it('reports a deleted spawner as missing and an unrecorded one as unknown', () => {
expect(
classifyDaemonSpawnerPath('/Applications/Orca.app/Contents/MacOS/Orca', () => false)
).toBe('missing')
expect(classifyDaemonSpawnerPath(null, alwaysExists)).toBe('unknown')
})
})
describe('classifyDaemonPtyCwd', () => {
it('maps the TCC-protected home folders and separates the rest of home from outside it', () => {
expect(classifyDaemonPtyCwd('/Users/a/Documents/repo', '/Users/a')).toBe('documents')
expect(classifyDaemonPtyCwd('/Users/a/Desktop', '/Users/a/')).toBe('desktop')
expect(classifyDaemonPtyCwd('/Users/a/Downloads/x/y', '/Users/a')).toBe('downloads')
expect(classifyDaemonPtyCwd('/Users/a/projects/repo', '/Users/a')).toBe('other-home')
expect(classifyDaemonPtyCwd('/Users/a', '/Users/a')).toBe('other-home')
expect(classifyDaemonPtyCwd('/Volumes/ext/repo', '/Users/a')).toBe('outside-home')
// A sibling home that merely shares the prefix is not inside this home.
expect(classifyDaemonPtyCwd('/Users/ab/Documents', '/Users/a')).toBe('outside-home')
})
})
// Privacy invariant: enum-only. A raw path, version, or exact count must be rejected by .strict().
describe('daemon_adopted / daemon_pty_cwd_denied schemas', () => {
const adopted = {
app_version_match: 'different',
spawner_path_class: 'updater-cache',
tcc_attribution: 'intact',
live_session_count_bucket: '2-5'
}
const denied = {
cwd_class: 'documents',
app_version_match: 'different',
spawner_path_class: 'updater-cache'
}
it('accepts the enum payloads', () => {
expect(eventSchemas.daemon_adopted.safeParse(adopted).success).toBe(true)
expect(eventSchemas.daemon_pty_cwd_denied.safeParse(denied).success).toBe(true)
})
it('rejects leaked paths, versions, counts, and unknown enum values', () => {
for (const leak of [
{ spawner_exec_path: '/Users/alice/Library/Caches/ShipIt/Orca.app' },
{ app_version: '1.4.187' },
{ live_session_count: 3 },
{ cwd: '/Users/alice/Documents' }
]) {
expect(eventSchemas.daemon_adopted.safeParse({ ...adopted, ...leak }).success).toBe(false)
expect(eventSchemas.daemon_pty_cwd_denied.safeParse({ ...denied, ...leak }).success).toBe(
false
)
}
expect(
eventSchemas.daemon_adopted.safeParse({ ...adopted, spawner_path_class: '/Applications' })
.success
).toBe(false)
expect(
eventSchemas.daemon_pty_cwd_denied.safeParse({ ...denied, cwd_class: 'Documents' }).success
).toBe(false)
})
})
+67
View File
@@ -0,0 +1,67 @@
// Enums for the `daemon_adopted` and `daemon_pty_cwd_denied` telemetry events (#17696).
// Both exist to measure how often a macOS app runs on a daemon left behind by an earlier app
// bundle, and how often such a daemon actually spawns a terminal whose cwd it cannot read.
// Enum-only: no paths, versions, or exact counts ever reach the wire.
/** How the adopted daemon's recorded app version compares to the running app. */
export const DAEMON_ADOPTED_APP_VERSION_MATCH = ['same', 'different', 'unknown'] as const
export type DaemonAdoptedAppVersionMatch = (typeof DAEMON_ADOPTED_APP_VERSION_MATCH)[number]
/**
* Where the binary that forked the adopted daemon lives now. `updater-cache` is the Squirrel
* ShipIt staging area — a daemon attributed there is the reported #17696 shape.
*/
export const DAEMON_SPAWNER_PATH_CLASSES = [
'applications',
'updater-cache',
'other',
'missing',
'unknown'
] as const
export type DaemonSpawnerPathClass = (typeof DAEMON_SPAWNER_PATH_CLASSES)[number]
export const DAEMON_TCC_ATTRIBUTION_VALUES = ['intact', 'severed', 'unknown'] as const
/** Which macOS-protected folder class the denied cwd falls under. */
export const DAEMON_PTY_CWD_CLASSES = [
'documents',
'desktop',
'downloads',
'other-home',
'outside-home'
] as const
export type DaemonPtyCwdClass = (typeof DAEMON_PTY_CWD_CLASSES)[number]
export function classifyDaemonSpawnerPath(
spawnerExecPath: string | null,
exists: (path: string) => boolean
): DaemonSpawnerPathClass {
if (!spawnerExecPath) {
return 'unknown'
}
if (!exists(spawnerExecPath)) {
return 'missing'
}
if (/\/Library\/Caches\/[^/]*ShipIt\//.test(spawnerExecPath)) {
return 'updater-cache'
}
return /^(?:\/private)?\/Applications\//.test(spawnerExecPath) ? 'applications' : 'other'
}
export function classifyDaemonPtyCwd(cwd: string, homeDir: string): DaemonPtyCwdClass {
const home = homeDir.replace(/\/+$/, '')
if (!home || !(cwd === home || cwd.startsWith(`${home}/`))) {
return 'outside-home'
}
const topLevel = cwd.slice(home.length + 1).split('/')[0]
switch (topLevel) {
case 'Documents':
return 'documents'
case 'Desktop':
return 'desktop'
case 'Downloads':
return 'downloads'
default:
return 'other-home'
}
}
@@ -14,6 +14,12 @@ import {
DAEMON_AUDIT_TRIGGER_VALUES,
DAEMON_EVIDENCE_SOURCE_VALUES
} from './daemon-audit-eligibility'
import {
DAEMON_ADOPTED_APP_VERSION_MATCH,
DAEMON_PTY_CWD_CLASSES,
DAEMON_SPAWNER_PATH_CLASSES,
DAEMON_TCC_ATTRIBUTION_VALUES
} from './daemon-adoption-telemetry'
import { errorClassSchema, settingsChangedKeySchema } from './telemetry-property-schemas'
// Why: daemon start-failure signal (fleet-wide outage like v1.4.129-rc.1); enum-only so raw stderr never reaches the wire.
@@ -50,6 +56,27 @@ export const mainThreadHangDetectedSchema = z
})
.strict()
// Why: #17696 — a macOS app adopting a daemon from an earlier bundle is invisible to
// `daemon_lifecycle` (nothing is replaced). Once per macOS launch that adopts; enum-only.
export const daemonAdoptedSchema = z
.object({
app_version_match: z.enum(DAEMON_ADOPTED_APP_VERSION_MATCH),
spawner_path_class: z.enum(DAEMON_SPAWNER_PATH_CLASSES),
tcc_attribution: z.enum(DAEMON_TCC_ATTRIBUTION_VALUES),
live_session_count_bucket: z.enum(DAEMON_LIFECYCLE_SESSION_BUCKETS)
})
.strict()
// Why: the #17696 symptom itself — the daemon spawned a terminal into a cwd it cannot read while
// the app can. Emitted only on that proven divergence, so a missing or app-unreadable cwd never counts.
export const daemonPtyCwdDeniedSchema = z
.object({
cwd_class: z.enum(DAEMON_PTY_CWD_CLASSES),
app_version_match: z.enum(DAEMON_ADOPTED_APP_VERSION_MATCH),
spawner_path_class: z.enum(DAEMON_SPAWNER_PATH_CLASSES)
})
.strict()
// Why: daemon replace/retire lifecycle signal — issue #7936 was undiagnosable without asking a user for daemon.log.
// Enum-only + bucketed session count so no paths, raw versions, or exact counts reach the wire.
// The union keeps each reason pinned to its transition, so a death can't be reported as a replace.
+4
View File
@@ -14,8 +14,10 @@ import {
agentHookTransportBlockedSchema,
agentHookUnattributedSchema,
codexTrustGrantSchema,
daemonAdoptedSchema,
daemonAuditEligibilitySchema,
daemonLifecycleSchema,
daemonPtyCwdDeniedSchema,
daemonStartFailedSchema,
mainThreadHangDetectedSchema,
remoteOutboundBudgetCloseSchema,
@@ -122,6 +124,8 @@ export const eventSchemas = {
daemon_start_failed: daemonStartFailedSchema,
main_thread_hang_detected: mainThreadHangDetectedSchema,
daemon_lifecycle: daemonLifecycleSchema,
daemon_adopted: daemonAdoptedSchema,
daemon_pty_cwd_denied: daemonPtyCwdDeniedSchema,
daemon_audit_eligibility: daemonAuditEligibilitySchema,
runtime_rpc_start_failed: runtimeRpcStartFailedSchema,
remote_outbound_budget_close: remoteOutboundBudgetCloseSchema,