feat(telemetry): classify codex trust-grant fallbacks and attribute grant lane (#10001)
* feat(telemetry): classify codex trust-grant fallbacks and attribute grant lane * fix(telemetry): tighten codex trust-grant classification
This commit is contained in:
parent
db56a9b7f3
commit
407d7afc11
|
|
@ -28,6 +28,7 @@
|
|||
"../src/main/codex/codex-managed-trust-reconciliation.ts",
|
||||
"../src/main/codex/codex-process-exit-deadline.ts",
|
||||
"../src/main/codex/codex-trust-config-rollback.ts",
|
||||
"../src/main/codex/codex-trust-grant-telemetry.ts",
|
||||
"../src/main/codex/codex-trust-grant-host.ts",
|
||||
"../src/main/codex/codex-trust-grant-ledger.ts",
|
||||
"../src/main/codex/codex-user-hook-trust-rebase-client.ts",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
} from '../../shared/wsl-login-shell-command'
|
||||
|
||||
export const WSL_CODEX_AVAILABILITY_TIMEOUT_MS = 5_000
|
||||
export const WSL_CODEX_NOT_FOUND_MESSAGE = 'Codex CLI not found in the WSL login-shell PATH.'
|
||||
|
||||
export function buildWslCodexAvailabilityArgs(distro: string): string[] {
|
||||
const command = [buildCodexPathLookup(), '[ -n "$resolved" ]'].join('\n')
|
||||
|
|
@ -16,7 +17,7 @@ export function buildWslCodexIdentityArgs(distro: string): string[] {
|
|||
const command = [
|
||||
buildCodexPathLookup(),
|
||||
'if [ -z "$resolved" ]; then',
|
||||
" printf '%s\\n' 'Codex CLI not found in the WSL login-shell PATH.' >&2",
|
||||
` printf '%s\\n' '${WSL_CODEX_NOT_FOUND_MESSAGE}' >&2`,
|
||||
' exit 127',
|
||||
'fi',
|
||||
'printf \'%s\\n\' "$resolved"',
|
||||
|
|
@ -29,7 +30,7 @@ export function buildWslCodexAppServerArgs(distro: string, linuxHomePath: string
|
|||
const command = [
|
||||
buildCodexPathLookup(),
|
||||
'if [ -z "$resolved" ]; then',
|
||||
" printf '%s\\n' 'Codex CLI not found in the WSL login-shell PATH.' >&2",
|
||||
` printf '%s\\n' '${WSL_CODEX_NOT_FOUND_MESSAGE}' >&2`,
|
||||
' exit 127',
|
||||
'fi',
|
||||
`export CODEX_HOME=${quotePosixShell(linuxHomePath)}`,
|
||||
|
|
|
|||
|
|
@ -321,7 +321,7 @@ describe('runCodexHookTrustGrantSession', () => {
|
|||
})
|
||||
|
||||
const result = await runCodexHookTrustGrantSession(request)
|
||||
expect(result.outcome).toBe('verify-failed')
|
||||
expect(result).toMatchObject({ outcome: 'verify-failed', reasonClass: 'list-mismatch' })
|
||||
})
|
||||
|
||||
it('rejects duplicate normalized aliases that conceal a missing expected key', async () => {
|
||||
|
|
@ -337,7 +337,8 @@ describe('runCodexHookTrustGrantSession', () => {
|
|||
})
|
||||
|
||||
await expect(runCodexHookTrustGrantSession(request)).resolves.toMatchObject({
|
||||
outcome: 'verify-failed'
|
||||
outcome: 'verify-failed',
|
||||
reasonClass: 'list-mismatch'
|
||||
})
|
||||
expect(existsSync(recordFile)).toBe(false)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -41,6 +41,13 @@ export type CodexGrantedHookTrust = {
|
|||
trustedHash: string
|
||||
}
|
||||
|
||||
/** Closed verify-failure taxonomy — crosses the grant-bridge JSON envelope, so
|
||||
* telemetry never has to parse the free-form `reason` diagnostics string. */
|
||||
export type CodexTrustGrantSessionVerifyClass =
|
||||
| 'list-mismatch'
|
||||
| 'post-grant-untrusted'
|
||||
| 'post-grant-mismatch'
|
||||
|
||||
export type CodexHookTrustGrantSessionResult =
|
||||
| {
|
||||
outcome: 'granted'
|
||||
|
|
@ -48,7 +55,7 @@ export type CodexHookTrustGrantSessionResult =
|
|||
/** False when every expected entry was already trusted (no write). */
|
||||
wroteTrust: boolean
|
||||
}
|
||||
| { outcome: 'verify-failed'; reason: string }
|
||||
| { outcome: 'verify-failed'; reason: string; reasonClass: CodexTrustGrantSessionVerifyClass }
|
||||
|
||||
type CodexHookListing = {
|
||||
key: string
|
||||
|
|
@ -117,7 +124,8 @@ export async function runCodexHookTrustGrantSession(
|
|||
) {
|
||||
return {
|
||||
outcome: 'verify-failed',
|
||||
reason: `hooks/list reported ${managedListings.length} entries covering ${managedKeyCoverage.size} of ${expectedKeys.size} expected managed entries`
|
||||
reason: `hooks/list reported ${managedListings.length} entries covering ${managedKeyCoverage.size} of ${expectedKeys.size} expected managed entries`,
|
||||
reasonClass: 'list-mismatch'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -144,13 +152,17 @@ export async function runCodexHookTrustGrantSession(
|
|||
!setContainsEvery(verifiedKeyCoverage, expectedKeys) ||
|
||||
untrusted.length > 0
|
||||
) {
|
||||
return {
|
||||
outcome: 'verify-failed',
|
||||
reason:
|
||||
untrusted.length > 0
|
||||
? `post-grant verify left ${untrusted.length} entries ${untrusted[0].trustStatus}`
|
||||
: `post-grant verify reported ${verifiedListings.length} entries covering ${verifiedKeyCoverage.size} of ${expectedKeys.size} expected entries`
|
||||
}
|
||||
return untrusted.length > 0
|
||||
? {
|
||||
outcome: 'verify-failed',
|
||||
reason: `post-grant verify left ${untrusted.length} entries ${untrusted[0].trustStatus}`,
|
||||
reasonClass: 'post-grant-untrusted'
|
||||
}
|
||||
: {
|
||||
outcome: 'verify-failed',
|
||||
reason: `post-grant verify reported ${verifiedListings.length} entries covering ${verifiedKeyCoverage.size} of ${expectedKeys.size} expected entries`,
|
||||
reasonClass: 'post-grant-mismatch'
|
||||
}
|
||||
}
|
||||
return {
|
||||
outcome: 'granted',
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ import {
|
|||
CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS,
|
||||
getCodexTrustGrantDiagnostics,
|
||||
grantManagedCodexHookTrust,
|
||||
setCodexTrustGrantTelemetry,
|
||||
type CodexManagedTrustGrantPlan
|
||||
} from './codex-hook-trust-grant'
|
||||
import { setCodexTrustGrantTelemetry } from './codex-trust-grant-telemetry'
|
||||
import { readCodexTrustGrantLedgerHome } from './codex-trust-grant-ledger'
|
||||
import {
|
||||
computeTrustKey,
|
||||
|
|
@ -76,7 +76,8 @@ function buildPlan(entries: CodexTrustEntry[]): CodexManagedTrustGrantPlan {
|
|||
tomlPath: join(runtimeHomeDir, 'config.toml'),
|
||||
managedCommand: MANAGED_COMMAND,
|
||||
managedEntries: entries,
|
||||
host: { kind: 'native' }
|
||||
host: { kind: 'native' },
|
||||
telemetryLane: 'real-home'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -248,7 +249,11 @@ describe('grantManagedCodexHookTrust', () => {
|
|||
|
||||
it('falls back on verify-failed without marking unsupported', () => {
|
||||
const entries = [managedEntry('session_start')]
|
||||
const runner = vi.fn(() => ({ outcome: 'verify-failed' as const, reason: 'missing entries' }))
|
||||
const runner = vi.fn(() => ({
|
||||
outcome: 'verify-failed' as const,
|
||||
reason: 'missing entries',
|
||||
reasonClass: 'list-mismatch' as const
|
||||
}))
|
||||
_internals.setGrantSessionRunnerSync(runner)
|
||||
|
||||
expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({
|
||||
|
|
@ -307,7 +312,11 @@ describe('grantManagedCodexHookTrust', () => {
|
|||
mkdirSync(runtimeHomeDir, { recursive: true })
|
||||
_internals.setGrantSessionRunnerSync(() => {
|
||||
writeFileSync(plan.tomlPath, '[hooks.state."rpc-partial"]\ntrusted_hash = "changed"\n')
|
||||
return { outcome: 'verify-failed', reason: 'post-write listing failed' }
|
||||
return {
|
||||
outcome: 'verify-failed',
|
||||
reason: 'post-write listing failed',
|
||||
reasonClass: 'post-grant-mismatch'
|
||||
}
|
||||
})
|
||||
|
||||
expect(grantManagedCodexHookTrust(plan)).toMatchObject({
|
||||
|
|
@ -347,3 +356,93 @@ describe('grantManagedCodexHookTrust', () => {
|
|||
expect(request.hooksListCwd).toBe('/home/alice/.codex-runtime')
|
||||
})
|
||||
})
|
||||
|
||||
describe('trust-grant telemetry detail', () => {
|
||||
type CapturedEvent = Record<string, unknown>
|
||||
|
||||
function captureTelemetry(): CapturedEvent[] {
|
||||
const events: CapturedEvent[] = []
|
||||
setCodexTrustGrantTelemetry((event) => {
|
||||
events.push(event)
|
||||
})
|
||||
return events
|
||||
}
|
||||
|
||||
it('attributes the plan lane on granted events', () => {
|
||||
const events = captureTelemetry()
|
||||
const entries = [managedEntry('session_start')]
|
||||
_internals.setGrantSessionRunnerSync(() => grantedSessionResult(entries))
|
||||
|
||||
expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ lane: 'rpc' })
|
||||
expect(events).toEqual([{ outcome: 'granted', hostKind: 'native', lane: 'real-home' }])
|
||||
})
|
||||
|
||||
it('reports the managed lane independently of host kind', () => {
|
||||
const events = captureTelemetry()
|
||||
const entries = [managedEntry('session_start')]
|
||||
_internals.setGrantSessionRunnerSync(() => grantedSessionResult(entries))
|
||||
|
||||
grantManagedCodexHookTrust({ ...buildPlan(entries), telemetryLane: 'managed' })
|
||||
expect(events).toEqual([{ outcome: 'granted', hostKind: 'native', lane: 'managed' }])
|
||||
})
|
||||
|
||||
it('classifies error fallbacks on the wire', () => {
|
||||
const events = captureTelemetry()
|
||||
const entries = [managedEntry('session_start')]
|
||||
_internals.setGrantSessionRunnerSync(() => {
|
||||
throw new Error('spawn codex ENOENT')
|
||||
})
|
||||
|
||||
expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({
|
||||
lane: 'fallback',
|
||||
reason: 'error'
|
||||
})
|
||||
expect(events).toEqual([
|
||||
{
|
||||
outcome: 'fallback',
|
||||
hostKind: 'native',
|
||||
lane: 'real-home',
|
||||
reason: 'error',
|
||||
errorClass: 'binary-missing'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('carries the session verify class through the fallback event', () => {
|
||||
const events = captureTelemetry()
|
||||
const entries = [managedEntry('session_start')]
|
||||
_internals.setGrantSessionRunnerSync(() => ({
|
||||
outcome: 'verify-failed' as const,
|
||||
reason: 'post-grant verify left 1 entries untrusted',
|
||||
reasonClass: 'post-grant-untrusted' as const
|
||||
}))
|
||||
|
||||
grantManagedCodexHookTrust(buildPlan(entries))
|
||||
expect(events).toEqual([
|
||||
{
|
||||
outcome: 'verify_failed',
|
||||
hostKind: 'native',
|
||||
lane: 'real-home',
|
||||
reason: 'verify-failed',
|
||||
verifyClass: 'post-grant-untrusted'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('classifies module-detected verify failures', () => {
|
||||
const events = captureTelemetry()
|
||||
const entries = [managedEntry('session_start'), managedEntry('stop')]
|
||||
_internals.setGrantSessionRunnerSync(() => grantedSessionResult([entries[0]!, entries[0]!]))
|
||||
|
||||
grantManagedCodexHookTrust(buildPlan(entries))
|
||||
expect(events).toEqual([
|
||||
{
|
||||
outcome: 'verify_failed',
|
||||
hostKind: 'native',
|
||||
lane: 'real-home',
|
||||
reason: 'verify-failed',
|
||||
verifyClass: 'duplicate-key'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,6 +3,13 @@ import {
|
|||
type CodexHookTrustGrantRequest,
|
||||
type CodexHookTrustGrantSessionResult
|
||||
} from './codex-app-server-client'
|
||||
import {
|
||||
classifyCodexTrustGrantError,
|
||||
emitCodexTrustGrantTelemetry,
|
||||
type CodexTrustGrantFallbackReason,
|
||||
type CodexTrustGrantTelemetryLane,
|
||||
type CodexTrustGrantVerifyClass
|
||||
} from './codex-trust-grant-telemetry'
|
||||
import { runCodexHookTrustGrantSessionSync } from './codex-app-server-grant-bridge'
|
||||
import {
|
||||
codexAppServerCapabilityCache,
|
||||
|
|
@ -46,69 +53,31 @@ export type CodexManagedTrustGrantPlan = {
|
|||
/** Managed trust identities Orca just wrote (no trustedHash). */
|
||||
managedEntries: readonly CodexTrustEntry[]
|
||||
host: CodexTrustGrantHost
|
||||
telemetryLane: CodexTrustGrantTelemetryLane
|
||||
/** Match a pane where CODEX_HOME is absent instead of an explicit managed home. */
|
||||
useDefaultCodexHome?: boolean
|
||||
}
|
||||
|
||||
export type CodexTrustGrantFallbackReason =
|
||||
| 'disabled'
|
||||
| 'no-managed-entries'
|
||||
| 'unsupported'
|
||||
| 'unsupported-cached'
|
||||
| 'verify-failed'
|
||||
| 'retry-cached'
|
||||
| 'error'
|
||||
export type { CodexTrustGrantFallbackReason, CodexTrustGrantTelemetryLane }
|
||||
|
||||
export type CodexManagedTrustGrantOutcome =
|
||||
| { lane: 'rpc'; entries: CodexTrustEntry[] }
|
||||
| { lane: 'fallback'; reason: CodexTrustGrantFallbackReason }
|
||||
|
||||
export type CodexTrustGrantDiagnostics = {
|
||||
granted: number
|
||||
ledgerHits: number
|
||||
fellBack: number
|
||||
verifyFailed: number
|
||||
lastFallbackReason: CodexTrustGrantFallbackReason | null
|
||||
}
|
||||
|
||||
const diagnostics: CodexTrustGrantDiagnostics = {
|
||||
const diagnostics = {
|
||||
granted: 0,
|
||||
ledgerHits: 0,
|
||||
fellBack: 0,
|
||||
verifyFailed: 0,
|
||||
lastFallbackReason: null
|
||||
lastFallbackReason: null as CodexTrustGrantFallbackReason | null
|
||||
}
|
||||
export type CodexTrustGrantDiagnostics = typeof diagnostics
|
||||
const transientRetryAfterByHost = new Map<string, number>()
|
||||
|
||||
export function getCodexTrustGrantDiagnostics(): CodexTrustGrantDiagnostics {
|
||||
return { ...diagnostics }
|
||||
}
|
||||
|
||||
type CodexTrustGrantTelemetry = (event: {
|
||||
outcome: 'granted' | 'fallback' | 'verify_failed'
|
||||
hostKind: 'native' | 'wsl'
|
||||
reason?: CodexTrustGrantFallbackReason
|
||||
}) => void
|
||||
|
||||
// Why: hook-service is bundled into plain-node CLI entries where electron
|
||||
// (and therefore the telemetry client) cannot load; the Electron main process
|
||||
// injects the tracker at startup instead of a static import.
|
||||
let telemetry: CodexTrustGrantTelemetry = () => {}
|
||||
|
||||
export function setCodexTrustGrantTelemetry(tracker: CodexTrustGrantTelemetry): void {
|
||||
telemetry = tracker
|
||||
}
|
||||
|
||||
function emitTelemetry(event: Parameters<CodexTrustGrantTelemetry>[0]): void {
|
||||
try {
|
||||
telemetry(event)
|
||||
} catch (error) {
|
||||
// Why: observability must never turn a verified grant into fallback or
|
||||
// violate this launch-prep API's no-throw contract.
|
||||
console.warn('[codex-trust-grant] failed to emit telemetry', error)
|
||||
}
|
||||
}
|
||||
|
||||
type GrantSessionRunnerSync = (
|
||||
request: CodexHookTrustGrantRequest
|
||||
) => CodexHookTrustGrantSessionResult
|
||||
|
|
@ -118,7 +87,8 @@ let runSessionSync: GrantSessionRunnerSync = runCodexHookTrustGrantSessionSync
|
|||
function fallback(
|
||||
plan: CodexManagedTrustGrantPlan,
|
||||
reason: CodexTrustGrantFallbackReason,
|
||||
detail?: unknown
|
||||
detail?: unknown,
|
||||
verifyClass?: CodexTrustGrantVerifyClass
|
||||
): CodexManagedTrustGrantOutcome {
|
||||
diagnostics.fellBack += 1
|
||||
diagnostics.lastFallbackReason = reason
|
||||
|
|
@ -129,10 +99,13 @@ function fallback(
|
|||
`[codex-trust-grant] falling back to self-computed trust (reason=${reason}, host=${plan.host.kind})`,
|
||||
detail ?? ''
|
||||
)
|
||||
emitTelemetry({
|
||||
emitCodexTrustGrantTelemetry({
|
||||
outcome: reason === 'verify-failed' ? 'verify_failed' : 'fallback',
|
||||
hostKind: plan.host.kind,
|
||||
reason
|
||||
lane: plan.telemetryLane,
|
||||
reason,
|
||||
...(reason === 'error' ? { errorClass: classifyCodexTrustGrantError(detail) } : {}),
|
||||
...(verifyClass !== undefined ? { verifyClass } : {})
|
||||
})
|
||||
return { lane: 'fallback', reason }
|
||||
}
|
||||
|
|
@ -272,7 +245,7 @@ export function grantManagedCodexHookTrust(
|
|||
hostKey,
|
||||
Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
|
||||
)
|
||||
return fallback(plan, 'verify-failed', result.reason)
|
||||
return fallback(plan, 'verify-failed', result.reason, result.reasonClass)
|
||||
}
|
||||
|
||||
const byNormalizedKey = new Map(expected.map((item) => [item.normalizedKey, item]))
|
||||
|
|
@ -287,7 +260,12 @@ export function grantManagedCodexHookTrust(
|
|||
hostKey,
|
||||
Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
|
||||
)
|
||||
return fallback(plan, 'verify-failed', `unexpected granted key ${granted.key}`)
|
||||
return fallback(
|
||||
plan,
|
||||
'verify-failed',
|
||||
`unexpected granted key ${granted.key}`,
|
||||
'unexpected-key'
|
||||
)
|
||||
}
|
||||
if (seenNormalizedKeys.has(granted.normalizedKey)) {
|
||||
restoreCodexTrustConfig(plan.tomlPath, configSnapshot)
|
||||
|
|
@ -295,7 +273,12 @@ export function grantManagedCodexHookTrust(
|
|||
hostKey,
|
||||
Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
|
||||
)
|
||||
return fallback(plan, 'verify-failed', `duplicate granted key ${granted.key}`)
|
||||
return fallback(
|
||||
plan,
|
||||
'verify-failed',
|
||||
`duplicate granted key ${granted.key}`,
|
||||
'duplicate-key'
|
||||
)
|
||||
}
|
||||
seenNormalizedKeys.add(granted.normalizedKey)
|
||||
grantedEntries.push({ ...match.entry, trustedHash: granted.trustedHash })
|
||||
|
|
@ -310,7 +293,12 @@ export function grantManagedCodexHookTrust(
|
|||
hostKey,
|
||||
Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
|
||||
)
|
||||
return fallback(plan, 'verify-failed', 'granted entry set did not cover expected entries')
|
||||
return fallback(
|
||||
plan,
|
||||
'verify-failed',
|
||||
'granted entry set did not cover expected entries',
|
||||
'coverage'
|
||||
)
|
||||
}
|
||||
transientRetryAfterByHost.delete(hostKey)
|
||||
try {
|
||||
|
|
@ -327,7 +315,11 @@ export function grantManagedCodexHookTrust(
|
|||
`[codex-trust-grant] granted ${grantedEntries.length} managed hook entries via codex app-server ` +
|
||||
`(host=${plan.host.kind}, wrote=${result.wroteTrust}, ${Date.now() - startedAtMs}ms)`
|
||||
)
|
||||
emitTelemetry({ outcome: 'granted', hostKind: plan.host.kind })
|
||||
emitCodexTrustGrantTelemetry({
|
||||
outcome: 'granted',
|
||||
hostKind: plan.host.kind,
|
||||
lane: plan.telemetryLane
|
||||
})
|
||||
return { lane: 'rpc', entries: grantedEntries }
|
||||
} catch (error) {
|
||||
return fallback(plan, 'error', error)
|
||||
|
|
|
|||
|
|
@ -196,6 +196,7 @@ function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane {
|
|||
managedCommand: material.command,
|
||||
managedEntries,
|
||||
host: { kind: 'native' },
|
||||
telemetryLane: 'real-home',
|
||||
useDefaultCodexHome: true
|
||||
})
|
||||
if (grant.lane === 'rpc') {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { WSL_CODEX_NOT_FOUND_MESSAGE } from '../codex-accounts/wsl-codex-command'
|
||||
import { CodexAppServerTimeoutError } from './codex-app-server-client'
|
||||
import { classifyCodexTrustGrantError } from './codex-trust-grant-telemetry'
|
||||
|
||||
describe('classifyCodexTrustGrantError', () => {
|
||||
it.each([
|
||||
[new CodexAppServerTimeoutError('entry exceeded 20000ms session deadline'), 'timeout'],
|
||||
[new Error('spawn codex ENOENT'), 'binary-missing'],
|
||||
[new Error('spawn /Users/ada/.local/bin/codex ENOENT'), 'binary-missing'],
|
||||
[
|
||||
new Error(
|
||||
`codex app-server exited before completing the session: ${WSL_CODEX_NOT_FOUND_MESSAGE}`
|
||||
),
|
||||
'binary-missing'
|
||||
],
|
||||
[new Error('spawn wsl.exe ENOENT'), 'unexpected'],
|
||||
[
|
||||
new Error("ENOENT: no such file or directory, open '/home/ada/.codex/config.toml'"),
|
||||
'unexpected'
|
||||
],
|
||||
[new Error('codex trust-grant entry bundle not found'), 'entry-failed'],
|
||||
[new Error('codex trust-grant entry produced no result (exit 1)'), 'entry-failed'],
|
||||
[
|
||||
new Error('codex app-server exited before completing the session: panicked at main.rs'),
|
||||
'early-exit'
|
||||
],
|
||||
[new Error('codex app-server config/batchWrite failed: unknown key'), 'rpc-failed'],
|
||||
[new Error('write EPIPE'), 'unexpected'],
|
||||
['not an error object', 'unexpected']
|
||||
] as const)('classifies %s as %s', (error, expected) => {
|
||||
expect(classifyCodexTrustGrantError(error)).toBe(expected)
|
||||
})
|
||||
|
||||
it('keeps an ENOENT-mentioning stderr tail classified as early-exit', () => {
|
||||
expect(
|
||||
classifyCodexTrustGrantError(
|
||||
new Error('codex app-server exited before completing the session: ENOENT in codex output')
|
||||
)
|
||||
).toBe('early-exit')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import type { CodexTrustGrantSessionVerifyClass } from './codex-app-server-client'
|
||||
import { WSL_CODEX_NOT_FOUND_MESSAGE } from '../codex-accounts/wsl-codex-command'
|
||||
|
||||
/** Which install surface asked for the grant: the system-default real ~/.codex
|
||||
* or a managed (mirror/per-account) home. Telemetry attribution only — the
|
||||
* grant behaves identically; host_kind alone cannot distinguish the lanes
|
||||
* (native hosts grant for both surfaces). */
|
||||
export type CodexTrustGrantTelemetryLane = 'real-home' | 'managed'
|
||||
|
||||
export type CodexTrustGrantFallbackReason =
|
||||
| 'disabled'
|
||||
| 'no-managed-entries'
|
||||
| 'unsupported'
|
||||
| 'unsupported-cached'
|
||||
| 'verify-failed'
|
||||
| 'retry-cached'
|
||||
| 'error'
|
||||
|
||||
/** Closed classification of `reason: 'error'` fallbacks. Errors cross the
|
||||
* grant-bridge envelope as message text (only timeout/unsupported keep their
|
||||
* name), so classes are matched on the bounded message shapes each layer
|
||||
* produces — never forwarded raw. */
|
||||
export type CodexTrustGrantErrorClass =
|
||||
| 'binary-missing'
|
||||
| 'timeout'
|
||||
| 'entry-failed'
|
||||
| 'early-exit'
|
||||
| 'rpc-failed'
|
||||
| 'unexpected'
|
||||
|
||||
export type CodexTrustGrantVerifyClass =
|
||||
| CodexTrustGrantSessionVerifyClass
|
||||
| 'unexpected-key'
|
||||
| 'duplicate-key'
|
||||
| 'coverage'
|
||||
|
||||
export function classifyCodexTrustGrantError(error: unknown): CodexTrustGrantErrorClass {
|
||||
if (!(error instanceof Error)) {
|
||||
return 'unexpected'
|
||||
}
|
||||
if (error.name === 'CodexAppServerTimeoutError') {
|
||||
return 'timeout'
|
||||
}
|
||||
const message = error.message
|
||||
if (message.includes('codex trust-grant entry')) {
|
||||
return 'entry-failed'
|
||||
}
|
||||
if (
|
||||
/^spawn (?:.*[\\/])?codex(?:\.(?:cmd|exe|bat))? ENOENT$/.test(message) ||
|
||||
message.includes(WSL_CODEX_NOT_FOUND_MESSAGE)
|
||||
) {
|
||||
return 'binary-missing'
|
||||
}
|
||||
if (message.includes('exited before completing the session')) {
|
||||
return 'early-exit'
|
||||
}
|
||||
if (/codex app-server \S+ failed:/.test(message)) {
|
||||
return 'rpc-failed'
|
||||
}
|
||||
return 'unexpected'
|
||||
}
|
||||
|
||||
export type CodexTrustGrantTelemetryEvent = {
|
||||
outcome: 'granted' | 'fallback' | 'verify_failed'
|
||||
hostKind: 'native' | 'wsl'
|
||||
lane: CodexTrustGrantTelemetryLane
|
||||
reason?: CodexTrustGrantFallbackReason
|
||||
errorClass?: CodexTrustGrantErrorClass
|
||||
verifyClass?: CodexTrustGrantVerifyClass
|
||||
}
|
||||
|
||||
type CodexTrustGrantTelemetry = (event: CodexTrustGrantTelemetryEvent) => void
|
||||
|
||||
// Why: hook-service is bundled into plain-node CLI entries where electron
|
||||
// (and therefore the telemetry client) cannot load; the Electron main process
|
||||
// injects the tracker at startup instead of a static import.
|
||||
let telemetry: CodexTrustGrantTelemetry = () => {}
|
||||
|
||||
export function setCodexTrustGrantTelemetry(tracker: CodexTrustGrantTelemetry): void {
|
||||
telemetry = tracker
|
||||
}
|
||||
|
||||
export function emitCodexTrustGrantTelemetry(event: CodexTrustGrantTelemetryEvent): void {
|
||||
try {
|
||||
telemetry(event)
|
||||
} catch (error) {
|
||||
// Why: observability must never turn a verified grant into fallback or
|
||||
// violate the launch-prep no-throw contract of the grant lane.
|
||||
console.warn('[codex-trust-grant] failed to emit telemetry', error)
|
||||
}
|
||||
}
|
||||
|
|
@ -916,7 +916,8 @@ function installManagedHooksIntoWslRuntime(
|
|||
tomlPath: plan.tomlPath,
|
||||
managedCommand: command,
|
||||
managedEntries: trustEntries,
|
||||
host: { kind: 'wsl', distro: plan.wslDistro, linuxRuntimeHome: plan.linuxRuntimeHome }
|
||||
host: { kind: 'wsl', distro: plan.wslDistro, linuxRuntimeHome: plan.linuxRuntimeHome },
|
||||
telemetryLane: 'managed'
|
||||
})
|
||||
if (grant.lane === 'fallback') {
|
||||
// Why: WSL runtime homes may carry user hook approvals we did not rebuild
|
||||
|
|
@ -1353,7 +1354,8 @@ export class CodexHookService {
|
|||
tomlPath,
|
||||
managedCommand: command,
|
||||
managedEntries: managedTrustEntries,
|
||||
host: { kind: 'native' }
|
||||
host: { kind: 'native' },
|
||||
telemetryLane: 'managed'
|
||||
})
|
||||
if (grant.lane === 'rpc') {
|
||||
recentGrantEntries = grant.entries
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ import {
|
|||
ensureRealHomeCodexHookState,
|
||||
isRealHomeCodexHookLaneUsable
|
||||
} from './codex/codex-real-home-hook-install'
|
||||
import { setCodexTrustGrantTelemetry } from './codex/codex-hook-trust-grant'
|
||||
import { setCodexTrustGrantTelemetry } from './codex/codex-trust-grant-telemetry'
|
||||
import { startCodexSessionBackfillInBackground } from './codex/codex-session-backfill'
|
||||
import { startCodexSessionIndexHealInBackground } from './codex/codex-session-index-heal'
|
||||
import { createCodexSessionMigrationScheduler } from './codex/codex-session-migration-scheduler'
|
||||
|
|
@ -1852,11 +1852,14 @@ app.whenReady().then(async () => {
|
|||
// Why: the trust-grant module is bundled into plain-node CLI entries where
|
||||
// the telemetry client cannot load, so the tracker is injected here instead
|
||||
// of imported there.
|
||||
setCodexTrustGrantTelemetry(({ outcome, hostKind, reason }) => {
|
||||
setCodexTrustGrantTelemetry(({ outcome, hostKind, lane, reason, errorClass, verifyClass }) => {
|
||||
track('codex_trust_grant', {
|
||||
outcome,
|
||||
host_kind: hostKind,
|
||||
...(reason !== undefined ? { fallback_reason: reason } : {})
|
||||
lane,
|
||||
...(reason !== undefined ? { fallback_reason: reason } : {}),
|
||||
...(errorClass !== undefined ? { error_class: errorClass } : {}),
|
||||
...(verifyClass !== undefined ? { verify_class: verifyClass } : {})
|
||||
})
|
||||
})
|
||||
// Why: the error-tracking lane (telemetry-error-tracking.md) is its own
|
||||
|
|
|
|||
|
|
@ -367,11 +367,15 @@ const daemonStartFailedSchema = z.object({ error_class: errorClassSchema }).stri
|
|||
// Rollout signal for granting Codex hook trust via codex app-server RPCs
|
||||
// instead of Orca's self-computed trusted_hash. `fallback`/`verify_failed`
|
||||
// spikes mean the RPC lane is not taking; steady-state ledger skips are not
|
||||
// reported (they would only measure launch volume).
|
||||
// reported (they would only measure launch volume). `lane` attributes the
|
||||
// grant surface (real ~/.codex vs managed home); `error_class`/`verify_class`
|
||||
// are closed classifications so `error` fallbacks are diagnosable in the
|
||||
// field — e.g. `binary-missing` = codex CLI absent, no rollout impact.
|
||||
const codexTrustGrantSchema = z
|
||||
.object({
|
||||
outcome: z.enum(['granted', 'fallback', 'verify_failed']),
|
||||
host_kind: z.enum(['native', 'wsl']),
|
||||
lane: z.enum(['real-home', 'managed']),
|
||||
fallback_reason: z
|
||||
.enum([
|
||||
'disabled',
|
||||
|
|
@ -382,6 +386,19 @@ const codexTrustGrantSchema = z
|
|||
'retry-cached',
|
||||
'error'
|
||||
])
|
||||
.optional(),
|
||||
error_class: z
|
||||
.enum(['binary-missing', 'timeout', 'entry-failed', 'early-exit', 'rpc-failed', 'unexpected'])
|
||||
.optional(),
|
||||
verify_class: z
|
||||
.enum([
|
||||
'list-mismatch',
|
||||
'post-grant-untrusted',
|
||||
'post-grant-mismatch',
|
||||
'unexpected-key',
|
||||
'duplicate-key',
|
||||
'coverage'
|
||||
])
|
||||
.optional()
|
||||
})
|
||||
.strict()
|
||||
|
|
|
|||
Loading…
Reference in New Issue