22 Commits

Author SHA1 Message Date
7dd3095974 privacy: remove external data transmissions & add GitHub release workflow
Remove three active external data transmission paths:

1. WebFetch domain blocklist (api.anthropic.com/api/web/domain_info)
   - src/tools/WebFetchTool/utils.ts
   - Was sending every domain a user tried to fetch to Anthropic
   - Replaced with always-allowed stub; tool permission dialog is
     the primary security boundary

2. Codex API router (chatgpt.com/backend-api/codex/responses)
   - src/services/api/codex-fetch-adapter.ts
   - Would have forwarded full conversation content to OpenAI
   - createCodexFetch now returns HTTP 403 stub

3. OpenAI API adapter (api.openai.com/v1/chat/completions)
   - src/utils/codex-fetch-adapter.ts
   - Would have forwarded messages to OpenAI
   - fetchCodexResponse now throws immediately

Already-disabled paths (no changes needed):
- Analytics logEvent/logEventAsync: empty stubs in services/analytics/index.ts
- GrowthBook/Statsig: local cache only, no outbound requests
- Auto-updater GCS: already guarded by CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
- MCP registry: already guarded by CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
- Release notes GitHub: already guarded by isEssentialTrafficOnly()

Add .github/workflows/release.yml:
- Builds self-contained binaries for macOS (x64+arm64), Linux (x64+arm64),
  Windows (x64) using bun compile on each native runner
- Triggers on version tags (v*.*.*) or manual workflow_dispatch
- Publishes binaries + SHA256SUMS.txt as a GitHub Release with
  per-platform install instructions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-14 15:46:47 +08:00
9ba783f10b Remove the remaining no-op tracing and telemetry-only helpers
The build no longer ships telemetry egress, so the next cleanup pass deletes the remaining tracing compatibility layer and the helper modules whose only job was to shape telemetry payloads. This removes the dead session/beta/perfetto tracing files, drops telemetry-only file-operation and plugin-fetch helpers, and rewires the affected callers to keep only their real product behavior.

Constraint: Preserve existing user-visible behavior and feature-gated product logic while removing inert tracing/reporting scaffolding
Constraint: Leave GrowthBook in place for now because it functions as the repo's local feature-flag adapter, not a live reporting path
Rejected: Delete growthbook.ts in the same pass | Its call surface is wide and now tied to local product behavior rather than telemetry export
Rejected: Leave no-op tracing and helper modules in place | They continued to create audit noise and implied behavior that no longer existed
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Remaining analytics-named code should be treated as either local compatibility calls or feature-gate infrastructure unless a concrete egress path is reintroduced
Tested: bun test src/services/analytics/index.test.ts src/components/FeedbackSurvey/submitTranscriptShare.test.ts
Tested: bun run ./scripts/build.ts
Not-tested: bun x tsc --noEmit (repository has pre-existing unrelated type errors)
2026-04-09 14:26:11 +08:00
5af8acb2bb Checkpoint the full local bridge and audit work before telemetry removal
You asked for all local code to be committed before the broader telemetry-removal pass. This commit snapshots the current bridge/session ingress changes together with the local audit documents so the next cleanup can proceed from a stable rollback point.

Constraint: Preserve the exact local worktree state before the telemetry-removal refactor begins
Constraint: Avoid mixing this baseline snapshot with the upcoming telemetry deletions
Rejected: Fold these staged changes into the telemetry-removal commit | Would blur the before/after boundary and make rollback harder
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Treat this commit as the pre-removal checkpoint when reviewing later telemetry cleanup diffs
Tested: Not run (baseline snapshot commit requested before the next cleanup pass)
Not-tested: Runtime, build, and typecheck for the staged bridge/session changes
2026-04-09 14:09:44 +08:00
523b8c0a4a Strip dead OTel event noise from telemetry compatibility paths
The open build no longer exports OpenTelemetry events, but several user-prompt, tool, hook, API, and survey paths were still constructing and calling a no-op logOTelEvent helper. This removes those dead calls, drops the now-unused helper module, and deletes an unreferenced GrowthBook experiment event artifact so the remaining compatibility layer is less distracting during future audits.

Constraint: Keep the local logEvent and tracing compatibility surfaces untouched where they still structure control flow
Constraint: Avoid touching unrelated bridge and session changes already present in the worktree
Rejected: Remove sessionTracing compatibility entirely | Call surface is still broad and intertwined with non-telemetry control flow
Rejected: Leave no-op OTel event calls in place | They add audit noise without preserving behavior
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Continue treating remaining telemetry-named helpers as removable only when their call sites are proven behavior-free
Tested: bun test src/services/analytics/index.test.ts src/components/FeedbackSurvey/submitTranscriptShare.test.ts
Tested: bun run ./scripts/build.ts
Not-tested: bun x tsc --noEmit (repository has pre-existing unrelated type errors)
2026-04-09 14:01:41 +08:00
2264aea591 Reduce misleading telemetry shims in the open build
The open build already treated analytics and tracing as inert, but several empty sink and shutdown modules still made startup and exit paths look like they initialized or flushed telemetry. This trims those dead compatibility layers, updates the surrounding comments to match reality, and adds small regression tests that lock in the inert analytics boundary and disabled transcript sharing behavior.

Constraint: Preserve the no-op logEvent/logOTelEvent compatibility surface for existing call sites
Constraint: Avoid touching unrelated bridge and session work already in progress in the worktree
Rejected: Remove every remaining logEvent/logOTelEvent call site | Too broad for a safe first cleanup pass
Rejected: Keep the empty sink/shutdown modules | Continued to mislead future audits and maintenance
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Treat remaining analytics and GrowthBook helpers as compatibility surfaces until each call path is individually proven dead
Tested: bun test src/services/analytics/index.test.ts src/components/FeedbackSurvey/submitTranscriptShare.test.ts
Tested: bun run ./scripts/build.ts
Not-tested: bun x tsc --noEmit (repository has pre-existing unrelated type errors)
2026-04-09 13:58:03 +08:00
86e7dbd1ab Tighten bridge and teleport debug redaction 2026-04-04 11:09:07 +08:00
5149320afd Reduce remaining remote URL debug detail 2026-04-04 10:48:40 +08:00
4d506aabf7 Reduce remaining file, LSP, and XAA debug detail 2026-04-04 10:31:29 +08:00
010ded8476 Reduce remaining MCP and persistence debug detail 2026-04-04 09:52:38 +08:00
02f22d80bd Reduce local debug detail in mailbox and session helpers 2026-04-04 09:34:41 +08:00
7cf8afab73 Reduce MCP OAuth debug detail 2026-04-04 09:23:12 +08:00
832035a087 Trim teammate prompt UI and MCP debug logs 2026-04-04 09:19:52 +08:00
f06a2c2740 Remove legacy swarm permission disk sync 2026-04-04 08:49:15 +08:00
f65baebb3c Reduce swarm local persistence 2026-04-04 03:37:55 +08:00
eb96764770 Clarify inert plugin telemetry helpers 2026-04-04 03:21:59 +08:00
3e5461df9b Remove remaining trusted-device bridge hooks 2026-04-04 03:12:52 +08:00
ce8f0dfd2b Disable perfetto trace file output 2026-04-04 03:08:08 +08:00
1b4603ed3b Simplify inert analytics compatibility layer 2026-04-04 03:01:57 +08:00
dccd151718 Remove dead analytics and telemetry scaffolding 2026-04-04 02:51:35 +08:00
a95f0a540a Remove dead telemetry stubs 2026-04-04 01:12:54 +08:00
497f81f4f9 Disable analytics, GrowthBook, and telemetry egress 2026-04-03 23:17:26 +08:00
9e7338d54c Document removed egress paths in README 2026-04-03 22:50:05 +08:00
108 changed files with 3158 additions and 10812 deletions

169
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,169 @@
name: Build & Release
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g. v2.1.88)'
required: false
default: ''
permissions:
contents: write
jobs:
build:
name: Build (${{ matrix.os }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- os: linux-x64
runner: ubuntu-latest
artifact: claude-linux-x64
- os: linux-arm64
runner: ubuntu-24.04-arm
artifact: claude-linux-arm64
- os: macos-x64
runner: macos-13
artifact: claude-macos-x64
- os: macos-arm64
runner: macos-latest
artifact: claude-macos-arm64
- os: windows-x64
runner: windows-latest
artifact: claude-windows-x64.exe
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.11'
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build binary
run: bun run compile
- name: Rename binary (Unix)
if: runner.os != 'Windows'
run: |
mkdir -p release
cp dist/cli release/${{ matrix.artifact }}
chmod +x release/${{ matrix.artifact }}
- name: Rename binary (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path release
Copy-Item dist/cli.exe release/${{ matrix.artifact }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: release/${{ matrix.artifact }}
retention-days: 7
release:
name: Create GitHub Release
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: release/
- name: Flatten release directory
run: |
find release/ -type f | while read f; do
mv "$f" release/$(basename "$f")
done
find release/ -type d -empty -delete
- name: Determine release tag
id: tag
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ github.event.inputs.tag }}" ]; then
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
else
echo "tag=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT
fi
- name: Generate checksums
run: |
cd release
sha256sum claude-linux-x64 claude-linux-arm64 claude-macos-x64 claude-macos-arm64 claude-windows-x64.exe > SHA256SUMS.txt 2>/dev/null || true
cat SHA256SUMS.txt
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.tag.outputs.tag }}
name: Claude Code ${{ steps.tag.outputs.tag }}
draft: false
prerelease: false
generate_release_notes: true
body: |
## 安装说明 / Installation
### macOS (Apple Silicon)
```bash
curl -L https://github.com/${{ github.repository }}/releases/download/${{ steps.tag.outputs.tag }}/claude-macos-arm64 -o claude
chmod +x claude && sudo mv claude /usr/local/bin/claude
```
### macOS (Intel)
```bash
curl -L https://github.com/${{ github.repository }}/releases/download/${{ steps.tag.outputs.tag }}/claude-macos-x64 -o claude
chmod +x claude && sudo mv claude /usr/local/bin/claude
```
### Linux (x64)
```bash
curl -L https://github.com/${{ github.repository }}/releases/download/${{ steps.tag.outputs.tag }}/claude-linux-x64 -o claude
chmod +x claude && sudo mv claude /usr/local/bin/claude
```
### Linux (ARM64)
```bash
curl -L https://github.com/${{ github.repository }}/releases/download/${{ steps.tag.outputs.tag }}/claude-linux-arm64 -o claude
chmod +x claude && sudo mv claude /usr/local/bin/claude
```
### Windows (x64)
下载 `claude-windows-x64.exe`,将其重命名为 `claude.exe` 并添加到 PATH。
### 验证 / Verify
```bash
claude --version
```
### 隐私说明 / Privacy
本构建已移除以下外部数据传输:
- ✅ 已删除 WebFetch 域名检查(不再向 Anthropic 上报访问域名)
- ✅ 已禁用 Codex API 路由(不再将对话转发至 OpenAI chatgpt.com
- ✅ Analytics/遥测已为空存根(无实际数据发送)
- ✅ GrowthBook/Statsig 仅使用本地缓存(无远程请求)
files: |
release/claude-linux-x64
release/claude-linux-arm64
release/claude-macos-x64
release/claude-macos-arm64
release/claude-windows-x64.exe
release/SHA256SUMS.txt

View File

@@ -33,3 +33,25 @@ bun run compile
- `node_modules/`, `dist/`, and generated CLI binaries are ignored by Git.
- `bun.lock` is kept in the repository for reproducible installs.
## Local Info Egress Status
This fork has removed several local system and project metadata egress paths that existed in the recovered upstream code.
Removed in this repository:
- Model-request context injection of working directory, git status/history, `CLAUDE.md`, current date, platform, shell, and OS version.
- Feedback upload and transcript-share upload paths.
- Remote Control / Bridge registration fields that sent machine name, git branch, and git repository URL, plus git source/outcome data in bridge session creation.
- Trusted-device enrollment and trusted-device token header emission for bridge requests.
- `/insights` automatic S3 upload; reports now stay local via `file://` paths only.
- Datadog analytics and Anthropic 1P event-logging egress.
- GrowthBook remote evaluation/network fetches; local env/config overrides and cached values remain available for compatibility.
- OpenTelemetry initialization and event export paths.
- Perfetto local trace-file output paths that could persist request/tool metadata to disk.
- Extra dead telemetry scaffolding tied to the removed egress paths, including startup/session analytics fanout, logout telemetry flush, and remote GrowthBook metadata collectors.
Still present:
- Normal Claude API requests are still part of product functionality; this fork only removes extra local metadata injection, not core model/network access.
- Minimal compatibility helpers for analytics and GrowthBook still exist in the tree as local no-op or cache-only code.

View File

@@ -0,0 +1,366 @@
# `/Users/yovinchen/project/claude` 与 `/Users/yovinchen/Downloads/free-code-main` 差异分析
## 1. 分析目标
本文档用于比较当前工作区:
- `/Users/yovinchen/project/claude`
与参考项目:
- `/Users/yovinchen/Downloads/free-code-main`
重点回答三个问题:
1. 当前项目相对参考项目改了什么。
2. 哪些改动属于“恢复后为保证可运行而做的必要修复”。
3. 哪些差异仍然值得继续收敛或补做验证。
## 2. 总体结论
当前项目不是简单复制参考项目,而是一个“基于参考快照恢复后可运行化”的工作副本。
核心判断如下:
1. 工程配置层与参考项目总体高度接近。
2. 当前项目为了恢复 `bun run dev``build``compile` 能力,加入了一层运行时补丁和仓库管理文件。
3. 源码层存在较多文件差异,主要集中在 CLI 启动链路、遥测、认证、模型配置、LogoV2、Claude in Chrome、MCP/SDK 辅助代码等区域。
4. 当前项目额外引入了一批 `.js` 文件,明显属于“补齐运行时依赖/类型生成产物/兼容层”的恢复性文件。
5. 参考项目仍然保留一些当前仓库没有带入的资源文件、说明文件和脚本文件,这些不一定影响运行,但会影响“与参考仓库完全一致”的完整度。
## 3. 差异概览
### 3.1 顶层目录差异
当前项目独有的顶层内容:
- `.gitattributes`
- `docs/`
- `vendor/`
- `cli.js.map`
- `.DS_Store`
参考项目独有的顶层内容:
- `.env`
- `CLAUDE.md`
- `FEATURES.md`
- `assets/`
- `changes.md`
- `install.sh`
- `run.sh`
说明:
1. 当前项目更像“已接入 Git 管理、可持续维护”的开发仓库。
2. 参考项目更像“恢复快照 + 使用说明 + 辅助资源”的完整分发目录。
3. `assets/``CLAUDE.md``FEATURES.md``changes.md` 当前未带入,功能上未必是阻塞,但文档与资源完整度低于参考项目。
### 3.2 源码文件差异规模
通过目录级比较可见:
1. `src/` 下有约 `55` 个同名文件内容不同。
2. 参考项目在 `src/` 下没有发现当前缺失而参考独有的源码文件。
3. 当前项目反而额外多出一批源码/运行时补丁文件。
这说明当前项目的主体源码骨架已经基本补齐,但很多文件内容已经偏离参考项目,不再是“原样恢复”。
## 4. 工程配置差异
### 4.1 `package.json`
文件:
- `/Users/yovinchen/project/claude/package.json`
- `/Users/yovinchen/Downloads/free-code-main/package.json`
关键差异:
1. 包身份不同
- 当前:`name = "claude-code-recover"`
- 参考:`name = "claude-code-source-snapshot"`
2. 版本号不同
- 当前:`2.1.88`
- 参考:`2.1.87`
3. 当前项目增加了 `main: "./cli"`
4. `bin` 被精简
- 当前只保留 `claude`
- 参考同时暴露 `claude``claude-source`
5. `scripts` 被精简
- 当前保留:`build``compile``dev`
- 参考还包含:`build:dev``build:dev:full`
6. 当前 `dev` 脚本加入了 `MACRO` 注入
- 当前:通过 `bun run -d 'MACRO:...' ./src/entrypoints/cli.tsx`
- 参考:直接 `bun run ./src/entrypoints/cli.tsx`
7. 当前额外声明了依赖:
- `scheduler`
分析:
1. 这些差异不是随机漂移,而是为了让恢复后的工作区更适合直接运行。
2. `MACRO` 注入是本项目最关键的运行性修复之一,因为当前源码曾出现 `MACRO is not defined` 的实际故障。
3. 删除 `claude-source` 和精简 `scripts` 会降低与参考项目的“接口一致性”,但能让当前项目更聚焦于单一运行入口。
4. 新增 `scheduler` 很像一个恢复期补依赖动作,说明当前项目在实际运行时遇到过依赖缺失。
### 4.2 `tsconfig.json`
文件:
- `/Users/yovinchen/project/claude/tsconfig.json`
- `/Users/yovinchen/Downloads/free-code-main/tsconfig.json`
关键差异:
1. 当前项目增加了:
- `"ignoreDeprecations": "6.0"`
分析:
1. 这属于 TypeScript 版本兼容调优。
2. 它不会直接改变运行时行为,但说明当前项目更偏向“先保证开发过程稳定”。
### 4.3 构建脚本
文件:
- `/Users/yovinchen/project/claude/scripts/build.ts`
- `/Users/yovinchen/Downloads/free-code-main/scripts/build.ts`
结论:
1. 构建脚本主体保持一致。
2. 当前工程与参考项目的差异主要不在构建逻辑本身,而在于 `package.json` 对入口和开发脚本的包装方式。
## 5. 运行时恢复性差异
这一类差异是当前项目最值得单独识别的部分,因为它们明显是“为了跑起来”而不是“为了贴近参考”。
### 5.1 `MACRO` 兜底与注入
关键文件:
- `/Users/yovinchen/project/claude/src/entrypoints/cli.tsx`
- `/Users/yovinchen/project/claude/src/main.tsx`
观察到的现象:
1. 当前项目与参考项目在这两个入口文件上都存在差异。
2. 当前项目为了开发态运行,已经通过 `package.json``dev` 脚本显式注入 `MACRO`
3. 当前项目的 `src/main.tsx` 中还保留了一层 `MAIN_MACRO` 兜底逻辑,而参考项目直接使用 `MACRO.VERSION`
分析:
1. 这是非常明确的“开发态/恢复态兼容修复”。
2. 它解决的是参考项目默认依赖构建期注入、但恢复项目直接 `bun run` 时缺少注入的问题。
3. 这类修复提高了当前项目的可运行性,但也让入口行为不再完全等同于参考项目。
### 5.2 SDK 运行时补齐文件
当前项目独有文件:
- `/Users/yovinchen/project/claude/src/entrypoints/sdk/controlTypes.js`
- `/Users/yovinchen/project/claude/src/entrypoints/sdk/coreTypes.generated.js`
- `/Users/yovinchen/project/claude/src/entrypoints/sdk/runtimeTypes.js`
- `/Users/yovinchen/project/claude/src/entrypoints/sdk/settingsTypes.generated.js`
- `/Users/yovinchen/project/claude/src/entrypoints/sdk/toolTypes.js`
分析:
1. 参考项目只有对应的 `.ts` 类型/生成源码,而当前项目额外保留了 `.js` 文件。
2. 这些文件高概率是为了解决 Bun 运行时直接加载、模块解析或类型生成产物缺失的问题。
3. 它们属于典型“恢复补丁文件”。
风险:
1. 如果这些 `.js` 文件并非由统一生成流程产出,而是手工补入,那么后续源码变更后容易和 `.ts` 文件脱节。
2. 如果要长期维护,最好明确这些文件是“源码的一部分”还是“应由生成流程产出”。
### 5.3 其他当前项目独有源码
当前项目独有文件:
- `/Users/yovinchen/project/claude/src/skills/bundled/verify/SKILL.md`
- `/Users/yovinchen/project/claude/src/skills/bundled/verify/examples/cli.md`
- `/Users/yovinchen/project/claude/src/skills/bundled/verify/examples/server.md`
- `/Users/yovinchen/project/claude/src/tools/TungstenTool/TungstenLiveMonitor.js`
- `/Users/yovinchen/project/claude/src/tools/TungstenTool/TungstenTool.js`
- `/Users/yovinchen/project/claude/src/tools/WorkflowTool/constants.js`
- `/Users/yovinchen/project/claude/src/types/connectorText.js`
分析:
1. 这批文件同样更像运行时补齐或恢复期追加文件,而不是参考项目原始快照的一部分。
2. 其中 `.js` 文件的存在说明当前项目对“直接运行”做过较强适配。
3. `verify` 技能目录属于额外内置资源,偏离参考项目,但不一定是负面差异。
## 6. 同名源码文件差异分布
当前与参考项目存在内容差异的主要文件区域包括:
- `src/main.tsx`
- `src/entrypoints/cli.tsx`
- `src/entrypoints/init.ts`
- `src/commands.ts`
- `src/commands/release-notes/release-notes.ts`
- `src/commands/ultraplan.tsx`
- `src/components/ConsoleOAuthFlow.tsx`
- `src/components/LogoV2/*`
- `src/components/StructuredDiff/colorDiff.ts`
- `src/constants/*`
- `src/hooks/useApiKeyVerification.ts`
- `src/screens/REPL.tsx`
- `src/services/analytics/*`
- `src/services/api/client.ts`
- `src/services/mcp/client.ts`
- `src/services/oauth/*`
- `src/services/voice.ts`
- `src/skills/bundled/claudeInChrome.ts`
- `src/skills/bundled/verifyContent.ts`
- `src/utils/auth.ts`
- `src/utils/claudeInChrome/*`
- `src/utils/config.ts`
- `src/utils/logoV2Utils.ts`
- `src/utils/model/*`
- `src/utils/modifiers.ts`
- `src/utils/releaseNotes.ts`
- `src/utils/ripgrep.ts`
- `src/utils/telemetry/*`
- `src/utils/theme.ts`
分析:
1. 差异覆盖面很广,不像单点修复,更像恢复过程中发生过多轮替换、补抄和本地修订。
2. 受影响的区域里很多都属于“用户可感知行为”或“外部集成逻辑”比如认证、OAuth、模型选择、遥测、CLI 启动参数、UI 展示。
3. 这意味着当前项目虽然已经可运行,但和参考项目在行为层面未必完全一致。
## 7. 文档、资源和仓库管理层差异
### 7.1 当前项目新增的仓库管理能力
当前项目比参考项目多出:
- `.gitattributes`
- 更严格的 `.gitignore`
- `docs/`
其中当前 `.gitignore` 比参考项目更偏向真实开发仓库,额外忽略了:
- `.DS_Store`
- `.idea/`
- `.claude/`
- `cli.js.map`
- `*.log`
分析:
1. 当前项目已经从“快照目录”转向“可持续维护仓库”。
2. 这是正向改动,但它说明当前项目的目标已经不只是还原参考仓库。
### 7.2 当前缺失的参考项目文档与资源
参考项目存在、当前项目没有纳入的内容:
- `/Users/yovinchen/Downloads/free-code-main/CLAUDE.md`
- `/Users/yovinchen/Downloads/free-code-main/FEATURES.md`
- `/Users/yovinchen/Downloads/free-code-main/changes.md`
- `/Users/yovinchen/Downloads/free-code-main/assets/`
- `/Users/yovinchen/Downloads/free-code-main/install.sh`
- `/Users/yovinchen/Downloads/free-code-main/run.sh`
分析:
1. 当前项目缺的更多是“说明性与辅助性内容”,而不是主干源码。
2. 如果目标是“恢复可运行 CLI”这些缺失不是第一优先级。
3. 如果目标是“尽量贴近参考项目完整交付物”,这些内容应该补回或至少评估是否要保留。
## 8. 差异定性判断
### 8.1 明显合理的差异
这部分差异大概率是正确且有价值的:
1. `package.json``dev` 脚本注入 `MACRO`
2. `tsconfig.json` 增加 `ignoreDeprecations`
3. 增加 `.gitignore``.gitattributes``docs/`
4. 将当前仓库定位为可维护的 Git 项目
### 8.2 明显属于恢复补丁的差异
这部分差异很可能是为了跑起来而做的临时或兼容性补丁:
1. `src/main.tsx``MAIN_MACRO` 兜底
2. `src/entrypoints/sdk/*.js`
3. `src/tools/TungstenTool/*.js`
4. `src/tools/WorkflowTool/constants.js`
5. `src/types/connectorText.js`
6. `scheduler` 依赖补入
### 8.3 需要继续验证的差异
这部分差异可能带来行为偏移,建议后续重点回归:
1. `src/main.tsx`
2. `src/entrypoints/cli.tsx`
3. `src/services/oauth/*`
4. `src/services/api/client.ts`
5. `src/services/mcp/client.ts`
6. `src/utils/model/*`
7. `src/services/analytics/*`
8. `src/components/LogoV2/*`
9. `src/commands.ts``src/commands/ultraplan.tsx`
原因:
1. 这些区域要么直接影响 CLI 主流程,要么影响鉴权/模型/遥测/展示逻辑。
2. 即使项目现在能跑,也不代表与参考项目完全同构。
## 9. 建议的后续动作
### 9.1 如果目标是“继续可用优先”
建议:
1. 保留当前 `MACRO` 注入方案。
2. 继续把 `.js` 补丁文件当作运行时兼容层管理。
3. 用当前仓库作为主维护仓库,不强求逐字对齐参考项目。
### 9.2 如果目标是“尽量收敛到参考项目”
建议:
1. 逐步审计 `src/main.tsx``src/entrypoints/cli.tsx``package.json`
2. 确认 `src/entrypoints/sdk/*.js` 等补丁文件是否可以通过生成流程替代。
3. 评估是否恢复 `claude-source``build:dev``build:dev:full`
4. 视需求补回 `assets/``CLAUDE.md``FEATURES.md``changes.md``install.sh``run.sh`
### 9.3 如果目标是“做正式恢复基线”
建议:
1. 把当前差异分成:
- `必要修复`
- `兼容补丁`
- `尚未验证的行为偏移`
2. 为主链路建立最少一轮验证:
- `bun run dev -- --help`
- `bun run dev -- --version`
- `bun run build`
- `bun run compile`
3. 针对鉴权、模型选择、OAuth、MCP 连接、遥测开关做专项回归。
## 10. 最终结论
当前项目已经不是参考项目的简单副本,而是一个“参考快照基础上恢复成功、可直接运行、带本地修补层”的工程化版本。
可以用一句话概括:
`/Users/yovinchen/project/claude` 的主要价值在于“已经能跑并且适合继续维护”,而 `/Users/yovinchen/Downloads/free-code-main` 的主要价值在于“作为参考基线和资源来源”。
如果下一步要继续治理代码,最合理的策略不是盲目回滚当前差异,而是先把差异分类,再决定哪些保留、哪些收敛、哪些补测试。

View File

@@ -0,0 +1,423 @@
# `free-code-main` 本地系统信息外发移除实现报告
- 分析时间: 2026-04-03
- 对照文档: `docs/local-system-info-egress-audit.md`
- 分析对象: `/Users/yovinchen/Downloads/free-code-main`
- 对照基线: `/Users/yovinchen/project/claude`
- 分析方式: 静态代码审计 + 关键链路比对 + 同名文件差异核查
- 说明: 本报告只基于源码静态分析,不包含运行时抓包或服务端验证。
## 结论摘要
结论是: **`free-code-main` 只“部分移除”了审计文档里的本地系统信息外发链路。**
更准确地说,它做的是:
1. **把 telemetry / analytics / OTel 相关外发出口失活了**
- Datadog
- Anthropic 1P event logging
- OTel 事件与 metrics/tracing 初始化
- GrowthBook 远程评估链路也被间接短路
2. **但没有把“所有本地信息外发”都移除**
- 模型请求里的环境/项目上下文注入仍在
- Feedback 上传仍在
- Transcript Share 仍在
- Remote Control / Bridge 上传 `hostname`、目录、分支、git remote URL 的链路仍在
- Trusted Device 注册仍在
- `/insights` 的 ant-only 上传逻辑仍在
3. **移除方式不是“彻底删代码”,而是“保留兼容接口 + 启动链路短路 + sink/no-op stub 化”**
- 这意味着仓库里仍然保留了不少采集/导出代码。
- 但默认运行时,关键出口函数已经被改成空实现,导致这些链路无法真正发出请求。
因此,如果问题是:
> `free-code-main` 是否已经把 `docs/local-system-info-egress-audit.md` 中描述的“本地系统信息外发”整体移除?
答案是:
**没有整体移除,只移除了其中“遥测/观测”这一类外发;产品主链路里的上下文外发和若干用户触发上传链路仍然存在。**
## 对照矩阵
| 审计项 | `free-code-main` 状态 | 结论 |
| --- | --- | --- |
| F1 模型请求 system prompt / user context | 未移除 | 默认仍会把 cwd、git 状态、CLAUDE.md、日期以及 prompts 里的平台/壳层/OS 版本注入到模型请求 |
| F2 Datadog analytics | 已移除 | Datadog 初始化与上报函数被 stub 成 no-op |
| F3 Anthropic 1P event logging | 已移除 | 1P logger 整体改为空实现,启用判断恒为 `false` |
| F4 GrowthBook remote eval | 实际已失活 | 依赖 `is1PEventLoggingEnabled()`,而 1P 已被硬关,默认不会创建 GrowthBook client |
| F5 Feedback | 未移除 | 用户触发后仍会 POST 到 `claude_cli_feedback` |
| F6 Transcript Share | 未移除 | 用户触发后仍会 POST 到 `claude_code_shared_session_transcripts` |
| F7 Remote Control / Bridge | 未移除 | 仍会采集并上送 `hostname`、目录、分支、git remote URL |
| F8 Trusted Device | 未移除 | 仍会注册 `Claude Code on <hostname> · <platform>` |
| F9 OpenTelemetry | 已移除 | telemetry 初始化与 `logOTelEvent()` 都被改成 no-op |
| F10 `/insights` 内部上传 | 未移除 | ant-only S3 上传逻辑仍保留 |
## 关键判断
这次比对里最重要的判断有两个:
1. **`README.md` 里的 “Telemetry removed” 只覆盖了“遥测/观测”语义,不等于“所有本地信息外发已删除”。**
2. **`free-code-main` 的移除策略主要是“切断出口”,而不是“删除所有采集代码”。**
这也是为什么你会看到:
- `src/services/analytics/metadata.ts` 这类环境信息构造代码还在
- `src/utils/api.ts` 里上下文统计代码还在
- `src/services/analytics/firstPartyEventLoggingExporter.ts``src/utils/telemetry/bigqueryExporter.ts` 这类导出器文件也还在
但是:
- 事件 sink
- telemetry bootstrap
- OTel event logging
- Datadog / 1P logger 初始化
都已经被改成空实现或被前置条件短路掉了。
## 已移除部分: 实现方式分析
### 1. Analytics 公共入口被改成 compatibility boundary + no-op
`/Users/yovinchen/Downloads/free-code-main/src/services/analytics/index.ts:4-40` 明确写到:
- “open build intentionally ships without product telemetry”
- 保留模块只是为了不改动现有调用点
- `attachAnalyticsSink()``logEvent()``logEventAsync()` 都是空实现
这意味着:
- 各业务模块里仍然可以继续 `import { logEvent }`
- 但这些调用不会再入队、不会再挂 sink、也不会再向任何后端发送
对照 `/Users/yovinchen/project/claude/src/services/analytics/index.ts`,当前工作区版本还保留:
- 事件队列
- `attachAnalyticsSink()` 的真实绑定
- `logEvent()` / `logEventAsync()` 的真实分发
所以这里是非常明确的“出口 stub 化”。
### 2. Datadog 被直接 stub 掉
`/Users/yovinchen/Downloads/free-code-main/src/services/analytics/datadog.ts:1-12` 中:
- `initializeDatadog()` 直接返回 `false`
- `shutdownDatadog()` 空实现
- `trackDatadogEvent()` 空实现
而对照 `/Users/yovinchen/project/claude/src/services/analytics/datadog.ts:12-140`,基线版本仍然保留:
- Datadog endpoint
- 批量缓冲
- `axios.post(...)`
因此 F2 可以判定为**已移除**。
### 3. 1P event logging 被整体空实现
`/Users/yovinchen/Downloads/free-code-main/src/services/analytics/firstPartyEventLogger.ts:1-48` 中:
- `is1PEventLoggingEnabled()` 恒为 `false`
- `logEventTo1P()` 空实现
- `initialize1PEventLogging()` 空实现
- `reinitialize1PEventLoggingIfConfigChanged()` 空实现
这和基线 `/Users/yovinchen/project/claude/src/services/analytics/firstPartyEventLogger.ts:141-220` 中真实存在的:
- `getEventMetadata(...)`
- `getCoreUserData(true)`
- OTel logger emit
形成了直接对照。
需要注意的是:
- `src/services/analytics/firstPartyEventLoggingExporter.ts` 文件仍然存在
- 里面仍保留 `/api/event_logging/batch` 的完整实现
但由于 logger 初始化入口已经空了,这个 exporter 在默认路径上已经不会被接上。
因此 F3 的移除方式属于:
**保留 exporter 源码,但把“上游 logger/provider 初始化”整体切断。**
### 4. Analytics sink 初始化被清空,启动调用点保留
`/Users/yovinchen/Downloads/free-code-main/src/services/analytics/sink.ts:1-10` 中:
- `initializeAnalyticsGates()` 空实现
- `initializeAnalyticsSink()` 空实现
但启动链路并没有删调用点:
- `/Users/yovinchen/Downloads/free-code-main/src/main.tsx:83-86,416-417` 仍然 import 并调用 `initializeAnalyticsGates()`
- `/Users/yovinchen/Downloads/free-code-main/src/setup.ts:371` 仍然调用 `initSinks()`
这说明作者的思路不是“到处改业务调用点”,而是:
**保留启动顺序与依赖图,统一在 sink 层面把行为变空。**
### 5. OTel 初始化被显式短路
`/Users/yovinchen/Downloads/free-code-main/src/entrypoints/init.ts:207-212` 直接把:
- `initializeTelemetryAfterTrust()`
改成了立即 `return`
同时:
- `/Users/yovinchen/Downloads/free-code-main/src/utils/telemetry/instrumentation.ts:1-24`
- `bootstrapTelemetry()` 空实现
- `isTelemetryEnabled()` 恒为 `false`
- `initializeTelemetry()` 返回 `null`
- `flushTelemetry()` 空实现
- `/Users/yovinchen/Downloads/free-code-main/src/utils/telemetry/events.ts:1-12`
- `logOTelEvent()` 空实现
- 用户 prompt 内容默认只会被 `redactIfDisabled()` 处理成 `<REDACTED>`
而调用点仍保留:
- `/Users/yovinchen/Downloads/free-code-main/src/main.tsx:2595-2597` 仍会调用 `initializeTelemetryAfterTrust()`
- 多个业务模块仍会调用 `logOTelEvent(...)`
所以 F9 的移除方式也是:
**不删调用点,只把 telemetry bootstrap 和 event emit 统一改成 no-op。**
### 6. GrowthBook 不是“彻底删文件”,而是被前置条件短路
`/Users/yovinchen/Downloads/free-code-main/src/services/analytics/growthbook.ts:420-425`:
- `isGrowthBookEnabled()` 直接返回 `is1PEventLoggingEnabled()`
而 1P 在 `firstPartyEventLogger.ts:26-27` 中已经被硬编码为 `false`
继续往下看:
- `growthbook.ts:490-493` 在 client 创建前就会因为 `!isGrowthBookEnabled()` 返回 `null`
- `growthbook.ts:685-691``748-750` 会在取 feature value 时直接返回默认值
这意味着从当前源码推断:
- 默认路径不会创建 GrowthBook client
- 默认路径不会执行 remote eval 网络请求
- 默认路径不会把 `deviceID/sessionId/platform/org/email` 发出去
所以 F4 应该判定为:
**远程评估外发链路实际上已失活。**
这里有一个值得单独记录的点:
- `README.md:58-64` 写的是 “GrowthBook feature flag evaluation still works locally but does not report back”
- 但从当前代码看,更准确的说法应该是:
- **默认的远程评估链路已经被短路**
- 留下的是兼容性结构和本地 override/cache 框架
这条判断是**基于源码的推断**。
### 7. 本地采集代码仍有残留,但最终不会出网
这部分很关键,容易误判。
`free-code-main` 不是把所有采集逻辑都删掉了。典型例子:
- `/Users/yovinchen/Downloads/free-code-main/src/services/analytics/metadata.ts:574-740`
- 仍会构造 `platform``arch``nodeVersion``terminal`、Linux distro、`process.memoryUsage()``process.cpuUsage()`、repo remote hash 等元数据
- `/Users/yovinchen/Downloads/free-code-main/src/utils/api.ts:479-562`
- 仍会收集 `gitStatusSize``claudeMdSize`、项目文件数、MCP tool 数量
- 最后仍调用 `logEvent('tengu_context_size', ...)`
- `/Users/yovinchen/Downloads/free-code-main/src/main.tsx:2521-2522`
- 启动时仍会执行 `logContextMetrics(...)`
但由于 `src/services/analytics/index.ts:28-38``logEvent()` 已经是空实现,这些数据虽然可能仍在本地被计算,但不会从该链路继续发出。
所以更准确的评价是:
**移除的是 egress不是所有 collection 语句。**
## 未移除部分: 逐项核对
### F1. 默认模型请求上下文外发未移除
这部分在 `free-code-main` 里仍然存在,而且关键文件与基线高度一致。
直接证据:
- `/Users/yovinchen/Downloads/free-code-main/src/constants/prompts.ts:606-648`
- `computeEnvInfo()` 仍拼接:
- `Working directory`
- `Is directory a git repo`
- `Platform`
- `Shell`
- `OS Version`
- `/Users/yovinchen/Downloads/free-code-main/src/constants/prompts.ts:651-709`
- `computeSimpleEnvInfo()` 仍拼接:
- `Primary working directory`
- `Platform`
- `Shell`
- `OS Version`
- `/Users/yovinchen/Downloads/free-code-main/src/context.ts:36-109`
- `getGitStatus()` 仍读取:
- 当前分支
- 默认分支
- `git status --short`
- 最近 5 条提交
- `git config user.name`
- `/Users/yovinchen/Downloads/free-code-main/src/context.ts:116-149`
- `getSystemContext()` 仍把 `gitStatus` 放入上下文
- `/Users/yovinchen/Downloads/free-code-main/src/context.ts:155-187`
- `getUserContext()` 仍把 `CLAUDE.md` 内容和日期放入上下文
- `/Users/yovinchen/Downloads/free-code-main/src/utils/api.ts:437-474`
- `appendSystemContext()` / `prependUserContext()` 仍会把这些内容拼进消息
- `/Users/yovinchen/Downloads/free-code-main/src/query.ts:449-451,659-661`
- 查询时仍将这些上下文交给模型调用
- `/Users/yovinchen/Downloads/free-code-main/src/services/api/claude.ts:1822-1832`
- 最终仍通过 `anthropic.beta.messages.create(...)` 发送
补充比对:
- `src/constants/prompts.ts`
- `src/context.ts`
- `src/utils/api.ts`
- `src/query.ts`
与基线仓库对应文件比对时,未看到针对这条链路的“移除性改造”。
因此 F1 在 `free-code-main` 中**没有被移除**。
### F5. Feedback 上传未移除
`/Users/yovinchen/Downloads/free-code-main/src/components/Feedback.tsx:523-550` 仍会在用户触发时:
- 刷新 OAuth
- 取 auth headers
- POST 到 `https://api.anthropic.com/api/claude_cli_feedback`
这个文件与基线对应文件比对无差异。
因此 F5 **未移除**
### F6. Transcript Share 上传未移除
`/Users/yovinchen/Downloads/free-code-main/src/components/FeedbackSurvey/submitTranscriptShare.ts:37-94` 仍会收集:
- `platform`
- `transcript`
- `subagentTranscripts`
- `rawTranscriptJsonl`
并 POST 到:
- `https://api.anthropic.com/api/claude_code_shared_session_transcripts`
这个文件与基线对应文件比对无差异。
因此 F6 **未移除**
### F7. Remote Control / Bridge 未移除
`/Users/yovinchen/Downloads/free-code-main/src/bridge/bridgeMain.ts:2340-2435` 仍会采集:
- `branch`
- `gitRepoUrl`
- `machineName = hostname()`
- `dir`
随后:
- `/Users/yovinchen/Downloads/free-code-main/src/bridge/bridgeApi.ts:142-178`
仍会把这些字段 POST 到:
- `/v1/environments/bridge`
上传体中明确包含:
- `machine_name`
- `directory`
- `branch`
- `git_repo_url`
`src/bridge/bridgeApi.ts` 与基线对应文件比对无差异。
因此 F7 **未移除**
### F8. Trusted Device 未移除
`/Users/yovinchen/Downloads/free-code-main/src/bridge/trustedDevice.ts:142-159` 仍会向:
- `${baseUrl}/api/auth/trusted_devices`
提交:
- `display_name: Claude Code on ${hostname()} · ${process.platform}`
这条链路虽然会受 `isEssentialTrafficOnly()` 影响,但代码并未被删除。
`src/bridge/trustedDevice.ts` 与基线对应文件比对无差异。
因此 F8 **未移除**
### F10. `/insights` ant-only 上传未移除
`/Users/yovinchen/Downloads/free-code-main/src/commands/insights.ts:3075-3098` 仍保留:
- `process.env.USER_TYPE === 'ant'` 分支
- 使用 `ff cp` 上传 HTML report 到 S3
这条链路不是默认外部版路径,但它在源码里仍然存在。
因此 F10 **未移除**
## 与基线仓库的“未改动区域”总结
以下文件经对比未看到差异,说明 `free-code-main` 没有在这些链路上做“移除”改造:
- `src/constants/prompts.ts`
- `src/context.ts`
- `src/utils/api.ts`
- `src/query.ts`
- `src/components/Feedback.tsx`
- `src/components/FeedbackSurvey/submitTranscriptShare.ts`
- `src/bridge/bridgeApi.ts`
- `src/bridge/trustedDevice.ts`
- `src/commands/insights.ts`
这也是为什么报告结论是“部分移除”,而不是“整体移除”。
## 最终结论
如果把 `docs/local-system-info-egress-audit.md` 中的链路拆开看,`free-code-main` 的状态可以总结为:
1. **遥测类默认外发**
- Datadog: 已移除
- 1P event logging: 已移除
- OTel: 已移除
- GrowthBook remote eval: 默认已失活
2. **产品主链路或用户触发上传**
- 模型 system/user context 外发: 未移除
- Feedback: 未移除
- Transcript Share: 未移除
- Remote Control / Bridge: 未移除
- Trusted Device: 未移除
- `/insights` ant-only 上传: 未移除
因此,`free-code-main` 的真实定位更适合表述为:
**它移除了“遥测/观测型外发实现”,但没有移除“产品功能本身依赖的本地信息外发”。**
如果后续目标是做“彻底版本地信息外发移除”,还需要继续处理至少这些区域:
- `src/constants/prompts.ts`
- `src/context.ts`
- `src/utils/api.ts`
- `src/components/Feedback.tsx`
- `src/components/FeedbackSurvey/submitTranscriptShare.ts`
- `src/bridge/*`
- `src/commands/insights.ts`

View File

@@ -0,0 +1,430 @@
# 本地系统信息外发审计报告
- 审计时间: 2026-04-03
- 审计对象: `/Users/yovinchen/project/claude`
- 审计方式: 静态代码扫描 + 关键数据流人工追踪
- 说明: 本报告基于源码静态分析得出,未做运行时抓包或服务端行为验证。
## 结论摘要
结论是: **存在“采集本地/环境信息并向外发送”的代码路径,而且其中一部分是默认链路。**
我把风险按类型拆开后,结论如下:
1. **默认会发生的外发**
- 模型请求链路会把本地环境信息放进 system prompt / meta message 后发送给 Claude API。
- analytics/telemetry 链路会把平台、架构、Node 版本、终端、运行时、Linux 发行版、进程内存/CPU 指标等发送到 Datadog 和 Anthropic 1P 事件日志接口。
2. **用户显式触发后才会发生的外发**
- Feedback / Transcript Share 会上传 transcript、平台信息、错误信息、最近 API 请求等。
- Remote Control / Bridge 会上传 `hostname`、本地目录、git 分支、git remote URL。
- Trusted Device 注册会上传 `hostname + platform` 组成的设备显示名。
- 可选 OpenTelemetry 在启用后会把 `user.id``session.id``organization.id``user.email``terminal.type` 等发往配置的 OTLP endpoint。
3. **目前未发现的自动采集项**
- 未发现自动读取并外发 MAC 地址、网卡列表、IP 地址、`/etc/machine-id`、BIOS/主板序列号、硬件 UUID、`dmidecode``ioreg``system_profiler` 之类更敏感的硬件唯一标识。
4. **额外重要发现**
- 这套代码不仅会外发“系统信息”,还会外发一部分“项目上下文”。
- 典型例子包括: 当前工作目录、是否 git 仓库、当前分支、main 分支、git user.name、`git status --short`、最近 5 条提交、`CLAUDE.md` 内容、当前日期。
## 审计方法
本次审计主要做了两件事:
1. 搜索本地系统/环境信息采集点。
- 关键词包括 `os.*``process.platform``process.arch``process.env``hostname()``userInfo()``/etc/os-release``uname``git status``getCwd()` 等。
2. 搜索外发点并做数据流关联。
- 关键词包括 `axios.post``fetch``WebSocket``anthropic.beta.messages.create``Datadog``event_logging``trusted_devices``/v1/environments/bridge``/v1/sessions` 等。
## 发现清单
| 编号 | 链路 | 是否默认 | 外发内容 | 目标位置 | 结论 |
| --- | --- | --- | --- | --- | --- |
| F1 | 模型请求 system prompt / user context | 是 | cwd、平台、shell、OS 版本、git 状态、git 用户、最近提交、`CLAUDE.md`、日期 | Claude API | 已确认 |
| F2 | Datadog analytics | 是 | 平台、架构、Node 版本、终端、运行时、Linux 发行版/内核、进程 CPU/内存、repo remote hash | Datadog | 已确认 |
| F3 | Anthropic 1P event logging | 是 | 与 F2 类似,外加 user/account/org 元数据与 process blob | `https://api.anthropic.com/api/event_logging/batch` | 已确认 |
| F4 | GrowthBook remote eval | 大概率是 | deviceId、sessionId、platform、org/account、email、版本、GitHub Actions 元数据 | `https://api.anthropic.com/` 上的 GrowthBook 接口 | **推断成立概率高** |
| F5 | Feedback | 否,用户触发 | platform、terminal、是否 git、transcript、raw transcript、errors、lastApiRequest | `https://api.anthropic.com/api/claude_cli_feedback` | 已确认 |
| F6 | Transcript Share | 否,用户触发 | platform、transcript、subagent transcripts、raw transcript JSONL | `https://api.anthropic.com/api/claude_code_shared_session_transcripts` | 已确认 |
| F7 | Remote Control / Bridge | 否,功能触发 | hostname、directory、branch、git_repo_url、session context | `/v1/environments/bridge``/v1/sessions` | 已确认 |
| F8 | Trusted Device | 否,登录/设备注册 | `Claude Code on <hostname> · <platform>` | `/api/auth/trusted_devices` | 已确认 |
| F9 | OpenTelemetry | 否,需启用 | user/session/account/email/terminal + OTEL 检测到的 OS/host arch | 配置的 OTLP endpoint | 已确认 |
| F10 | `/insights` 内部上传 | 非外部版默认不可用 | username、报告文件 | S3 | 已确认,且 `ant-only` |
## 详细分析
### F1. 默认模型请求链路会外发本地环境和项目上下文
证据链如下:
1. `src/constants/prompts.ts:606-648``computeEnvInfo()` 会构造环境块,包含:
- `Working directory`
- `Is directory a git repo`
- `Platform`
- `Shell`
- `OS Version`
2. `src/constants/prompts.ts:651-709``computeSimpleEnvInfo()` 也会构造同类信息,且包含 `Primary working directory`
3. `src/context.ts:36-103``getGitStatus()` 会进一步读取:
- 当前分支
- main 分支
- `git config user.name`
- `git status --short`
- 最近 5 条提交
4. `src/context.ts:116-149``getSystemContext()` 会把 `gitStatus` 注入系统上下文。
5. `src/context.ts:155-187``getUserContext()` 会把 `CLAUDE.md` 内容和当前日期放入用户上下文。
6. `src/utils/api.ts:437-446``appendSystemContext()` 会把 `systemContext` 拼到 system prompt。
7. `src/utils/api.ts:449-470``prependUserContext()` 会把 `userContext` 作为 `<system-reminder>` 前置到消息里。
8. `src/query.ts:449-450``src/query.ts:659-661` 把这两部分上下文真正交给模型调用。
9. `src/services/api/claude.ts:3213-3236` 会把 `systemPrompt` 序列化为 API 文本块,`src/services/api/claude.ts:1822-1832` 通过 `anthropic.beta.messages.create(...)` 发出请求。
结论:
- **这是默认链路**,不是用户额外点击“上传”后才发生。
- 外发的不只是主机 OS 信息,还包括当前项目目录和 git 元信息。
- 从数据敏感性看,`cwd``git user.name`、最近提交标题、`CLAUDE.md` 都可能包含组织或项目标识。
### F2. 默认 Datadog analytics 会外发环境与进程指标
证据链如下:
1. `src/main.tsx:416-430` 会在启动早期初始化用户/上下文/analytics gate。
2. `src/main.tsx:943-946` 会初始化 sinks从而启用 analytics sink。
3. `src/services/analytics/metadata.ts:417-467` 定义了要采集的 `EnvContext``ProcessMetrics` 字段。
4. `src/services/analytics/metadata.ts:574-637` 实际构造环境信息,包含:
- `platform` / `platformRaw`
- `arch`
- `nodeVersion`
- `terminal`
- `packageManagers`
- `runtimes`
- `isCi`
- `isClaudeCodeRemote`
- `remoteEnvironmentType`
- `containerId`
- `github actions` 相关字段
- `wslVersion`
- `linuxDistroId`
- `linuxDistroVersion`
- `linuxKernel`
- `vcs`
5. `src/services/analytics/metadata.ts:648-678` 采集进程指标,包含:
- `uptime`
- `rss`
- `heapTotal`
- `heapUsed`
- `external`
- `arrayBuffers`
- `constrainedMemory`
- `cpuUsage`
- `cpuPercent`
6. `src/services/analytics/metadata.ts:701-739` 会把这些信息合并进每个 analytics event并附加 `rh`
7. `src/utils/git.ts:329-337` 表明 `rh`**git remote URL 的 SHA256 前 16 位哈希**,不是明文 remote URL。
8. `src/services/analytics/datadog.ts:12-13` 指向 Datadog endpoint`src/services/analytics/datadog.ts:108-115` 通过 `axios.post(...)` 发送。
结论:
- **Datadog 默认是活跃链路**,除非被隐私设置或 provider 条件关闭。
- 这条链路没有看到把 `cwd`、源码正文、文件路径直接送去 Datadog它主要发送环境维度与运行指标。
- repo remote 不是明文发出,而是哈希值。
### F3. 默认 Anthropic 1P event logging 也会外发环境与身份元数据
证据链如下:
1. `src/services/analytics/firstPartyEventLogger.ts:141-177` 表明 1P event logging 默认启用时,会把 `core_metadata``user_metadata``event_metadata` 一起记录。
2. `src/services/analytics/firstPartyEventLoggingExporter.ts:114-120` 指定 1P 上报 endpoint 为:
- `https://api.anthropic.com/api/event_logging/batch`
- 或 staging 对应路径
3. `src/services/analytics/firstPartyEventLoggingExporter.ts:587-609` 表明最终通过 `axios.post(this.endpoint, payload, ...)` 发送。
4. `src/services/analytics/metadata.ts:796-970` 表明在 1P 格式化阶段,以下字段会进入上报内容:
- `platform/platform_raw`
- `arch`
- `node_version`
- `terminal`
- `package_managers`
- `runtimes`
- `is_ci`
- `is_github_action`
- `linux_distro_id`
- `linux_distro_version`
- `linux_kernel`
- `vcs`
- `process` base64 blob
- `account_uuid`
- `organization_uuid`
- `session_id`
- `client_type`
结论:
- **这也是默认链路**。
- 与 Datadog 相比1P event logging 能接收更完整的内部结构化元数据。
### F4. GrowthBook 很可能会把本地/身份属性发到远端做特性分流
证据链如下:
1. `src/services/analytics/growthbook.ts:454-484` 构造了 `attributes`,包含:
- `id` / `deviceID`
- `sessionId`
- `platform`
- `apiBaseUrlHost`
- `organizationUUID`
- `accountUUID`
- `userType`
- `subscriptionType`
- `rateLimitTier`
- `firstTokenTime`
- `email`
- `appVersion`
- `githubActionsMetadata`
2. `src/services/analytics/growthbook.ts:526-536` 使用:
- `apiHost`
- `attributes`
- `remoteEval: true`
创建 `GrowthBook` client。
判断:
- 由于真正的 HTTP 逻辑在第三方库内部,不在本仓库源码里直接展开,所以这里我不能把“已确认发送”说死。
- 但从 `attributes + apiHost + remoteEval: true` 的组合看,**高概率**存在把这些属性发送到 GrowthBook 后端做远程特性评估的行为。
- 这一条应标记为 **推断**,但可信度较高。
### F5. Feedback 会在用户触发时上传平台、转录、错误和最近请求
证据链如下:
1. `src/components/Feedback.tsx:54-68``FeedbackData` 定义包含:
- `platform`
- `gitRepo`
- `version`
- `transcript`
- `rawTranscriptJsonl`
2. `src/components/Feedback.tsx:206-224` 实际组装 `reportData` 时还加入:
- `terminal`
- `errors`
- `lastApiRequest`
- `subagentTranscripts`
3. `src/components/Feedback.tsx:543-550` 发送到 `https://api.anthropic.com/api/claude_cli_feedback`
结论:
- 这是 **用户显式触发** 的上传,不属于静默默认遥测。
- 但数据面比普通 analytics 大得多,包含对话转录和最近 API 请求内容。
### F6. Transcript Share 会在用户触发时上传 transcript 和平台
证据链如下:
1. `src/components/FeedbackSurvey/submitTranscriptShare.ts:37-70` 采集:
- `platform`
- `transcript`
- `subagentTranscripts`
- `rawTranscriptJsonl`
2. `src/components/FeedbackSurvey/submitTranscriptShare.ts:87-94` 发送到 `https://api.anthropic.com/api/claude_code_shared_session_transcripts`
结论:
- 这是 **显式分享链路**
- 风险面和 Feedback 类似,重点在 transcript 内容,而不是系统信息本身。
### F7. Remote Control / Bridge 会上传 hostname、目录、分支、git remote URL
证据链如下:
1. `src/bridge/bridgeMain.ts:2340-2452``src/bridge/bridgeMain.ts:2874-2909` 都会在 bridge 启动时读取:
- `branch`
- `gitRepoUrl`
- `machineName = hostname()`
- `dir`
2. `src/bridge/initReplBridge.ts:463-505` 也会把 `hostname()`、branch、gitRepoUrl 传入 bridge core。
3. `src/bridge/bridgeApi.ts:142-183` 注册环境时 POST 到 `/v1/environments/bridge`,字段包括:
- `machine_name`
- `directory`
- `branch`
- `git_repo_url`
- `max_sessions`
- `worker_type`
4. `src/bridge/createSession.ts:77-136` 创建 session 时还会把 git 仓库上下文放进 `session_context`,包括:
- 规范化后的 repo URL
- revision / branch
- owner/repo
- model
结论:
- 这是 **功能型外发**,不是无条件默认发生。
- 但一旦启用 Remote Control它会把本地主机名和项目标识信息发送出去。
### F8. Trusted Device 会上传 hostname + platform
证据链如下:
1. `src/bridge/trustedDevice.ts:145-159` 会向 `${baseUrl}/api/auth/trusted_devices` 发送:
- `display_name: "Claude Code on <hostname> · <platform>"`
结论:
- 这是 **登录/设备注册链路**,不是普通对话请求。
- 这里出现了明确的 `hostname()` 外发。
### F9. OpenTelemetry 是可选链路,但一旦启用也会对外发送本地属性
证据链如下:
1. `src/utils/telemetry/instrumentation.ts:324-325` 表明只有 `CLAUDE_CODE_ENABLE_TELEMETRY=1` 时才启用。
2. `src/utils/telemetry/instrumentation.ts:458-510` 会组装 OTEL resource包含:
- service/version
- WSL version
- OS detector 结果
- host arch detector 结果
- env detector 结果
3. `src/utils/telemetry/instrumentation.ts:575-607` 会初始化 log exporter 并对外发送。
4. `src/utils/telemetryAttributes.ts:29-68` 还会加入:
- `user.id`
- `session.id`
- `app.version`
- `organization.id`
- `user.email`
- `user.account_uuid`
- `user.account_id`
- `terminal.type`
结论:
- 这是 **可选链路**,默认不是强制开启。
- 但如果启用并配置了 OTLP endpoint确实会把本地身份/终端/会话属性发到外部。
### F10. `/insights` 还存在内部版上传链路
证据链如下:
1. `src/commands/insights.ts:2721-2736` 报告元数据包含:
- `username`
- 生成时间
- 版本
- 远程 homespace 信息
2. `src/commands/insights.ts:3075-3098` 会在 `process.env.USER_TYPE === 'ant'` 时尝试上传 HTML 报告到 S3。
结论:
- 这是 **内部版 ant-only** 逻辑,不应算外部公开版本默认行为。
- 但从源码角度,确实存在上传用户名和报告的链路。
## 未发现项
本次静态审计中,**没有发现**以下类型的自动采集/外发实现:
- `os.networkInterfaces()`
- `os.userInfo()` 用于遥测/外发
- `/etc/machine-id`
- `node-machine-id`
- `dmidecode`
- `ioreg`
- `system_profiler`
- `wmic bios`
- `getmac`
- `ifconfig` / `ip addr` / `ipconfig /all` 被程序主动执行用于遥测
- MAC 地址、IP 地址、硬件序列号、主板 UUID、BIOS UUID 等硬件唯一标识
补充说明:
- 搜到的 `ip addr``ipconfig``hostname` 主要出现在 Bash/PowerShell 工具的只读命令校验规则里,不是程序自身自动采集再上报。
- `hostname()` 的真实外发点主要集中在 Remote Control / Trusted Device。
## 开关与缓解建议
### 1. 如果你的目标是关闭默认 analytics/telemetry
源码里明确支持以下限制:
- `src/utils/privacyLevel.ts:1-55`
- `src/services/analytics/config.ts:11-26`
建议:
- 设置 `DISABLE_TELEMETRY=1`
- 会进入 `no-telemetry`
- Datadog / 1P analytics 会被关闭
- 设置 `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`
- 会进入 `essential-traffic`
- 非必要网络流量会被进一步压缩
### 2. 如果你的目标是避免把本地目录和 git 信息送入模型
需要重点关注默认 prompt 链路,因为这部分不是传统“遥测”,而是模型上下文本身。
缓解思路:
- 在不敏感目录中运行,而不是直接在真实业务仓库根目录运行
- 避免在 `git user.name`、commit message、`CLAUDE.md` 中放入敏感标识
- 禁用或清理 `CLAUDE.md`
- 不启用 Remote Control / Bridge / Transcript Share / Feedback
### 3. 如果你的目标是避免 hostname 外发
避免使用:
- Remote Control / Bridge
- Trusted Device 注册 / 某些登录设备绑定流程
## 最终判断
从“是否采集本地系统信息并向外发送”这个问题本身看,答案是:
**是,存在,并且不止一条。**
但需要区分严重程度:
- **默认自动发生** 的,主要是:
- 模型请求中的环境/项目上下文
- analytics 中的环境/进程元数据
- **需要用户显式动作或特定功能开启** 才发生的,主要是:
- Feedback / Transcript Share
- Remote Control / Bridge
- Trusted Device
- OpenTelemetry
- ant-only `/insights`
- **未发现** 自动采集 MAC/IP/硬件序列号/机器唯一硬件 ID 的实现。
## 审计局限
- 本报告只基于本仓库源码,不包含第三方依赖内部实现的完全展开。
- 因此 GrowthBook `remoteEval` 被标为“高概率推断”,不是 100% 抓包确认。
- 如果你需要,我下一步可以继续补一版:
- 运行时抓包建议
- 外发域名清单
- 按“默认开启 / 可关闭 / 必须用户触发”生成一张更适合合规审查的表

View File

@@ -23,16 +23,6 @@ type BridgeApiDeps = {
* tokens don't refresh, so 401 goes straight to BridgeFatalError.
*/
onAuth401?: (staleAccessToken: string) => Promise<boolean>
/**
* Returns the trusted device token to send as X-Trusted-Device-Token on
* bridge API calls. Bridge sessions have SecurityTier=ELEVATED on the
* server (CCR v2); when the server's enforcement flag is on,
* ConnectBridgeWorker requires a trusted device at JWT-issuance.
* Optional — when absent or returning undefined, the header is omitted
* and the server falls through to its flag-off/no-op path. The CLI-side
* gate is tengu_sessions_elevated_auth_enforcement (see trustedDevice.ts).
*/
getTrustedDeviceToken?: () => string | undefined
}
const BETA_HEADER = 'environments-2025-11-01'
@@ -65,6 +55,36 @@ export class BridgeFatalError extends Error {
}
}
function summarizeBridgeApiPayloadForDebug(data: unknown): string {
if (data === null) return 'null'
if (data === undefined) return 'undefined'
if (Array.isArray(data)) {
return debugBody({
type: 'array',
length: data.length,
})
}
if (typeof data !== 'object') {
return String(data)
}
const value = data as Record<string, unknown>
const workData =
value.data && typeof value.data === 'object'
? (value.data as Record<string, unknown>)
: undefined
return debugBody({
type: 'object',
keys: Object.keys(value)
.sort()
.slice(0, 10),
hasEnvironmentId: typeof value.environment_id === 'string',
hasEnvironmentSecret: typeof value.environment_secret === 'string',
hasWorkId: typeof value.id === 'string',
workType: typeof workData?.type === 'string' ? workData.type : undefined,
hasSessionId: typeof workData?.id === 'string',
})
}
export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient {
function debug(msg: string): void {
deps.onDebug?.(msg)
@@ -74,18 +94,13 @@ export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient {
const EMPTY_POLL_LOG_INTERVAL = 100
function getHeaders(accessToken: string): Record<string, string> {
const headers: Record<string, string> = {
return {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
'anthropic-beta': BETA_HEADER,
'x-environment-runner-version': deps.runnerVersion,
}
const deviceToken = deps.getTrustedDeviceToken?.()
if (deviceToken) {
headers['X-Trusted-Device-Token'] = deviceToken
}
return headers
}
function resolveAuth(): string {
@@ -183,12 +198,14 @@ export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient {
handleErrorStatus(response.status, response.data, 'Registration')
debug(
`[bridge:api] POST /v1/environments/bridge -> ${response.status} environment_id=${response.data.environment_id}`,
`[bridge:api] POST /v1/environments/bridge -> ${response.status}`,
)
debug(
`[bridge:api] >>> ${debugBody({ max_sessions: config.maxSessions, metadata: { worker_type: config.workerType } })}`,
)
debug(`[bridge:api] <<< ${debugBody(response.data)}`)
debug(
`[bridge:api] <<< ${summarizeBridgeApiPayloadForDebug(response.data)}`,
)
return response.data
},
@@ -236,9 +253,11 @@ export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient {
}
debug(
`[bridge:api] GET .../work/poll -> ${response.status} workId=${response.data.id} type=${response.data.data?.type}${response.data.data?.id ? ` sessionId=${response.data.data.id}` : ''}`,
`[bridge:api] GET .../work/poll -> ${response.status} type=${response.data.data?.type ?? 'unknown'}`,
)
debug(
`[bridge:api] <<< ${summarizeBridgeApiPayloadForDebug(response.data)}`,
)
debug(`[bridge:api] <<< ${debugBody(response.data)}`)
return response.data
},
@@ -442,7 +461,9 @@ export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient {
`[bridge:api] POST /v1/sessions/${sessionId}/events -> ${response.status}`,
)
debug(`[bridge:api] >>> ${debugBody({ events: [event] })}`)
debug(`[bridge:api] <<< ${debugBody(response.data)}`)
debug(
`[bridge:api] <<< ${summarizeBridgeApiPayloadForDebug(response.data)}`,
)
},
}
}

View File

@@ -3,8 +3,6 @@ import { randomUUID } from 'crypto'
import { tmpdir } from 'os'
import { basename, join, resolve } from 'path'
import { getRemoteSessionUrl } from '../constants/product.js'
import { shutdownDatadog } from '../services/analytics/datadog.js'
import { shutdown1PEventLogging } from '../services/analytics/firstPartyEventLogger.js'
import { checkGate_CACHED_OR_BLOCKING } from '../services/analytics/growthbook.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
@@ -30,12 +28,11 @@ import {
import { formatDuration } from './bridgeStatusUtil.js'
import { createBridgeLogger } from './bridgeUI.js'
import { createCapacityWake } from './capacityWake.js'
import { describeAxiosError } from './debugUtils.js'
import { describeAxiosError, summarizeBridgeErrorForDebug } from './debugUtils.js'
import { createTokenRefreshScheduler } from './jwtUtils.js'
import { getPollIntervalConfig } from './pollConfig.js'
import { toCompatSessionId, toInfraSessionId } from './sessionIdCompat.js'
import { createSessionSpawner, safeFilenameId } from './sessionRunner.js'
import { getTrustedDeviceToken } from './trustedDevice.js'
import {
BRIDGE_LOGIN_ERROR,
type BridgeApiClient,
@@ -2042,16 +2039,15 @@ export async function bridgeMain(args: string[]): Promise<void> {
)
enableConfigs()
// Initialize analytics and error reporting sinks. The bridge bypasses the
// setup() init flow, so we call initSinks() directly to attach sinks here.
// Initialize shared sinks. The bridge bypasses setup(), so it attaches the
// local error-log sink directly here.
const { initSinks } = await import('../utils/sinks.js')
initSinks()
// Gate-aware validation: --spawn / --capacity / --create-session-in-dir require
// the multi-session gate. parseArgs has already validated flag combinations;
// here we only check the gate since that requires an async GrowthBook call.
// Runs after enableConfigs() (GrowthBook cache reads global config) and after
// initSinks() so the denial event can be enqueued.
// Runs after enableConfigs() because GrowthBook cache reads global config.
const multiSessionEnabled = await isMultiSessionSpawnEnabled()
if (usedMultiSessionFeature && !multiSessionEnabled) {
await logEventAsync('tengu_bridge_multi_session_denied', {
@@ -2059,14 +2055,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
used_capacity: parsedCapacity !== undefined,
used_create_session_in_dir: parsedCreateSessionInDir !== undefined,
})
// logEventAsync only enqueues — process.exit() discards buffered events.
// Flush explicitly, capped at 500ms to match gracefulShutdown.ts.
// (sleep() doesn't unref its timer, but process.exit() follows immediately
// so the ref'd timer can't delay shutdown.)
await Promise.race([
Promise.all([shutdown1PEventLogging(), shutdownDatadog()]),
sleep(500, undefined, { unref: true }),
]).catch(() => {})
// biome-ignore lint/suspicious/noConsole: intentional error output
console.error(
'Error: Multi-session Remote Control is not enabled for your account yet.',
@@ -2344,7 +2332,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
runnerVersion: MACRO.VERSION,
onDebug: logForDebugging,
onAuth401: handleOAuth401Error,
getTrustedDeviceToken,
})
// When resuming a session via --session-id, fetch it to learn its
@@ -2877,7 +2864,6 @@ export async function runBridgeHeadless(
runnerVersion: MACRO.VERSION,
onDebug: log,
onAuth401: opts.onAuth401,
getTrustedDeviceToken,
})
let environmentId: string

View File

@@ -23,9 +23,9 @@ import type { Message } from '../types/message.js'
import { normalizeControlMessageKeys } from '../utils/controlMessageCompat.js'
import { logForDebugging } from '../utils/debug.js'
import { stripDisplayTagsAllowEmpty } from '../utils/displayTags.js'
import { errorMessage } from '../utils/errors.js'
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
import { jsonParse } from '../utils/slowOperations.js'
import { summarizeBridgeErrorForDebug } from './debugUtils.js'
import type { ReplBridgeTransport } from './replBridgeTransport.js'
// ─── Type guards ─────────────────────────────────────────────────────────────
@@ -179,13 +179,13 @@ export function handleIngressMessage(
// receiving any frames, etc).
if (uuid && recentInboundUUIDs.has(uuid)) {
logForDebugging(
`[bridge:repl] Ignoring re-delivered inbound: type=${parsed.type} uuid=${uuid}`,
`[bridge:repl] Ignoring re-delivered inbound: type=${parsed.type}`,
)
return
}
logForDebugging(
`[bridge:repl] Ingress message type=${parsed.type}${uuid ? ` uuid=${uuid}` : ''}`,
`[bridge:repl] Ingress message type=${parsed.type}`,
)
if (parsed.type === 'user') {
@@ -202,7 +202,9 @@ export function handleIngressMessage(
}
} catch (err) {
logForDebugging(
`[bridge:repl] Failed to parse ingress message: ${errorMessage(err)}`,
`[bridge:repl] Failed to parse ingress message: ${summarizeBridgeErrorForDebug(
err,
)}`,
)
}
}
@@ -277,7 +279,7 @@ export function handleServerControlRequest(
const event = { ...response, session_id: sessionId }
void transport.write(event)
logForDebugging(
`[bridge:repl] Rejected ${request.request.subtype} (outbound-only) request_id=${request.request_id}`,
`[bridge:repl] Rejected ${request.request.subtype} (outbound-only)`,
)
return
}
@@ -386,7 +388,7 @@ export function handleServerControlRequest(
const event = { ...response, session_id: sessionId }
void transport.write(event)
logForDebugging(
`[bridge:repl] Sent control_response for ${request.request.subtype} request_id=${request.request_id} result=${response.response.subtype}`,
`[bridge:repl] Sent control_response for ${request.request.subtype} result=${response.response.subtype}`,
)
}

View File

@@ -9,7 +9,7 @@
import axios from 'axios'
import { logForDebugging } from '../utils/debug.js'
import { errorMessage } from '../utils/errors.js'
import { toError } from '../utils/errors.js'
import { jsonStringify } from '../utils/slowOperations.js'
import { extractErrorDetail } from './debugUtils.js'
@@ -23,6 +23,62 @@ function oauthHeaders(accessToken: string): Record<string, string> {
}
}
function summarizeCodeSessionResponseForDebug(data: unknown): string {
if (data === null) return 'null'
if (data === undefined) return 'undefined'
if (Array.isArray(data)) {
return jsonStringify({
payloadType: 'array',
length: data.length,
})
}
if (typeof data === 'object') {
const value = data as Record<string, unknown>
const session =
value.session && typeof value.session === 'object'
? (value.session as Record<string, unknown>)
: undefined
return jsonStringify({
payloadType: 'object',
keys: Object.keys(value)
.sort()
.slice(0, 10),
hasSession: Boolean(session),
hasSessionId: typeof session?.id === 'string',
hasWorkerJwt: typeof value.worker_jwt === 'string',
hasApiBaseUrl: typeof value.api_base_url === 'string',
hasExpiresIn: typeof value.expires_in === 'number',
hasWorkerEpoch:
typeof value.worker_epoch === 'number' ||
typeof value.worker_epoch === 'string',
})
}
return typeof data
}
function summarizeCodeSessionErrorForDebug(err: unknown): string {
const error = toError(err)
const summary: Record<string, unknown> = {
errorType: error.constructor.name,
errorName: error.name,
hasMessage: error.message.length > 0,
hasStack: Boolean(error.stack),
}
if (err && typeof err === 'object') {
const errorObj = err as Record<string, unknown>
if (typeof errorObj.code === 'string' || typeof errorObj.code === 'number') {
summary.code = errorObj.code
}
if (errorObj.response && typeof errorObj.response === 'object') {
const response = errorObj.response as Record<string, unknown>
if (typeof response.status === 'number') {
summary.httpStatus = response.status
}
}
}
return jsonStringify(summary)
}
export async function createCodeSession(
baseUrl: string,
accessToken: string,
@@ -47,7 +103,9 @@ export async function createCodeSession(
)
} catch (err: unknown) {
logForDebugging(
`[code-session] Session create request failed: ${errorMessage(err)}`,
`[code-session] Session create request failed: ${summarizeCodeSessionErrorForDebug(
err,
)}`,
)
return null
}
@@ -72,7 +130,9 @@ export async function createCodeSession(
!data.session.id.startsWith('cse_')
) {
logForDebugging(
`[code-session] No session.id (cse_*) in response: ${jsonStringify(data).slice(0, 200)}`,
`[code-session] No session.id (cse_*) in response: ${summarizeCodeSessionResponseForDebug(
data,
)}`,
)
return null
}
@@ -95,27 +155,24 @@ export async function fetchRemoteCredentials(
baseUrl: string,
accessToken: string,
timeoutMs: number,
trustedDeviceToken?: string,
): Promise<RemoteCredentials | null> {
const url = `${baseUrl}/v1/code/sessions/${sessionId}/bridge`
const headers = oauthHeaders(accessToken)
if (trustedDeviceToken) {
headers['X-Trusted-Device-Token'] = trustedDeviceToken
}
let response
try {
response = await axios.post(
url,
{},
{
headers,
headers: oauthHeaders(accessToken),
timeout: timeoutMs,
validateStatus: s => s < 500,
},
)
} catch (err: unknown) {
logForDebugging(
`[code-session] /bridge request failed: ${errorMessage(err)}`,
`[code-session] /bridge request failed: ${summarizeCodeSessionErrorForDebug(
err,
)}`,
)
return null
}
@@ -141,7 +198,9 @@ export async function fetchRemoteCredentials(
!('worker_epoch' in data)
) {
logForDebugging(
`[code-session] /bridge response malformed (need worker_jwt, expires_in, api_base_url, worker_epoch): ${jsonStringify(data).slice(0, 200)}`,
`[code-session] /bridge response malformed (need worker_jwt, expires_in, api_base_url, worker_epoch): ${summarizeCodeSessionResponseForDebug(
data,
)}`,
)
return null
}

View File

@@ -21,15 +21,10 @@ const SECRET_PATTERN = new RegExp(
'g',
)
const REDACT_MIN_LENGTH = 16
export function redactSecrets(s: string): string {
return s.replace(SECRET_PATTERN, (_match, field: string, value: string) => {
if (value.length < REDACT_MIN_LENGTH) {
return `"${field}":"[REDACTED]"`
}
const redacted = `${value.slice(0, 8)}...${value.slice(-4)}`
return `"${field}":"${redacted}"`
void value
return `"${field}":"[REDACTED]"`
})
}
@@ -52,6 +47,73 @@ export function debugBody(data: unknown): string {
return s.slice(0, DEBUG_MSG_LIMIT) + `... (${s.length} chars)`
}
function summarizeValueShapeForDebug(value: unknown): unknown {
if (value === null) return 'null'
if (value === undefined) return 'undefined'
if (Array.isArray(value)) {
return {
type: 'array',
length: value.length,
}
}
if (typeof value === 'object') {
return {
type: 'object',
keys: Object.keys(value as Record<string, unknown>)
.sort()
.slice(0, 10),
}
}
return typeof value
}
export function summarizeBridgeErrorForDebug(err: unknown): string {
const summary: Record<string, unknown> = {}
if (err instanceof Error) {
summary.errorType = err.constructor.name
summary.errorName = err.name
summary.hasMessage = err.message.length > 0
summary.hasStack = Boolean(err.stack)
} else {
summary.errorType = typeof err
summary.hasValue = err !== undefined && err !== null
}
if (err && typeof err === 'object') {
const errorObj = err as Record<string, unknown>
if (
typeof errorObj.code === 'string' ||
typeof errorObj.code === 'number'
) {
summary.code = errorObj.code
}
if (
typeof errorObj.errno === 'string' ||
typeof errorObj.errno === 'number'
) {
summary.errno = errorObj.errno
}
if (typeof errorObj.status === 'number') {
summary.status = errorObj.status
}
if (typeof errorObj.syscall === 'string') {
summary.syscall = errorObj.syscall
}
if (errorObj.response && typeof errorObj.response === 'object') {
const response = errorObj.response as Record<string, unknown>
if (typeof response.status === 'number') {
summary.httpStatus = response.status
}
if ('data' in response) {
summary.responseData = summarizeValueShapeForDebug(response.data)
}
}
}
return jsonStringify(summary)
}
/**
* Extract a descriptive error message from an axios error (or any error).
* For HTTP errors, appends the server's response body message if available,

View File

@@ -107,7 +107,7 @@ export function createTokenRefreshScheduler({
// (such as the follow-up refresh set by doRefresh) so the refresh
// chain is not broken.
logForDebugging(
`[${label}:token] Could not decode JWT expiry for sessionId=${sessionId}, token prefix=${token.slice(0, 15)}…, keeping existing timer`,
`[${label}:token] Could not decode JWT expiry for sessionId=${sessionId}, keeping existing timer`,
)
return
}
@@ -209,7 +209,7 @@ export function createTokenRefreshScheduler({
failureCounts.delete(sessionId)
logForDebugging(
`[${label}:token] Refreshing token for sessionId=${sessionId}: new token prefix=${oauthToken.slice(0, 15)}`,
`[${label}:token] Refreshing token for sessionId=${sessionId}`,
)
logEvent('tengu_bridge_token_refreshed', {})
onRefresh(sessionId, oauthToken)

View File

@@ -38,7 +38,6 @@ import { buildCCRv2SdkUrl } from './workSecret.js'
import { toCompatSessionId } from './sessionIdCompat.js'
import { FlushGate } from './flushGate.js'
import { createTokenRefreshScheduler } from './jwtUtils.js'
import { getTrustedDeviceToken } from './trustedDevice.js'
import {
getEnvLessBridgeConfig,
type EnvLessBridgeConfig,
@@ -51,7 +50,10 @@ import {
extractTitleText,
BoundedUUIDSet,
} from './bridgeMessaging.js'
import { logBridgeSkip } from './debugUtils.js'
import {
logBridgeSkip,
summarizeBridgeErrorForDebug,
} from './debugUtils.js'
import { logForDebugging } from '../utils/debug.js'
import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
import { isInProtectedNamespace } from '../utils/envUtils.js'
@@ -182,7 +184,7 @@ export async function initEnvLessBridgeCore(
return null
}
const sessionId: string = createdSessionId
logForDebugging(`[remote-bridge] Created session ${sessionId}`)
logForDebugging('[remote-bridge] Created remote bridge session')
logForDiagnosticsNoPII('info', 'bridge_repl_v2_session_created')
// ── 2. Fetch bridge credentials (POST /bridge → worker_jwt, expires_in, api_base_url) ──
@@ -215,7 +217,7 @@ export async function initEnvLessBridgeCore(
// ── 3. Build v2 transport (SSETransport + CCRClient) ────────────────────
const sessionUrl = buildCCRv2SdkUrl(credentials.api_base_url, sessionId)
logForDebugging(`[remote-bridge] v2 session URL: ${sessionUrl}`)
logForDebugging('[remote-bridge] Configured v2 session transport endpoint')
let transport: ReplBridgeTransport
try {
@@ -236,10 +238,12 @@ export async function initEnvLessBridgeCore(
})
} catch (err) {
logForDebugging(
`[remote-bridge] v2 transport setup failed: ${errorMessage(err)}`,
`[remote-bridge] v2 transport setup failed: ${summarizeBridgeErrorForDebug(
err,
)}`,
{ level: 'error' },
)
onStateChange?.('failed', `Transport setup failed: ${errorMessage(err)}`)
onStateChange?.('failed', 'Transport setup failed')
logBridgeSkip('v2_transport_setup_failed', undefined, true)
void archiveSession(
sessionId,
@@ -357,7 +361,9 @@ export async function initEnvLessBridgeCore(
)
} catch (err) {
logForDebugging(
`[remote-bridge] Proactive refresh rebuild failed: ${errorMessage(err)}`,
`[remote-bridge] Proactive refresh rebuild failed: ${summarizeBridgeErrorForDebug(
err,
)}`,
{ level: 'error' },
)
logForDiagnosticsNoPII(
@@ -365,7 +371,7 @@ export async function initEnvLessBridgeCore(
'bridge_repl_v2_proactive_refresh_failed',
)
if (!tornDown) {
onStateChange?.('failed', `Refresh failed: ${errorMessage(err)}`)
onStateChange?.('failed', 'Refresh failed')
}
} finally {
authRecoveryInFlight = false
@@ -395,9 +401,13 @@ export async function initEnvLessBridgeCore(
// (Same guard pattern as replBridge.ts:1119.)
const flushTransport = transport
void flushHistory(initialMessages)
.catch(e =>
logForDebugging(`[remote-bridge] flushHistory failed: ${e}`),
)
.catch(e => {
logForDebugging(
`[remote-bridge] flushHistory failed: ${summarizeBridgeErrorForDebug(
e,
)}`,
)
})
.finally(() => {
// authRecoveryInFlight catches the v1-vs-v2 asymmetry: v1 nulls
// transport synchronously in setOnClose (replBridge.ts:1175), so
@@ -577,12 +587,14 @@ export async function initEnvLessBridgeCore(
logForDebugging('[remote-bridge] Transport rebuilt after 401')
} catch (err) {
logForDebugging(
`[remote-bridge] 401 recovery failed: ${errorMessage(err)}`,
`[remote-bridge] 401 recovery failed: ${summarizeBridgeErrorForDebug(
err,
)}`,
{ level: 'error' },
)
logForDiagnosticsNoPII('error', 'bridge_repl_v2_jwt_refresh_failed')
if (!tornDown) {
onStateChange?.('failed', `JWT refresh failed: ${errorMessage(err)}`)
onStateChange?.('failed', 'JWT refresh failed')
}
} finally {
authRecoveryInFlight = false
@@ -707,7 +719,9 @@ export async function initEnvLessBridgeCore(
)
} catch (err) {
logForDebugging(
`[remote-bridge] Teardown 401 retry threw: ${errorMessage(err)}`,
`[remote-bridge] Teardown 401 retry threw: ${summarizeBridgeErrorForDebug(
err,
)}`,
{ level: 'error' },
)
}
@@ -824,7 +838,7 @@ export async function initEnvLessBridgeCore(
sendControlRequest(request: SDKControlRequest) {
if (authRecoveryInFlight) {
logForDebugging(
`[remote-bridge] Dropping control_request during 401 recovery: ${request.request_id}`,
'[remote-bridge] Dropping control_request during 401 recovery',
)
return
}
@@ -833,9 +847,7 @@ export async function initEnvLessBridgeCore(
transport.reportState('requires_action')
}
void transport.write(event)
logForDebugging(
`[remote-bridge] Sent control_request request_id=${request.request_id}`,
)
logForDebugging('[remote-bridge] Sent control_request')
},
sendControlResponse(response: SDKControlResponse) {
if (authRecoveryInFlight) {
@@ -852,7 +864,7 @@ export async function initEnvLessBridgeCore(
sendControlCancelRequest(requestId: string) {
if (authRecoveryInFlight) {
logForDebugging(
`[remote-bridge] Dropping control_cancel_request during 401 recovery: ${requestId}`,
'[remote-bridge] Dropping control_cancel_request during 401 recovery',
)
return
}
@@ -866,9 +878,7 @@ export async function initEnvLessBridgeCore(
// those paths, so without this the server stays on requires_action.
transport.reportState('running')
void transport.write(event)
logForDebugging(
`[remote-bridge] Sent control_cancel_request request_id=${requestId}`,
)
logForDebugging('[remote-bridge] Sent control_cancel_request')
},
sendResult() {
if (authRecoveryInFlight) {
@@ -877,7 +887,7 @@ export async function initEnvLessBridgeCore(
}
transport.reportState('idle')
void transport.write(makeResultMessage(sessionId))
logForDebugging(`[remote-bridge] Sent result`)
logForDebugging('[remote-bridge] Sent result')
},
async teardown() {
unregister()
@@ -925,9 +935,8 @@ import {
} from './codeSessionApi.js'
import { getBridgeBaseUrlOverride } from './bridgeConfig.js'
// CLI-side wrapper that applies the CLAUDE_BRIDGE_BASE_URL dev override and
// injects the trusted-device token (both are env/GrowthBook reads that the
// SDK-facing codeSessionApi.ts export must stay free of).
// CLI-side wrapper that applies the CLAUDE_BRIDGE_BASE_URL dev override while
// keeping the SDK-facing codeSessionApi.ts export free of CLI config reads.
export async function fetchRemoteCredentials(
sessionId: string,
baseUrl: string,
@@ -939,7 +948,6 @@ export async function fetchRemoteCredentials(
baseUrl,
accessToken,
timeoutMs,
getTrustedDeviceToken(),
)
if (!creds) return null
return getBridgeBaseUrlOverride()
@@ -995,12 +1003,13 @@ async function archiveSession(
},
)
logForDebugging(
`[remote-bridge] Archive ${compatId} status=${response.status}`,
`[remote-bridge] Archive status=${response.status}`,
)
return response.status
} catch (err) {
const msg = errorMessage(err)
logForDebugging(`[remote-bridge] Archive failed: ${msg}`)
logForDebugging(
`[remote-bridge] Archive failed: ${summarizeBridgeErrorForDebug(err)}`,
)
return axios.isAxiosError(err) && err.code === 'ECONNABORTED'
? 'timeout'
: 'error'

View File

@@ -30,7 +30,6 @@ import {
} from './workSecret.js'
import { toCompatSessionId, toInfraSessionId } from './sessionIdCompat.js'
import { updateSessionBridgeId } from '../utils/concurrentSessions.js'
import { getTrustedDeviceToken } from './trustedDevice.js'
import { HybridTransport } from '../cli/transports/HybridTransport.js'
import {
type ReplBridgeTransport,
@@ -44,6 +43,7 @@ import {
describeAxiosError,
extractHttpStatus,
logBridgeSkip,
summarizeBridgeErrorForDebug,
} from './debugUtils.js'
import type { Message } from '../types/message.js'
import type { SDKMessage } from '../entrypoints/agentSdkTypes.ts'
@@ -304,7 +304,7 @@ export async function initBridgeCore(
const prior = rawPrior?.source === 'repl' ? rawPrior : null
logForDebugging(
`[bridge:repl] initBridgeCore #${seq} starting (initialMessages=${initialMessages?.length ?? 0}${prior ? ` perpetual prior=env:${prior.environmentId}` : ''})`,
`[bridge:repl] initBridgeCore #${seq} starting (initialMessages=${initialMessages?.length ?? 0}${prior ? ' perpetual prior pointer present' : ''})`,
)
// 5. Register bridge environment
@@ -314,7 +314,6 @@ export async function initBridgeCore(
runnerVersion: MACRO.VERSION,
onDebug: logForDebugging,
onAuth401,
getTrustedDeviceToken,
})
// Ant-only: interpose so /bridge-kick can inject poll/register/heartbeat
// failures. Zero cost in external builds (rawApi passes through unchanged).
@@ -344,7 +343,9 @@ export async function initBridgeCore(
} catch (err) {
logBridgeSkip(
'registration_failed',
`[bridge:repl] Environment registration failed: ${errorMessage(err)}`,
`[bridge:repl] Environment registration failed: ${summarizeBridgeErrorForDebug(
err,
)}`,
)
// Stale pointer may be the cause (expired/deleted env) — clear it so
// the next start doesn't retry the same dead ID.
@@ -355,7 +356,7 @@ export async function initBridgeCore(
return null
}
logForDebugging(`[bridge:repl] Environment registered: ${environmentId}`)
logForDebugging('[bridge:repl] Environment registered')
logForDiagnosticsNoPII('info', 'bridge_repl_env_registered')
logEvent('tengu_bridge_repl_env_registered', {})
@@ -373,7 +374,7 @@ export async function initBridgeCore(
): Promise<boolean> {
if (environmentId !== requestedEnvId) {
logForDebugging(
`[bridge:repl] Env mismatch (requested ${requestedEnvId}, got ${environmentId}) — cannot reconnect in place`,
'[bridge:repl] Env mismatch — cannot reconnect in place',
)
return false
}
@@ -391,13 +392,13 @@ export async function initBridgeCore(
for (const id of candidates) {
try {
await api.reconnectSession(environmentId, id)
logForDebugging(
`[bridge:repl] Reconnected session ${id} in place on env ${environmentId}`,
)
logForDebugging('[bridge:repl] Reconnected existing session in place')
return true
} catch (err) {
logForDebugging(
`[bridge:repl] reconnectSession(${id}) failed: ${errorMessage(err)}`,
`[bridge:repl] reconnectSession failed: ${summarizeBridgeErrorForDebug(
err,
)}`,
)
}
}
@@ -681,7 +682,9 @@ export async function initBridgeCore(
} catch (err) {
bridgeConfig.reuseEnvironmentId = undefined
logForDebugging(
`[bridge:repl] Environment re-registration failed: ${errorMessage(err)}`,
`[bridge:repl] Environment re-registration failed: ${summarizeBridgeErrorForDebug(
err,
)}`,
)
return false
}
@@ -690,7 +693,7 @@ export async function initBridgeCore(
bridgeConfig.reuseEnvironmentId = undefined
logForDebugging(
`[bridge:repl] Re-registered: requested=${requestedEnvId} got=${environmentId}`,
'[bridge:repl] Re-registered environment',
)
// Bail out if teardown started while we were registering
@@ -986,7 +989,7 @@ export async function initBridgeCore(
injectFault: injectBridgeFault,
wakePollLoop,
describe: () =>
`env=${environmentId} session=${currentSessionId} transport=${transport?.getStateLabel() ?? 'null'} workId=${currentWorkId ?? 'null'}`,
`transport=${transport?.getStateLabel() ?? 'null'} hasSession=${Boolean(currentSessionId)} hasWork=${Boolean(currentWorkId)}`,
})
}
@@ -1040,7 +1043,9 @@ export async function initBridgeCore(
.stopWork(environmentId, currentWorkId, false)
.catch((e: unknown) => {
logForDebugging(
`[bridge:repl] stopWork after heartbeat fatal: ${errorMessage(e)}`,
`[bridge:repl] stopWork after heartbeat fatal: ${summarizeBridgeErrorForDebug(
e,
)}`,
)
})
}
@@ -1367,7 +1372,7 @@ export async function initBridgeCore(
const sessionUrl = buildCCRv2SdkUrl(baseUrl, workSessionId)
const thisGen = v2Generation
logForDebugging(
`[bridge:repl] CCR v2: sessionUrl=${sessionUrl} session=${workSessionId} gen=${thisGen}`,
`[bridge:repl] CCR v2: creating transport gen=${thisGen}`,
)
void createV2ReplTransport({
sessionUrl,
@@ -1401,7 +1406,9 @@ export async function initBridgeCore(
},
(err: unknown) => {
logForDebugging(
`[bridge:repl] CCR v2: createV2ReplTransport failed: ${errorMessage(err)}`,
`[bridge:repl] CCR v2: createV2ReplTransport failed: ${summarizeBridgeErrorForDebug(
err,
)}`,
{ level: 'error' },
)
logEvent('tengu_bridge_repl_ccr_v2_init_failed', {})
@@ -1416,7 +1423,9 @@ export async function initBridgeCore(
.stopWork(environmentId, currentWorkId, false)
.catch((e: unknown) => {
logForDebugging(
`[bridge:repl] stopWork after v2 init failure: ${errorMessage(e)}`,
`[bridge:repl] stopWork after v2 init failure: ${summarizeBridgeErrorForDebug(
e,
)}`,
)
})
currentWorkId = null
@@ -1437,10 +1446,8 @@ export async function initBridgeCore(
// secret. refreshHeaders picks up the latest OAuth token on each
// WS reconnect attempt.
const wsUrl = buildSdkUrl(sessionIngressUrl, workSessionId)
logForDebugging(`[bridge:repl] Ingress URL: ${wsUrl}`)
logForDebugging(
`[bridge:repl] Creating HybridTransport: session=${workSessionId}`,
)
logForDebugging('[bridge:repl] Using session ingress WebSocket endpoint')
logForDebugging('[bridge:repl] Creating HybridTransport')
// v1OauthToken was validated non-null above (we'd have returned early).
const oauthToken = v1OauthToken ?? ''
wireTransport(
@@ -1525,7 +1532,9 @@ export async function initBridgeCore(
logForDebugging('[bridge:repl] keep_alive sent')
void transport.write({ type: 'keep_alive' }).catch((err: unknown) => {
logForDebugging(
`[bridge:repl] keep_alive write failed: ${errorMessage(err)}`,
`[bridge:repl] keep_alive write failed: ${summarizeBridgeErrorForDebug(
err,
)}`,
)
})
}, keepAliveIntervalMs)
@@ -1538,15 +1547,13 @@ export async function initBridgeCore(
doTeardownImpl = async (): Promise<void> => {
if (teardownStarted) {
logForDebugging(
`[bridge:repl] Teardown already in progress, skipping duplicate call env=${environmentId} session=${currentSessionId}`,
'[bridge:repl] Teardown already in progress, skipping duplicate call',
)
return
}
teardownStarted = true
const teardownStart = Date.now()
logForDebugging(
`[bridge:repl] Teardown starting: env=${environmentId} session=${currentSessionId} workId=${currentWorkId ?? 'none'} transportState=${transport?.getStateLabel() ?? 'null'}`,
)
logForDebugging('[bridge:repl] Teardown starting')
if (pointerRefreshTimer !== null) {
clearInterval(pointerRefreshTimer)
@@ -1595,7 +1602,7 @@ export async function initBridgeCore(
source: 'repl',
})
logForDebugging(
`[bridge:repl] Teardown (perpetual): leaving env=${environmentId} session=${currentSessionId} alive on server, duration=${Date.now() - teardownStart}ms`,
`[bridge:repl] Teardown (perpetual): leaving bridge session alive on server, duration=${Date.now() - teardownStart}ms`,
)
return
}
@@ -1621,7 +1628,9 @@ export async function initBridgeCore(
})
.catch((err: unknown) => {
logForDebugging(
`[bridge:repl] Teardown stopWork failed: ${errorMessage(err)}`,
`[bridge:repl] Teardown stopWork failed: ${summarizeBridgeErrorForDebug(
err,
)}`,
)
})
: Promise.resolve()
@@ -1638,7 +1647,9 @@ export async function initBridgeCore(
await api.deregisterEnvironment(environmentId).catch((err: unknown) => {
logForDebugging(
`[bridge:repl] Teardown deregister failed: ${errorMessage(err)}`,
`[bridge:repl] Teardown deregister failed: ${summarizeBridgeErrorForDebug(
err,
)}`,
)
})
@@ -1648,16 +1659,14 @@ export async function initBridgeCore(
await clearBridgePointer(dir)
logForDebugging(
`[bridge:repl] Teardown complete: env=${environmentId} duration=${Date.now() - teardownStart}ms`,
`[bridge:repl] Teardown complete: duration=${Date.now() - teardownStart}ms`,
)
}
// 8. Register cleanup for graceful shutdown
const unregister = registerCleanup(() => doTeardownImpl?.())
logForDebugging(
`[bridge:repl] Ready: env=${environmentId} session=${currentSessionId}`,
)
logForDebugging('[bridge:repl] Ready')
onStateChange?.('ready')
return {
@@ -1715,7 +1724,7 @@ export async function initBridgeCore(
if (!transport) {
const types = filtered.map(m => m.type).join(',')
logForDebugging(
`[bridge:repl] Transport not configured, dropping ${filtered.length} message(s) [${types}] for session=${currentSessionId}`,
`[bridge:repl] Transport not configured, dropping ${filtered.length} message(s) [${types}]`,
{ level: 'warn' },
)
return
@@ -1750,7 +1759,7 @@ export async function initBridgeCore(
if (filtered.length === 0) return
if (!transport) {
logForDebugging(
`[bridge:repl] Transport not configured, dropping ${filtered.length} SDK message(s) for session=${currentSessionId}`,
`[bridge:repl] Transport not configured, dropping ${filtered.length} SDK message(s)`,
{ level: 'warn' },
)
return
@@ -1770,9 +1779,7 @@ export async function initBridgeCore(
}
const event = { ...request, session_id: currentSessionId }
void transport.write(event)
logForDebugging(
`[bridge:repl] Sent control_request request_id=${request.request_id}`,
)
logForDebugging('[bridge:repl] Sent control_request')
},
sendControlResponse(response: SDKControlResponse) {
if (!transport) {
@@ -1798,21 +1805,17 @@ export async function initBridgeCore(
session_id: currentSessionId,
}
void transport.write(event)
logForDebugging(
`[bridge:repl] Sent control_cancel_request request_id=${requestId}`,
)
logForDebugging('[bridge:repl] Sent control_cancel_request')
},
sendResult() {
if (!transport) {
logForDebugging(
`[bridge:repl] sendResult: skipping, transport not configured session=${currentSessionId}`,
'[bridge:repl] sendResult: skipping, transport not configured',
)
return
}
void transport.write(makeResultMessage(currentSessionId))
logForDebugging(
`[bridge:repl] Sent result for session=${currentSessionId}`,
)
logForDebugging('[bridge:repl] Sent result')
},
async teardown() {
unregister()
@@ -1905,7 +1908,7 @@ async function startWorkPollLoop({
const MAX_ENVIRONMENT_RECREATIONS = 3
logForDebugging(
`[bridge:repl] Starting work poll loop for env=${getCredentials().environmentId}`,
'[bridge:repl] Starting work poll loop',
)
let consecutiveErrors = 0
@@ -2008,7 +2011,9 @@ async function startWorkPollLoop({
)
} catch (err) {
logForDebugging(
`[bridge:repl:heartbeat] Failed: ${errorMessage(err)}`,
`[bridge:repl:heartbeat] Failed: ${summarizeBridgeErrorForDebug(
err,
)}`,
)
if (err instanceof BridgeFatalError) {
cap.cleanup()
@@ -2126,7 +2131,9 @@ async function startWorkPollLoop({
secret = decodeWorkSecret(work.secret)
} catch (err) {
logForDebugging(
`[bridge:repl] Failed to decode work secret: ${errorMessage(err)}`,
`[bridge:repl] Failed to decode work secret: ${summarizeBridgeErrorForDebug(
err,
)}`,
)
logEvent('tengu_bridge_repl_work_secret_failed', {})
// Can't ack (needs the JWT we failed to decode). stopWork uses OAuth.
@@ -2137,12 +2144,14 @@ async function startWorkPollLoop({
// Explicitly acknowledge to prevent redelivery. Non-fatal on failure:
// server re-delivers, and the onWorkReceived callback handles dedup.
logForDebugging(`[bridge:repl] Acknowledging workId=${work.id}`)
logForDebugging('[bridge:repl] Acknowledging work item')
try {
await api.acknowledgeWork(envId, work.id, secret.session_ingress_token)
} catch (err) {
logForDebugging(
`[bridge:repl] Acknowledge failed workId=${work.id}: ${errorMessage(err)}`,
`[bridge:repl] Acknowledge failed: ${summarizeBridgeErrorForDebug(
err,
)}`,
)
}
@@ -2194,7 +2203,7 @@ async function startWorkPollLoop({
const currentEnvId = getCredentials().environmentId
if (envId !== currentEnvId) {
logForDebugging(
`[bridge:repl] Stale poll error for old env=${envId}, current env=${currentEnvId} — skipping onEnvironmentLost`,
'[bridge:repl] Stale poll error for superseded environment — skipping onEnvironmentLost',
)
consecutiveErrors = 0
firstErrorTime = null
@@ -2240,9 +2249,7 @@ async function startWorkPollLoop({
consecutiveErrors = 0
firstErrorTime = null
onStateChange?.('ready')
logForDebugging(
`[bridge:repl] Re-registered environment: ${newCreds.environmentId}`,
)
logForDebugging('[bridge:repl] Re-registered environment')
continue
}
@@ -2378,7 +2385,7 @@ async function startWorkPollLoop({
}
logForDebugging(
`[bridge:repl] Work poll loop ended (aborted=${signal.aborted}) env=${getCredentials().environmentId}`,
`[bridge:repl] Work poll loop ended (aborted=${signal.aborted})`,
)
}

View File

@@ -3,9 +3,9 @@ import { CCRClient } from '../cli/transports/ccrClient.js'
import type { HybridTransport } from '../cli/transports/HybridTransport.js'
import { SSETransport } from '../cli/transports/SSETransport.js'
import { logForDebugging } from '../utils/debug.js'
import { errorMessage } from '../utils/errors.js'
import { updateSessionIngressAuthToken } from '../utils/sessionIngressAuth.js'
import type { SessionState } from '../utils/sessionState.js'
import { summarizeBridgeErrorForDebug } from './debugUtils.js'
import { registerWorker } from './workSecret.js'
/**
@@ -54,8 +54,6 @@ export type ReplBridgeTransport = {
* (user watches the REPL locally); multi-session worker callers do.
*/
reportState(state: SessionState): void
/** PUT /worker external_metadata (v2 only; v1 is a no-op). */
reportMetadata(metadata: Record<string, unknown>): void
/**
* POST /worker/events/{id}/delivery (v2 only; v1 is a no-op). Populates
* CCR's processing_at/processed_at columns. `received` is auto-fired by
@@ -96,7 +94,6 @@ export function createV1ReplTransport(
return hybrid.droppedBatchCount
},
reportState: () => {},
reportMetadata: () => {},
reportDelivery: () => {},
flush: () => Promise.resolve(),
}
@@ -182,7 +179,7 @@ export async function createV2ReplTransport(opts: {
const epoch = opts.epoch ?? (await registerWorker(sessionUrl, ingressToken))
logForDebugging(
`[bridge:repl] CCR v2: worker sessionId=${sessionId} epoch=${epoch}${opts.epoch !== undefined ? ' (from /bridge)' : ' (via registerWorker)'}`,
`[bridge:repl] CCR v2: worker registered epoch=${epoch}${opts.epoch !== undefined ? ' (from /bridge)' : ' (via registerWorker)'}`,
)
// Derive SSE stream URL. Same logic as transportUtils.ts:26-33 but
@@ -220,7 +217,9 @@ export async function createV2ReplTransport(opts: {
onCloseCb?.(4090)
} catch (closeErr: unknown) {
logForDebugging(
`[bridge:repl] CCR v2: error during epoch-mismatch cleanup: ${errorMessage(closeErr)}`,
`[bridge:repl] CCR v2: error during epoch-mismatch cleanup: ${summarizeBridgeErrorForDebug(
closeErr,
)}`,
{ level: 'error' },
)
}
@@ -324,9 +323,6 @@ export async function createV2ReplTransport(opts: {
reportState(state) {
ccr.reportState(state)
},
reportMetadata(metadata) {
ccr.reportMetadata(metadata)
},
reportDelivery(eventId, status) {
ccr.reportDelivery(eventId, status)
},
@@ -353,7 +349,9 @@ export async function createV2ReplTransport(opts: {
},
(err: unknown) => {
logForDebugging(
`[bridge:repl] CCR v2 initialize failed: ${errorMessage(err)}`,
`[bridge:repl] CCR v2 initialize failed: ${summarizeBridgeErrorForDebug(
err,
)}`,
{ level: 'error' },
)
// Close transport resources and notify replBridge via onClose

View File

@@ -1,10 +1,9 @@
import { type ChildProcess, spawn } from 'child_process'
import { createWriteStream, type WriteStream } from 'fs'
import { tmpdir } from 'os'
import { dirname, join } from 'path'
import { basename, dirname, join } from 'path'
import { createInterface } from 'readline'
import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
import { debugTruncate } from './debugUtils.js'
import type {
SessionActivity,
SessionDoneStatus,
@@ -25,6 +24,61 @@ export function safeFilenameId(id: string): string {
return id.replace(/[^a-zA-Z0-9_-]/g, '_')
}
function summarizeSessionRunnerErrorForDebug(error: unknown): string {
return jsonStringify({
errorType:
error instanceof Error ? error.constructor.name : typeof error,
errorName: error instanceof Error ? error.name : undefined,
hasMessage: error instanceof Error ? error.message.length > 0 : false,
hasStack: error instanceof Error ? Boolean(error.stack) : false,
})
}
function summarizeSessionRunnerFrameForDebug(data: string): string {
try {
const parsed = jsonParse(data)
if (parsed && typeof parsed === 'object') {
const value = parsed as Record<string, unknown>
return jsonStringify({
frameType: typeof value.type === 'string' ? value.type : 'unknown',
subtype:
typeof value.subtype === 'string'
? value.subtype
: value.response &&
typeof value.response === 'object' &&
typeof (value.response as Record<string, unknown>).subtype ===
'string'
? (value.response as Record<string, unknown>).subtype
: value.request &&
typeof value.request === 'object' &&
typeof (value.request as Record<string, unknown>).subtype ===
'string'
? (value.request as Record<string, unknown>).subtype
: undefined,
hasUuid: typeof value.uuid === 'string',
length: data.length,
})
}
} catch {
// fall through to raw-length summary
}
return jsonStringify({
frameType: 'unparsed',
length: data.length,
})
}
function summarizeSessionRunnerArgsForDebug(args: string[]): string {
return jsonStringify({
argCount: args.length,
hasSdkUrl: args.includes('--sdk-url'),
hasSessionId: args.includes('--session-id'),
hasDebugFile: args.includes('--debug-file'),
hasVerbose: args.includes('--verbose'),
hasPermissionMode: args.includes('--permission-mode'),
})
}
/**
* A control_request emitted by the child CLI when it needs permission to
* execute a **specific** tool invocation (not a general capability check).
@@ -144,9 +198,7 @@ function extractActivities(
summary,
timestamp: now,
})
onDebug(
`[bridge:activity] sessionId=${sessionId} tool_use name=${name} ${inputPreview(input)}`,
)
onDebug(`[bridge:activity] tool_use name=${name}`)
} else if (b.type === 'text') {
const text = (b.text as string) ?? ''
if (text.length > 0) {
@@ -156,7 +208,7 @@ function extractActivities(
timestamp: now,
})
onDebug(
`[bridge:activity] sessionId=${sessionId} text "${text.slice(0, 100)}"`,
`[bridge:activity] text length=${text.length}`,
)
}
}
@@ -171,9 +223,7 @@ function extractActivities(
summary: 'Session completed',
timestamp: now,
})
onDebug(
`[bridge:activity] sessionId=${sessionId} result subtype=success`,
)
onDebug('[bridge:activity] result subtype=success')
} else if (subtype) {
const errors = msg.errors as string[] | undefined
const errorSummary = errors?.[0] ?? `Error: ${subtype}`
@@ -182,13 +232,9 @@ function extractActivities(
summary: errorSummary,
timestamp: now,
})
onDebug(
`[bridge:activity] sessionId=${sessionId} result subtype=${subtype} error="${errorSummary}"`,
)
onDebug(`[bridge:activity] result subtype=${subtype}`)
} else {
onDebug(
`[bridge:activity] sessionId=${sessionId} result subtype=undefined`,
)
onDebug('[bridge:activity] result subtype=undefined')
}
break
}
@@ -233,18 +279,6 @@ function extractUserMessageText(
return text ? text : undefined
}
/** Build a short preview of tool input for debug logging. */
function inputPreview(input: Record<string, unknown>): string {
const parts: string[] = []
for (const [key, val] of Object.entries(input)) {
if (typeof val === 'string') {
parts.push(`${key}="${val.slice(0, 100)}"`)
}
if (parts.length >= 3) break
}
return parts.join(' ')
}
export function createSessionSpawner(deps: SessionSpawnerDeps): SessionSpawner {
return {
spawn(opts: SessionSpawnOpts, dir: string): SessionHandle {
@@ -277,11 +311,15 @@ export function createSessionSpawner(deps: SessionSpawnerDeps): SessionSpawner {
transcriptStream = createWriteStream(transcriptPath, { flags: 'a' })
transcriptStream.on('error', err => {
deps.onDebug(
`[bridge:session] Transcript write error: ${err.message}`,
`[bridge:session] Transcript write error: ${summarizeSessionRunnerErrorForDebug(
err,
)}`,
)
transcriptStream = null
})
deps.onDebug(`[bridge:session] Transcript log: ${transcriptPath}`)
deps.onDebug(
`[bridge:session] Transcript log configured (${basename(transcriptPath)})`,
)
}
const args = [
@@ -323,11 +361,15 @@ export function createSessionSpawner(deps: SessionSpawnerDeps): SessionSpawner {
}
deps.onDebug(
`[bridge:session] Spawning sessionId=${opts.sessionId} sdkUrl=${opts.sdkUrl} accessToken=${opts.accessToken ? 'present' : 'MISSING'}`,
`[bridge:session] Spawning child session process (accessToken=${opts.accessToken ? 'present' : 'MISSING'})`,
)
deps.onDebug(
`[bridge:session] Child args: ${summarizeSessionRunnerArgsForDebug(args)}`,
)
deps.onDebug(`[bridge:session] Child args: ${args.join(' ')}`)
if (debugFile) {
deps.onDebug(`[bridge:session] Debug log: ${debugFile}`)
deps.onDebug(
`[bridge:session] Debug log configured (${basename(debugFile)})`,
)
}
// Pipe all three streams: stdin for control, stdout for NDJSON parsing,
@@ -339,9 +381,7 @@ export function createSessionSpawner(deps: SessionSpawnerDeps): SessionSpawner {
windowsHide: true,
})
deps.onDebug(
`[bridge:session] sessionId=${opts.sessionId} pid=${child.pid}`,
)
deps.onDebug('[bridge:session] Child process started')
const activities: SessionActivity[] = []
let currentActivity: SessionActivity | null = null
@@ -376,7 +416,7 @@ export function createSessionSpawner(deps: SessionSpawnerDeps): SessionSpawner {
// Log all messages flowing from the child CLI to the bridge
deps.onDebug(
`[bridge:ws] sessionId=${opts.sessionId} <<< ${debugTruncate(line)}`,
`[bridge:ws] <<< ${summarizeSessionRunnerFrameForDebug(line)}`,
)
// In verbose mode, forward raw output to stderr
@@ -455,25 +495,23 @@ export function createSessionSpawner(deps: SessionSpawnerDeps): SessionSpawner {
if (signal === 'SIGTERM' || signal === 'SIGINT') {
deps.onDebug(
`[bridge:session] sessionId=${opts.sessionId} interrupted signal=${signal} pid=${child.pid}`,
`[bridge:session] interrupted signal=${signal ?? 'unknown'}`,
)
resolve('interrupted')
} else if (code === 0) {
deps.onDebug(
`[bridge:session] sessionId=${opts.sessionId} completed exit_code=0 pid=${child.pid}`,
)
deps.onDebug('[bridge:session] completed exit_code=0')
resolve('completed')
} else {
deps.onDebug(
`[bridge:session] sessionId=${opts.sessionId} failed exit_code=${code} pid=${child.pid}`,
)
deps.onDebug(`[bridge:session] failed exit_code=${code}`)
resolve('failed')
}
})
child.on('error', err => {
deps.onDebug(
`[bridge:session] sessionId=${opts.sessionId} spawn error: ${err.message}`,
`[bridge:session] spawn error: ${summarizeSessionRunnerErrorForDebug(
err,
)}`,
)
resolve('failed')
})
@@ -490,9 +528,7 @@ export function createSessionSpawner(deps: SessionSpawnerDeps): SessionSpawner {
},
kill(): void {
if (!child.killed) {
deps.onDebug(
`[bridge:session] Sending SIGTERM to sessionId=${opts.sessionId} pid=${child.pid}`,
)
deps.onDebug('[bridge:session] Sending SIGTERM to child process')
// On Windows, child.kill('SIGTERM') throws; use default signal.
if (process.platform === 'win32') {
child.kill()
@@ -506,9 +542,7 @@ export function createSessionSpawner(deps: SessionSpawnerDeps): SessionSpawner {
// not when the process exits. We need to send SIGKILL even after SIGTERM.
if (!sigkillSent && child.pid) {
sigkillSent = true
deps.onDebug(
`[bridge:session] Sending SIGKILL to sessionId=${opts.sessionId} pid=${child.pid}`,
)
deps.onDebug('[bridge:session] Sending SIGKILL to child process')
if (process.platform === 'win32') {
child.kill()
} else {
@@ -519,7 +553,7 @@ export function createSessionSpawner(deps: SessionSpawnerDeps): SessionSpawner {
writeStdin(data: string): void {
if (child.stdin && !child.stdin.destroyed) {
deps.onDebug(
`[bridge:ws] sessionId=${opts.sessionId} >>> ${debugTruncate(data)}`,
`[bridge:ws] >>> ${summarizeSessionRunnerFrameForDebug(data)}`,
)
child.stdin.write(data)
}
@@ -536,9 +570,7 @@ export function createSessionSpawner(deps: SessionSpawnerDeps): SessionSpawner {
variables: { CLAUDE_CODE_SESSION_ACCESS_TOKEN: token },
}) + '\n',
)
deps.onDebug(
`[bridge:session] Sent token refresh via stdin for sessionId=${opts.sessionId}`,
)
deps.onDebug('[bridge:session] Sent token refresh via stdin')
},
}

View File

@@ -1,67 +1,22 @@
import memoize from 'lodash-es/memoize.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
import { logForDebugging } from '../utils/debug.js'
import { getSecureStorage } from '../utils/secureStorage/index.js'
/**
* Trusted device token source for bridge (remote-control) sessions.
* Trusted-device compatibility helpers for bridge (remote-control) sessions.
*
* Bridge sessions have SecurityTier=ELEVATED on the server (CCR v2).
* The server gates ConnectBridgeWorker on its own flag
* (sessions_elevated_auth_enforcement in Anthropic Main); this CLI-side
* flag controls whether the CLI sends X-Trusted-Device-Token at all.
* Two flags so rollout can be staged: flip CLI-side first (headers
* start flowing, server still no-ops), then flip server-side.
*
* Enrollment (POST /auth/trusted_devices) is gated server-side by
* account_session.created_at < 10min, so it must happen during /login.
* Token is persistent (90d rolling expiry) and stored in keychain.
*
* See anthropics/anthropic#274559 (spec), #310375 (B1b tenant RPCs),
* #295987 (B2 Python routes), #307150 (C1' CCR v2 gate).
* This fork disables trusted-device enrollment and header emission. The
* remaining helpers only clear any previously stored token during login/logout
* so old state is not carried forward.
*/
const TRUSTED_DEVICE_GATE = 'tengu_sessions_elevated_auth_enforcement'
function isGateEnabled(): boolean {
return getFeatureValue_CACHED_MAY_BE_STALE(TRUSTED_DEVICE_GATE, false)
}
// Memoized — secureStorage.read() spawns a macOS `security` subprocess (~40ms).
// bridgeApi.ts calls this from getHeaders() on every poll/heartbeat/ack.
// Cache cleared on logout (clearAuthRelatedCaches) and after any local update.
//
// Only the storage read is memoized — the GrowthBook gate is checked live so
// that a gate flip after GrowthBook refresh takes effect without a restart.
const readStoredToken = memoize((): string | undefined => {
// Env var takes precedence for testing/canary.
const envToken = process.env.CLAUDE_TRUSTED_DEVICE_TOKEN
if (envToken) {
return envToken
}
return getSecureStorage().read()?.trustedDeviceToken
})
export function getTrustedDeviceToken(): string | undefined {
if (!isGateEnabled()) {
return undefined
}
return readStoredToken()
}
export function clearTrustedDeviceTokenCache(): void {
readStoredToken.cache?.clear?.()
return
}
/**
* Clear the stored trusted device token from secure storage and the memo cache.
* Called during /login so a stale token from the previous account isn't sent
* as X-Trusted-Device-Token after account switches.
* Clear any stored trusted-device token from secure storage.
*/
export function clearTrustedDeviceToken(): void {
if (!isGateEnabled()) {
return
}
const secureStorage = getSecureStorage()
try {
const data = secureStorage.read()
@@ -72,7 +27,6 @@ export function clearTrustedDeviceToken(): void {
} catch {
// Best-effort — don't block login if storage is inaccessible
}
readStoredToken.cache?.clear?.()
}
/**

View File

@@ -2,6 +2,33 @@ import axios from 'axios'
import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
import type { WorkSecret } from './types.js'
function summarizeRegisterWorkerResponseForDebug(data: unknown): string {
if (data === null) return 'null'
if (data === undefined) return 'undefined'
if (Array.isArray(data)) {
return jsonStringify({
payloadType: 'array',
length: data.length,
})
}
if (typeof data === 'object') {
const value = data as Record<string, unknown>
return jsonStringify({
payloadType: 'object',
keys: Object.keys(value)
.sort()
.slice(0, 10),
hasWorkerEpoch:
typeof value.worker_epoch === 'number' ||
typeof value.worker_epoch === 'string',
hasSessionIngressToken:
typeof value.session_ingress_token === 'string',
hasApiBaseUrl: typeof value.api_base_url === 'string',
})
}
return typeof data
}
/** Decode a base64url-encoded work secret and validate its version. */
export function decodeWorkSecret(secret: string): WorkSecret {
const json = Buffer.from(secret, 'base64url').toString('utf-8')
@@ -120,7 +147,9 @@ export async function registerWorker(
!Number.isSafeInteger(epoch)
) {
throw new Error(
`registerWorker: invalid worker_epoch in response: ${jsonStringify(response.data)}`,
`registerWorker: invalid worker_epoch in response: ${summarizeRegisterWorkerResponseForDebug(
response.data,
)}`,
)
}
return epoch

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -2,8 +2,6 @@ import * as React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { readFile, stat } from 'fs/promises';
import { getLastAPIRequest } from 'src/bootstrap/state.js';
import { logEventTo1P } from 'src/services/analytics/firstPartyEventLogger.js';
import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js';
import { getLastAssistantMessage, normalizeMessagesForAPI } from 'src/utils/messages.js';
import type { CommandResultDisplay } from '../commands.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';

View File

@@ -0,0 +1,14 @@
import { describe, expect, it } from 'bun:test'
import { submitTranscriptShare } from './submitTranscriptShare.js'
describe('submitTranscriptShare', () => {
it('returns the disabled result in this build', async () => {
await expect(
submitTranscriptShare([], 'good_feedback_survey', 'appearance-id'),
).resolves.toEqual({
success: false,
disabled: true,
})
})
})

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,11 +1,8 @@
import { profileCheckpoint } from '../utils/startupProfiler.js'
import '../bootstrap/state.js'
import '../utils/config.js'
import type { Attributes, MetricOptions } from '@opentelemetry/api'
import memoize from 'lodash-es/memoize.js'
import { getIsNonInteractiveSession } from 'src/bootstrap/state.js'
import type { AttributedCounter } from '../bootstrap/state.js'
import { getSessionCounter, setMeter } from '../bootstrap/state.js'
import { shutdownLspServerManager } from '../services/lsp/manager.js'
import { populateOAuthAccountInfoIfNeeded } from '../services/oauth/client.js'
import {
@@ -41,19 +38,9 @@ import {
ensureScratchpadDir,
isScratchpadEnabled,
} from '../utils/permissions/filesystem.js'
// initializeTelemetry is loaded lazily via import() in setMeterState() to defer
// ~400KB of OpenTelemetry + protobuf modules until telemetry is actually initialized.
// gRPC exporters (~700KB via @grpc/grpc-js) are further lazy-loaded within instrumentation.ts.
import { configureGlobalAgents } from '../utils/proxy.js'
import { isBetaTracingEnabled } from '../utils/telemetry/betaSessionTracing.js'
import { getTelemetryAttributes } from '../utils/telemetryAttributes.js'
import { setShellIfWindows } from '../utils/windowsPaths.js'
// initialize1PEventLogging is dynamically imported to defer OpenTelemetry sdk-logs/resources
// Track if telemetry has been initialized to prevent double initialization
let telemetryInitialized = false
export const init = memoize(async (): Promise<void> => {
const initStartTime = Date.now()
logForDiagnosticsNoPII('info', 'init_started')
@@ -87,22 +74,8 @@ export const init = memoize(async (): Promise<void> => {
setupGracefulShutdown()
profileCheckpoint('init_after_graceful_shutdown')
// Initialize 1P event logging (no security concerns, but deferred to avoid
// loading OpenTelemetry sdk-logs at startup). growthbook.js is already in
// the module cache by this point (firstPartyEventLogger imports it), so the
// second dynamic import adds no load cost.
void Promise.all([
import('../services/analytics/firstPartyEventLogger.js'),
import('../services/analytics/growthbook.js'),
]).then(([fp, gb]) => {
fp.initialize1PEventLogging()
// Rebuild the logger provider if tengu_1p_event_batch_config changes
// mid-session. Change detection (isEqual) is inside the handler so
// unchanged refreshes are no-ops.
gb.onGrowthBookRefresh(() => {
void fp.reinitialize1PEventLoggingIfConfigChanged()
})
})
// Telemetry/log export is disabled in this build. Keep the startup
// checkpoint so callers depending on the init timeline still see it.
profileCheckpoint('init_after_1p_event_logging')
// Populate OAuth account info if it is not already cached in config. This is needed since the
@@ -236,105 +209,3 @@ export const init = memoize(async (): Promise<void> => {
}
}
})
/**
* Initialize telemetry after trust has been granted.
* For remote-settings-eligible users, waits for settings to load (non-blocking),
* then re-applies env vars (to include remote settings) before initializing telemetry.
* For non-eligible users, initializes telemetry immediately.
* This should only be called once, after the trust dialog has been accepted.
*/
export function initializeTelemetryAfterTrust(): void {
if (isEligibleForRemoteManagedSettings()) {
// For SDK/headless mode with beta tracing, initialize eagerly first
// to ensure the tracer is ready before the first query runs.
// The async path below will still run but doInitializeTelemetry() guards against double init.
if (getIsNonInteractiveSession() && isBetaTracingEnabled()) {
void doInitializeTelemetry().catch(error => {
logForDebugging(
`[3P telemetry] Eager telemetry init failed (beta tracing): ${errorMessage(error)}`,
{ level: 'error' },
)
})
}
logForDebugging(
'[3P telemetry] Waiting for remote managed settings before telemetry init',
)
void waitForRemoteManagedSettingsToLoad()
.then(async () => {
logForDebugging(
'[3P telemetry] Remote managed settings loaded, initializing telemetry',
)
// Re-apply env vars to pick up remote settings before initializing telemetry.
applyConfigEnvironmentVariables()
await doInitializeTelemetry()
})
.catch(error => {
logForDebugging(
`[3P telemetry] Telemetry init failed (remote settings path): ${errorMessage(error)}`,
{ level: 'error' },
)
})
} else {
void doInitializeTelemetry().catch(error => {
logForDebugging(
`[3P telemetry] Telemetry init failed: ${errorMessage(error)}`,
{ level: 'error' },
)
})
}
}
async function doInitializeTelemetry(): Promise<void> {
if (telemetryInitialized) {
// Already initialized, nothing to do
return
}
// Set flag before init to prevent double initialization
telemetryInitialized = true
try {
await setMeterState()
} catch (error) {
// Reset flag on failure so subsequent calls can retry
telemetryInitialized = false
throw error
}
}
async function setMeterState(): Promise<void> {
// Lazy-load instrumentation to defer ~400KB of OpenTelemetry + protobuf
const { initializeTelemetry } = await import(
'../utils/telemetry/instrumentation.js'
)
// Initialize customer OTLP telemetry (metrics, logs, traces)
const meter = await initializeTelemetry()
if (meter) {
// Create factory function for attributed counters
const createAttributedCounter = (
name: string,
options: MetricOptions,
): AttributedCounter => {
const counter = meter?.createCounter(name, options)
return {
add(value: number, additionalAttributes: Attributes = {}) {
// Always fetch fresh telemetry attributes to ensure they're up to date
const currentAttributes = getTelemetryAttributes()
const mergedAttributes = {
...currentAttributes,
...additionalAttributes,
}
counter?.add(value, mergedAttributes)
},
}
}
setMeter(meter, createAttributedCounter)
// Increment session counter here because the startup telemetry path
// runs before this async initialization completes, so the counter
// would be null there.
getSessionCounter()?.add(1)
}
}

View File

@@ -1,6 +1,6 @@
// Centralized analytics/telemetry logging for tool permission decisions.
// All permission approve/reject events flow through logPermissionDecision(),
// which fans out to Statsig analytics, OTel telemetry, and code-edit metrics.
// which fans out to analytics compatibility calls and code-edit metrics.
import { feature } from 'bun:bundle'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
@@ -11,7 +11,6 @@ import { getCodeEditToolDecisionCounter } from '../../bootstrap/state.js'
import type { Tool as ToolType, ToolUseContext } from '../../Tool.js'
import { getLanguageName } from '../../utils/cliHighlight.js'
import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'
import { logOTelEvent } from '../../utils/telemetry/events.js'
import type {
PermissionApprovalSource,
PermissionRejectionSource,
@@ -227,11 +226,6 @@ function logPermissionDecision(
timestamp: Date.now(),
})
void logOTelEvent('tool_decision', {
decision,
source: sourceString,
tool_name: sanitizeToolNameForAnalytics(tool.name),
})
}
export { isCodeEditingTool, buildCodeEditToolAttributes, logPermissionDecision }

View File

@@ -59,7 +59,7 @@ import {
isShutdownApproved,
isShutdownRequest,
isTeamPermissionUpdate,
markMessagesAsRead,
markMessagesAsReadByPredicate,
readUnreadMessages,
type TeammateMessage,
writeToMailbox,
@@ -195,10 +195,20 @@ export function useInboxPoller({
}
}
// Helper to mark messages as read in the inbox file.
// Helper to remove the unread batch we just processed from the inbox file.
// Called after messages are successfully delivered or reliably queued.
const deliveredMessageKeys = new Set(
unread.map(message => `${message.from}|${message.timestamp}|${message.text}`),
)
const markRead = () => {
void markMessagesAsRead(agentName, currentAppState.teamContext?.teamName)
void markMessagesAsReadByPredicate(
agentName,
message =>
deliveredMessageKeys.has(
`${message.from}|${message.timestamp}|${message.text}`,
),
currentAppState.teamContext?.teamName,
)
}
// Separate permission messages from regular teammate messages
@@ -503,9 +513,7 @@ export function useInboxPoller({
for (const m of teamPermissionUpdates) {
const parsed = isTeamPermissionUpdate(m.text)
if (!parsed) {
logForDebugging(
`[InboxPoller] Failed to parse team permission update: ${m.text.substring(0, 100)}`,
)
logForDebugging('[InboxPoller] Failed to parse team permission update')
continue
}
@@ -522,10 +530,7 @@ export function useInboxPoller({
// Apply the permission update to the teammate's context
logForDebugging(
`[InboxPoller] Applying team permission update: ${parsed.toolName} allowed in ${parsed.directoryPath}`,
)
logForDebugging(
`[InboxPoller] Permission update rules: ${jsonStringify(parsed.permissionUpdate.rules)}`,
`[InboxPoller] Applying team permission update for ${parsed.toolName} (${parsed.permissionUpdate.rules.length} rule(s))`,
)
setAppState(prev => {
@@ -536,7 +541,7 @@ export function useInboxPoller({
destination: 'session',
})
logForDebugging(
`[InboxPoller] Updated session allow rules: ${jsonStringify(updated.alwaysAllowRules.session)}`,
`[InboxPoller] Updated session allow rules (${updated.alwaysAllowRules.session.length} total)`,
)
return {
...prev,
@@ -563,9 +568,7 @@ export function useInboxPoller({
const parsed = isModeSetRequest(m.text)
if (!parsed) {
logForDebugging(
`[InboxPoller] Failed to parse mode set request: ${m.text.substring(0, 100)}`,
)
logForDebugging('[InboxPoller] Failed to parse mode set request')
continue
}

View File

@@ -1,31 +1,16 @@
/**
* Swarm Permission Poller Hook
* Swarm permission callback registry helpers.
*
* This hook polls for permission responses from the team leader when running
* as a worker agent in a swarm. When a response is received, it calls the
* appropriate callback (onAllow/onReject) to continue execution.
*
* This hook should be used in conjunction with the worker-side integration
* in useCanUseTool.ts, which creates pending requests that this hook monitors.
* Permission requests/responses now flow entirely through teammate mailboxes.
* Workers register callbacks here, and the inbox poller dispatches mailbox
* responses back into those callbacks.
*/
import { useCallback, useEffect, useRef } from 'react'
import { useInterval } from 'usehooks-ts'
import { logForDebugging } from '../utils/debug.js'
import { errorMessage } from '../utils/errors.js'
import {
type PermissionUpdate,
permissionUpdateSchema,
} from '../utils/permissions/PermissionUpdateSchema.js'
import {
isSwarmWorker,
type PermissionResponse,
pollForResponse,
removeWorkerResponse,
} from '../utils/swarm/permissionSync.js'
import { getAgentName, getTeamName } from '../utils/teammate.js'
const POLL_INTERVAL_MS = 500
/**
* Validate permissionUpdates from external sources (mailbox IPC, disk polling).
@@ -226,105 +211,9 @@ export function processSandboxPermissionResponse(params: {
}
/**
* Process a permission response by invoking the registered callback
*/
function processResponse(response: PermissionResponse): boolean {
const callback = pendingCallbacks.get(response.requestId)
if (!callback) {
logForDebugging(
`[SwarmPermissionPoller] No callback registered for request ${response.requestId}`,
)
return false
}
logForDebugging(
`[SwarmPermissionPoller] Processing response for request ${response.requestId}: ${response.decision}`,
)
// Remove from registry before invoking callback
pendingCallbacks.delete(response.requestId)
if (response.decision === 'approved') {
const permissionUpdates = parsePermissionUpdates(response.permissionUpdates)
const updatedInput = response.updatedInput
callback.onAllow(updatedInput, permissionUpdates)
} else {
callback.onReject(response.feedback)
}
return true
}
/**
* Hook that polls for permission responses when running as a swarm worker.
*
* This hook:
* 1. Only activates when isSwarmWorker() returns true
* 2. Polls every 500ms for responses
* 3. When a response is found, invokes the registered callback
* 4. Cleans up the response file after processing
* Legacy no-op hook kept for compatibility with older imports.
* Mailbox responses are handled by useInboxPoller instead of disk polling.
*/
export function useSwarmPermissionPoller(): void {
const isProcessingRef = useRef(false)
const poll = useCallback(async () => {
// Don't poll if not a swarm worker
if (!isSwarmWorker()) {
return
}
// Prevent concurrent polling
if (isProcessingRef.current) {
return
}
// Don't poll if no callbacks are registered
if (pendingCallbacks.size === 0) {
return
}
isProcessingRef.current = true
try {
const agentName = getAgentName()
const teamName = getTeamName()
if (!agentName || !teamName) {
return
}
// Check each pending request for a response
for (const [requestId, _callback] of pendingCallbacks) {
const response = await pollForResponse(requestId, agentName, teamName)
if (response) {
// Process the response
const processed = processResponse(response)
if (processed) {
// Clean up the response from the worker's inbox
await removeWorkerResponse(requestId, agentName, teamName)
}
}
}
} catch (error) {
logForDebugging(
`[SwarmPermissionPoller] Error during poll: ${errorMessage(error)}`,
)
} finally {
isProcessingRef.current = false
}
}, [])
// Only poll if we're a swarm worker
const shouldPoll = isSwarmWorker()
useInterval(() => void poll(), shouldPoll ? POLL_INTERVAL_MS : null)
// Initial poll on mount
useEffect(() => {
if (isSwarmWorker()) {
void poll()
}
}, [poll])
// Intentionally empty.
}

File diff suppressed because one or more lines are too long

View File

@@ -29,7 +29,7 @@ import React from 'react';
import { getOauthConfig } from './constants/oauth.js';
import { getRemoteSessionUrl } from './constants/product.js';
import { getSystemContext, getUserContext } from './context.js';
import { init, initializeTelemetryAfterTrust } from './entrypoints/init.js';
import { init } from './entrypoints/init.js';
import { addToHistory } from './history.js';
import type { Root } from './ink.js';
import { launchRepl } from './replLauncher.js';
@@ -49,7 +49,7 @@ import { isAgentSwarmsEnabled } from './utils/agentSwarmsEnabled.js';
import { count, uniq } from './utils/array.js';
import { installAsciicastRecorder } from './utils/asciicast.js';
import { getSubscriptionType, isClaudeAISubscriber, prefetchAwsCredentialsAndBedRockInfoIfSafe, prefetchGcpCredentialsIfSafe, validateForceLoginOrg } from './utils/auth.js';
import { checkHasTrustDialogAccepted, getGlobalConfig, getRemoteControlAtStartup, isAutoUpdaterDisabled, saveGlobalConfig } from './utils/config.js';
import { checkHasTrustDialogAccepted, getGlobalConfig, getRemoteControlAtStartup, saveGlobalConfig } from './utils/config.js';
import { seedEarlyInput, stopCapturingEarlyInput } from './utils/earlyInput.js';
import { getInitialEffortSetting, parseEffortValue } from './utils/effort.js';
import { getInitialFastModeSetting, isFastModeEnabled, prefetchFastModeStatus, resolveFastModeStatusFromCache } from './utils/fastMode.js';
@@ -80,10 +80,8 @@ const coordinatorModeModule = feature('COORDINATOR_MODE') ? require('./coordinat
const assistantModule = feature('KAIROS') ? require('./assistant/index.js') as typeof import('./assistant/index.js') : null;
const kairosGate = feature('KAIROS') ? require('./assistant/gate.js') as typeof import('./assistant/gate.js') : null;
import { relative, resolve } from 'path';
import { isAnalyticsDisabled } from 'src/services/analytics/config.js';
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js';
import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js';
import { initializeAnalyticsGates } from 'src/services/analytics/sink.js';
import { getOriginalCwd, setAdditionalDirectoriesForClaudeMd, setIsRemoteMode, setMainLoopModelOverride, setMainThreadAgentType, setTeleportedSessionInfo } from './bootstrap/state.js';
import { filterCommandsForRemoteMode, getCommands } from './commands.js';
import type { StatsStore } from './context/stats.js';
@@ -103,15 +101,13 @@ import type { Message as MessageType } from './types/message.js';
import { assertMinVersion } from './utils/autoUpdater.js';
import { CLAUDE_IN_CHROME_SKILL_HINT, CLAUDE_IN_CHROME_SKILL_HINT_WITH_WEBBROWSER } from './utils/claudeInChrome/prompt.js';
import { setupClaudeInChrome, shouldAutoEnableClaudeInChrome, shouldEnableClaudeInChrome } from './utils/claudeInChrome/setup.js';
import { getContextWindowForModel } from './utils/context.js';
import { loadConversationForResume } from './utils/conversationRecovery.js';
import { buildDeepLinkBanner } from './utils/deepLink/banner.js';
import { hasNodeOption, isBareMode, isEnvTruthy, isInProtectedNamespace } from './utils/envUtils.js';
import { isBareMode, isEnvTruthy, isInProtectedNamespace } from './utils/envUtils.js';
import { refreshExampleCommands } from './utils/exampleCommands.js';
import type { FpsMetrics } from './utils/fpsTracker.js';
import { getWorktreePaths } from './utils/getWorktreePaths.js';
import { findGitRoot, getBranch, getIsGit, getWorktreeCount } from './utils/git.js';
import { getGhAuthStatus } from './utils/github/ghAuthStatus.js';
import { findGitRoot, getBranch } from './utils/git.js';
import { safeParseJSON } from './utils/json.js';
import { logError } from './utils/log.js';
import { getModelDeprecationWarning } from './utils/model/deprecation.js';
@@ -121,9 +117,7 @@ import { PERMISSION_MODES } from './utils/permissions/PermissionMode.js';
import { checkAndDisableBypassPermissions, getAutoModeEnabledStateIfCached, initializeToolPermissionContext, initialPermissionModeFromCLI, isDefaultPermissionModeAuto, parseToolListFromCLI, removeDangerousPermissions, stripDangerousPermissionsForAutoMode, verifyAutoModeGateAccess } from './utils/permissions/permissionSetup.js';
import { cleanupOrphanedPluginVersionsInBackground } from './utils/plugins/cacheUtils.js';
import { initializeVersionedPlugins } from './utils/plugins/installedPluginsManager.js';
import { getManagedPluginNames } from './utils/plugins/managedPlugins.js';
import { getGlobExclusionsForPluginCache } from './utils/plugins/orphanedPluginFilter.js';
import { getPluginSeedDirs } from './utils/plugins/pluginDirectories.js';
import { countFilesRoundedRg } from './utils/ripgrep.js';
import { processSessionStartHooks, processSetupHooks } from './utils/sessionStart.js';
import { cacheSessionTitle, getSessionIdFromLog, loadTranscriptFromFile, saveAgentSetting, saveMode, searchSessionsByCustomTitle, sessionIdExists } from './utils/sessionStorage.js';
@@ -132,8 +126,6 @@ import { getInitialSettings, getManagedSettingsKeysForLogging, getSettingsForSou
import { resetSettingsCache } from './utils/settings/settingsCache.js';
import type { ValidationError } from './utils/settings/validation.js';
import { DEFAULT_TASKS_MODE_TASK_LIST_ID, TASK_STATUSES } from './utils/tasks.js';
import { logPluginLoadErrors, logPluginsEnabledForSession } from './utils/telemetry/pluginTelemetry.js';
import { logSkillsLoaded } from './utils/telemetry/skillLoadedEvent.js';
import { generateTempFilePath } from './utils/tempfile.js';
import { validateUuid } from './utils/uuid.js';
// Plugin startup checks are now handled non-blockingly in REPL.tsx
@@ -196,7 +188,7 @@ import { filterAllowedSdkBetas } from './utils/betas.js';
import { isInBundledMode, isRunningWithBun } from './utils/bundledMode.js';
import { logForDiagnosticsNoPII } from './utils/diagLogs.js';
import { filterExistingPaths, getKnownPathsForRepo } from './utils/githubRepoPathMapping.js';
import { clearPluginCache, loadAllPluginsCacheOnly } from './utils/plugins/pluginLoader.js';
import { clearPluginCache } from './utils/plugins/pluginLoader.js';
import { migrateChangelogFromConfig } from './utils/releaseNotes.js';
import { SandboxManager } from './utils/sandbox/sandbox-adapter.js';
import { fetchSession, prepareApiRequest } from './utils/teleport/api.js';
@@ -270,56 +262,6 @@ if ("external" !== 'ant' && isBeingDebugged()) {
process.exit(1);
}
/**
* Per-session skill/plugin telemetry. Called from both the interactive path
* and the headless -p path (before runHeadless) — both go through
* main.tsx but branch before the interactive startup path, so it needs two
* call sites here rather than one here + one in QueryEngine.
*/
function logSessionTelemetry(): void {
const model = parseUserSpecifiedModel(getInitialMainLoopModel() ?? getDefaultMainLoopModel());
void logSkillsLoaded(getCwd(), getContextWindowForModel(model, getSdkBetas()));
void loadAllPluginsCacheOnly().then(({
enabled,
errors
}) => {
const managedNames = getManagedPluginNames();
logPluginsEnabledForSession(enabled, managedNames, getPluginSeedDirs());
logPluginLoadErrors(errors, managedNames);
}).catch(err => logError(err));
}
function getCertEnvVarTelemetry(): Record<string, boolean> {
const result: Record<string, boolean> = {};
if (process.env.NODE_EXTRA_CA_CERTS) {
result.has_node_extra_ca_certs = true;
}
if (process.env.CLAUDE_CODE_CLIENT_CERT) {
result.has_client_cert = true;
}
if (hasNodeOption('--use-system-ca')) {
result.has_use_system_ca = true;
}
if (hasNodeOption('--use-openssl-ca')) {
result.has_use_openssl_ca = true;
}
return result;
}
async function logStartupTelemetry(): Promise<void> {
if (isAnalyticsDisabled()) return;
const [isGit, worktreeCount, ghAuthStatus] = await Promise.all([getIsGit(), getWorktreeCount(), getGhAuthStatus()]);
logEvent('tengu_startup_telemetry', {
is_git: isGit,
worktree_count: worktreeCount,
gh_auth_status: ghAuthStatus as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
sandbox_enabled: SandboxManager.isSandboxingEnabled(),
are_unsandboxed_commands_allowed: SandboxManager.areUnsandboxedCommandsAllowed(),
is_auto_bash_allowed_if_sandbox_enabled: SandboxManager.isAutoAllowBashIfSandboxedEnabled(),
auto_updater_disabled: isAutoUpdaterDisabled(),
prefers_reduced_motion: getInitialSettings().prefersReducedMotion ?? false,
...getCertEnvVarTelemetry()
});
}
// @[MODEL LAUNCH]: Consider any migrations you may need for model strings. See migrateSonnet1mToSonnet45.ts for an example.
// Bump this when adding a new sync migration so existing users re-run the set.
const CURRENT_MIGRATION_VERSION = 11;
@@ -413,8 +355,7 @@ export function startDeferredPrefetches(): void {
}
void countFilesRoundedRg(getCwd(), AbortSignal.timeout(3000), []);
// Analytics and feature flag initialization
void initializeAnalyticsGates();
// Feature flag initialization
void prefetchOfficialMcpUrls();
void refreshModelCapabilities();
@@ -923,11 +864,8 @@ async function run(): Promise<CommanderCommand> {
process.title = 'claude';
}
// Attach logging sinks so subcommand handlers can use logEvent/logError.
// Before PR #11106 logEvent dispatched directly; after, events queue until
// a sink attaches. setup() attaches sinks for the default command, but
// subcommands (doctor, mcp, plugin, auth) never call setup() and would
// silently drop events on process.exit(). Both inits are idempotent.
// Attach shared sinks for subcommands that bypass setup(). Today this is
// just the local error-log sink; analytics/event logging is already inert.
const {
initSinks
} = await import('./utils/sinks.js');
@@ -2285,11 +2223,8 @@ async function run(): Promise<CommanderCommand> {
resetUserCache();
// Refresh GrowthBook after login to get updated feature flags (e.g., for claude.ai MCPs)
refreshGrowthBookAfterAuthChange();
// Clear any stale trusted device token then enroll for Remote Control.
// Both self-gate on tengu_sessions_elevated_auth_enforcement internally
// — enrollTrustedDevice() via checkGate_CACHED_OR_BLOCKING (awaits
// the GrowthBook reinit above), clearTrustedDeviceToken() via the
// sync cached check (acceptable since clear is idempotent).
// Clear any stale trusted-device token, then run the no-op enrollment
// stub so the disabled bridge path stays consistent after login.
void import('./bridge/trustedDevice.js').then(m => {
m.clearTrustedDeviceToken();
return m.enrollTrustedDevice();
@@ -2587,15 +2522,10 @@ async function run(): Promise<CommanderCommand> {
setHasFormattedOutput(true);
}
// Apply full environment variables in print mode since trust dialog is bypassed
// This includes potentially dangerous environment variables from untrusted sources
// Apply full environment variables in print mode since trust dialog is bypassed.
// but print mode is considered trusted (as documented in help text)
applyConfigEnvironmentVariables();
// Initialize telemetry after env vars are applied so OTEL endpoint env vars and
// otelHeadersHelper (which requires trust to execute) are available.
initializeTelemetryAfterTrust();
// Kick SessionStart hooks now so the subprocess spawn overlaps with
// MCP connect + plugin init + print.ts import below. loadInitialMessages
// joins this at print.ts:4397. Guarded same as loadInitialMessages —
@@ -2820,7 +2750,6 @@ async function run(): Promise<CommanderCommand> {
void import('./utils/sdkHeapDumpMonitor.js').then(m => m.startSdkMemoryMonitor());
}
}
logSessionTelemetry();
profileCheckpoint('before_print_import');
const {
runHeadless
@@ -3043,15 +2972,11 @@ async function run(): Promise<CommanderCommand> {
// Increment numStartups synchronously — first-render readers like
// shouldShowEffortCallout (via useState initializer) need the updated
// value before setImmediate fires. Defer only telemetry.
// value immediately.
saveGlobalConfig(current => ({
...current,
numStartups: (current.numStartups ?? 0) + 1
}));
setImmediate(() => {
void logStartupTelemetry();
logSessionTelemetry();
});
// Set up per-turn session environment data uploader (ant-only build).
// Default-enabled for all ant users when working in an Anthropic-owned

View File

@@ -8,7 +8,6 @@ import type {
SDKControlResponse,
} from '../entrypoints/sdk/controlTypes.ts'
import { logForDebugging } from '../utils/debug.js'
import { errorMessage } from '../utils/errors.js'
import { logError } from '../utils/log.js'
import { getWebSocketTLSOptions } from '../utils/mtls.js'
import { getWebSocketProxyAgent, getWebSocketProxyUrl } from '../utils/proxy.js'
@@ -54,6 +53,16 @@ function isSessionsMessage(value: unknown): value is SessionsMessage {
return typeof value.type === 'string'
}
function summarizeSessionsWebSocketErrorForDebug(error: unknown): string {
return jsonStringify({
errorType:
error instanceof Error ? error.constructor.name : typeof error,
errorName: error instanceof Error ? error.name : undefined,
hasMessage: error instanceof Error ? error.message.length > 0 : false,
hasStack: error instanceof Error ? Boolean(error.stack) : false,
})
}
export type SessionsWebSocketCallbacks = {
onMessage: (message: SessionsMessage) => void
onClose?: () => void
@@ -108,7 +117,9 @@ export class SessionsWebSocket {
const baseUrl = getOauthConfig().BASE_API_URL.replace('https://', 'wss://')
const url = `${baseUrl}/v1/sessions/ws/${this.sessionId}/subscribe?organization_uuid=${this.orgUuid}`
logForDebugging(`[SessionsWebSocket] Connecting to ${url}`)
logForDebugging(
'[SessionsWebSocket] Connecting to session subscription endpoint',
)
// Get fresh token for each connection attempt
const accessToken = this.getAccessToken()
@@ -152,9 +163,7 @@ export class SessionsWebSocket {
// eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
ws.addEventListener('close', (event: CloseEvent) => {
logForDebugging(
`[SessionsWebSocket] Closed: code=${event.code} reason=${event.reason}`,
)
logForDebugging(`[SessionsWebSocket] Closed: code=${event.code}`)
this.handleClose(event.code)
})
@@ -187,14 +196,19 @@ export class SessionsWebSocket {
})
ws.on('error', (err: Error) => {
logError(new Error(`[SessionsWebSocket] Error: ${err.message}`))
logError(
new Error(
`[SessionsWebSocket] Error: ${summarizeSessionsWebSocketErrorForDebug(
err,
)}`,
),
)
this.callbacks.onError?.(err)
})
ws.on('close', (code: number, reason: Buffer) => {
logForDebugging(
`[SessionsWebSocket] Closed: code=${code} reason=${reason.toString()}`,
)
void reason
logForDebugging(`[SessionsWebSocket] Closed: code=${code}`)
this.handleClose(code)
})
@@ -222,7 +236,9 @@ export class SessionsWebSocket {
} catch (error) {
logError(
new Error(
`[SessionsWebSocket] Failed to parse message: ${errorMessage(error)}`,
`[SessionsWebSocket] Failed to parse message: ${summarizeSessionsWebSocketErrorForDebug(
error,
)}`,
),
)
}

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,7 @@
* Shared analytics configuration
*
* Common logic for determining when analytics should be disabled
* across all analytics systems (Datadog, 1P)
* across the remaining local analytics compatibility surfaces.
*/
import { isEnvTruthy } from '../../utils/envUtils.js'
@@ -31,7 +31,7 @@ export function isAnalyticsDisabled(): boolean {
*
* Unlike isAnalyticsDisabled(), this does NOT block on 3P providers
* (Bedrock/Vertex/Foundry). The survey is a local UI prompt with no
* transcript data — enterprise customers capture responses via OTEL.
* transcript upload in this fork.
*/
export function isFeedbackSurveyDisabled(): boolean {
return process.env.NODE_ENV === 'test' || isTelemetryDisabled()

View File

@@ -1,307 +0,0 @@
import axios from 'axios'
import { createHash } from 'crypto'
import memoize from 'lodash-es/memoize.js'
import { getOrCreateUserID } from '../../utils/config.js'
import { logError } from '../../utils/log.js'
import { getCanonicalName } from '../../utils/model/model.js'
import { getAPIProvider } from '../../utils/model/providers.js'
import { MODEL_COSTS } from '../../utils/modelCost.js'
import { isAnalyticsDisabled } from './config.js'
import { getEventMetadata } from './metadata.js'
const DATADOG_LOGS_ENDPOINT =
'https://http-intake.logs.us5.datadoghq.com/api/v2/logs'
const DATADOG_CLIENT_TOKEN = 'pubbbf48e6d78dae54bceaa4acf463299bf'
const DEFAULT_FLUSH_INTERVAL_MS = 15000
const MAX_BATCH_SIZE = 100
const NETWORK_TIMEOUT_MS = 5000
const DATADOG_ALLOWED_EVENTS = new Set([
'chrome_bridge_connection_succeeded',
'chrome_bridge_connection_failed',
'chrome_bridge_disconnected',
'chrome_bridge_tool_call_completed',
'chrome_bridge_tool_call_error',
'chrome_bridge_tool_call_started',
'chrome_bridge_tool_call_timeout',
'tengu_api_error',
'tengu_api_success',
'tengu_brief_mode_enabled',
'tengu_brief_mode_toggled',
'tengu_brief_send',
'tengu_cancel',
'tengu_compact_failed',
'tengu_exit',
'tengu_flicker',
'tengu_init',
'tengu_model_fallback_triggered',
'tengu_oauth_error',
'tengu_oauth_success',
'tengu_oauth_token_refresh_failure',
'tengu_oauth_token_refresh_success',
'tengu_oauth_token_refresh_lock_acquiring',
'tengu_oauth_token_refresh_lock_acquired',
'tengu_oauth_token_refresh_starting',
'tengu_oauth_token_refresh_completed',
'tengu_oauth_token_refresh_lock_releasing',
'tengu_oauth_token_refresh_lock_released',
'tengu_query_error',
'tengu_session_file_read',
'tengu_started',
'tengu_tool_use_error',
'tengu_tool_use_granted_in_prompt_permanent',
'tengu_tool_use_granted_in_prompt_temporary',
'tengu_tool_use_rejected_in_prompt',
'tengu_tool_use_success',
'tengu_uncaught_exception',
'tengu_unhandled_rejection',
'tengu_voice_recording_started',
'tengu_voice_toggled',
'tengu_team_mem_sync_pull',
'tengu_team_mem_sync_push',
'tengu_team_mem_sync_started',
'tengu_team_mem_entries_capped',
])
const TAG_FIELDS = [
'arch',
'clientType',
'errorType',
'http_status_range',
'http_status',
'kairosActive',
'model',
'platform',
'provider',
'skillMode',
'subscriptionType',
'toolName',
'userBucket',
'userType',
'version',
'versionBase',
]
function camelToSnakeCase(str: string): string {
return str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`)
}
type DatadogLog = {
ddsource: string
ddtags: string
message: string
service: string
hostname: string
[key: string]: unknown
}
let logBatch: DatadogLog[] = []
let flushTimer: NodeJS.Timeout | null = null
let datadogInitialized: boolean | null = null
async function flushLogs(): Promise<void> {
if (logBatch.length === 0) return
const logsToSend = logBatch
logBatch = []
try {
await axios.post(DATADOG_LOGS_ENDPOINT, logsToSend, {
headers: {
'Content-Type': 'application/json',
'DD-API-KEY': DATADOG_CLIENT_TOKEN,
},
timeout: NETWORK_TIMEOUT_MS,
})
} catch (error) {
logError(error)
}
}
function scheduleFlush(): void {
if (flushTimer) return
flushTimer = setTimeout(() => {
flushTimer = null
void flushLogs()
}, getFlushIntervalMs()).unref()
}
export const initializeDatadog = memoize(async (): Promise<boolean> => {
if (isAnalyticsDisabled()) {
datadogInitialized = false
return false
}
try {
datadogInitialized = true
return true
} catch (error) {
logError(error)
datadogInitialized = false
return false
}
})
/**
* Flush remaining Datadog logs and shut down.
* Called from gracefulShutdown() before process.exit() since
* forceExit() prevents the beforeExit handler from firing.
*/
export async function shutdownDatadog(): Promise<void> {
if (flushTimer) {
clearTimeout(flushTimer)
flushTimer = null
}
await flushLogs()
}
// NOTE: use via src/services/analytics/index.ts > logEvent
export async function trackDatadogEvent(
eventName: string,
properties: { [key: string]: boolean | number | undefined },
): Promise<void> {
if (process.env.NODE_ENV !== 'production') {
return
}
// Don't send events for 3P providers (Bedrock, Vertex, Foundry)
if (getAPIProvider() !== 'firstParty') {
return
}
// Fast path: use cached result if available to avoid await overhead
let initialized = datadogInitialized
if (initialized === null) {
initialized = await initializeDatadog()
}
if (!initialized || !DATADOG_ALLOWED_EVENTS.has(eventName)) {
return
}
try {
const metadata = await getEventMetadata({
model: properties.model,
betas: properties.betas,
})
// Destructure to avoid duplicate envContext (once nested, once flattened)
const { envContext, ...restMetadata } = metadata
const allData: Record<string, unknown> = {
...restMetadata,
...envContext,
...properties,
userBucket: getUserBucket(),
}
// Normalize MCP tool names to "mcp" for cardinality reduction
if (
typeof allData.toolName === 'string' &&
allData.toolName.startsWith('mcp__')
) {
allData.toolName = 'mcp'
}
// Normalize model names for cardinality reduction (external users only)
if (process.env.USER_TYPE !== 'ant' && typeof allData.model === 'string') {
const shortName = getCanonicalName(allData.model.replace(/\[1m]$/i, ''))
allData.model = shortName in MODEL_COSTS ? shortName : 'other'
}
// Truncate dev version to base + date (remove timestamp and sha for cardinality reduction)
// e.g. "2.0.53-dev.20251124.t173302.sha526cc6a" -> "2.0.53-dev.20251124"
if (typeof allData.version === 'string') {
allData.version = allData.version.replace(
/^(\d+\.\d+\.\d+-dev\.\d{8})\.t\d+\.sha[a-f0-9]+$/,
'$1',
)
}
// Transform status to http_status and http_status_range to avoid Datadog reserved field
if (allData.status !== undefined && allData.status !== null) {
const statusCode = String(allData.status)
allData.http_status = statusCode
// Determine status range (1xx, 2xx, 3xx, 4xx, 5xx)
const firstDigit = statusCode.charAt(0)
if (firstDigit >= '1' && firstDigit <= '5') {
allData.http_status_range = `${firstDigit}xx`
}
// Remove original status field to avoid conflict with Datadog's reserved field
delete allData.status
}
// Build ddtags with high-cardinality fields for filtering.
// event:<name> is prepended so the event name is searchable via the
// log search API — the `message` field (where eventName also lives)
// is a DD reserved field and is NOT queryable from dashboard widget
// queries or the aggregation API. See scripts/release/MONITORING.md.
const allDataRecord = allData
const tags = [
`event:${eventName}`,
...TAG_FIELDS.filter(
field =>
allDataRecord[field] !== undefined && allDataRecord[field] !== null,
).map(field => `${camelToSnakeCase(field)}:${allDataRecord[field]}`),
]
const log: DatadogLog = {
ddsource: 'nodejs',
ddtags: tags.join(','),
message: eventName,
service: 'claude-code',
hostname: 'claude-code',
env: process.env.USER_TYPE,
}
// Add all fields as searchable attributes (not duplicated in tags)
for (const [key, value] of Object.entries(allData)) {
if (value !== undefined && value !== null) {
log[camelToSnakeCase(key)] = value
}
}
logBatch.push(log)
// Flush immediately if batch is full, otherwise schedule
if (logBatch.length >= MAX_BATCH_SIZE) {
if (flushTimer) {
clearTimeout(flushTimer)
flushTimer = null
}
void flushLogs()
} else {
scheduleFlush()
}
} catch (error) {
logError(error)
}
}
const NUM_USER_BUCKETS = 30
/**
* Gets a 'bucket' that the user ID falls into.
*
* For alerting purposes, we want to alert on the number of users impacted
* by an issue, rather than the number of events- often a small number of users
* can generate a large number of events (e.g. due to retries). To approximate
* this without ruining cardinality by counting user IDs directly, we hash the user ID
* and assign it to one of a fixed number of buckets.
*
* This allows us to estimate the number of unique users by counting unique buckets,
* while preserving user privacy and reducing cardinality.
*/
const getUserBucket = memoize((): number => {
const userId = getOrCreateUserID()
const hash = createHash('sha256').update(userId).digest('hex')
return parseInt(hash.slice(0, 8), 16) % NUM_USER_BUCKETS
})
function getFlushIntervalMs(): number {
// Allow tests to override to not block on the default flush interval.
return (
parseInt(process.env.CLAUDE_CODE_DATADOG_FLUSH_INTERVAL_MS || '', 10) ||
DEFAULT_FLUSH_INTERVAL_MS
)
}

View File

@@ -1,449 +0,0 @@
import type { AnyValueMap, Logger, logs } from '@opentelemetry/api-logs'
import { resourceFromAttributes } from '@opentelemetry/resources'
import {
BatchLogRecordProcessor,
LoggerProvider,
} from '@opentelemetry/sdk-logs'
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from '@opentelemetry/semantic-conventions'
import { randomUUID } from 'crypto'
import { isEqual } from 'lodash-es'
import { getOrCreateUserID } from '../../utils/config.js'
import { logForDebugging } from '../../utils/debug.js'
import { logError } from '../../utils/log.js'
import { getPlatform, getWslVersion } from '../../utils/platform.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import { profileCheckpoint } from '../../utils/startupProfiler.js'
import { getCoreUserData } from '../../utils/user.js'
import { isAnalyticsDisabled } from './config.js'
import { FirstPartyEventLoggingExporter } from './firstPartyEventLoggingExporter.js'
import type { GrowthBookUserAttributes } from './growthbook.js'
import { getDynamicConfig_CACHED_MAY_BE_STALE } from './growthbook.js'
import { getEventMetadata } from './metadata.js'
import { isSinkKilled } from './sinkKillswitch.js'
/**
* Configuration for sampling individual event types.
* Each event name maps to an object containing sample_rate (0-1).
* Events not in the config are logged at 100% rate.
*/
export type EventSamplingConfig = {
[eventName: string]: {
sample_rate: number
}
}
const EVENT_SAMPLING_CONFIG_NAME = 'tengu_event_sampling_config'
/**
* Get the event sampling configuration from GrowthBook.
* Uses cached value if available, updates cache in background.
*/
export function getEventSamplingConfig(): EventSamplingConfig {
return getDynamicConfig_CACHED_MAY_BE_STALE<EventSamplingConfig>(
EVENT_SAMPLING_CONFIG_NAME,
{},
)
}
/**
* Determine if an event should be sampled based on its sample rate.
* Returns the sample rate if sampled, null if not sampled.
*
* @param eventName - Name of the event to check
* @returns The sample_rate if event should be logged, null if it should be dropped
*/
export function shouldSampleEvent(eventName: string): number | null {
const config = getEventSamplingConfig()
const eventConfig = config[eventName]
// If no config for this event, log at 100% rate (no sampling)
if (!eventConfig) {
return null
}
const sampleRate = eventConfig.sample_rate
// Validate sample rate is in valid range
if (typeof sampleRate !== 'number' || sampleRate < 0 || sampleRate > 1) {
return null
}
// Sample rate of 1 means log everything (no need to add metadata)
if (sampleRate >= 1) {
return null
}
// Sample rate of 0 means drop everything
if (sampleRate <= 0) {
return 0
}
// Randomly decide whether to sample this event
return Math.random() < sampleRate ? sampleRate : 0
}
const BATCH_CONFIG_NAME = 'tengu_1p_event_batch_config'
type BatchConfig = {
scheduledDelayMillis?: number
maxExportBatchSize?: number
maxQueueSize?: number
skipAuth?: boolean
maxAttempts?: number
path?: string
baseUrl?: string
}
function getBatchConfig(): BatchConfig {
return getDynamicConfig_CACHED_MAY_BE_STALE<BatchConfig>(
BATCH_CONFIG_NAME,
{},
)
}
// Module-local state for event logging (not exposed globally)
let firstPartyEventLogger: ReturnType<typeof logs.getLogger> | null = null
let firstPartyEventLoggerProvider: LoggerProvider | null = null
// Last batch config used to construct the provider — used by
// reinitialize1PEventLoggingIfConfigChanged to decide whether a rebuild is
// needed when GrowthBook refreshes.
let lastBatchConfig: BatchConfig | null = null
/**
* Flush and shutdown the 1P event logger.
* This should be called as the final step before process exit to ensure
* all events (including late ones from API responses) are exported.
*/
export async function shutdown1PEventLogging(): Promise<void> {
if (!firstPartyEventLoggerProvider) {
return
}
try {
await firstPartyEventLoggerProvider.shutdown()
if (process.env.USER_TYPE === 'ant') {
logForDebugging('1P event logging: final shutdown complete')
}
} catch {
// Ignore shutdown errors
}
}
/**
* Check if 1P event logging is enabled.
* Respects the same opt-outs as other analytics sinks:
* - Test environment
* - Third-party cloud providers (Bedrock/Vertex)
* - Global telemetry opt-outs
* - Non-essential traffic disabled
*
* Note: Unlike BigQuery metrics, event logging does NOT check organization-level
* metrics opt-out via API. It follows the same pattern as Statsig event logging.
*/
export function is1PEventLoggingEnabled(): boolean {
// Respect standard analytics opt-outs
return !isAnalyticsDisabled()
}
/**
* Log a 1st-party event for internal analytics (async version).
* Events are batched and exported to /api/event_logging/batch
*
* This enriches the event with core metadata (model, session, env context, etc.)
* at log time, similar to logEventToStatsig.
*
* @param eventName - Name of the event (e.g., 'tengu_api_query')
* @param metadata - Additional metadata for the event (intentionally no strings, to avoid accidentally logging code/filepaths)
*/
async function logEventTo1PAsync(
firstPartyEventLogger: Logger,
eventName: string,
metadata: Record<string, number | boolean | undefined> = {},
): Promise<void> {
try {
// Enrich with core metadata at log time (similar to Statsig pattern)
const coreMetadata = await getEventMetadata({
model: metadata.model,
betas: metadata.betas,
})
// Build attributes - OTel supports nested objects natively via AnyValueMap
// Cast through unknown since our nested objects are structurally compatible
// with AnyValue but TS doesn't recognize it due to missing index signatures
const attributes = {
event_name: eventName,
event_id: randomUUID(),
// Pass objects directly - no JSON serialization needed
core_metadata: coreMetadata,
user_metadata: getCoreUserData(true),
event_metadata: metadata,
} as unknown as AnyValueMap
// Add user_id if available
const userId = getOrCreateUserID()
if (userId) {
attributes.user_id = userId
}
// Debug logging when debug mode is enabled
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
`[ANT-ONLY] 1P event: ${eventName} ${jsonStringify(metadata, null, 0)}`,
)
}
// Emit log record
firstPartyEventLogger.emit({
body: eventName,
attributes,
})
} catch (e) {
if (process.env.NODE_ENV === 'development') {
throw e
}
if (process.env.USER_TYPE === 'ant') {
logError(e as Error)
}
// swallow
}
}
/**
* Log a 1st-party event for internal analytics.
* Events are batched and exported to /api/event_logging/batch
*
* @param eventName - Name of the event (e.g., 'tengu_api_query')
* @param metadata - Additional metadata for the event (intentionally no strings, to avoid accidentally logging code/filepaths)
*/
export function logEventTo1P(
eventName: string,
metadata: Record<string, number | boolean | undefined> = {},
): void {
if (!is1PEventLoggingEnabled()) {
return
}
if (!firstPartyEventLogger || isSinkKilled('firstParty')) {
return
}
// Fire and forget - don't block on metadata enrichment
void logEventTo1PAsync(firstPartyEventLogger, eventName, metadata)
}
/**
* GrowthBook experiment event data for logging
*/
export type GrowthBookExperimentData = {
experimentId: string
variationId: number
userAttributes?: GrowthBookUserAttributes
experimentMetadata?: Record<string, unknown>
}
// api.anthropic.com only serves the "production" GrowthBook environment
// (see starling/starling/cli/cli.py DEFAULT_ENVIRONMENTS). Staging and
// development environments are not exported to the prod API.
function getEnvironmentForGrowthBook(): string {
return 'production'
}
/**
* Log a GrowthBook experiment assignment event to 1P.
* Events are batched and exported to /api/event_logging/batch
*
* @param data - GrowthBook experiment assignment data
*/
export function logGrowthBookExperimentTo1P(
data: GrowthBookExperimentData,
): void {
if (!is1PEventLoggingEnabled()) {
return
}
if (!firstPartyEventLogger || isSinkKilled('firstParty')) {
return
}
const userId = getOrCreateUserID()
const { accountUuid, organizationUuid } = getCoreUserData(true)
// Build attributes for GrowthbookExperimentEvent
const attributes = {
event_type: 'GrowthbookExperimentEvent',
event_id: randomUUID(),
experiment_id: data.experimentId,
variation_id: data.variationId,
...(userId && { device_id: userId }),
...(accountUuid && { account_uuid: accountUuid }),
...(organizationUuid && { organization_uuid: organizationUuid }),
...(data.userAttributes && {
session_id: data.userAttributes.sessionId,
user_attributes: jsonStringify(data.userAttributes),
}),
...(data.experimentMetadata && {
experiment_metadata: jsonStringify(data.experimentMetadata),
}),
environment: getEnvironmentForGrowthBook(),
}
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
`[ANT-ONLY] 1P GrowthBook experiment: ${data.experimentId} variation=${data.variationId}`,
)
}
firstPartyEventLogger.emit({
body: 'growthbook_experiment',
attributes,
})
}
const DEFAULT_LOGS_EXPORT_INTERVAL_MS = 10000
const DEFAULT_MAX_EXPORT_BATCH_SIZE = 200
const DEFAULT_MAX_QUEUE_SIZE = 8192
/**
* Initialize 1P event logging infrastructure.
* This creates a separate LoggerProvider for internal event logging,
* independent of customer OTLP telemetry.
*
* This uses its own minimal resource configuration with just the attributes
* we need for internal analytics (service name, version, platform info).
*/
export function initialize1PEventLogging(): void {
profileCheckpoint('1p_event_logging_start')
const enabled = is1PEventLoggingEnabled()
if (!enabled) {
if (process.env.USER_TYPE === 'ant') {
logForDebugging('1P event logging not enabled')
}
return
}
// Fetch batch processor configuration from GrowthBook dynamic config
// Uses cached value if available, refreshes in background
const batchConfig = getBatchConfig()
lastBatchConfig = batchConfig
profileCheckpoint('1p_event_after_growthbook_config')
const scheduledDelayMillis =
batchConfig.scheduledDelayMillis ||
parseInt(
process.env.OTEL_LOGS_EXPORT_INTERVAL ||
DEFAULT_LOGS_EXPORT_INTERVAL_MS.toString(),
)
const maxExportBatchSize =
batchConfig.maxExportBatchSize || DEFAULT_MAX_EXPORT_BATCH_SIZE
const maxQueueSize = batchConfig.maxQueueSize || DEFAULT_MAX_QUEUE_SIZE
// Build our own resource for 1P event logging with minimal attributes
const platform = getPlatform()
const attributes: Record<string, string> = {
[ATTR_SERVICE_NAME]: 'claude-code',
[ATTR_SERVICE_VERSION]: MACRO.VERSION,
}
// Add WSL-specific attributes if running on WSL
if (platform === 'wsl') {
const wslVersion = getWslVersion()
if (wslVersion) {
attributes['wsl.version'] = wslVersion
}
}
const resource = resourceFromAttributes(attributes)
// Create a new LoggerProvider with the EventLoggingExporter
// NOTE: This is kept separate from customer telemetry logs to ensure
// internal events don't leak to customer endpoints and vice versa.
// We don't register this globally - it's only used for internal event logging.
const eventLoggingExporter = new FirstPartyEventLoggingExporter({
maxBatchSize: maxExportBatchSize,
skipAuth: batchConfig.skipAuth,
maxAttempts: batchConfig.maxAttempts,
path: batchConfig.path,
baseUrl: batchConfig.baseUrl,
isKilled: () => isSinkKilled('firstParty'),
})
firstPartyEventLoggerProvider = new LoggerProvider({
resource,
processors: [
new BatchLogRecordProcessor(eventLoggingExporter, {
scheduledDelayMillis,
maxExportBatchSize,
maxQueueSize,
}),
],
})
// Initialize event logger from our internal provider (NOT from global API)
// IMPORTANT: We must get the logger from our local provider, not logs.getLogger()
// because logs.getLogger() returns a logger from the global provider, which is
// separate and used for customer telemetry.
firstPartyEventLogger = firstPartyEventLoggerProvider.getLogger(
'com.anthropic.claude_code.events',
MACRO.VERSION,
)
}
/**
* Rebuild the 1P event logging pipeline if the batch config changed.
* Register this with onGrowthBookRefresh so long-running sessions pick up
* changes to batch size, delay, endpoint, etc.
*
* Event-loss safety:
* 1. Null the logger first — concurrent logEventTo1P() calls hit the
* !firstPartyEventLogger guard and bail during the swap window. This drops
* a handful of events but prevents emitting to a draining provider.
* 2. forceFlush() drains the old BatchLogRecordProcessor buffer to the
* exporter. Export failures go to disk at getCurrentBatchFilePath() which
* is keyed by module-level BATCH_UUID + sessionId — unchanged across
* reinit — so the NEW exporter's disk-backed retry picks them up.
* 3. Swap to new provider/logger; old provider shutdown runs in background
* (buffer already drained, just cleanup).
*/
export async function reinitialize1PEventLoggingIfConfigChanged(): Promise<void> {
if (!is1PEventLoggingEnabled() || !firstPartyEventLoggerProvider) {
return
}
const newConfig = getBatchConfig()
if (isEqual(newConfig, lastBatchConfig)) {
return
}
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
`1P event logging: ${BATCH_CONFIG_NAME} changed, reinitializing`,
)
}
const oldProvider = firstPartyEventLoggerProvider
const oldLogger = firstPartyEventLogger
firstPartyEventLogger = null
try {
await oldProvider.forceFlush()
} catch {
// Export failures are already on disk; new exporter will retry them.
}
firstPartyEventLoggerProvider = null
try {
initialize1PEventLogging()
} catch (e) {
// Restore so the next GrowthBook refresh can retry. oldProvider was
// only forceFlush()'d, not shut down — it's still functional. Without
// this, both stay null and the !firstPartyEventLoggerProvider gate at
// the top makes recovery impossible.
firstPartyEventLoggerProvider = oldProvider
firstPartyEventLogger = oldLogger
logError(e)
return
}
void oldProvider.shutdown().catch(() => {})
}

View File

@@ -1,806 +0,0 @@
import type { HrTime } from '@opentelemetry/api'
import { type ExportResult, ExportResultCode } from '@opentelemetry/core'
import type {
LogRecordExporter,
ReadableLogRecord,
} from '@opentelemetry/sdk-logs'
import axios from 'axios'
import { randomUUID } from 'crypto'
import { appendFile, mkdir, readdir, unlink, writeFile } from 'fs/promises'
import * as path from 'path'
import type { CoreUserData } from 'src/utils/user.js'
import {
getIsNonInteractiveSession,
getSessionId,
} from '../../bootstrap/state.js'
import { ClaudeCodeInternalEvent } from '../../types/generated/events_mono/claude_code/v1/claude_code_internal_event.js'
import { GrowthbookExperimentEvent } from '../../types/generated/events_mono/growthbook/v1/growthbook_experiment_event.js'
import {
getClaudeAIOAuthTokens,
hasProfileScope,
isClaudeAISubscriber,
} from '../../utils/auth.js'
import { checkHasTrustDialogAccepted } from '../../utils/config.js'
import { logForDebugging } from '../../utils/debug.js'
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
import { errorMessage, isFsInaccessible, toError } from '../../utils/errors.js'
import { getAuthHeaders } from '../../utils/http.js'
import { readJSONLFile } from '../../utils/json.js'
import { logError } from '../../utils/log.js'
import { sleep } from '../../utils/sleep.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import { getClaudeCodeUserAgent } from '../../utils/userAgent.js'
import { isOAuthTokenExpired } from '../oauth/client.js'
import { stripProtoFields } from './index.js'
import { type EventMetadata, to1PEventFormat } from './metadata.js'
// Unique ID for this process run - used to isolate failed event files between runs
const BATCH_UUID = randomUUID()
// File prefix for failed event storage
const FILE_PREFIX = '1p_failed_events.'
// Storage directory for failed events - evaluated at runtime to respect CLAUDE_CONFIG_DIR in tests
function getStorageDir(): string {
return path.join(getClaudeConfigHomeDir(), 'telemetry')
}
// API envelope - event_data is the JSON output from proto toJSON()
type FirstPartyEventLoggingEvent = {
event_type: 'ClaudeCodeInternalEvent' | 'GrowthbookExperimentEvent'
event_data: unknown
}
type FirstPartyEventLoggingPayload = {
events: FirstPartyEventLoggingEvent[]
}
/**
* Exporter for 1st-party event logging to /api/event_logging/batch.
*
* Export cycles are controlled by OpenTelemetry's BatchLogRecordProcessor, which
* triggers export() when either:
* - Time interval elapses (default: 5 seconds via scheduledDelayMillis)
* - Batch size is reached (default: 200 events via maxExportBatchSize)
*
* This exporter adds resilience on top:
* - Append-only log for failed events (concurrency-safe)
* - Quadratic backoff retry for failed events, dropped after maxAttempts
* - Immediate retry of queued events when any export succeeds (endpoint is healthy)
* - Chunking large event sets into smaller batches
* - Auth fallback: retries without auth on 401 errors
*/
export class FirstPartyEventLoggingExporter implements LogRecordExporter {
private readonly endpoint: string
private readonly timeout: number
private readonly maxBatchSize: number
private readonly skipAuth: boolean
private readonly batchDelayMs: number
private readonly baseBackoffDelayMs: number
private readonly maxBackoffDelayMs: number
private readonly maxAttempts: number
private readonly isKilled: () => boolean
private pendingExports: Promise<void>[] = []
private isShutdown = false
private readonly schedule: (
fn: () => Promise<void>,
delayMs: number,
) => () => void
private cancelBackoff: (() => void) | null = null
private attempts = 0
private isRetrying = false
private lastExportErrorContext: string | undefined
constructor(
options: {
timeout?: number
maxBatchSize?: number
skipAuth?: boolean
batchDelayMs?: number
baseBackoffDelayMs?: number
maxBackoffDelayMs?: number
maxAttempts?: number
path?: string
baseUrl?: string
// Injected killswitch probe. Checked per-POST so that disabling the
// firstParty sink also stops backoff retries (not just new emits).
// Passed in rather than imported to avoid a cycle with firstPartyEventLogger.ts.
isKilled?: () => boolean
schedule?: (fn: () => Promise<void>, delayMs: number) => () => void
} = {},
) {
// Default: prod, except when ANTHROPIC_BASE_URL is explicitly staging.
// Overridable via tengu_1p_event_batch_config.baseUrl.
const baseUrl =
options.baseUrl ||
(process.env.ANTHROPIC_BASE_URL === 'https://api-staging.anthropic.com'
? 'https://api-staging.anthropic.com'
: 'https://api.anthropic.com')
this.endpoint = `${baseUrl}${options.path || '/api/event_logging/batch'}`
this.timeout = options.timeout || 10000
this.maxBatchSize = options.maxBatchSize || 200
this.skipAuth = options.skipAuth ?? false
this.batchDelayMs = options.batchDelayMs || 100
this.baseBackoffDelayMs = options.baseBackoffDelayMs || 500
this.maxBackoffDelayMs = options.maxBackoffDelayMs || 30000
this.maxAttempts = options.maxAttempts ?? 8
this.isKilled = options.isKilled ?? (() => false)
this.schedule =
options.schedule ??
((fn, ms) => {
const t = setTimeout(fn, ms)
return () => clearTimeout(t)
})
// Retry any failed events from previous runs of this session (in background)
void this.retryPreviousBatches()
}
// Expose for testing
async getQueuedEventCount(): Promise<number> {
return (await this.loadEventsFromCurrentBatch()).length
}
// --- Storage helpers ---
private getCurrentBatchFilePath(): string {
return path.join(
getStorageDir(),
`${FILE_PREFIX}${getSessionId()}.${BATCH_UUID}.json`,
)
}
private async loadEventsFromFile(
filePath: string,
): Promise<FirstPartyEventLoggingEvent[]> {
try {
return await readJSONLFile<FirstPartyEventLoggingEvent>(filePath)
} catch {
return []
}
}
private async loadEventsFromCurrentBatch(): Promise<
FirstPartyEventLoggingEvent[]
> {
return this.loadEventsFromFile(this.getCurrentBatchFilePath())
}
private async saveEventsToFile(
filePath: string,
events: FirstPartyEventLoggingEvent[],
): Promise<void> {
try {
if (events.length === 0) {
try {
await unlink(filePath)
} catch {
// File doesn't exist, nothing to delete
}
} else {
// Ensure storage directory exists
await mkdir(getStorageDir(), { recursive: true })
// Write as JSON lines (one event per line)
const content = events.map(e => jsonStringify(e)).join('\n') + '\n'
await writeFile(filePath, content, 'utf8')
}
} catch (error) {
logError(error)
}
}
private async appendEventsToFile(
filePath: string,
events: FirstPartyEventLoggingEvent[],
): Promise<void> {
if (events.length === 0) return
try {
// Ensure storage directory exists
await mkdir(getStorageDir(), { recursive: true })
// Append as JSON lines (one event per line) - atomic on most filesystems
const content = events.map(e => jsonStringify(e)).join('\n') + '\n'
await appendFile(filePath, content, 'utf8')
} catch (error) {
logError(error)
}
}
private async deleteFile(filePath: string): Promise<void> {
try {
await unlink(filePath)
} catch {
// File doesn't exist or can't be deleted, ignore
}
}
// --- Previous batch retry (startup) ---
private async retryPreviousBatches(): Promise<void> {
try {
const prefix = `${FILE_PREFIX}${getSessionId()}.`
let files: string[]
try {
files = (await readdir(getStorageDir()))
.filter((f: string) => f.startsWith(prefix) && f.endsWith('.json'))
.filter((f: string) => !f.includes(BATCH_UUID)) // Exclude current batch
} catch (e) {
if (isFsInaccessible(e)) return
throw e
}
for (const file of files) {
const filePath = path.join(getStorageDir(), file)
void this.retryFileInBackground(filePath)
}
} catch (error) {
logError(error)
}
}
private async retryFileInBackground(filePath: string): Promise<void> {
if (this.attempts >= this.maxAttempts) {
await this.deleteFile(filePath)
return
}
const events = await this.loadEventsFromFile(filePath)
if (events.length === 0) {
await this.deleteFile(filePath)
return
}
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
`1P event logging: retrying ${events.length} events from previous batch`,
)
}
const failedEvents = await this.sendEventsInBatches(events)
if (failedEvents.length === 0) {
await this.deleteFile(filePath)
if (process.env.USER_TYPE === 'ant') {
logForDebugging('1P event logging: previous batch retry succeeded')
}
} else {
// Save only the failed events back (not all original events)
await this.saveEventsToFile(filePath, failedEvents)
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
`1P event logging: previous batch retry failed, ${failedEvents.length} events remain`,
)
}
}
}
async export(
logs: ReadableLogRecord[],
resultCallback: (result: ExportResult) => void,
): Promise<void> {
if (this.isShutdown) {
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
'1P event logging export failed: Exporter has been shutdown',
)
}
resultCallback({
code: ExportResultCode.FAILED,
error: new Error('Exporter has been shutdown'),
})
return
}
const exportPromise = this.doExport(logs, resultCallback)
this.pendingExports.push(exportPromise)
// Clean up completed exports
void exportPromise.finally(() => {
const index = this.pendingExports.indexOf(exportPromise)
if (index > -1) {
void this.pendingExports.splice(index, 1)
}
})
}
private async doExport(
logs: ReadableLogRecord[],
resultCallback: (result: ExportResult) => void,
): Promise<void> {
try {
// Filter for event logs only (by scope name)
const eventLogs = logs.filter(
log =>
log.instrumentationScope?.name === 'com.anthropic.claude_code.events',
)
if (eventLogs.length === 0) {
resultCallback({ code: ExportResultCode.SUCCESS })
return
}
// Transform new logs (failed events are retried independently via backoff)
const events = this.transformLogsToEvents(eventLogs).events
if (events.length === 0) {
resultCallback({ code: ExportResultCode.SUCCESS })
return
}
if (this.attempts >= this.maxAttempts) {
resultCallback({
code: ExportResultCode.FAILED,
error: new Error(
`Dropped ${events.length} events: max attempts (${this.maxAttempts}) reached`,
),
})
return
}
// Send events
const failedEvents = await this.sendEventsInBatches(events)
this.attempts++
if (failedEvents.length > 0) {
await this.queueFailedEvents(failedEvents)
this.scheduleBackoffRetry()
const context = this.lastExportErrorContext
? ` (${this.lastExportErrorContext})`
: ''
resultCallback({
code: ExportResultCode.FAILED,
error: new Error(
`Failed to export ${failedEvents.length} events${context}`,
),
})
return
}
// Success - reset backoff and immediately retry any queued events
this.resetBackoff()
if ((await this.getQueuedEventCount()) > 0 && !this.isRetrying) {
void this.retryFailedEvents()
}
resultCallback({ code: ExportResultCode.SUCCESS })
} catch (error) {
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
`1P event logging export failed: ${errorMessage(error)}`,
)
}
logError(error)
resultCallback({
code: ExportResultCode.FAILED,
error: toError(error),
})
}
}
private async sendEventsInBatches(
events: FirstPartyEventLoggingEvent[],
): Promise<FirstPartyEventLoggingEvent[]> {
// Chunk events into batches
const batches: FirstPartyEventLoggingEvent[][] = []
for (let i = 0; i < events.length; i += this.maxBatchSize) {
batches.push(events.slice(i, i + this.maxBatchSize))
}
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
`1P event logging: exporting ${events.length} events in ${batches.length} batch(es)`,
)
}
// Send each batch with delay between them. On first failure, assume the
// endpoint is down and short-circuit: queue the failed batch plus all
// remaining unsent batches without POSTing them. The backoff retry will
// probe again with a single batch next tick.
const failedBatchEvents: FirstPartyEventLoggingEvent[] = []
let lastErrorContext: string | undefined
for (let i = 0; i < batches.length; i++) {
const batch = batches[i]!
try {
await this.sendBatchWithRetry({ events: batch })
} catch (error) {
lastErrorContext = getAxiosErrorContext(error)
for (let j = i; j < batches.length; j++) {
failedBatchEvents.push(...batches[j]!)
}
if (process.env.USER_TYPE === 'ant') {
const skipped = batches.length - 1 - i
logForDebugging(
`1P event logging: batch ${i + 1}/${batches.length} failed (${lastErrorContext}); short-circuiting ${skipped} remaining batch(es)`,
)
}
break
}
if (i < batches.length - 1 && this.batchDelayMs > 0) {
await sleep(this.batchDelayMs)
}
}
if (failedBatchEvents.length > 0 && lastErrorContext) {
this.lastExportErrorContext = lastErrorContext
}
return failedBatchEvents
}
private async queueFailedEvents(
events: FirstPartyEventLoggingEvent[],
): Promise<void> {
const filePath = this.getCurrentBatchFilePath()
// Append-only: just add new events to file (atomic on most filesystems)
await this.appendEventsToFile(filePath, events)
const context = this.lastExportErrorContext
? ` (${this.lastExportErrorContext})`
: ''
const message = `1P event logging: ${events.length} events failed to export${context}`
logError(new Error(message))
}
private scheduleBackoffRetry(): void {
// Don't schedule if already retrying or shutdown
if (this.cancelBackoff || this.isRetrying || this.isShutdown) {
return
}
// Quadratic backoff (matching Statsig SDK): base * attempts²
const delay = Math.min(
this.baseBackoffDelayMs * this.attempts * this.attempts,
this.maxBackoffDelayMs,
)
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
`1P event logging: scheduling backoff retry in ${delay}ms (attempt ${this.attempts})`,
)
}
this.cancelBackoff = this.schedule(async () => {
this.cancelBackoff = null
await this.retryFailedEvents()
}, delay)
}
private async retryFailedEvents(): Promise<void> {
const filePath = this.getCurrentBatchFilePath()
// Keep retrying while there are events and endpoint is healthy
while (!this.isShutdown) {
const events = await this.loadEventsFromFile(filePath)
if (events.length === 0) break
if (this.attempts >= this.maxAttempts) {
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
`1P event logging: max attempts (${this.maxAttempts}) reached, dropping ${events.length} events`,
)
}
await this.deleteFile(filePath)
this.resetBackoff()
return
}
this.isRetrying = true
// Clear file before retry (we have events in memory now)
await this.deleteFile(filePath)
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
`1P event logging: retrying ${events.length} failed events (attempt ${this.attempts + 1})`,
)
}
const failedEvents = await this.sendEventsInBatches(events)
this.attempts++
this.isRetrying = false
if (failedEvents.length > 0) {
// Write failures back to disk
await this.saveEventsToFile(filePath, failedEvents)
this.scheduleBackoffRetry()
return // Failed - wait for backoff
}
// Success - reset backoff and continue loop to drain any newly queued events
this.resetBackoff()
if (process.env.USER_TYPE === 'ant') {
logForDebugging('1P event logging: backoff retry succeeded')
}
}
}
private resetBackoff(): void {
this.attempts = 0
if (this.cancelBackoff) {
this.cancelBackoff()
this.cancelBackoff = null
}
}
private async sendBatchWithRetry(
payload: FirstPartyEventLoggingPayload,
): Promise<void> {
if (this.isKilled()) {
// Throw so the caller short-circuits remaining batches and queues
// everything to disk. Zero network traffic while killed; the backoff
// timer keeps ticking and will resume POSTs as soon as the GrowthBook
// cache picks up the cleared flag.
throw new Error('firstParty sink killswitch active')
}
const baseHeaders: Record<string, string> = {
'Content-Type': 'application/json',
'User-Agent': getClaudeCodeUserAgent(),
'x-service-name': 'claude-code',
}
// Skip auth if trust hasn't been established yet
// This prevents executing apiKeyHelper commands before the trust dialog
// Non-interactive sessions implicitly have workspace trust
const hasTrust =
checkHasTrustDialogAccepted() || getIsNonInteractiveSession()
if (process.env.USER_TYPE === 'ant' && !hasTrust) {
logForDebugging('1P event logging: Trust not accepted')
}
// Skip auth when the OAuth token is expired or lacks user:profile
// scope (service key sessions). Falls through to unauthenticated send.
let shouldSkipAuth = this.skipAuth || !hasTrust
if (!shouldSkipAuth && isClaudeAISubscriber()) {
const tokens = getClaudeAIOAuthTokens()
if (!hasProfileScope()) {
shouldSkipAuth = true
} else if (tokens && isOAuthTokenExpired(tokens.expiresAt)) {
shouldSkipAuth = true
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
'1P event logging: OAuth token expired, skipping auth to avoid 401',
)
}
}
}
// Try with auth headers first (unless trust not established or token is known to be expired)
const authResult = shouldSkipAuth
? { headers: {}, error: 'trust not established or Oauth token expired' }
: getAuthHeaders()
const useAuth = !authResult.error
if (!useAuth && process.env.USER_TYPE === 'ant') {
logForDebugging(
`1P event logging: auth not available, sending without auth`,
)
}
const headers = useAuth
? { ...baseHeaders, ...authResult.headers }
: baseHeaders
try {
const response = await axios.post(this.endpoint, payload, {
timeout: this.timeout,
headers,
})
this.logSuccess(payload.events.length, useAuth, response.data)
return
} catch (error) {
// Handle 401 by retrying without auth
if (
useAuth &&
axios.isAxiosError(error) &&
error.response?.status === 401
) {
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
'1P event logging: 401 auth error, retrying without auth',
)
}
const response = await axios.post(this.endpoint, payload, {
timeout: this.timeout,
headers: baseHeaders,
})
this.logSuccess(payload.events.length, false, response.data)
return
}
throw error
}
}
private logSuccess(
eventCount: number,
withAuth: boolean,
responseData: unknown,
): void {
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
`1P event logging: ${eventCount} events exported successfully${withAuth ? ' (with auth)' : ' (without auth)'}`,
)
logForDebugging(`API Response: ${jsonStringify(responseData, null, 2)}`)
}
}
private hrTimeToDate(hrTime: HrTime): Date {
const [seconds, nanoseconds] = hrTime
return new Date(seconds * 1000 + nanoseconds / 1000000)
}
private transformLogsToEvents(
logs: ReadableLogRecord[],
): FirstPartyEventLoggingPayload {
const events: FirstPartyEventLoggingEvent[] = []
for (const log of logs) {
const attributes = log.attributes || {}
// Check if this is a GrowthBook experiment event
if (attributes.event_type === 'GrowthbookExperimentEvent') {
const timestamp = this.hrTimeToDate(log.hrTime)
const account_uuid = attributes.account_uuid as string | undefined
const organization_uuid = attributes.organization_uuid as
| string
| undefined
events.push({
event_type: 'GrowthbookExperimentEvent',
event_data: GrowthbookExperimentEvent.toJSON({
event_id: attributes.event_id as string,
timestamp,
experiment_id: attributes.experiment_id as string,
variation_id: attributes.variation_id as number,
environment: attributes.environment as string,
user_attributes: attributes.user_attributes as string,
experiment_metadata: attributes.experiment_metadata as string,
device_id: attributes.device_id as string,
session_id: attributes.session_id as string,
auth:
account_uuid || organization_uuid
? { account_uuid, organization_uuid }
: undefined,
}),
})
continue
}
// Extract event name
const eventName =
(attributes.event_name as string) || (log.body as string) || 'unknown'
// Extract metadata objects directly (no JSON parsing needed)
const coreMetadata = attributes.core_metadata as EventMetadata | undefined
const userMetadata = attributes.user_metadata as CoreUserData
const eventMetadata = (attributes.event_metadata || {}) as Record<
string,
unknown
>
if (!coreMetadata) {
// Emit partial event if core metadata is missing
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
`1P event logging: core_metadata missing for event ${eventName}`,
)
}
events.push({
event_type: 'ClaudeCodeInternalEvent',
event_data: ClaudeCodeInternalEvent.toJSON({
event_id: attributes.event_id as string | undefined,
event_name: eventName,
client_timestamp: this.hrTimeToDate(log.hrTime),
session_id: getSessionId(),
additional_metadata: Buffer.from(
jsonStringify({
transform_error: 'core_metadata attribute is missing',
}),
).toString('base64'),
}),
})
continue
}
// Transform to 1P format
const formatted = to1PEventFormat(
coreMetadata,
userMetadata,
eventMetadata,
)
// _PROTO_* keys are PII-tagged values meant only for privileged BQ
// columns. Hoist known keys to proto fields, then defensively strip any
// remaining _PROTO_* so an unrecognized future key can't silently land
// in the general-access additional_metadata blob. sink.ts applies the
// same strip before Datadog; this closes the 1P side.
const {
_PROTO_skill_name,
_PROTO_plugin_name,
_PROTO_marketplace_name,
...rest
} = formatted.additional
const additionalMetadata = stripProtoFields(rest)
events.push({
event_type: 'ClaudeCodeInternalEvent',
event_data: ClaudeCodeInternalEvent.toJSON({
event_id: attributes.event_id as string | undefined,
event_name: eventName,
client_timestamp: this.hrTimeToDate(log.hrTime),
device_id: attributes.user_id as string | undefined,
email: userMetadata?.email,
auth: formatted.auth,
...formatted.core,
env: formatted.env,
process: formatted.process,
skill_name:
typeof _PROTO_skill_name === 'string'
? _PROTO_skill_name
: undefined,
plugin_name:
typeof _PROTO_plugin_name === 'string'
? _PROTO_plugin_name
: undefined,
marketplace_name:
typeof _PROTO_marketplace_name === 'string'
? _PROTO_marketplace_name
: undefined,
additional_metadata:
Object.keys(additionalMetadata).length > 0
? Buffer.from(jsonStringify(additionalMetadata)).toString(
'base64',
)
: undefined,
}),
})
}
return { events }
}
async shutdown(): Promise<void> {
this.isShutdown = true
this.resetBackoff()
await this.forceFlush()
if (process.env.USER_TYPE === 'ant') {
logForDebugging('1P event logging exporter shutdown complete')
}
}
async forceFlush(): Promise<void> {
await Promise.all(this.pendingExports)
if (process.env.USER_TYPE === 'ant') {
logForDebugging('1P event logging exporter flush complete')
}
}
}
function getAxiosErrorContext(error: unknown): string {
if (!axios.isAxiosError(error)) {
return errorMessage(error)
}
const parts: string[] = []
const requestId = error.response?.headers?.['request-id']
if (requestId) {
parts.push(`request-id=${requestId}`)
}
if (error.response?.status) {
parts.push(`status=${error.response.status}`)
}
if (error.code) {
parts.push(`code=${error.code}`)
}
if (error.message) {
parts.push(error.message)
}
return parts.join(', ')
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'bun:test'
import {
_resetForTesting,
attachAnalyticsSink,
logEvent,
logEventAsync,
} from './index.js'
describe('analytics compatibility boundary', () => {
it('stays inert even if a sink is attached', async () => {
let syncCalls = 0
let asyncCalls = 0
attachAnalyticsSink({
logEvent: () => {
syncCalls += 1
},
logEventAsync: async () => {
asyncCalls += 1
},
})
logEvent('tengu_test_event', {})
await logEventAsync('tengu_test_event_async', {})
expect(syncCalls).toBe(0)
expect(asyncCalls).toBe(0)
_resetForTesting()
})
})

View File

@@ -1,11 +1,9 @@
/**
* Analytics service - public API for event logging
*
* This module serves as the main entry point for analytics events in Claude CLI.
*
* DESIGN: This module has NO dependencies to avoid import cycles.
* Events are queued until attachAnalyticsSink() is called during app initialization.
* The sink handles routing to Datadog and 1P event logging.
* The open build intentionally ships without product telemetry. We keep this
* module as a compatibility boundary so existing call sites can remain
* unchanged while all analytics become inert.
*/
/**
@@ -19,53 +17,22 @@
export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = never
/**
* Marker type for values routed to PII-tagged proto columns via `_PROTO_*`
* payload keys. The destination BQ column has privileged access controls,
* so unredacted values are acceptable — unlike general-access backends.
*
* sink.ts strips `_PROTO_*` keys before Datadog fanout; only the 1P
* exporter (firstPartyEventLoggingExporter) sees them and hoists them to the
* top-level proto field. A single stripProtoFields call guards all non-1P
* sinks — no per-sink filtering to forget.
* Marker type for values that previously flowed to privileged `_PROTO_*`
* columns. The export remains so existing call sites keep their explicit
* privacy annotations even though external analytics export is disabled.
*
* Usage: `rawName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED`
*/
export type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED = never
/**
* Strip `_PROTO_*` keys from a payload destined for general-access storage.
* Used by:
* - sink.ts: before Datadog fanout (never sees PII-tagged values)
* - firstPartyEventLoggingExporter: defensive strip of additional_metadata
* after hoisting known _PROTO_* keys to proto fields — prevents a future
* unrecognized _PROTO_foo from silently landing in the BQ JSON blob.
*
* Returns the input unchanged (same reference) when no _PROTO_ keys present.
*/
export function stripProtoFields<V>(
metadata: Record<string, V>,
): Record<string, V> {
let result: Record<string, V> | undefined
for (const key in metadata) {
if (key.startsWith('_PROTO_')) {
if (result === undefined) {
result = { ...metadata }
}
delete result[key]
}
}
return result ?? metadata
return metadata
}
// Internal type for logEvent metadata - different from the enriched EventMetadata in metadata.ts
type LogEventMetadata = { [key: string]: boolean | number | undefined }
type QueuedEvent = {
eventName: string
metadata: LogEventMetadata
async: boolean
}
/**
* Sink interface for the analytics backend
*/
@@ -77,97 +44,26 @@ export type AnalyticsSink = {
) => Promise<void>
}
// Event queue for events logged before sink is attached
const eventQueue: QueuedEvent[] = []
// Sink - initialized during app startup
let sink: AnalyticsSink | null = null
/**
* Attach the analytics sink that will receive all events.
* Queued events are drained asynchronously via queueMicrotask to avoid
* adding latency to the startup path.
*
* Idempotent: if a sink is already attached, this is a no-op. This allows
* calling from both the preAction hook (for subcommands) and setup() (for
* the default command) without coordination.
*/
export function attachAnalyticsSink(newSink: AnalyticsSink): void {
if (sink !== null) {
return
}
sink = newSink
// Drain the queue asynchronously to avoid blocking startup
if (eventQueue.length > 0) {
const queuedEvents = [...eventQueue]
eventQueue.length = 0
// Log queue size for ants to help debug analytics initialization timing
if (process.env.USER_TYPE === 'ant') {
sink.logEvent('analytics_sink_attached', {
queued_event_count: queuedEvents.length,
})
}
queueMicrotask(() => {
for (const event of queuedEvents) {
if (event.async) {
void sink!.logEventAsync(event.eventName, event.metadata)
} else {
sink!.logEvent(event.eventName, event.metadata)
}
}
})
}
}
export function attachAnalyticsSink(_newSink: AnalyticsSink): void {}
/**
* Log an event to analytics backends (synchronous)
*
* Events may be sampled based on the 'tengu_event_sampling_config' dynamic config.
* When sampled, the sample_rate is added to the event metadata.
*
* If no sink is attached, events are queued and drained when the sink attaches.
*/
export function logEvent(
eventName: string,
// intentionally no strings unless AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
// to avoid accidentally logging code/filepaths
metadata: LogEventMetadata,
): void {
if (sink === null) {
eventQueue.push({ eventName, metadata, async: false })
return
}
sink.logEvent(eventName, metadata)
}
_eventName: string,
_metadata: LogEventMetadata,
): void {}
/**
* Log an event to analytics backends (asynchronous)
*
* Events may be sampled based on the 'tengu_event_sampling_config' dynamic config.
* When sampled, the sample_rate is added to the event metadata.
*
* If no sink is attached, events are queued and drained when the sink attaches.
*/
export async function logEventAsync(
eventName: string,
// intentionally no strings, to avoid accidentally logging code/filepaths
metadata: LogEventMetadata,
): Promise<void> {
if (sink === null) {
eventQueue.push({ eventName, metadata, async: true })
return
}
await sink.logEventAsync(eventName, metadata)
}
_eventName: string,
_metadata: LogEventMetadata,
): Promise<void> {}
/**
* Reset analytics state for testing purposes only.
* @internal
*/
export function _resetForTesting(): void {
sink = null
eventQueue.length = 0
}
export function _resetForTesting(): void {}

View File

@@ -1,72 +1,13 @@
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
/**
* Shared event metadata enrichment for analytics systems
*
* This module provides a single source of truth for collecting and formatting
* event metadata across all analytics systems (Datadog, 1P).
*/
import { extname } from 'path'
import memoize from 'lodash-es/memoize.js'
import { env, getHostPlatformForAnalytics } from '../../utils/env.js'
import { envDynamic } from '../../utils/envDynamic.js'
import { getModelBetas } from '../../utils/betas.js'
import { getMainLoopModel } from '../../utils/model/model.js'
import {
getSessionId,
getIsInteractive,
getKairosActive,
getClientType,
getParentSessionId as getParentSessionIdFromState,
} from '../../bootstrap/state.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
import { isOfficialMcpUrl } from '../mcp/officialRegistry.js'
import { isClaudeAISubscriber, getSubscriptionType } from '../../utils/auth.js'
import { getRepoRemoteHash } from '../../utils/git.js'
import {
getWslVersion,
getLinuxDistroInfo,
detectVcs,
} from '../../utils/platform.js'
import type { CoreUserData } from 'src/utils/user.js'
import { getAgentContext } from '../../utils/agentContext.js'
import type { EnvironmentMetadata } from '../../types/generated/events_mono/claude_code/v1/claude_code_internal_event.js'
import type { PublicApiAuth } from '../../types/generated/events_mono/common/v1/auth.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import {
getAgentId,
getParentSessionId as getTeammateParentSessionId,
getTeamName,
isTeammate,
} from '../../utils/teammate.js'
import { feature } from 'bun:bundle'
import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from './index.js'
/**
* Marker type for verifying analytics metadata doesn't contain sensitive data
*
* This type forces explicit verification that string values being logged
* don't contain code snippets, file paths, or other sensitive information.
*
* The metadata is expected to be JSON-serializable.
*
* Usage: `myString as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS`
*
* The type is `never` which means it can never actually hold a value - this is
* intentional as it's only used for type-casting to document developer intent.
* Local-only analytics helpers retained for compatibility after telemetry
* export removal. These helpers only sanitize or classify values in-process.
*/
export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = never
/**
* Sanitizes tool names for analytics logging to avoid PII exposure.
*
* MCP tool names follow the format `mcp__<server>__<tool>` and can reveal
* user-specific server configurations, which is considered PII-medium.
* This function redacts MCP tool names while preserving built-in tool names
* (Bash, Read, Write, etc.) which are safe to log.
*
* @param toolName - The tool name to sanitize
* @returns The original name for built-in tools, or 'mcp_tool' for MCP tools
*/
export type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS }
export function sanitizeToolNameForAnalytics(
toolName: string,
): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS {
@@ -76,103 +17,17 @@ export function sanitizeToolNameForAnalytics(
return toolName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
}
/**
* Check if detailed tool name logging is enabled for OTLP events.
* When enabled, MCP server/tool names and Skill names are logged.
* Disabled by default to protect PII (user-specific server configurations).
*
* Enable with OTEL_LOG_TOOL_DETAILS=1
*/
export function isToolDetailsLoggingEnabled(): boolean {
return isEnvTruthy(process.env.OTEL_LOG_TOOL_DETAILS)
}
/**
* Check if detailed tool name logging (MCP server/tool names) is enabled
* for analytics events.
*
* Per go/taxonomy, MCP names are medium PII. We log them for:
* - Cowork (entrypoint=local-agent) — no ZDR concept, log all MCPs
* - claude.ai-proxied connectors — always official (from claude.ai's list)
* - Servers whose URL matches the official MCP registry — directory
* connectors added via `claude mcp add`, not customer-specific config
*
* Custom/user-configured MCPs stay sanitized (toolName='mcp_tool').
*/
export function isAnalyticsToolDetailsLoggingEnabled(
mcpServerType: string | undefined,
mcpServerBaseUrl: string | undefined,
): boolean {
if (process.env.CLAUDE_CODE_ENTRYPOINT === 'local-agent') {
return true
}
if (mcpServerType === 'claudeai-proxy') {
return true
}
if (mcpServerBaseUrl && isOfficialMcpUrl(mcpServerBaseUrl)) {
return true
}
return false
}
/**
* Built-in first-party MCP servers whose names are fixed reserved strings,
* not user-configured — so logging them is not PII. Checked in addition to
* isAnalyticsToolDetailsLoggingEnabled's transport/URL gates, which a stdio
* built-in would otherwise fail.
*
* Feature-gated so the set is empty when the feature is off: the name
* reservation (main.tsx, config.ts addMcpServer) is itself feature-gated, so
* a user-configured 'computer-use' is possible in builds without the feature.
*/
/* eslint-disable @typescript-eslint/no-require-imports */
const BUILTIN_MCP_SERVER_NAMES: ReadonlySet<string> = new Set(
feature('CHICAGO_MCP')
? [
(
require('../../utils/computerUse/common.js') as typeof import('../../utils/computerUse/common.js')
).COMPUTER_USE_MCP_SERVER_NAME,
]
: [],
)
/* eslint-enable @typescript-eslint/no-require-imports */
/**
* Spreadable helper for logEvent payloads — returns {mcpServerName, mcpToolName}
* if the gate passes, empty object otherwise. Consolidates the identical IIFE
* pattern at each tengu_tool_use_* call site.
*/
export function mcpToolDetailsForAnalytics(
toolName: string,
mcpServerType: string | undefined,
mcpServerBaseUrl: string | undefined,
): {
export function mcpToolDetailsForAnalytics(): {
mcpServerName?: AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
mcpToolName?: AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
} {
const details = extractMcpToolDetails(toolName)
if (!details) {
return {}
}
if (
!BUILTIN_MCP_SERVER_NAMES.has(details.serverName) &&
!isAnalyticsToolDetailsLoggingEnabled(mcpServerType, mcpServerBaseUrl)
) {
return {}
}
return {
mcpServerName: details.serverName,
mcpToolName: details.mcpToolName,
}
return {}
}
/**
* Extract MCP server and tool names from a full MCP tool name.
* MCP tool names follow the format: mcp__<server>__<tool>
*
* @param toolName - The full tool name (e.g., 'mcp__slack__read_channel')
* @returns Object with serverName and toolName, or undefined if not an MCP tool
*/
export function extractMcpToolDetails(toolName: string):
| {
serverName: AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
@@ -183,16 +38,13 @@ export function extractMcpToolDetails(toolName: string):
return undefined
}
// Format: mcp__<server>__<tool>
const parts = toolName.split('__')
if (parts.length < 3) {
return undefined
}
const serverName = parts[1]
// Tool name may contain __ so rejoin remaining parts
const mcpToolName = parts.slice(2).join('__')
if (!serverName || !mcpToolName) {
return undefined
}
@@ -205,13 +57,6 @@ export function extractMcpToolDetails(toolName: string):
}
}
/**
* Extract skill name from Skill tool input.
*
* @param toolName - The tool name (should be 'Skill')
* @param input - The tool input containing the skill name
* @returns The skill name if this is a Skill tool call, undefined otherwise
*/
export function extractSkillName(
toolName: string,
input: unknown,
@@ -233,93 +78,14 @@ export function extractSkillName(
return undefined
}
const TOOL_INPUT_STRING_TRUNCATE_AT = 512
const TOOL_INPUT_STRING_TRUNCATE_TO = 128
const TOOL_INPUT_MAX_JSON_CHARS = 4 * 1024
const TOOL_INPUT_MAX_COLLECTION_ITEMS = 20
const TOOL_INPUT_MAX_DEPTH = 2
function truncateToolInputValue(value: unknown, depth = 0): unknown {
if (typeof value === 'string') {
if (value.length > TOOL_INPUT_STRING_TRUNCATE_AT) {
return `${value.slice(0, TOOL_INPUT_STRING_TRUNCATE_TO)}…[${value.length} chars]`
}
return value
}
if (
typeof value === 'number' ||
typeof value === 'boolean' ||
value === null ||
value === undefined
) {
return value
}
if (depth >= TOOL_INPUT_MAX_DEPTH) {
return '<nested>'
}
if (Array.isArray(value)) {
const mapped = value
.slice(0, TOOL_INPUT_MAX_COLLECTION_ITEMS)
.map(v => truncateToolInputValue(v, depth + 1))
if (value.length > TOOL_INPUT_MAX_COLLECTION_ITEMS) {
mapped.push(`…[${value.length} items]`)
}
return mapped
}
if (typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>)
// Skip internal marker keys (e.g. _simulatedSedEdit re-introduced by
// SedEditPermissionRequest) so they don't leak into telemetry.
.filter(([k]) => !k.startsWith('_'))
const mapped = entries
.slice(0, TOOL_INPUT_MAX_COLLECTION_ITEMS)
.map(([k, v]) => [k, truncateToolInputValue(v, depth + 1)])
if (entries.length > TOOL_INPUT_MAX_COLLECTION_ITEMS) {
mapped.push(['…', `${entries.length} keys`])
}
return Object.fromEntries(mapped)
}
return String(value)
}
/**
* Serialize a tool's input arguments for the OTel tool_result event.
* Truncates long strings and deep nesting to keep the output bounded while
* preserving forensically useful fields like file paths, URLs, and MCP args.
* Returns undefined when OTEL_LOG_TOOL_DETAILS is not enabled.
*/
export function extractToolInputForTelemetry(
input: unknown,
_input: unknown,
): string | undefined {
if (!isToolDetailsLoggingEnabled()) {
return undefined
}
const truncated = truncateToolInputValue(input)
let json = jsonStringify(truncated)
if (json.length > TOOL_INPUT_MAX_JSON_CHARS) {
json = json.slice(0, TOOL_INPUT_MAX_JSON_CHARS) + '…[truncated]'
}
return json
return undefined
}
/**
* Maximum length for file extensions to be logged.
* Extensions longer than this are considered potentially sensitive
* (e.g., hash-based filenames like "key-hash-abcd-123-456") and
* will be replaced with 'other'.
*/
const MAX_FILE_EXTENSION_LENGTH = 10
/**
* Extracts and sanitizes a file extension for analytics logging.
*
* Uses Node's path.extname for reliable cross-platform extension extraction.
* Returns 'other' for extensions exceeding MAX_FILE_EXTENSION_LENGTH to avoid
* logging potentially sensitive data (like hash-based filenames).
*
* @param filePath - The file path to extract the extension from
* @returns The sanitized extension, 'other' for long extensions, or undefined if no extension
*/
export function getFileExtensionForAnalytics(
filePath: string,
): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS | undefined {
@@ -328,7 +94,7 @@ export function getFileExtensionForAnalytics(
return undefined
}
const extension = ext.slice(1) // remove leading dot
const extension = ext.slice(1)
if (extension.length > MAX_FILE_EXTENSION_LENGTH) {
return 'other' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
}
@@ -336,7 +102,6 @@ export function getFileExtensionForAnalytics(
return extension as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
}
/** Allow list of commands we extract file extensions from. */
const FILE_COMMANDS = new Set([
'rm',
'mv',
@@ -357,23 +122,16 @@ const FILE_COMMANDS = new Set([
'sed',
])
/** Regex to split bash commands on compound operators (&&, ||, ;, |). */
const COMPOUND_OPERATOR_REGEX = /\s*(?:&&|\|\||[;|])\s*/
/** Regex to split on whitespace. */
const WHITESPACE_REGEX = /\s+/
/**
* Extracts file extensions from a bash command for analytics.
* Best-effort: splits on operators and whitespace, extracts extensions
* from non-flag args of allowed commands. No heavy shell parsing needed
* because grep patterns and sed scripts rarely resemble file extensions.
*/
export function getFileExtensionsFromBashCommand(
command: string,
simulatedSedEditFilePath?: string,
): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS | undefined {
if (!command.includes('.') && !simulatedSedEditFilePath) return undefined
if (!command.includes('.') && !simulatedSedEditFilePath) {
return undefined
}
let result: string | undefined
const seen = new Set<string>()
@@ -398,7 +156,7 @@ export function getFileExtensionsFromBashCommand(
for (let i = 1; i < tokens.length; i++) {
const arg = tokens[i]!
if (arg.charCodeAt(0) === 45 /* - */) continue
if (arg.charCodeAt(0) === 45) continue
const ext = getFileExtensionForAnalytics(arg)
if (ext && !seen.has(ext)) {
seen.add(ext)
@@ -407,567 +165,8 @@ export function getFileExtensionsFromBashCommand(
}
}
if (!result) return undefined
return result as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
}
/**
* Environment context metadata
*/
export type EnvContext = {
platform: string
platformRaw: string
arch: string
nodeVersion: string
terminal: string | null
packageManagers: string
runtimes: string
isRunningWithBun: boolean
isCi: boolean
isClaubbit: boolean
isClaudeCodeRemote: boolean
isLocalAgentMode: boolean
isConductor: boolean
remoteEnvironmentType?: string
coworkerType?: string
claudeCodeContainerId?: string
claudeCodeRemoteSessionId?: string
tags?: string
isGithubAction: boolean
isClaudeCodeAction: boolean
isClaudeAiAuth: boolean
version: string
versionBase?: string
buildTime: string
deploymentEnvironment: string
githubEventName?: string
githubActionsRunnerEnvironment?: string
githubActionsRunnerOs?: string
githubActionRef?: string
wslVersion?: string
linuxDistroId?: string
linuxDistroVersion?: string
linuxKernel?: string
vcs?: string
}
/**
* Process metrics included with all analytics events.
*/
export type ProcessMetrics = {
uptime: number
rss: number
heapTotal: number
heapUsed: number
external: number
arrayBuffers: number
constrainedMemory: number | undefined
cpuUsage: NodeJS.CpuUsage
cpuPercent: number | undefined
}
/**
* Core event metadata shared across all analytics systems
*/
export type EventMetadata = {
model: string
sessionId: string
userType: string
betas?: string
envContext: EnvContext
entrypoint?: string
agentSdkVersion?: string
isInteractive: string
clientType: string
processMetrics?: ProcessMetrics
sweBenchRunId: string
sweBenchInstanceId: string
sweBenchTaskId: string
// Swarm/team agent identification for analytics attribution
agentId?: string // CLAUDE_CODE_AGENT_ID (format: agentName@teamName) or subagent UUID
parentSessionId?: string // CLAUDE_CODE_PARENT_SESSION_ID (team lead's session)
agentType?: 'teammate' | 'subagent' | 'standalone' // Distinguishes swarm teammates, Agent tool subagents, and standalone agents
teamName?: string // Team name for swarm agents (from env var or AsyncLocalStorage)
subscriptionType?: string // OAuth subscription tier (max, pro, enterprise, team)
rh?: string // Hashed repo remote URL (first 16 chars of SHA256), for joining with server-side data
kairosActive?: true // KAIROS assistant mode active (ant-only; set in main.tsx after gate check)
skillMode?: 'discovery' | 'coach' | 'discovery_and_coach' // Which skill surfacing mechanism(s) are gated on (ant-only; for BQ session segmentation)
observerMode?: 'backseat' | 'skillcoach' | 'both' // Which observer classifiers are gated on (ant-only; for BQ cohort splits on tengu_backseat_* events)
}
/**
* Options for enriching event metadata
*/
export type EnrichMetadataOptions = {
// Model to use, falls back to getMainLoopModel() if not provided
model?: unknown
// Explicit betas string (already joined)
betas?: unknown
// Additional metadata to include (optional)
additionalMetadata?: Record<string, unknown>
}
/**
* Get agent identification for analytics.
* Priority: AsyncLocalStorage context (subagents) > env vars (swarm teammates)
*/
function getAgentIdentification(): {
agentId?: string
parentSessionId?: string
agentType?: 'teammate' | 'subagent' | 'standalone'
teamName?: string
} {
// Check AsyncLocalStorage first (for subagents running in same process)
const agentContext = getAgentContext()
if (agentContext) {
const result: ReturnType<typeof getAgentIdentification> = {
agentId: agentContext.agentId,
parentSessionId: agentContext.parentSessionId,
agentType: agentContext.agentType,
}
if (agentContext.agentType === 'teammate') {
result.teamName = agentContext.teamName
}
return result
}
// Fall back to swarm helpers (for swarm agents)
const agentId = getAgentId()
const parentSessionId = getTeammateParentSessionId()
const teamName = getTeamName()
const isSwarmAgent = isTeammate()
// For standalone agents (have agent ID but not a teammate), set agentType to 'standalone'
const agentType = isSwarmAgent
? ('teammate' as const)
: agentId
? ('standalone' as const)
: undefined
if (agentId || agentType || parentSessionId || teamName) {
return {
...(agentId ? { agentId } : {}),
...(agentType ? { agentType } : {}),
...(parentSessionId ? { parentSessionId } : {}),
...(teamName ? { teamName } : {}),
}
}
// Check bootstrap state for parent session ID (e.g., plan mode -> implementation)
const stateParentSessionId = getParentSessionIdFromState()
if (stateParentSessionId) {
return { parentSessionId: stateParentSessionId }
}
return {}
}
/**
* Extract base version from full version string. "2.0.36-dev.20251107.t174150.sha2709699" → "2.0.36-dev"
*/
const getVersionBase = memoize((): string | undefined => {
const match = MACRO.VERSION.match(/^\d+\.\d+\.\d+(?:-[a-z]+)?/)
return match ? match[0] : undefined
})
/**
* Builds the environment context object
*/
const buildEnvContext = memoize(async (): Promise<EnvContext> => {
const [packageManagers, runtimes, linuxDistroInfo, vcs] = await Promise.all([
env.getPackageManagers(),
env.getRuntimes(),
getLinuxDistroInfo(),
detectVcs(),
])
return {
platform: getHostPlatformForAnalytics(),
// Raw process.platform so freebsd/openbsd/aix/sunos are visible in BQ.
// getHostPlatformForAnalytics() buckets those into 'linux'; here we want
// the truth. CLAUDE_CODE_HOST_PLATFORM still overrides for container/remote.
platformRaw: process.env.CLAUDE_CODE_HOST_PLATFORM || process.platform,
arch: env.arch,
nodeVersion: env.nodeVersion,
terminal: envDynamic.terminal,
packageManagers: packageManagers.join(','),
runtimes: runtimes.join(','),
isRunningWithBun: env.isRunningWithBun(),
isCi: isEnvTruthy(process.env.CI),
isClaubbit: isEnvTruthy(process.env.CLAUBBIT),
isClaudeCodeRemote: isEnvTruthy(process.env.CLAUDE_CODE_REMOTE),
isLocalAgentMode: process.env.CLAUDE_CODE_ENTRYPOINT === 'local-agent',
isConductor: env.isConductor(),
...(process.env.CLAUDE_CODE_REMOTE_ENVIRONMENT_TYPE && {
remoteEnvironmentType: process.env.CLAUDE_CODE_REMOTE_ENVIRONMENT_TYPE,
}),
// Gated by feature flag to prevent leaking "coworkerType" string in external builds
...(feature('COWORKER_TYPE_TELEMETRY')
? process.env.CLAUDE_CODE_COWORKER_TYPE
? { coworkerType: process.env.CLAUDE_CODE_COWORKER_TYPE }
: {}
: {}),
...(process.env.CLAUDE_CODE_CONTAINER_ID && {
claudeCodeContainerId: process.env.CLAUDE_CODE_CONTAINER_ID,
}),
...(process.env.CLAUDE_CODE_REMOTE_SESSION_ID && {
claudeCodeRemoteSessionId: process.env.CLAUDE_CODE_REMOTE_SESSION_ID,
}),
...(process.env.CLAUDE_CODE_TAGS && {
tags: process.env.CLAUDE_CODE_TAGS,
}),
isGithubAction: isEnvTruthy(process.env.GITHUB_ACTIONS),
isClaudeCodeAction: isEnvTruthy(process.env.CLAUDE_CODE_ACTION),
isClaudeAiAuth: isClaudeAISubscriber(),
version: MACRO.VERSION,
versionBase: getVersionBase(),
buildTime: MACRO.BUILD_TIME,
deploymentEnvironment: env.detectDeploymentEnvironment(),
...(isEnvTruthy(process.env.GITHUB_ACTIONS) && {
githubEventName: process.env.GITHUB_EVENT_NAME,
githubActionsRunnerEnvironment: process.env.RUNNER_ENVIRONMENT,
githubActionsRunnerOs: process.env.RUNNER_OS,
githubActionRef: process.env.GITHUB_ACTION_PATH?.includes(
'claude-code-action/',
)
? process.env.GITHUB_ACTION_PATH.split('claude-code-action/')[1]
: undefined,
}),
...(getWslVersion() && { wslVersion: getWslVersion() }),
...(linuxDistroInfo ?? {}),
...(vcs.length > 0 ? { vcs: vcs.join(',') } : {}),
}
})
// --
// CPU% delta tracking — inherently process-global, same pattern as logBatch/flushTimer in datadog.ts
let prevCpuUsage: NodeJS.CpuUsage | null = null
let prevWallTimeMs: number | null = null
/**
* Builds process metrics object for all users.
*/
function buildProcessMetrics(): ProcessMetrics | undefined {
try {
const mem = process.memoryUsage()
const cpu = process.cpuUsage()
const now = Date.now()
let cpuPercent: number | undefined
if (prevCpuUsage && prevWallTimeMs) {
const wallDeltaMs = now - prevWallTimeMs
if (wallDeltaMs > 0) {
const userDeltaUs = cpu.user - prevCpuUsage.user
const systemDeltaUs = cpu.system - prevCpuUsage.system
cpuPercent =
((userDeltaUs + systemDeltaUs) / (wallDeltaMs * 1000)) * 100
}
}
prevCpuUsage = cpu
prevWallTimeMs = now
return {
uptime: process.uptime(),
rss: mem.rss,
heapTotal: mem.heapTotal,
heapUsed: mem.heapUsed,
external: mem.external,
arrayBuffers: mem.arrayBuffers,
// eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
constrainedMemory: process.constrainedMemory(),
cpuUsage: cpu,
cpuPercent,
}
} catch {
if (!result) {
return undefined
}
}
/**
* Get core event metadata shared across all analytics systems.
*
* This function collects environment, runtime, and context information
* that should be included with all analytics events.
*
* @param options - Configuration options
* @returns Promise resolving to enriched metadata object
*/
export async function getEventMetadata(
options: EnrichMetadataOptions = {},
): Promise<EventMetadata> {
const model = options.model ? String(options.model) : getMainLoopModel()
const betas =
typeof options.betas === 'string'
? options.betas
: getModelBetas(model).join(',')
const [envContext, repoRemoteHash] = await Promise.all([
buildEnvContext(),
getRepoRemoteHash(),
])
const processMetrics = buildProcessMetrics()
const metadata: EventMetadata = {
model,
sessionId: getSessionId(),
userType: process.env.USER_TYPE || '',
...(betas.length > 0 ? { betas: betas } : {}),
envContext,
...(process.env.CLAUDE_CODE_ENTRYPOINT && {
entrypoint: process.env.CLAUDE_CODE_ENTRYPOINT,
}),
...(process.env.CLAUDE_AGENT_SDK_VERSION && {
agentSdkVersion: process.env.CLAUDE_AGENT_SDK_VERSION,
}),
isInteractive: String(getIsInteractive()),
clientType: getClientType(),
...(processMetrics && { processMetrics }),
sweBenchRunId: process.env.SWE_BENCH_RUN_ID || '',
sweBenchInstanceId: process.env.SWE_BENCH_INSTANCE_ID || '',
sweBenchTaskId: process.env.SWE_BENCH_TASK_ID || '',
// Swarm/team agent identification
// Priority: AsyncLocalStorage context (subagents) > env vars (swarm teammates)
...getAgentIdentification(),
// Subscription tier for DAU-by-tier analytics
...(getSubscriptionType() && {
subscriptionType: getSubscriptionType()!,
}),
// Assistant mode tag — lives outside memoized buildEnvContext() because
// setKairosActive() runs at main.tsx:~1648, after the first event may
// have already fired and memoized the env. Read fresh per-event instead.
...(feature('KAIROS') && getKairosActive()
? { kairosActive: true as const }
: {}),
// Repo remote hash for joining with server-side repo bundle data
...(repoRemoteHash && { rh: repoRemoteHash }),
}
return metadata
}
/**
* Core event metadata for 1P event logging (snake_case format).
*/
export type FirstPartyEventLoggingCoreMetadata = {
session_id: string
model: string
user_type: string
betas?: string
entrypoint?: string
agent_sdk_version?: string
is_interactive: boolean
client_type: string
swe_bench_run_id?: string
swe_bench_instance_id?: string
swe_bench_task_id?: string
// Swarm/team agent identification
agent_id?: string
parent_session_id?: string
agent_type?: 'teammate' | 'subagent' | 'standalone'
team_name?: string
}
/**
* Complete event logging metadata format for 1P events.
*/
export type FirstPartyEventLoggingMetadata = {
env: EnvironmentMetadata
process?: string
// auth is a top-level field on ClaudeCodeInternalEvent (proto PublicApiAuth).
// account_id is intentionally omitted — only UUID fields are populated client-side.
auth?: PublicApiAuth
// core fields correspond to the top level of ClaudeCodeInternalEvent.
// They get directly exported to their individual columns in the BigQuery tables
core: FirstPartyEventLoggingCoreMetadata
// additional fields are populated in the additional_metadata field of the
// ClaudeCodeInternalEvent proto. Includes but is not limited to information
// that differs by event type.
additional: Record<string, unknown>
}
/**
* Convert metadata to 1P event logging format (snake_case fields).
*
* The /api/event_logging/batch endpoint expects snake_case field names
* for environment and core metadata.
*
* @param metadata - Core event metadata
* @param additionalMetadata - Additional metadata to include
* @returns Metadata formatted for 1P event logging
*/
export function to1PEventFormat(
metadata: EventMetadata,
userMetadata: CoreUserData,
additionalMetadata: Record<string, unknown> = {},
): FirstPartyEventLoggingMetadata {
const {
envContext,
processMetrics,
rh,
kairosActive,
skillMode,
observerMode,
...coreFields
} = metadata
// Convert envContext to snake_case.
// IMPORTANT: env is typed as the proto-generated EnvironmentMetadata so that
// adding a field here that the proto doesn't define is a compile error. The
// generated toJSON() serializer silently drops unknown keys — a hand-written
// parallel type previously let #11318, #13924, #19448, and coworker_type all
// ship fields that never reached BQ.
// Adding a field? Update the monorepo proto first (go/cc-logging):
// event_schemas/.../claude_code/v1/claude_code_internal_event.proto
// then run `bun run generate:proto` here.
const env: EnvironmentMetadata = {
platform: envContext.platform,
platform_raw: envContext.platformRaw,
arch: envContext.arch,
node_version: envContext.nodeVersion,
terminal: envContext.terminal || 'unknown',
package_managers: envContext.packageManagers,
runtimes: envContext.runtimes,
is_running_with_bun: envContext.isRunningWithBun,
is_ci: envContext.isCi,
is_claubbit: envContext.isClaubbit,
is_claude_code_remote: envContext.isClaudeCodeRemote,
is_local_agent_mode: envContext.isLocalAgentMode,
is_conductor: envContext.isConductor,
is_github_action: envContext.isGithubAction,
is_claude_code_action: envContext.isClaudeCodeAction,
is_claude_ai_auth: envContext.isClaudeAiAuth,
version: envContext.version,
build_time: envContext.buildTime,
deployment_environment: envContext.deploymentEnvironment,
}
// Add optional env fields
if (envContext.remoteEnvironmentType) {
env.remote_environment_type = envContext.remoteEnvironmentType
}
if (feature('COWORKER_TYPE_TELEMETRY') && envContext.coworkerType) {
env.coworker_type = envContext.coworkerType
}
if (envContext.claudeCodeContainerId) {
env.claude_code_container_id = envContext.claudeCodeContainerId
}
if (envContext.claudeCodeRemoteSessionId) {
env.claude_code_remote_session_id = envContext.claudeCodeRemoteSessionId
}
if (envContext.tags) {
env.tags = envContext.tags
.split(',')
.map(t => t.trim())
.filter(Boolean)
}
if (envContext.githubEventName) {
env.github_event_name = envContext.githubEventName
}
if (envContext.githubActionsRunnerEnvironment) {
env.github_actions_runner_environment =
envContext.githubActionsRunnerEnvironment
}
if (envContext.githubActionsRunnerOs) {
env.github_actions_runner_os = envContext.githubActionsRunnerOs
}
if (envContext.githubActionRef) {
env.github_action_ref = envContext.githubActionRef
}
if (envContext.wslVersion) {
env.wsl_version = envContext.wslVersion
}
if (envContext.linuxDistroId) {
env.linux_distro_id = envContext.linuxDistroId
}
if (envContext.linuxDistroVersion) {
env.linux_distro_version = envContext.linuxDistroVersion
}
if (envContext.linuxKernel) {
env.linux_kernel = envContext.linuxKernel
}
if (envContext.vcs) {
env.vcs = envContext.vcs
}
if (envContext.versionBase) {
env.version_base = envContext.versionBase
}
// Convert core fields to snake_case
const core: FirstPartyEventLoggingCoreMetadata = {
session_id: coreFields.sessionId,
model: coreFields.model,
user_type: coreFields.userType,
is_interactive: coreFields.isInteractive === 'true',
client_type: coreFields.clientType,
}
// Add other core fields
if (coreFields.betas) {
core.betas = coreFields.betas
}
if (coreFields.entrypoint) {
core.entrypoint = coreFields.entrypoint
}
if (coreFields.agentSdkVersion) {
core.agent_sdk_version = coreFields.agentSdkVersion
}
if (coreFields.sweBenchRunId) {
core.swe_bench_run_id = coreFields.sweBenchRunId
}
if (coreFields.sweBenchInstanceId) {
core.swe_bench_instance_id = coreFields.sweBenchInstanceId
}
if (coreFields.sweBenchTaskId) {
core.swe_bench_task_id = coreFields.sweBenchTaskId
}
// Swarm/team agent identification
if (coreFields.agentId) {
core.agent_id = coreFields.agentId
}
if (coreFields.parentSessionId) {
core.parent_session_id = coreFields.parentSessionId
}
if (coreFields.agentType) {
core.agent_type = coreFields.agentType
}
if (coreFields.teamName) {
core.team_name = coreFields.teamName
}
// Map userMetadata to output fields.
// Based on src/utils/user.ts getUser(), but with fields present in other
// parts of ClaudeCodeInternalEvent deduplicated.
// Convert camelCase GitHubActionsMetadata to snake_case for 1P API
// Note: github_actions_metadata is placed inside env (EnvironmentMetadata)
// rather than at the top level of ClaudeCodeInternalEvent
if (userMetadata.githubActionsMetadata) {
const ghMeta = userMetadata.githubActionsMetadata
env.github_actions_metadata = {
actor_id: ghMeta.actorId,
repository_id: ghMeta.repositoryId,
repository_owner_id: ghMeta.repositoryOwnerId,
}
}
let auth: PublicApiAuth | undefined
if (userMetadata.accountUuid || userMetadata.organizationUuid) {
auth = {
account_uuid: userMetadata.accountUuid,
organization_uuid: userMetadata.organizationUuid,
}
}
return {
env,
...(processMetrics && {
process: Buffer.from(jsonStringify(processMetrics)).toString('base64'),
}),
...(auth && { auth }),
core,
additional: {
...(rh && { rh }),
...(kairosActive && { is_assistant_mode: true }),
...(skillMode && { skill_mode: skillMode }),
...(observerMode && { observer_mode: observerMode }),
...additionalMetadata,
},
}
return result as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
}

View File

@@ -1,114 +0,0 @@
/**
* Analytics sink implementation
*
* This module contains the actual analytics routing logic and should be
* initialized during app startup. It routes events to Datadog and 1P event
* logging.
*
* Usage: Call initializeAnalyticsSink() during app startup to attach the sink.
*/
import { trackDatadogEvent } from './datadog.js'
import { logEventTo1P, shouldSampleEvent } from './firstPartyEventLogger.js'
import { checkStatsigFeatureGate_CACHED_MAY_BE_STALE } from './growthbook.js'
import { attachAnalyticsSink, stripProtoFields } from './index.js'
import { isSinkKilled } from './sinkKillswitch.js'
// Local type matching the logEvent metadata signature
type LogEventMetadata = { [key: string]: boolean | number | undefined }
const DATADOG_GATE_NAME = 'tengu_log_datadog_events'
// Module-level gate state - starts undefined, initialized during startup
let isDatadogGateEnabled: boolean | undefined = undefined
/**
* Check if Datadog tracking is enabled.
* Falls back to cached value from previous session if not yet initialized.
*/
function shouldTrackDatadog(): boolean {
if (isSinkKilled('datadog')) {
return false
}
if (isDatadogGateEnabled !== undefined) {
return isDatadogGateEnabled
}
// Fallback to cached value from previous session
try {
return checkStatsigFeatureGate_CACHED_MAY_BE_STALE(DATADOG_GATE_NAME)
} catch {
return false
}
}
/**
* Log an event (synchronous implementation)
*/
function logEventImpl(eventName: string, metadata: LogEventMetadata): void {
// Check if this event should be sampled
const sampleResult = shouldSampleEvent(eventName)
// If sample result is 0, the event was not selected for logging
if (sampleResult === 0) {
return
}
// If sample result is a positive number, add it to metadata
const metadataWithSampleRate =
sampleResult !== null
? { ...metadata, sample_rate: sampleResult }
: metadata
if (shouldTrackDatadog()) {
// Datadog is a general-access backend — strip _PROTO_* keys
// (unredacted PII-tagged values meant only for the 1P privileged column).
void trackDatadogEvent(eventName, stripProtoFields(metadataWithSampleRate))
}
// 1P receives the full payload including _PROTO_* — the exporter
// destructures and routes those keys to proto fields itself.
logEventTo1P(eventName, metadataWithSampleRate)
}
/**
* Log an event (asynchronous implementation)
*
* With Segment removed the two remaining sinks are fire-and-forget, so this
* just wraps the sync impl — kept to preserve the sink interface contract.
*/
function logEventAsyncImpl(
eventName: string,
metadata: LogEventMetadata,
): Promise<void> {
logEventImpl(eventName, metadata)
return Promise.resolve()
}
/**
* Initialize analytics gates during startup.
*
* Updates gate values from server. Early events use cached values from previous
* session to avoid data loss during initialization.
*
* Called from main.tsx during setupBackend().
*/
export function initializeAnalyticsGates(): void {
isDatadogGateEnabled =
checkStatsigFeatureGate_CACHED_MAY_BE_STALE(DATADOG_GATE_NAME)
}
/**
* Initialize the analytics sink.
*
* Call this during app startup to attach the analytics backend.
* Any events logged before this is called will be queued and drained.
*
* Idempotent: safe to call multiple times (subsequent calls are no-ops).
*/
export function initializeAnalyticsSink(): void {
attachAnalyticsSink({
logEvent: logEventImpl,
logEventAsync: logEventAsyncImpl,
})
}

View File

@@ -1,25 +0,0 @@
import { getDynamicConfig_CACHED_MAY_BE_STALE } from './growthbook.js'
// Mangled name: per-sink analytics killswitch
const SINK_KILLSWITCH_CONFIG_NAME = 'tengu_frond_boric'
export type SinkName = 'datadog' | 'firstParty'
/**
* GrowthBook JSON config that disables individual analytics sinks.
* Shape: { datadog?: boolean, firstParty?: boolean }
* A value of true for a key stops all dispatch to that sink.
* Default {} (nothing killed). Fail-open: missing/malformed config = sink stays on.
*
* NOTE: Must NOT be called from inside is1PEventLoggingEnabled() -
* growthbook.ts:isGrowthBookEnabled() calls that, so a lookup here would recurse.
* Call at per-event dispatch sites instead.
*/
export function isSinkKilled(sink: SinkName): boolean {
const config = getDynamicConfig_CACHED_MAY_BE_STALE<
Partial<Record<SinkName, boolean>>
>(SINK_KILLSWITCH_CONFIG_NAME, {})
// getFeatureValue_CACHED_MAY_BE_STALE guards on `!== undefined`, so a
// cached JSON null leaks through instead of falling back to {}.
return config?.[sink] === true
}

View File

@@ -209,11 +209,6 @@ import {
stopSessionActivity,
} from '../../utils/sessionActivity.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import {
isBetaTracingEnabled,
type LLMRequestNewContext,
startLLMRequestSpan,
} from '../../utils/telemetry/sessionTracing.js'
/* eslint-enable @typescript-eslint/no-require-imports */
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
@@ -1379,9 +1374,6 @@ async function* queryModel(
})
const useBetas = betas.length > 0
// Build minimal context for detailed tracing (when beta tracing is enabled)
// Note: The actual new_context message extraction is done in sessionTracing.ts using
// hash-based tracking per querySource (agent) from the messagesForAPI array
const extraToolSchemas = [...(options.extraToolSchemas ?? [])]
if (advisorModel) {
// Server tools must be in the tools array by API contract. Appended after
@@ -1485,23 +1477,6 @@ async function* queryModel(
})
}
const newContext: LLMRequestNewContext | undefined = isBetaTracingEnabled()
? {
systemPrompt: systemPrompt.join('\n\n'),
querySource: options.querySource,
tools: jsonStringify(allTools),
}
: undefined
// Capture the span so we can pass it to endLLMRequestSpan later
// This ensures responses are matched to the correct request when multiple requests run in parallel
const llmSpan = startLLMRequestSpan(
options.model,
newContext,
messagesForAPI,
isFastMode,
)
const startIncludingRetries = Date.now()
let start = Date.now()
let attemptNumber = 0
@@ -2730,7 +2705,6 @@ async function* queryModel(
didFallBackToNonStreaming,
queryTracking: options.queryTracking,
querySource: options.querySource,
llmSpan,
fastMode: isFastModeRequest,
previousRequestId,
})
@@ -2786,7 +2760,6 @@ async function* queryModel(
didFallBackToNonStreaming,
queryTracking: options.queryTracking,
querySource: options.querySource,
llmSpan,
fastMode: isFastModeRequest,
previousRequestId,
})
@@ -2874,10 +2847,7 @@ async function* queryModel(
costUSD,
queryTracking: options.queryTracking,
permissionMode: permissionContext.mode,
// Pass newMessages for beta tracing - extraction happens in logging.ts
// only when beta tracing is enabled
newMessages,
llmSpan,
globalCacheStrategy,
requestSetupMs: start - startIncludingRetries,
attemptStartTimes,

View File

@@ -736,77 +736,27 @@ async function translateCodexStreamToAnthropic(
// ── Main fetch interceptor ──────────────────────────────────────────
const CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex/responses'
/**
* Creates a fetch function that intercepts Anthropic API calls and routes them to Codex.
* @param accessToken - The Codex access token for authentication
* @returns A fetch function that translates Anthropic requests to Codex format
* createCodexFetch is disabled: routing conversations to chatgpt.com would
* send full user conversation content to OpenAI's backend, which is a
* privacy violation. The function is kept as a stub that always returns an
* error so existing call sites don't break at compile time.
*/
export function createCodexFetch(
accessToken: string,
_accessToken: string,
): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
const accountId = extractAccountId(accessToken)
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url = input instanceof Request ? input.url : String(input)
// Only intercept Anthropic API message calls
if (!url.includes('/v1/messages')) {
return globalThis.fetch(input, init)
}
// Parse the Anthropic request body
let anthropicBody: Record<string, unknown>
try {
const bodyText =
init?.body instanceof ReadableStream
? await new Response(init.body).text()
: typeof init?.body === 'string'
? init.body
: '{}'
anthropicBody = JSON.parse(bodyText)
} catch {
anthropicBody = {}
}
// Get current token (may have been refreshed)
const tokens = getCodexOAuthTokens()
const currentToken = tokens?.accessToken || accessToken
// Translate to Codex format
const { codexBody, codexModel } = translateToCodexBody(anthropicBody)
// Call Codex API
const codexResponse = await globalThis.fetch(CODEX_BASE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
Authorization: `Bearer ${currentToken}`,
'chatgpt-account-id': accountId,
originator: 'pi',
'OpenAI-Beta': 'responses=experimental',
return async (_input: RequestInfo | URL, _init?: RequestInit): Promise<Response> => {
const errorBody = {
type: 'error',
error: {
type: 'api_error',
message:
'Codex API routing is disabled. External Codex forwarding has been removed for privacy reasons.',
},
body: JSON.stringify(codexBody),
})
if (!codexResponse.ok) {
const errorText = await codexResponse.text()
const errorBody = {
type: 'error',
error: {
type: 'api_error',
message: `Codex API error (${codexResponse.status}): ${errorText}`,
},
}
return new Response(JSON.stringify(errorBody), {
status: codexResponse.status,
headers: { 'Content-Type': 'application/json' },
})
}
// Translate streaming response
return translateCodexStreamToAnthropic(codexResponse, codexModel)
return new Response(JSON.stringify(errorBody), {
status: 403,
headers: { 'Content-Type': 'application/json' },
})
}
}

View File

@@ -14,9 +14,10 @@ import * as path from 'path'
import { count } from '../../utils/array.js'
import { getCwd } from '../../utils/cwd.js'
import { logForDebugging } from '../../utils/debug.js'
import { errorMessage } from '../../utils/errors.js'
import { errorMessage, getErrnoCode } from '../../utils/errors.js'
import { logError } from '../../utils/log.js'
import { sleep } from '../../utils/sleep.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
@@ -45,6 +46,37 @@ function logDebug(message: string): void {
logForDebugging(`[files-api] ${message}`)
}
function summarizeFilesApiError(error: unknown): string {
const summary: Record<string, boolean | number | string> = {}
if (error instanceof Error) {
summary.errorType = error.constructor.name
summary.errorName = error.name
summary.hasMessage = error.message.length > 0
} else {
summary.errorType = typeof error
summary.hasValue = error !== undefined && error !== null
}
const errno = getErrnoCode(error)
if (errno) {
summary.errno = errno
}
if (axios.isAxiosError(error)) {
summary.errorType = 'AxiosError'
if (error.code) {
summary.axiosCode = error.code
}
if (typeof error.response?.status === 'number') {
summary.httpStatus = error.response.status
}
summary.hasResponseData = error.response?.data !== undefined
}
return jsonStringify(summary)
}
/**
* File specification parsed from CLI args
* Format: --file=<file_id>:<relative_path>
@@ -108,9 +140,7 @@ async function retryWithBackoff<T>(
}
lastError = result.error || `${operation} failed`
logDebug(
`${operation} attempt ${attempt}/${MAX_RETRIES} failed: ${lastError}`,
)
logDebug(`${operation} attempt ${attempt}/${MAX_RETRIES} failed`)
if (attempt < MAX_RETRIES) {
const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1)
@@ -142,7 +172,7 @@ export async function downloadFile(
'anthropic-beta': FILES_API_BETA_HEADER,
}
logDebug(`Downloading file ${fileId} from ${url}`)
logDebug(`Downloading file ${fileId} from configured Files API endpoint`)
return retryWithBackoff(`Download file ${fileId}`, async () => {
try {
@@ -191,9 +221,7 @@ export function buildDownloadPath(
): string | null {
const normalized = path.normalize(relativePath)
if (normalized.startsWith('..')) {
logDebugError(
`Invalid file path: ${relativePath}. Path must not traverse above workspace`,
)
logDebugError('Invalid file path rejected: path traversal is not allowed')
return null
}
@@ -243,7 +271,7 @@ export async function downloadAndSaveFile(
// Write the file
await fs.writeFile(fullPath, content)
logDebug(`Saved file ${fileId} to ${fullPath} (${content.length} bytes)`)
logDebug(`Saved file ${fileId} (${content.length} bytes)`)
return {
fileId,
@@ -252,10 +280,16 @@ export async function downloadAndSaveFile(
bytesWritten: content.length,
}
} catch (error) {
logDebugError(`Failed to download file ${fileId}: ${errorMessage(error)}`)
if (error instanceof Error) {
logError(error)
}
logDebugError(
`Failed to download file ${fileId}: ${summarizeFilesApiError(error)}`,
)
logError(
new Error(
`Files API download failed for ${fileId}: ${summarizeFilesApiError(
error,
)}`,
),
)
return {
fileId,
@@ -390,7 +424,7 @@ export async function uploadFile(
'anthropic-beta': FILES_API_BETA_HEADER,
}
logDebug(`Uploading file ${filePath} as ${relativePath}`)
logDebug('Uploading file to configured Files API endpoint')
// Read file content first (outside retry loop since it's not a network operation)
let content: Buffer
@@ -455,7 +489,7 @@ export async function uploadFile(
const body = Buffer.concat(bodyParts)
try {
return await retryWithBackoff(`Upload file ${relativePath}`, async () => {
return await retryWithBackoff('Upload session file', async () => {
try {
const response = await axios.post(url, body, {
headers: {
@@ -476,7 +510,7 @@ export async function uploadFile(
error: 'Upload succeeded but no file ID returned',
}
}
logDebug(`Uploaded file ${filePath} -> ${fileId} (${fileSize} bytes)`)
logDebug(`Uploaded file (${fileSize} bytes)`)
return {
done: true,
value: {
@@ -735,9 +769,7 @@ export function parseFileSpecs(fileSpecs: string[]): File[] {
const relativePath = spec.substring(colonIndex + 1)
if (!fileId || !relativePath) {
logDebugError(
`Invalid file spec: ${spec}. Both file_id and path are required`,
)
logDebugError('Invalid file spec: missing file_id or relative path')
continue
}

View File

@@ -22,12 +22,6 @@ import { logError } from 'src/utils/log.js'
import { getAPIProviderForStatsig } from 'src/utils/model/providers.js'
import type { PermissionMode } from 'src/utils/permissions/PermissionMode.js'
import { jsonStringify } from 'src/utils/slowOperations.js'
import { logOTelEvent } from 'src/utils/telemetry/events.js'
import {
endLLMRequestSpan,
isBetaTracingEnabled,
type Span,
} from 'src/utils/telemetry/sessionTracing.js'
import type { NonNullableUsage } from '../../entrypoints/sdk/sdkUtilityTypes.js'
import { consumeInvokingRequestId } from '../../utils/agentContext.js'
import {
@@ -247,7 +241,6 @@ export function logAPIError({
headers,
queryTracking,
querySource,
llmSpan,
fastMode,
previousRequestId,
}: {
@@ -266,8 +259,6 @@ export function logAPIError({
headers?: globalThis.Headers
queryTracking?: QueryChainTracking
querySource?: string
/** The span from startLLMRequestSpan - pass this to correctly match responses to requests */
llmSpan?: Span
fastMode?: boolean
previousRequestId?: string | null
}): void {
@@ -364,24 +355,6 @@ export function logAPIError({
...getAnthropicEnvMetadata(),
})
// Log API error event for OTLP
void logOTelEvent('api_error', {
model: model,
error: errStr,
status_code: String(status),
duration_ms: String(durationMs),
attempt: String(attempt),
speed: fastMode ? 'fast' : 'normal',
})
// Pass the span to correctly match responses to requests when beta tracing is enabled
endLLMRequestSpan(llmSpan, {
success: false,
statusCode: status ? parseInt(status) : undefined,
error: errStr,
attempt,
})
// Log first error for teleported sessions (reliability tracking)
const teleportInfo = getTeleportedSessionInfo()
if (teleportInfo?.isTeleported && !teleportInfo.hasLoggedFirstMessage) {
@@ -597,7 +570,6 @@ export function logAPISuccessAndDuration({
queryTracking,
permissionMode,
newMessages,
llmSpan,
globalCacheStrategy,
requestSetupMs,
attemptStartTimes,
@@ -622,11 +594,7 @@ export function logAPISuccessAndDuration({
costUSD: number
queryTracking?: QueryChainTracking
permissionMode?: PermissionMode
/** Assistant messages from the response - used to extract model_output and thinking_output
* when beta tracing is enabled */
newMessages?: AssistantMessage[]
/** The span from startLLMRequestSpan - pass this to correctly match responses to requests */
llmSpan?: Span
/** Strategy used for global prompt caching: 'tool_based', 'system_prompt', or 'none' */
globalCacheStrategy?: GlobalCacheStrategy
/** Time spent in pre-request setup before the successful attempt */
@@ -714,68 +682,6 @@ export function logAPISuccessAndDuration({
previousRequestId,
betas,
})
// Log API request event for OTLP
void logOTelEvent('api_request', {
model,
input_tokens: String(usage.input_tokens),
output_tokens: String(usage.output_tokens),
cache_read_tokens: String(usage.cache_read_input_tokens),
cache_creation_tokens: String(usage.cache_creation_input_tokens),
cost_usd: String(costUSD),
duration_ms: String(durationMs),
speed: fastMode ? 'fast' : 'normal',
})
// Extract model output, thinking output, and tool call flag when beta tracing is enabled
let modelOutput: string | undefined
let thinkingOutput: string | undefined
let hasToolCall: boolean | undefined
if (isBetaTracingEnabled() && newMessages) {
// Model output - visible to all users
modelOutput =
newMessages
.flatMap(m =>
m.message.content
.filter(c => c.type === 'text')
.map(c => (c as { type: 'text'; text: string }).text),
)
.join('\n') || undefined
// Thinking output - Ant-only (build-time gated)
if (process.env.USER_TYPE === 'ant') {
thinkingOutput =
newMessages
.flatMap(m =>
m.message.content
.filter(c => c.type === 'thinking')
.map(c => (c as { type: 'thinking'; thinking: string }).thinking),
)
.join('\n') || undefined
}
// Check if any tool_use blocks were in the output
hasToolCall = newMessages.some(m =>
m.message.content.some(c => c.type === 'tool_use'),
)
}
// Pass the span to correctly match responses to requests when beta tracing is enabled
endLLMRequestSpan(llmSpan, {
success: true,
inputTokens: usage.input_tokens,
outputTokens: usage.output_tokens,
cacheReadTokens: usage.cache_read_input_tokens,
cacheCreationTokens: usage.cache_creation_input_tokens,
attempt,
modelOutput,
thinkingOutput,
hasToolCall,
ttftMs: ttftMs ?? undefined,
requestSetupMs,
attemptStartTimes,
})
// Log first successful message for teleported sessions (reliability tracking)
const teleportInfo = getTeleportedSessionInfo()
if (teleportInfo?.isTeleported && !teleportInfo.hasLoggedFirstMessage) {

View File

@@ -1,159 +0,0 @@
import axios from 'axios'
import { hasProfileScope, isClaudeAISubscriber } from '../../utils/auth.js'
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
import { logForDebugging } from '../../utils/debug.js'
import { errorMessage } from '../../utils/errors.js'
import { getAuthHeaders, withOAuth401Retry } from '../../utils/http.js'
import { logError } from '../../utils/log.js'
import { memoizeWithTTLAsync } from '../../utils/memoize.js'
import { isEssentialTrafficOnly } from '../../utils/privacyLevel.js'
import { getClaudeCodeUserAgent } from '../../utils/userAgent.js'
type MetricsEnabledResponse = {
metrics_logging_enabled: boolean
}
type MetricsStatus = {
enabled: boolean
hasError: boolean
}
// In-memory TTL — dedupes calls within a single process
const CACHE_TTL_MS = 60 * 60 * 1000
// Disk TTL — org settings rarely change. When disk cache is fresher than this,
// we skip the network entirely (no background refresh). This is what collapses
// N `claude -p` invocations into ~1 API call/day.
const DISK_CACHE_TTL_MS = 24 * 60 * 60 * 1000
/**
* Internal function to call the API and check if metrics are enabled
* This is wrapped by memoizeWithTTLAsync to add caching behavior
*/
async function _fetchMetricsEnabled(): Promise<MetricsEnabledResponse> {
const authResult = getAuthHeaders()
if (authResult.error) {
throw new Error(`Auth error: ${authResult.error}`)
}
const headers = {
'Content-Type': 'application/json',
'User-Agent': getClaudeCodeUserAgent(),
...authResult.headers,
}
const endpoint = `https://api.anthropic.com/api/claude_code/organizations/metrics_enabled`
const response = await axios.get<MetricsEnabledResponse>(endpoint, {
headers,
timeout: 5000,
})
return response.data
}
async function _checkMetricsEnabledAPI(): Promise<MetricsStatus> {
// Incident kill switch: skip the network call when nonessential traffic is disabled.
// Returning enabled:false sheds load at the consumer (bigqueryExporter skips
// export). Matches the non-subscriber early-return shape below.
if (isEssentialTrafficOnly()) {
return { enabled: false, hasError: false }
}
try {
const data = await withOAuth401Retry(_fetchMetricsEnabled, {
also403Revoked: true,
})
logForDebugging(
`Metrics opt-out API response: enabled=${data.metrics_logging_enabled}`,
)
return {
enabled: data.metrics_logging_enabled,
hasError: false,
}
} catch (error) {
logForDebugging(
`Failed to check metrics opt-out status: ${errorMessage(error)}`,
)
logError(error)
return { enabled: false, hasError: true }
}
}
// Create memoized version with custom error handling
const memoizedCheckMetrics = memoizeWithTTLAsync(
_checkMetricsEnabledAPI,
CACHE_TTL_MS,
)
/**
* Fetch (in-memory memoized) and persist to disk on change.
* Errors are not persisted — a transient failure should not overwrite a
* known-good disk value.
*/
async function refreshMetricsStatus(): Promise<MetricsStatus> {
const result = await memoizedCheckMetrics()
if (result.hasError) {
return result
}
const cached = getGlobalConfig().metricsStatusCache
const unchanged = cached !== undefined && cached.enabled === result.enabled
// Skip write when unchanged AND timestamp still fresh — avoids config churn
// when concurrent callers race past a stale disk entry and all try to write.
if (unchanged && Date.now() - cached.timestamp < DISK_CACHE_TTL_MS) {
return result
}
saveGlobalConfig(current => ({
...current,
metricsStatusCache: {
enabled: result.enabled,
timestamp: Date.now(),
},
}))
return result
}
/**
* Check if metrics are enabled for the current organization.
*
* Two-tier cache:
* - Disk (24h TTL): survives process restarts. Fresh disk cache → zero network.
* - In-memory (1h TTL): dedupes the background refresh within a process.
*
* The caller (bigqueryExporter) tolerates stale reads — a missed export or
* an extra one during the 24h window is acceptable.
*/
export async function checkMetricsEnabled(): Promise<MetricsStatus> {
// Service key OAuth sessions lack user:profile scope → would 403.
// API key users (non-subscribers) fall through and use x-api-key auth.
// This check runs before the disk read so we never persist auth-state-derived
// answers — only real API responses go to disk. Otherwise a service-key
// session would poison the cache for a later full-OAuth session.
if (isClaudeAISubscriber() && !hasProfileScope()) {
return { enabled: false, hasError: false }
}
const cached = getGlobalConfig().metricsStatusCache
if (cached) {
if (Date.now() - cached.timestamp > DISK_CACHE_TTL_MS) {
// saveGlobalConfig's fallback path (config.ts:731) can throw if both
// locked and fallback writes fail — catch here so fire-and-forget
// doesn't become an unhandled rejection.
void refreshMetricsStatus().catch(logError)
}
return {
enabled: cached.enabled,
hasError: false,
}
}
// First-ever run on this machine: block on the network to populate disk.
return refreshMetricsStatus()
}
// Export for testing purposes only
export const _clearMetricsEnabledCacheForTesting = (): void => {
memoizedCheckMetrics.cache.clear()
}

View File

@@ -19,6 +19,37 @@ interface SessionIngressError {
}
}
function summarizeSessionIngressPayload(payload: unknown): string {
if (payload === null) return 'null'
if (payload === undefined) return 'undefined'
if (Array.isArray(payload)) return `array(${payload.length})`
if (typeof payload === 'object') {
const value = payload as Record<string, unknown>
return jsonStringify({
payloadType: 'object',
keys: Object.keys(value)
.sort()
.slice(0, 10),
loglinesCount: Array.isArray(value.loglines) ? value.loglines.length : 0,
dataCount: Array.isArray(value.data) ? value.data.length : 0,
hasNextCursor: typeof value.next_cursor === 'string',
})
}
return typeof payload
}
function summarizeSessionIngressErrorForDebug(error: unknown): string {
const err = error as AxiosError<SessionIngressError>
return jsonStringify({
errorType:
error instanceof Error ? error.constructor.name : typeof error,
hasMessage: error instanceof Error ? err.message.length > 0 : false,
hasStack: error instanceof Error ? Boolean(err.stack) : false,
status: err.status,
code: typeof err.code === 'string' ? err.code : undefined,
})
}
// Module-level state
const lastUuidMap: Map<string, UUID> = new Map()
@@ -81,9 +112,7 @@ async function appendSessionLogImpl(
if (response.status === 200 || response.status === 201) {
lastUuidMap.set(sessionId, entry.uuid)
logForDebugging(
`Successfully persisted session log entry for session ${sessionId}`,
)
logForDebugging('Successfully persisted session log entry')
return true
}
@@ -96,7 +125,7 @@ async function appendSessionLogImpl(
// Our entry IS the last entry on server - it was stored successfully previously
lastUuidMap.set(sessionId, entry.uuid)
logForDebugging(
`Session entry ${entry.uuid} already present on server, recovering from stale state`,
'Session entry already present on server, recovering from stale state',
)
logForDiagnosticsNoPII('info', 'session_persist_recovered_from_409')
return true
@@ -108,7 +137,7 @@ async function appendSessionLogImpl(
if (serverLastUuid) {
lastUuidMap.set(sessionId, serverLastUuid as UUID)
logForDebugging(
`Session 409: adopting server lastUuid=${serverLastUuid} from header, retrying entry ${entry.uuid}`,
'Session 409: adopting server last UUID from header and retrying',
)
} else {
// Server didn't return x-last-uuid (e.g. v1 endpoint). Re-fetch
@@ -118,7 +147,7 @@ async function appendSessionLogImpl(
if (adoptedUuid) {
lastUuidMap.set(sessionId, adoptedUuid)
logForDebugging(
`Session 409: re-fetched ${logs!.length} entries, adopting lastUuid=${adoptedUuid}, retrying entry ${entry.uuid}`,
`Session 409: re-fetched ${logs!.length} entries, adopting recovered last UUID and retrying`,
)
} else {
// Can't determine server state — give up
@@ -127,7 +156,7 @@ async function appendSessionLogImpl(
errorData.error?.message || 'Concurrent modification detected'
logError(
new Error(
`Session persistence conflict: UUID mismatch for session ${sessionId}, entry ${entry.uuid}. ${errorMessage}`,
`Session persistence conflict: UUID mismatch detected. ${errorMessage}`,
),
)
logForDiagnosticsNoPII(
@@ -149,7 +178,7 @@ async function appendSessionLogImpl(
// Other 4xx (429, etc.) - retryable
logForDebugging(
`Failed to persist session log: ${response.status} ${response.statusText}`,
`Failed to persist session log: status=${response.status}`,
)
logForDiagnosticsNoPII('error', 'session_persist_fail_status', {
status: response.status,
@@ -158,7 +187,13 @@ async function appendSessionLogImpl(
} catch (error) {
// Network errors, 5xx - retryable
const axiosError = error as AxiosError<SessionIngressError>
logError(new Error(`Error persisting session log: ${axiosError.message}`))
logError(
new Error(
`Error persisting session log: ${summarizeSessionIngressErrorForDebug(
error,
)}`,
),
)
logForDiagnosticsNoPII('error', 'session_persist_fail_status', {
status: axiosError.status,
attempt,
@@ -249,7 +284,7 @@ export async function getSessionLogsViaOAuth(
orgUUID: string,
): Promise<Entry[] | null> {
const url = `${getOauthConfig().BASE_API_URL}/v1/session_ingress/session/${sessionId}`
logForDebugging(`[session-ingress] Fetching session logs from: ${url}`)
logForDebugging('[session-ingress] Fetching session logs via OAuth endpoint')
const headers = {
...getOAuthHeaders(accessToken),
'x-organization-uuid': orgUUID,
@@ -299,7 +334,7 @@ export async function getTeleportEvents(
'x-organization-uuid': orgUUID,
}
logForDebugging(`[teleport] Fetching events from: ${baseUrl}`)
logForDebugging('[teleport] Fetching session events via teleport endpoint')
const all: Entry[] = []
let cursor: string | undefined
@@ -346,7 +381,7 @@ export async function getTeleportEvents(
// 404 mid-pagination (pages > 0) means session was deleted between
// pages — return what we have.
logForDebugging(
`[teleport] Session ${sessionId} not found (page ${pages})`,
`[teleport] Session not found while fetching events (page ${pages})`,
)
logForDiagnosticsNoPII('warn', 'teleport_events_not_found')
return pages === 0 ? null : all
@@ -362,7 +397,9 @@ export async function getTeleportEvents(
if (response.status !== 200) {
logError(
new Error(
`Teleport events returned ${response.status}: ${jsonStringify(response.data)}`,
`Teleport events returned ${response.status}: ${summarizeSessionIngressPayload(
response.data,
)}`,
),
)
logForDiagnosticsNoPII('error', 'teleport_events_bad_status')
@@ -373,7 +410,9 @@ export async function getTeleportEvents(
if (!Array.isArray(data)) {
logError(
new Error(
`Teleport events invalid response shape: ${jsonStringify(response.data)}`,
`Teleport events invalid response shape: ${summarizeSessionIngressPayload(
response.data,
)}`,
),
)
logForDiagnosticsNoPII('error', 'teleport_events_invalid_shape')
@@ -403,13 +442,13 @@ export async function getTeleportEvents(
// Don't fail — return what we have. Better to teleport with a
// truncated transcript than not at all.
logError(
new Error(`Teleport events hit page cap (${maxPages}) for ${sessionId}`),
new Error(`Teleport events hit page cap (${maxPages})`),
)
logForDiagnosticsNoPII('warn', 'teleport_events_page_cap')
}
logForDebugging(
`[teleport] Fetched ${all.length} events over ${pages} page(s) for ${sessionId}`,
`[teleport] Fetched ${all.length} events over ${pages} page(s)`,
)
return all
}
@@ -439,7 +478,9 @@ async function fetchSessionLogsFromUrl(
if (!data || typeof data !== 'object' || !Array.isArray(data.loglines)) {
logError(
new Error(
`Invalid session logs response format: ${jsonStringify(data)}`,
`Invalid session logs response format: ${summarizeSessionIngressPayload(
data,
)}`,
),
)
logForDiagnosticsNoPII('error', 'session_get_fail_invalid_response')
@@ -447,14 +488,12 @@ async function fetchSessionLogsFromUrl(
}
const logs = data.loglines as Entry[]
logForDebugging(
`Fetched ${logs.length} session logs for session ${sessionId}`,
)
logForDebugging(`Fetched ${logs.length} session logs`)
return logs
}
if (response.status === 404) {
logForDebugging(`No existing logs for session ${sessionId}`)
logForDebugging('No existing session logs')
logForDiagnosticsNoPII('warn', 'session_get_no_logs_for_session')
return []
}
@@ -468,7 +507,7 @@ async function fetchSessionLogsFromUrl(
}
logForDebugging(
`Failed to fetch session logs: ${response.status} ${response.statusText}`,
`Failed to fetch session logs: status=${response.status}`,
)
logForDiagnosticsNoPII('error', 'session_get_fail_status', {
status: response.status,
@@ -476,7 +515,13 @@ async function fetchSessionLogsFromUrl(
return null
} catch (error) {
const axiosError = error as AxiosError<SessionIngressError>
logError(new Error(`Error fetching session logs: ${axiosError.message}`))
logError(
new Error(
`Error fetching session logs: ${summarizeSessionIngressErrorForDebug(
error,
)}`,
),
)
logForDiagnosticsNoPII('error', 'session_get_fail_status', {
status: axiosError.status,
})

View File

@@ -6,7 +6,6 @@ import { clearSpeculativeChecks } from '../../tools/BashTool/bashPermissions.js'
import { clearClassifierApprovals } from '../../utils/classifierApprovals.js'
import { resetGetMemoryFilesCache } from '../../utils/claudemd.js'
import { clearSessionMessagesCache } from '../../utils/sessionStorage.js'
import { clearBetaTracingState } from '../../utils/telemetry/betaSessionTracing.js'
import { resetMicrocompactState } from './microCompact.js'
/**
@@ -67,7 +66,6 @@ export function runPostCompactCleanup(querySource?: QuerySource): void {
// model still has SkillTool in schema, invoked_skills preserves used
// skills, and dynamic additions are handled by skillChangeDetector /
// cacheUtils resets. See compactConversation() for full rationale.
clearBetaTracingState()
if (feature('COMMIT_ATTRIBUTION')) {
void import('../../utils/attributionHooks.js').then(m =>
m.sweepFileContentCache(),

View File

@@ -8,6 +8,34 @@ import type { DiagnosticFile } from '../diagnosticTracking.js'
import { registerPendingLSPDiagnostic } from './LSPDiagnosticRegistry.js'
import type { LSPServerManager } from './LSPServerManager.js'
function summarizeLspErrorForDebug(error: unknown): string {
const err = toError(error)
return jsonStringify({
errorType: err.constructor.name,
errorName: err.name,
hasMessage: err.message.length > 0,
})
}
function summarizeDiagnosticParamsForDebug(params: unknown): string {
if (!params || typeof params !== 'object') {
return jsonStringify({
paramsType: typeof params,
hasValue: params !== undefined && params !== null,
})
}
const paramRecord = params as Record<string, unknown>
const diagnostics = paramRecord.diagnostics
return jsonStringify({
keys: Object.keys(paramRecord)
.sort()
.slice(0, 10),
hasUri: typeof paramRecord.uri === 'string',
diagnosticsCount: Array.isArray(diagnostics) ? diagnostics.length : 0,
})
}
/**
* Map LSP severity to Claude diagnostic severity
*
@@ -54,7 +82,9 @@ export function formatDiagnosticsForAttachment(
const err = toError(error)
logError(err)
logForDebugging(
`Failed to convert URI to file path: ${params.uri}. Error: ${err.message}. Using original URI as fallback.`,
`Failed to convert diagnostic URI to file path; using original URI fallback (${summarizeLspErrorForDebug(
err,
)})`,
)
// Gracefully fallback to original URI - LSP servers may send malformed URIs
uri = params.uri
@@ -177,14 +207,16 @@ export function registerLSPNotificationHandlers(
)
logError(err)
logForDebugging(
`Invalid diagnostic params from ${serverName}: ${jsonStringify(params)}`,
`Invalid diagnostic params from ${serverName}: ${summarizeDiagnosticParamsForDebug(
params,
)}`,
)
return
}
const diagnosticParams = params as PublishDiagnosticsParams
logForDebugging(
`Received diagnostics from ${serverName}: ${diagnosticParams.diagnostics.length} diagnostic(s) for ${diagnosticParams.uri}`,
`Received diagnostics from ${serverName}: ${diagnosticParams.diagnostics.length} diagnostic(s)`,
)
// Convert LSP diagnostics to Claude format (can throw on invalid URIs)
@@ -199,7 +231,7 @@ export function registerLSPNotificationHandlers(
firstFile.diagnostics.length === 0
) {
logForDebugging(
`Skipping empty diagnostics from ${serverName} for ${diagnosticParams.uri}`,
`Skipping empty diagnostics from ${serverName}`,
)
return
}
@@ -223,9 +255,8 @@ export function registerLSPNotificationHandlers(
logError(err)
logForDebugging(
`Error registering LSP diagnostics from ${serverName}: ` +
`URI: ${diagnosticParams.uri}, ` +
`Diagnostic count: ${firstFile.diagnostics.length}, ` +
`Error: ${err.message}`,
`Error: ${summarizeLspErrorForDebug(err)}`,
)
// Track consecutive failures and warn after 3+
@@ -234,7 +265,7 @@ export function registerLSPNotificationHandlers(
lastError: '',
}
failures.count++
failures.lastError = err.message
failures.lastError = summarizeLspErrorForDebug(err)
diagnosticFailures.set(serverName, failures)
if (failures.count >= 3) {
@@ -251,7 +282,9 @@ export function registerLSPNotificationHandlers(
const err = toError(error)
logError(err)
logForDebugging(
`Unexpected error processing diagnostics from ${serverName}: ${err.message}`,
`Unexpected error processing diagnostics from ${serverName}: ${summarizeLspErrorForDebug(
err,
)}`,
)
// Track consecutive failures and warn after 3+
@@ -260,7 +293,7 @@ export function registerLSPNotificationHandlers(
lastError: '',
}
failures.count++
failures.lastError = err.message
failures.lastError = summarizeLspErrorForDebug(err)
diagnosticFailures.set(serverName, failures)
if (failures.count >= 3) {
@@ -284,13 +317,13 @@ export function registerLSPNotificationHandlers(
registrationErrors.push({
serverName,
error: err.message,
error: summarizeLspErrorForDebug(err),
})
logError(err)
logForDebugging(
`Failed to register diagnostics handler for ${serverName}: ` +
`Error: ${err.message}`,
`Error: ${summarizeLspErrorForDebug(err)}`,
)
}
}

View File

@@ -93,35 +93,77 @@ type MCPOAuthFlowErrorReason =
const MAX_LOCK_RETRIES = 5
/**
* OAuth query parameters that should be redacted from logs.
* These contain sensitive values that could enable CSRF or session fixation attacks.
*/
const SENSITIVE_OAUTH_PARAMS = [
'state',
'nonce',
'code_challenge',
'code_verifier',
'code',
]
/**
* Redacts sensitive OAuth query parameters from a URL for safe logging.
* Prevents exposure of state, nonce, code_challenge, code_verifier, and authorization codes.
*/
function redactSensitiveUrlParams(url: string): string {
try {
const parsedUrl = new URL(url)
for (const param of SENSITIVE_OAUTH_PARAMS) {
if (parsedUrl.searchParams.has(param)) {
parsedUrl.searchParams.set(param, '[REDACTED]')
}
function summarizeHeadersForDebug(
headers: Record<string, string> | undefined,
): {
headerCount: number
headerNames: string[]
hasAuthorization: boolean
} {
if (!headers) {
return {
headerCount: 0,
headerNames: [],
hasAuthorization: false,
}
return parsedUrl.toString()
} catch {
// Return as-is if not a valid URL
return url
}
const headerNames = Object.keys(headers).sort()
return {
headerCount: headerNames.length,
headerNames,
hasAuthorization: headerNames.some(
headerName => headerName.toLowerCase() === 'authorization',
),
}
}
function extractHttpStatusFromErrorMessage(message: string): number | undefined {
const statusMatch = message.match(/^HTTP (\d{3}):/)
if (!statusMatch) {
return undefined
}
return Number(statusMatch[1])
}
function summarizeOAuthErrorForDebug(error: unknown): string {
const summary: Record<string, boolean | number | string> = {}
if (error instanceof Error) {
summary.errorType = error.constructor.name
summary.errorName = error.name
summary.hasMessage = error.message.length > 0
const httpStatus = extractHttpStatusFromErrorMessage(error.message)
if (httpStatus !== undefined) {
summary.httpStatus = httpStatus
}
if (error instanceof OAuthError) {
summary.oauthErrorCode = error.errorCode
}
} else {
summary.errorType = typeof error
summary.hasValue = error !== undefined && error !== null
}
const errno = getErrnoCode(error)
if (errno) {
summary.errno = errno
}
if (axios.isAxiosError(error)) {
summary.errorType = 'AxiosError'
if (error.code) {
summary.axiosCode = error.code
}
if (typeof error.response?.status === 'number') {
summary.httpStatus = error.response.status
}
summary.hasResponseData = error.response?.data !== undefined
}
return jsonStringify(summary)
}
/**
@@ -295,7 +337,9 @@ async function fetchAuthServerMetadata(
// to the legacy path-aware retry.
logMCPDebug(
serverName,
`RFC 9728 discovery failed, falling back: ${errorMessage(err)}`,
`RFC 9728 discovery failed, falling back: ${summarizeOAuthErrorForDebug(
err,
)}`,
)
}
@@ -517,7 +561,7 @@ export async function revokeServerTokens(
: 'client_secret_basic'
logMCPDebug(
serverName,
`Revoking tokens via ${revocationEndpointStr} (${authMethod})`,
`Revoking tokens via discovered OAuth revocation endpoint (${authMethod})`,
)
// Revoke refresh token first (more important - prevents future access token generation)
@@ -537,7 +581,9 @@ export async function revokeServerTokens(
// Log but continue
logMCPDebug(
serverName,
`Failed to revoke refresh token: ${errorMessage(error)}`,
`Failed to revoke refresh token: ${summarizeOAuthErrorForDebug(
error,
)}`,
)
}
}
@@ -558,7 +604,9 @@ export async function revokeServerTokens(
} catch (error: unknown) {
logMCPDebug(
serverName,
`Failed to revoke access token: ${errorMessage(error)}`,
`Failed to revoke access token: ${summarizeOAuthErrorForDebug(
error,
)}`,
)
}
}
@@ -566,7 +614,10 @@ export async function revokeServerTokens(
}
} catch (error: unknown) {
// Log error but don't throw - revocation is best-effort
logMCPDebug(serverName, `Failed to revoke tokens: ${errorMessage(error)}`)
logMCPDebug(
serverName,
`Failed to revoke tokens: ${summarizeOAuthErrorForDebug(error)}`,
)
}
} else {
logMCPDebug(serverName, 'No tokens to revoke')
@@ -696,14 +747,11 @@ async function performMCPXaaAuth(
const haveKeys = Object.keys(
getSecureStorage().read()?.mcpOAuthClientConfig ?? {},
)
const headersForLogging = Object.fromEntries(
Object.entries(serverConfig.headers ?? {}).map(([k, v]) =>
k.toLowerCase() === 'authorization' ? [k, '[REDACTED]'] : [k, v],
),
)
logMCPDebug(
serverName,
`XAA: secret lookup miss. wanted=${wantedKey} have=[${haveKeys.join(', ')}] configHeaders=${jsonStringify(headersForLogging)}`,
`XAA: secret lookup miss. wanted=${wantedKey} availableKeys=${haveKeys.length} configHeaderSummary=${jsonStringify(
summarizeHeadersForDebug(serverConfig.headers),
)}`,
)
throw new Error(
`XAA: AS client secret not found for '${serverName}'. Re-add with --client-secret.`,
@@ -923,10 +971,7 @@ export async function performMCPOAuthFlow(
try {
resourceMetadataUrl = new URL(cachedResourceMetadataUrl)
} catch {
logMCPDebug(
serverName,
`Invalid cached resourceMetadataUrl: ${cachedResourceMetadataUrl}`,
)
logMCPDebug(serverName, 'Invalid cached resource metadata URL')
}
}
const wwwAuthParams: WWWAuthenticateParams = {
@@ -988,13 +1033,15 @@ export async function performMCPOAuthFlow(
provider.setMetadata(metadata)
logMCPDebug(
serverName,
`Fetched OAuth metadata with scope: ${getScopeFromMetadata(metadata) || 'NONE'}`,
`Fetched OAuth metadata (hasScope=${Boolean(
getScopeFromMetadata(metadata),
)})`,
)
}
} catch (error) {
logMCPDebug(
serverName,
`Failed to fetch OAuth metadata: ${errorMessage(error)}`,
`Failed to fetch OAuth metadata: ${summarizeOAuthErrorForDebug(error)}`,
)
}
@@ -1170,8 +1217,10 @@ export async function performMCPOAuthFlow(
server.listen(port, '127.0.0.1', async () => {
try {
logMCPDebug(serverName, `Starting SDK auth`)
logMCPDebug(serverName, `Server URL: ${serverConfig.url}`)
logMCPDebug(
serverName,
`Starting SDK auth (transport=${serverConfig.type})`,
)
// First call to start the auth flow - should redirect
// Pass the scope and resource_metadata from WWW-Authenticate header if available
@@ -1189,7 +1238,10 @@ export async function performMCPOAuthFlow(
)
}
} catch (error) {
logMCPDebug(serverName, `SDK auth error: ${error}`)
logMCPDebug(
serverName,
`SDK auth error: ${summarizeOAuthErrorForDebug(error)}`,
)
cleanup()
rejectOnce(new Error(`SDK auth failed: ${errorMessage(error)}`))
}
@@ -1235,9 +1287,13 @@ export async function performMCPOAuthFlow(
if (savedTokens) {
logMCPDebug(
serverName,
`Token access_token length: ${savedTokens.access_token?.length}`,
`Token summary after auth: ${jsonStringify({
hasAccessToken: Boolean(savedTokens.access_token),
hasRefreshToken: Boolean(savedTokens.refresh_token),
expiresInSec: savedTokens.expires_in,
hasScope: Boolean(savedTokens.scope),
})}`,
)
logMCPDebug(serverName, `Token expires_in: ${savedTokens.expires_in}`)
}
logEvent('tengu_mcp_oauth_flow_success', {
@@ -1257,7 +1313,10 @@ export async function performMCPOAuthFlow(
throw new Error('Unexpected auth result: ' + result)
}
} catch (error) {
logMCPDebug(serverName, `Error during auth completion: ${error}`)
logMCPDebug(
serverName,
`Error during auth completion: ${summarizeOAuthErrorForDebug(error)}`,
)
// Determine failure reason for attribution telemetry. The try block covers
// port acquisition, the callback server, the redirect flow, and token
@@ -1298,9 +1357,9 @@ export async function performMCPOAuthFlow(
// SDK does not attach HTTP status as a property, but the fallback ServerError
// embeds it in the message as "HTTP {status}:" when the response body was
// unparseable. Best-effort extraction.
const statusMatch = error.message.match(/^HTTP (\d{3}):/)
if (statusMatch) {
httpStatus = Number(statusMatch[1])
const parsedStatus = extractHttpStatusFromErrorMessage(error.message)
if (parsedStatus !== undefined) {
httpStatus = parsedStatus
}
// If client not found, clear the stored client ID and suggest retry
if (
@@ -1429,7 +1488,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
metadata.scope = metadataScope
logMCPDebug(
this.serverName,
`Using scope from metadata: ${metadata.scope}`,
'Using scope from metadata',
)
}
@@ -1445,7 +1504,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
get clientMetadataUrl(): string | undefined {
const override = process.env.MCP_OAUTH_CLIENT_METADATA_URL
if (override) {
logMCPDebug(this.serverName, `Using CIMD URL from env: ${override}`)
logMCPDebug(this.serverName, 'Using CIMD URL from env override')
return override
}
return MCP_CLIENT_METADATA_URL
@@ -1467,7 +1526,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
*/
markStepUpPending(scope: string): void {
this._pendingStepUpScope = scope
logMCPDebug(this.serverName, `Marked step-up pending: ${scope}`)
logMCPDebug(this.serverName, 'Marked step-up pending')
}
async state(): Promise<string> {
@@ -1606,7 +1665,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
} catch (e) {
logMCPDebug(
this.serverName,
`XAA silent exchange failed: ${errorMessage(e)}`,
`XAA silent exchange failed: ${summarizeOAuthErrorForDebug(e)}`,
)
}
// Fall through. Either id_token isn't cached (xaaRefresh returned
@@ -1632,7 +1691,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
if (needsStepUp) {
logMCPDebug(
this.serverName,
`Step-up pending (${this._pendingStepUpScope}), omitting refresh_token`,
'Step-up pending, omitting refresh_token',
)
}
@@ -1679,7 +1738,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
} catch (error) {
logMCPDebug(
this.serverName,
`Token refresh error: ${errorMessage(error)}`,
`Token refresh error: ${summarizeOAuthErrorForDebug(error)}`,
)
}
}
@@ -1693,10 +1752,15 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
token_type: 'Bearer',
}
logMCPDebug(this.serverName, `Returning tokens`)
logMCPDebug(this.serverName, `Token length: ${tokens.access_token?.length}`)
logMCPDebug(this.serverName, `Has refresh token: ${!!tokens.refresh_token}`)
logMCPDebug(this.serverName, `Expires in: ${Math.floor(expiresIn)}s`)
logMCPDebug(
this.serverName,
`Returning tokens: ${jsonStringify({
hasAccessToken: Boolean(tokens.access_token),
hasRefreshToken: Boolean(tokens.refresh_token),
hasScope: Boolean(tokens.scope),
expiresInSec: Math.floor(expiresIn),
})}`,
)
return tokens
}
@@ -1707,9 +1771,15 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
const existingData = storage.read() || {}
const serverKey = getServerKey(this.serverName, this.serverConfig)
logMCPDebug(this.serverName, `Saving tokens`)
logMCPDebug(this.serverName, `Token expires in: ${tokens.expires_in}`)
logMCPDebug(this.serverName, `Has refresh token: ${!!tokens.refresh_token}`)
logMCPDebug(
this.serverName,
`Saving tokens: ${jsonStringify({
hasAccessToken: Boolean(tokens.access_token),
hasRefreshToken: Boolean(tokens.refresh_token),
hasScope: Boolean(tokens.scope),
expiresInSec: tokens.expires_in,
})}`,
)
const updatedData: SecureStorageData = {
...existingData,
@@ -1783,7 +1853,9 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
} catch (e) {
logMCPDebug(
this.serverName,
`XAA: OIDC discovery failed in silent refresh: ${errorMessage(e)}`,
`XAA: OIDC discovery failed in silent refresh: ${summarizeOAuthErrorForDebug(
e,
)}`,
)
return undefined
}
@@ -1855,29 +1927,18 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
// Extract and store scopes from the authorization URL for later use in token exchange
const scopes = authorizationUrl.searchParams.get('scope')
logMCPDebug(
this.serverName,
`Authorization URL: ${redactSensitiveUrlParams(authorizationUrl.toString())}`,
)
logMCPDebug(this.serverName, `Scopes in URL: ${scopes || 'NOT FOUND'}`)
if (scopes) {
this._scopes = scopes
logMCPDebug(
this.serverName,
`Captured scopes from authorization URL: ${scopes}`,
)
logMCPDebug(this.serverName, 'Captured scopes from authorization URL')
} else {
// If no scope in URL, try to get it from metadata
const metadataScope = getScopeFromMetadata(this._metadata)
if (metadataScope) {
this._scopes = metadataScope
logMCPDebug(
this.serverName,
`Using scopes from metadata: ${metadataScope}`,
)
logMCPDebug(this.serverName, 'Using scopes from metadata')
} else {
logMCPDebug(this.serverName, `No scopes available from URL or metadata`)
logMCPDebug(this.serverName, 'No scopes available from URL or metadata')
}
}
@@ -1895,7 +1956,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
if (existing) {
existing.stepUpScope = this._scopes
storage.update(existingData)
logMCPDebug(this.serverName, `Persisted step-up scope: ${this._scopes}`)
logMCPDebug(this.serverName, 'Persisted step-up scope')
}
}
@@ -1916,8 +1977,6 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
}
logMCPDebug(this.serverName, `Redirecting to authorization URL`)
const redactedUrl = redactSensitiveUrlParams(urlString)
logMCPDebug(this.serverName, `Authorization URL: ${redactedUrl}`)
// Notify the UI about the authorization URL BEFORE opening the browser,
// so users can see the URL as a fallback if the browser fails to open
@@ -1926,7 +1985,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
}
if (!this.skipBrowserOpen) {
logMCPDebug(this.serverName, `Opening authorization URL: ${redactedUrl}`)
logMCPDebug(this.serverName, 'Opening authorization URL')
const success = await openBrowser(urlString)
if (!success) {
@@ -1938,7 +1997,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
} else {
logMCPDebug(
this.serverName,
`Skipping browser open (skipBrowserOpen=true). URL: ${redactedUrl}`,
'Skipping browser open (skipBrowserOpen=true)',
)
}
}
@@ -1991,7 +2050,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
}
storage.update(existingData)
logMCPDebug(this.serverName, `Invalidated credentials (scope: ${scope})`)
logMCPDebug(this.serverName, `Invalidated credentials (${scope})`)
}
async saveDiscoveryState(state: OAuthDiscoveryState): Promise<void> {
@@ -1999,10 +2058,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
const existingData = storage.read() || {}
const serverKey = getServerKey(this.serverName, this.serverConfig)
logMCPDebug(
this.serverName,
`Saving discovery state (authServer: ${state.authorizationServerUrl})`,
)
logMCPDebug(this.serverName, 'Saving discovery state')
// Persist only the URLs, NOT the full metadata blobs.
// authorizationServerMetadata alone is ~1.5-2KB per MCP server (every
@@ -2041,10 +2097,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
const cached = data?.mcpOAuth?.[serverKey]?.discoveryState
if (cached?.authorizationServerUrl) {
logMCPDebug(
this.serverName,
`Returning cached discovery state (authServer: ${cached.authorizationServerUrl})`,
)
logMCPDebug(this.serverName, 'Returning cached discovery state')
return {
authorizationServerUrl: cached.authorizationServerUrl,
@@ -2061,7 +2114,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
if (metadataUrl) {
logMCPDebug(
this.serverName,
`Fetching metadata from configured URL: ${metadataUrl}`,
'Fetching metadata from configured override URL',
)
try {
const metadata = await fetchAuthServerMetadata(
@@ -2079,7 +2132,9 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
} catch (error) {
logMCPDebug(
this.serverName,
`Failed to fetch from configured metadata URL: ${errorMessage(error)}`,
`Failed to fetch from configured metadata URL: ${summarizeOAuthErrorForDebug(
error,
)}`,
)
}
}
@@ -2231,7 +2286,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
} else if (cached?.authorizationServerUrl) {
logMCPDebug(
this.serverName,
`Re-discovering metadata from persisted auth server URL: ${cached.authorizationServerUrl}`,
'Re-discovering metadata from persisted auth server URL',
)
metadata = await discoverAuthorizationServerMetadata(
cached.authorizationServerUrl,
@@ -2287,10 +2342,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
// Invalid grant means the refresh token itself is invalid/revoked/expired.
// But another process may have already refreshed successfully — check first.
if (error instanceof InvalidGrantError) {
logMCPDebug(
this.serverName,
`Token refresh failed with invalid_grant: ${error.message}`,
)
logMCPDebug(this.serverName, 'Token refresh failed with invalid_grant')
clearKeychainCache()
const storage = getSecureStorage()
const data = storage.read()
@@ -2337,7 +2389,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
if (!isRetryable || attempt >= MAX_ATTEMPTS) {
logMCPDebug(
this.serverName,
`Token refresh failed: ${errorMessage(error)}`,
`Token refresh failed: ${summarizeOAuthErrorForDebug(error)}`,
)
emitRefreshEvent(
'failure',

View File

@@ -332,6 +332,94 @@ function mcpBaseUrlAnalytics(serverRef: ScopedMcpServerConfig): {
: {}
}
function mcpBaseUrlForDebug(serverRef: ScopedMcpServerConfig): string {
return getLoggingSafeMcpBaseUrl(serverRef) || '[unavailable]'
}
function summarizeHeadersForDebug(
headers: Record<string, string> | undefined,
): {
headerCount: number
headerNames: string[]
hasAuthorization: boolean
} {
if (!headers) {
return {
headerCount: 0,
headerNames: [],
hasAuthorization: false,
}
}
const headerNames = Object.keys(headers)
return {
headerCount: headerNames.length,
headerNames: headerNames.sort(),
hasAuthorization: headerNames.some(
headerName => headerName.toLowerCase() === 'authorization',
),
}
}
function summarizeProxyEnvForDebug(): Record<string, string | boolean> {
return {
hasNodeOptions: Boolean(process.env.NODE_OPTIONS),
uvThreadpoolSizeConfigured: Boolean(process.env.UV_THREADPOOL_SIZE),
hasHttpProxy: Boolean(process.env.HTTP_PROXY),
hasHttpsProxy: Boolean(process.env.HTTPS_PROXY),
hasNoProxy: Boolean(process.env.NO_PROXY),
}
}
function summarizeStderrForDebug(stderrOutput: string): string {
const trimmed = stderrOutput.trim()
const lineCount = trimmed === '' ? 0 : trimmed.split('\n').length
return `Server stderr captured (${trimmed.length} chars, ${lineCount} lines)`
}
function summarizeMcpErrorForDebug(error: unknown): string {
const summary: Record<string, boolean | number | string> = {}
if (error instanceof Error) {
summary.errorType = error.constructor.name
summary.errorName = error.name
summary.hasMessage = error.message.length > 0
summary.hasStack = Boolean(error.stack)
const errorObj = error as Error & {
code?: unknown
errno?: unknown
syscall?: unknown
status?: unknown
cause?: unknown
}
if (typeof errorObj.code === 'string' || typeof errorObj.code === 'number') {
summary.code = errorObj.code
}
if (
typeof errorObj.errno === 'string' ||
typeof errorObj.errno === 'number'
) {
summary.errno = errorObj.errno
}
if (typeof errorObj.syscall === 'string') {
summary.syscall = errorObj.syscall
}
if (typeof errorObj.status === 'number') {
summary.status = errorObj.status
}
if (errorObj.cause !== undefined) {
summary.hasCause = true
}
} else {
summary.errorType = typeof error
summary.hasValue = error !== undefined && error !== null
}
return jsonStringify(summary)
}
/**
* Shared handler for sse/http/claudeai-proxy auth failures during connect:
* emits tengu_mcp_server_needs_auth, caches the needs-auth entry, and returns
@@ -676,7 +764,10 @@ export const connectToServer = memoize(
)
logMCPDebug(name, `SSE transport initialized, awaiting connection`)
} else if (serverRef.type === 'sse-ide') {
logMCPDebug(name, `Setting up SSE-IDE transport to ${serverRef.url}`)
logMCPDebug(
name,
`Setting up SSE-IDE transport to ${mcpBaseUrlForDebug(serverRef)}`,
)
// IDE servers don't need authentication
// TODO: Use the auth token provided in the lockfile
const proxyOptions = getProxyFetchOptions()
@@ -735,7 +826,7 @@ export const connectToServer = memoize(
} else if (serverRef.type === 'ws') {
logMCPDebug(
name,
`Initializing WebSocket transport to ${serverRef.url}`,
`Initializing WebSocket transport to ${mcpBaseUrlForDebug(serverRef)}`,
)
const combinedHeaders = await getMcpServerHeaders(name, serverRef)
@@ -749,16 +840,17 @@ export const connectToServer = memoize(
...combinedHeaders,
}
// Redact sensitive headers before logging
const wsHeadersForLogging = mapValues(wsHeaders, (value, key) =>
key.toLowerCase() === 'authorization' ? '[REDACTED]' : value,
const wsHeadersForLogging = summarizeHeadersForDebug(
mapValues(wsHeaders, (_value, key) =>
key.toLowerCase() === 'authorization' ? '[REDACTED]' : '[set]',
),
)
logMCPDebug(
name,
`WebSocket transport options: ${jsonStringify({
url: serverRef.url,
headers: wsHeadersForLogging,
url: mcpBaseUrlForDebug(serverRef),
...wsHeadersForLogging,
hasSessionAuth: !!sessionIngressToken,
})}`,
)
@@ -782,20 +874,17 @@ export const connectToServer = memoize(
}
transport = new WebSocketTransport(wsClient)
} else if (serverRef.type === 'http') {
logMCPDebug(name, `Initializing HTTP transport to ${serverRef.url}`)
logMCPDebug(
name,
`Initializing HTTP transport to ${mcpBaseUrlForDebug(serverRef)}`,
)
logMCPDebug(
name,
`Node version: ${process.version}, Platform: ${process.platform}`,
)
logMCPDebug(
name,
`Environment: ${jsonStringify({
NODE_OPTIONS: process.env.NODE_OPTIONS || 'not set',
UV_THREADPOOL_SIZE: process.env.UV_THREADPOOL_SIZE || 'default',
HTTP_PROXY: process.env.HTTP_PROXY || 'not set',
HTTPS_PROXY: process.env.HTTPS_PROXY || 'not set',
NO_PROXY: process.env.NO_PROXY || 'not set',
})}`,
`Environment: ${jsonStringify(summarizeProxyEnvForDebug())}`,
)
// Create an auth provider for this server
@@ -843,16 +932,16 @@ export const connectToServer = memoize(
const headersForLogging = transportOptions.requestInit?.headers
? mapValues(
transportOptions.requestInit.headers as Record<string, string>,
(value, key) =>
key.toLowerCase() === 'authorization' ? '[REDACTED]' : value,
(_value, key) =>
key.toLowerCase() === 'authorization' ? '[REDACTED]' : '[set]',
)
: undefined
logMCPDebug(
name,
`HTTP transport options: ${jsonStringify({
url: serverRef.url,
headers: headersForLogging,
url: mcpBaseUrlForDebug(serverRef),
...summarizeHeadersForDebug(headersForLogging),
hasAuthProvider: !!authProvider,
timeoutMs: MCP_REQUEST_TIMEOUT_MS,
})}`,
@@ -879,7 +968,7 @@ export const connectToServer = memoize(
const oauthConfig = getOauthConfig()
const proxyUrl = `${oauthConfig.MCP_PROXY_URL}${oauthConfig.MCP_PROXY_PATH.replace('{server_id}', serverRef.id)}`
logMCPDebug(name, `Using claude.ai proxy at ${proxyUrl}`)
logMCPDebug(name, `Using claude.ai proxy transport`)
// eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
const fetchWithAuth = createClaudeAiProxyFetch(globalThis.fetch)
@@ -1025,23 +1114,28 @@ export const connectToServer = memoize(
// For HTTP transport, try a basic connectivity test first
if (serverRef.type === 'http') {
logMCPDebug(name, `Testing basic HTTP connectivity to ${serverRef.url}`)
logMCPDebug(
name,
`Testing basic HTTP connectivity to ${mcpBaseUrlForDebug(serverRef)}`,
)
try {
const testUrl = new URL(serverRef.url)
logMCPDebug(
name,
`Parsed URL: host=${testUrl.hostname}, port=${testUrl.port || 'default'}, protocol=${testUrl.protocol}`,
)
logMCPDebug(name, 'Parsed HTTP endpoint for preflight checks')
// Log DNS resolution attempt
if (
testUrl.hostname === '127.0.0.1' ||
testUrl.hostname === 'localhost'
) {
logMCPDebug(name, `Using loopback address: ${testUrl.hostname}`)
logMCPDebug(name, 'Using loopback HTTP endpoint')
}
} catch (urlError) {
logMCPDebug(name, `Failed to parse URL: ${urlError}`)
logMCPDebug(
name,
`Failed to parse HTTP endpoint for preflight: ${summarizeMcpErrorForDebug(
urlError,
)}`,
)
}
}
@@ -1079,7 +1173,7 @@ export const connectToServer = memoize(
try {
await Promise.race([connectPromise, timeoutPromise])
if (stderrOutput) {
logMCPError(name, `Server stderr: ${stderrOutput}`)
logMCPError(name, summarizeStderrForDebug(stderrOutput))
stderrOutput = '' // Release accumulated string to prevent memory growth
}
const elapsed = Date.now() - connectStartTime
@@ -1093,30 +1187,29 @@ export const connectToServer = memoize(
if (serverRef.type === 'sse' && error instanceof Error) {
logMCPDebug(
name,
`SSE Connection failed after ${elapsed}ms: ${jsonStringify({
url: serverRef.url,
error: error.message,
errorType: error.constructor.name,
stack: error.stack,
})}`,
`SSE connection failed after ${elapsed}ms: ${summarizeMcpErrorForDebug(
error,
)}`,
)
logMCPError(
name,
`SSE connection failed: ${summarizeMcpErrorForDebug(error)}`,
)
logMCPError(name, error)
if (error instanceof UnauthorizedError) {
return handleRemoteAuthFailure(name, serverRef, 'sse')
}
} else if (serverRef.type === 'http' && error instanceof Error) {
const errorObj = error as Error & {
cause?: unknown
code?: string
errno?: string | number
syscall?: string
}
logMCPDebug(
name,
`HTTP Connection failed after ${elapsed}ms: ${error.message} (code: ${errorObj.code || 'none'}, errno: ${errorObj.errno || 'none'})`,
`HTTP connection failed after ${elapsed}ms: ${summarizeMcpErrorForDebug(
error,
)}`,
)
logMCPError(
name,
`HTTP connection failed: ${summarizeMcpErrorForDebug(error)}`,
)
logMCPError(name, error)
if (error instanceof UnauthorizedError) {
return handleRemoteAuthFailure(name, serverRef, 'http')
@@ -1127,9 +1220,16 @@ export const connectToServer = memoize(
) {
logMCPDebug(
name,
`claude.ai proxy connection failed after ${elapsed}ms: ${error.message}`,
`claude.ai proxy connection failed after ${elapsed}ms: ${summarizeMcpErrorForDebug(
error,
)}`,
)
logMCPError(
name,
`claude.ai proxy connection failed: ${summarizeMcpErrorForDebug(
error,
)}`,
)
logMCPError(name, error)
// StreamableHTTPError has a `code` property with the HTTP status
const errorCode = (error as Error & { code?: number }).code
@@ -1149,7 +1249,7 @@ export const connectToServer = memoize(
}
transport.close().catch(() => {})
if (stderrOutput) {
logMCPError(name, `Server stderr: ${stderrOutput}`)
logMCPError(name, summarizeStderrForDebug(stderrOutput))
}
throw error
}
@@ -1208,7 +1308,9 @@ export const connectToServer = memoize(
} catch (error) {
logMCPError(
name,
`Failed to send ide_connected notification: ${error}`,
`Failed to send ide_connected notification: ${summarizeMcpErrorForDebug(
error,
)}`,
)
}
}
@@ -1242,7 +1344,10 @@ export const connectToServer = memoize(
hasTriggeredClose = true
logMCPDebug(name, `Closing transport (${reason})`)
void client.close().catch(e => {
logMCPDebug(name, `Error during close: ${errorMessage(e)}`)
logMCPDebug(
name,
`Error during close: ${summarizeMcpErrorForDebug(e)}`,
)
})
}
@@ -1306,7 +1411,10 @@ export const connectToServer = memoize(
`Failed to spawn process - check command and permissions`,
)
} else {
logMCPDebug(name, `Connection error: ${error.message}`)
logMCPDebug(
name,
`Connection error: ${summarizeMcpErrorForDebug(error)}`,
)
}
}
@@ -1407,12 +1515,20 @@ export const connectToServer = memoize(
try {
await inProcessServer.close()
} catch (error) {
logMCPDebug(name, `Error closing in-process server: ${error}`)
logMCPDebug(
name,
`Error closing in-process server: ${summarizeMcpErrorForDebug(
error,
)}`,
)
}
try {
await client.close()
} catch (error) {
logMCPDebug(name, `Error closing client: ${error}`)
logMCPDebug(
name,
`Error closing client: ${summarizeMcpErrorForDebug(error)}`,
)
}
return
}
@@ -1438,7 +1554,10 @@ export const connectToServer = memoize(
try {
process.kill(childPid, 'SIGINT')
} catch (error) {
logMCPDebug(name, `Error sending SIGINT: ${error}`)
logMCPDebug(
name,
`Error sending SIGINT: ${summarizeMcpErrorForDebug(error)}`,
)
return
}
@@ -1492,7 +1611,12 @@ export const connectToServer = memoize(
try {
process.kill(childPid, 'SIGTERM')
} catch (termError) {
logMCPDebug(name, `Error sending SIGTERM: ${termError}`)
logMCPDebug(
name,
`Error sending SIGTERM: ${summarizeMcpErrorForDebug(
termError,
)}`,
)
resolved = true
clearInterval(checkInterval)
clearTimeout(failsafeTimeout)
@@ -1525,7 +1649,9 @@ export const connectToServer = memoize(
} catch (killError) {
logMCPDebug(
name,
`Error sending SIGKILL: ${killError}`,
`Error sending SIGKILL: ${summarizeMcpErrorForDebug(
killError,
)}`,
)
}
} catch {
@@ -1557,7 +1683,12 @@ export const connectToServer = memoize(
})
}
} catch (processError) {
logMCPDebug(name, `Error terminating process: ${processError}`)
logMCPDebug(
name,
`Error terminating process: ${summarizeMcpErrorForDebug(
processError,
)}`,
)
}
}
@@ -1565,7 +1696,10 @@ export const connectToServer = memoize(
try {
await client.close()
} catch (error) {
logMCPDebug(name, `Error closing client: ${error}`)
logMCPDebug(
name,
`Error closing client: ${summarizeMcpErrorForDebug(error)}`,
)
}
}
@@ -1622,9 +1756,14 @@ export const connectToServer = memoize(
})
logMCPDebug(
name,
`Connection failed after ${connectionDurationMs}ms: ${errorMessage(error)}`,
`Connection failed after ${connectionDurationMs}ms: ${summarizeMcpErrorForDebug(
error,
)}`,
)
logMCPError(
name,
`Connection failed: ${summarizeMcpErrorForDebug(error)}`,
)
logMCPError(name, `Connection failed: ${errorMessage(error)}`)
if (inProcessServer) {
inProcessServer.close().catch(() => {})
@@ -1989,7 +2128,10 @@ export const fetchToolsForClient = memoizeWithLRU(
})
.filter(isIncludedMcpTool)
} catch (error) {
logMCPError(client.name, `Failed to fetch tools: ${errorMessage(error)}`)
logMCPError(
client.name,
`Failed to fetch tools: ${summarizeMcpErrorForDebug(error)}`,
)
return []
}
},
@@ -2021,7 +2163,7 @@ export const fetchResourcesForClient = memoizeWithLRU(
} catch (error) {
logMCPError(
client.name,
`Failed to fetch resources: ${errorMessage(error)}`,
`Failed to fetch resources: ${summarizeMcpErrorForDebug(error)}`,
)
return []
}
@@ -2087,7 +2229,9 @@ export const fetchCommandsForClient = memoizeWithLRU(
} catch (error) {
logMCPError(
client.name,
`Error running command '${prompt.name}': ${errorMessage(error)}`,
`Error running command '${prompt.name}': ${summarizeMcpErrorForDebug(
error,
)}`,
)
throw error
}
@@ -2097,7 +2241,7 @@ export const fetchCommandsForClient = memoizeWithLRU(
} catch (error) {
logMCPError(
client.name,
`Failed to fetch commands: ${errorMessage(error)}`,
`Failed to fetch commands: ${summarizeMcpErrorForDebug(error)}`,
)
return []
}
@@ -2198,7 +2342,10 @@ export async function reconnectMcpServerImpl(
}
} catch (error) {
// Handle errors gracefully - connection might have closed during fetch
logMCPError(name, `Error during reconnection: ${errorMessage(error)}`)
logMCPError(
name,
`Error during reconnection: ${summarizeMcpErrorForDebug(error)}`,
)
// Return with failed status
return {
@@ -2373,7 +2520,9 @@ export async function getMcpToolsCommandsAndResources(
// Handle errors gracefully - connection might have closed during fetch
logMCPError(
name,
`Error fetching tools/commands/resources: ${errorMessage(error)}`,
`Error fetching tools/commands/resources: ${summarizeMcpErrorForDebug(
error,
)}`,
)
// Still update with the client but no tools/commands
@@ -2460,7 +2609,7 @@ export function prefetchAllMcpResources(
}, mcpConfigs).catch(error => {
logMCPError(
'prefetchAllMcpResources',
`Failed to get MCP resources: ${errorMessage(error)}`,
`Failed to get MCP resources: ${summarizeMcpErrorForDebug(error)}`,
)
// Still resolve with empty results
void resolve({
@@ -3322,7 +3471,12 @@ export async function setupSdkMcpClients(
}
} catch (error) {
// If connection fails, return failed server
logMCPError(name, `Failed to connect SDK MCP server: ${error}`)
logMCPError(
name,
`Failed to connect SDK MCP server: ${summarizeMcpErrorForDebug(
error,
)}`,
)
return {
client: {
type: 'failed' as const,

View File

@@ -1397,6 +1397,7 @@ export function parseMcpConfigFromFilePath(params: {
configContent = fs.readFileSync(filePath, { encoding: 'utf8' })
} catch (error: unknown) {
const code = getErrnoCode(error)
const fileName = parse(filePath).base
if (code === 'ENOENT') {
return {
config: null,
@@ -1415,7 +1416,7 @@ export function parseMcpConfigFromFilePath(params: {
}
}
logForDebugging(
`MCP config read error for ${filePath} (scope=${scope}): ${error}`,
`MCP config read error (scope=${scope}, file=${fileName}, errno=${code ?? 'none'}, errorType=${error instanceof Error ? error.name : typeof error})`,
{ level: 'error' },
)
return {
@@ -1439,7 +1440,7 @@ export function parseMcpConfigFromFilePath(params: {
if (!parsedJson) {
logForDebugging(
`MCP config is not valid JSON: ${filePath} (scope=${scope}, length=${configContent.length}, first100=${jsonStringify(configContent.slice(0, 100))})`,
`MCP config is not valid JSON (scope=${scope}, file=${parse(filePath).base}, length=${configContent.length})`,
{ level: 'error' },
)
return {

View File

@@ -96,6 +96,24 @@ function redactTokens(raw: unknown): string {
return s.replace(SENSITIVE_TOKEN_RE, (_, k) => `"${k}":"[REDACTED]"`)
}
function summarizeXaaPayload(raw: unknown): string {
if (typeof raw === 'string') {
return `text(${raw.length} chars)`
}
if (Array.isArray(raw)) {
return `array(${raw.length})`
}
if (raw && typeof raw === 'object') {
return jsonStringify({
payloadType: 'object',
keys: Object.keys(raw as Record<string, unknown>)
.sort()
.slice(0, 10),
})
}
return typeof raw
}
// ─── Zod Schemas ────────────────────────────────────────────────────────────
const TokenExchangeResponseSchema = lazySchema(() =>
@@ -145,7 +163,7 @@ export async function discoverProtectedResource(
)
} catch (e) {
throw new Error(
`XAA: PRM discovery failed: ${e instanceof Error ? e.message : String(e)}`,
`XAA: PRM discovery failed (${e instanceof Error ? e.name : typeof e})`,
)
}
if (!prm.resource || !prm.authorization_servers?.[0]) {
@@ -154,9 +172,7 @@ export async function discoverProtectedResource(
)
}
if (normalizeUrl(prm.resource) !== normalizeUrl(serverUrl)) {
throw new Error(
`XAA: PRM discovery failed: PRM resource mismatch: expected ${serverUrl}, got ${prm.resource}`,
)
throw new Error('XAA: PRM discovery failed: PRM resource mismatch')
}
return {
resource: prm.resource,
@@ -183,22 +199,16 @@ export async function discoverAuthorizationServer(
fetchFn: opts?.fetchFn ?? defaultFetch,
})
if (!meta?.issuer || !meta.token_endpoint) {
throw new Error(
`XAA: AS metadata discovery failed: no valid metadata at ${asUrl}`,
)
throw new Error('XAA: AS metadata discovery failed: no valid metadata')
}
if (normalizeUrl(meta.issuer) !== normalizeUrl(asUrl)) {
throw new Error(
`XAA: AS metadata discovery failed: issuer mismatch: expected ${asUrl}, got ${meta.issuer}`,
)
throw new Error('XAA: AS metadata discovery failed: issuer mismatch')
}
// RFC 8414 §3.3 / RFC 9728 §3 require HTTPS. A PRM-advertised http:// AS
// that self-consistently reports an http:// issuer would pass the mismatch
// check above, then we'd POST id_token + client_secret over plaintext.
if (new URL(meta.token_endpoint).protocol !== 'https:') {
throw new Error(
`XAA: refusing non-HTTPS token endpoint: ${meta.token_endpoint}`,
)
throw new Error('XAA: refusing non-HTTPS token endpoint')
}
return {
issuer: meta.issuer,
@@ -263,7 +273,7 @@ export async function requestJwtAuthorizationGrant(opts: {
body: params,
})
if (!res.ok) {
const body = redactTokens(await res.text()).slice(0, 200)
const body = summarizeXaaPayload(redactTokens(await res.text()))
// 4xx → id_token rejected (invalid_grant etc.), clear cache.
// 5xx → IdP outage, id_token may still be valid, preserve it.
const shouldClear = res.status < 500
@@ -278,21 +288,25 @@ export async function requestJwtAuthorizationGrant(opts: {
} catch {
// Transient network condition (captive portal, proxy) — don't clear id_token.
throw new XaaTokenExchangeError(
`XAA: token exchange returned non-JSON (captive portal?) at ${opts.tokenEndpoint}`,
'XAA: token exchange returned non-JSON response (captive portal?)',
false,
)
}
const exchangeParsed = TokenExchangeResponseSchema().safeParse(rawExchange)
if (!exchangeParsed.success) {
throw new XaaTokenExchangeError(
`XAA: token exchange response did not match expected shape: ${redactTokens(rawExchange)}`,
`XAA: token exchange response did not match expected shape: ${summarizeXaaPayload(
redactTokens(rawExchange),
)}`,
true,
)
}
const result = exchangeParsed.data
if (!result.access_token) {
throw new XaaTokenExchangeError(
`XAA: token exchange response missing access_token: ${redactTokens(result)}`,
`XAA: token exchange response missing access_token: ${summarizeXaaPayload(
redactTokens(result),
)}`,
true,
)
}
@@ -373,7 +387,7 @@ export async function exchangeJwtAuthGrant(opts: {
body: params,
})
if (!res.ok) {
const body = redactTokens(await res.text()).slice(0, 200)
const body = summarizeXaaPayload(redactTokens(await res.text()))
throw new Error(`XAA: jwt-bearer grant failed: HTTP ${res.status}: ${body}`)
}
let rawTokens: unknown
@@ -381,13 +395,15 @@ export async function exchangeJwtAuthGrant(opts: {
rawTokens = await res.json()
} catch {
throw new Error(
`XAA: jwt-bearer grant returned non-JSON (captive portal?) at ${opts.tokenEndpoint}`,
'XAA: jwt-bearer grant returned non-JSON response (captive portal?)',
)
}
const tokensParsed = JwtBearerResponseSchema().safeParse(rawTokens)
if (!tokensParsed.success) {
throw new Error(
`XAA: jwt-bearer response did not match expected shape: ${redactTokens(rawTokens)}`,
`XAA: jwt-bearer response did not match expected shape: ${summarizeXaaPayload(
redactTokens(rawTokens),
)}`,
)
}
return tokensParsed.data
@@ -431,11 +447,14 @@ export async function performCrossAppAccess(
): Promise<XaaResult> {
const fetchFn = makeXaaFetch(abortSignal)
logMCPDebug(serverName, `XAA: discovering PRM for ${serverUrl}`)
logMCPDebug(serverName, 'XAA: discovering protected resource metadata')
const prm = await discoverProtectedResource(serverUrl, { fetchFn })
logMCPDebug(
serverName,
`XAA: discovered resource=${prm.resource} ASes=[${prm.authorization_servers.join(', ')}]`,
`XAA: discovered protected resource metadata ${jsonStringify({
hasResource: Boolean(prm.resource),
authorizationServerCount: prm.authorization_servers.length,
})}`,
)
// Try each advertised AS in order. grant_types_supported is OPTIONAL per
@@ -449,16 +468,16 @@ export async function performCrossAppAccess(
candidate = await discoverAuthorizationServer(asUrl, { fetchFn })
} catch (e) {
if (abortSignal?.aborted) throw e
asErrors.push(`${asUrl}: ${e instanceof Error ? e.message : String(e)}`)
asErrors.push(
`authorization server discovery failed (${e instanceof Error ? e.name : typeof e})`,
)
continue
}
if (
candidate.grant_types_supported &&
!candidate.grant_types_supported.includes(JWT_BEARER_GRANT)
) {
asErrors.push(
`${asUrl}: does not advertise jwt-bearer grant (supported: ${candidate.grant_types_supported.join(', ')})`,
)
asErrors.push('authorization server does not advertise jwt-bearer grant')
continue
}
asMeta = candidate
@@ -466,7 +485,7 @@ export async function performCrossAppAccess(
}
if (!asMeta) {
throw new Error(
`XAA: no authorization server supports jwt-bearer. Tried: ${asErrors.join('; ')}`,
`XAA: no authorization server supports jwt-bearer (${asErrors.length} candidates tried)`,
)
}
// Pick auth method from what the AS advertises. We handle
@@ -481,7 +500,7 @@ export async function performCrossAppAccess(
: 'client_secret_basic'
logMCPDebug(
serverName,
`XAA: AS issuer=${asMeta.issuer} token_endpoint=${asMeta.token_endpoint} auth_method=${authMethod}`,
`XAA: selected authorization server (auth_method=${authMethod})`,
)
logMCPDebug(serverName, `XAA: exchanging id_token for ID-JAG at IdP`)

View File

@@ -210,9 +210,7 @@ export async function discoverOidc(
signal: AbortSignal.timeout(IDP_REQUEST_TIMEOUT_MS),
})
if (!res.ok) {
throw new Error(
`XAA IdP: OIDC discovery failed: HTTP ${res.status} at ${url}`,
)
throw new Error(`XAA IdP: OIDC discovery failed (HTTP ${res.status})`)
}
// Captive portals and proxy auth pages return 200 with HTML. res.json()
// throws a raw SyntaxError before safeParse can give a useful message.
@@ -221,17 +219,15 @@ export async function discoverOidc(
body = await res.json()
} catch {
throw new Error(
`XAA IdP: OIDC discovery returned non-JSON at ${url} (captive portal or proxy?)`,
'XAA IdP: OIDC discovery returned non-JSON response (captive portal or proxy?)',
)
}
const parsed = OpenIdProviderDiscoveryMetadataSchema.safeParse(body)
if (!parsed.success) {
throw new Error(`XAA IdP: invalid OIDC metadata: ${parsed.error.message}`)
throw new Error('XAA IdP: invalid OIDC metadata')
}
if (new URL(parsed.data.token_endpoint).protocol !== 'https:') {
throw new Error(
`XAA IdP: refusing non-HTTPS token endpoint: ${parsed.data.token_endpoint}`,
)
throw new Error('XAA IdP: refusing non-HTTPS token endpoint')
}
return parsed.data
}
@@ -373,7 +369,7 @@ function waitForCallback(
),
)
} else {
rejectOnce(new Error(`XAA IdP: callback server failed: ${err.message}`))
rejectOnce(new Error('XAA IdP: callback server failed'))
}
})
@@ -405,11 +401,11 @@ export async function acquireIdpIdToken(
const cached = getCachedIdpIdToken(idpIssuer)
if (cached) {
logMCPDebug('xaa', `Using cached id_token for ${idpIssuer}`)
logMCPDebug('xaa', 'Using cached id_token for configured IdP')
return cached
}
logMCPDebug('xaa', `No cached id_token for ${idpIssuer}; starting OIDC login`)
logMCPDebug('xaa', 'No cached id_token for configured IdP; starting OIDC login')
const metadata = await discoverOidc(idpIssuer)
const port = opts.callbackPort ?? (await findAvailablePort())
@@ -478,10 +474,7 @@ export async function acquireIdpIdToken(
: Date.now() + (tokens.expires_in ?? 3600) * 1000
saveIdpIdToken(idpIssuer, tokens.id_token, expiresAt)
logMCPDebug(
'xaa',
`Cached id_token for ${idpIssuer} (expires ${new Date(expiresAt).toISOString()})`,
)
logMCPDebug('xaa', 'Cached id_token for configured IdP')
return tokens.id_token
}

View File

@@ -11,7 +11,6 @@ import {
import {
extractMcpToolDetails,
extractSkillName,
extractToolInputForTelemetry,
getFileExtensionForAnalytics,
getFileExtensionsFromBashCommand,
isToolDetailsLoggingEnabled,
@@ -87,17 +86,6 @@ import {
} from '../../utils/sessionActivity.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import { Stream } from '../../utils/stream.js'
import { logOTelEvent } from '../../utils/telemetry/events.js'
import {
addToolContentEvent,
endToolBlockedOnUserSpan,
endToolExecutionSpan,
endToolSpan,
isBetaTracingEnabled,
startToolBlockedOnUserSpan,
startToolExecutionSpan,
startToolSpan,
} from '../../utils/telemetry/sessionTracing.js'
import {
formatError,
formatZodValidationError,
@@ -204,7 +192,7 @@ function ruleSourceToOTelSource(
* Without it, we fall back conservatively: allow → user_temporary,
* deny → user_reject.
*/
function decisionReasonToOTelSource(
function decisionReasonToSource(
reason: PermissionDecisionReason | undefined,
behavior: 'allow' | 'deny',
): string {
@@ -890,29 +878,6 @@ async function checkPermissionsAndCallTool(
}
}
const toolAttributes: Record<string, string | number | boolean> = {}
if (processedInput && typeof processedInput === 'object') {
if (tool.name === FILE_READ_TOOL_NAME && 'file_path' in processedInput) {
toolAttributes.file_path = String(processedInput.file_path)
} else if (
(tool.name === FILE_EDIT_TOOL_NAME ||
tool.name === FILE_WRITE_TOOL_NAME) &&
'file_path' in processedInput
) {
toolAttributes.file_path = String(processedInput.file_path)
} else if (tool.name === BASH_TOOL_NAME && 'command' in processedInput) {
const bashInput = processedInput as BashToolInput
toolAttributes.full_command = bashInput.command
}
}
startToolSpan(
tool.name,
toolAttributes,
isBetaTracingEnabled() ? jsonStringify(processedInput) : undefined,
)
startToolBlockedOnUserSpan()
// Check whether we have permission to use the tool,
// and ask the user for permission if we don't
const permissionMode = toolUseContext.getAppState().toolPermissionContext.mode
@@ -945,33 +910,22 @@ async function checkPermissionsAndCallTool(
)
}
// Emit tool_decision OTel event and code-edit counter if the interactive
// permission path didn't already log it (headless mode bypasses permission
// logging, so we need to emit both the generic event and the code-edit
// counter here)
// Increment the code-edit counter here when the interactive permission path
// did not already log a decision (headless mode bypasses permission logging).
if (
permissionDecision.behavior !== 'ask' &&
!toolUseContext.toolDecisions?.has(toolUseID)
) {
const decision =
permissionDecision.behavior === 'allow' ? 'accept' : 'reject'
const source = decisionReasonToOTelSource(
permissionDecision.decisionReason,
permissionDecision.behavior,
)
void logOTelEvent('tool_decision', {
decision,
source,
tool_name: sanitizeToolNameForAnalytics(tool.name),
})
// Increment code-edit tool decision counter for headless mode
if (isCodeEditingTool(tool.name)) {
void buildCodeEditToolAttributes(
tool,
processedInput,
decision,
source,
decisionReasonToSource(
permissionDecision.decisionReason,
permissionDecision.behavior,
),
).then(attributes => getCodeEditToolDecisionCounter()?.add(1, attributes))
}
}
@@ -994,10 +948,6 @@ async function checkPermissionsAndCallTool(
if (permissionDecision.behavior !== 'allow') {
logForDebugging(`${tool.name} tool permission denied`)
const decisionInfo = toolUseContext.toolDecisions?.get(toolUseID)
endToolBlockedOnUserSpan('reject', decisionInfo?.source || 'unknown')
endToolSpan()
logEvent('tengu_tool_use_can_use_tool_rejected', {
messageID:
messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
@@ -1131,10 +1081,6 @@ async function checkPermissionsAndCallTool(
processedInput = permissionDecision.updatedInput
}
// Prepare tool parameters for logging in tool_result event.
// Gated by OTEL_LOG_TOOL_DETAILS — tool parameters can contain sensitive
// content (bash commands, MCP server names, etc.) so they're opt-in only.
const telemetryToolInput = extractToolInputForTelemetry(processedInput)
let toolParameters: Record<string, unknown> = {}
if (isToolDetailsLoggingEnabled()) {
if (tool.name === BASH_TOOL_NAME && 'command' in processedInput) {
@@ -1168,13 +1114,6 @@ async function checkPermissionsAndCallTool(
}
}
const decisionInfo = toolUseContext.toolDecisions?.get(toolUseID)
endToolBlockedOnUserSpan(
decisionInfo?.decision || 'unknown',
decisionInfo?.source || 'unknown',
)
startToolExecutionSpan()
const startTime = Date.now()
startSessionActivity('tool_exec')
@@ -1223,51 +1162,6 @@ async function checkPermissionsAndCallTool(
const durationMs = Date.now() - startTime
addToToolDuration(durationMs)
// Log tool content/output as span event if enabled
if (result.data && typeof result.data === 'object') {
const contentAttributes: Record<string, string | number | boolean> = {}
// Read tool: capture file_path and content
if (tool.name === FILE_READ_TOOL_NAME && 'content' in result.data) {
if ('file_path' in processedInput) {
contentAttributes.file_path = String(processedInput.file_path)
}
contentAttributes.content = String(result.data.content)
}
// Edit/Write tools: capture file_path and diff
if (
(tool.name === FILE_EDIT_TOOL_NAME ||
tool.name === FILE_WRITE_TOOL_NAME) &&
'file_path' in processedInput
) {
contentAttributes.file_path = String(processedInput.file_path)
// For Edit, capture the actual changes made
if (tool.name === FILE_EDIT_TOOL_NAME && 'diff' in result.data) {
contentAttributes.diff = String(result.data.diff)
}
// For Write, capture the written content
if (tool.name === FILE_WRITE_TOOL_NAME && 'content' in processedInput) {
contentAttributes.content = String(processedInput.content)
}
}
// Bash tool: capture command
if (tool.name === BASH_TOOL_NAME && 'command' in processedInput) {
const bashInput = processedInput as BashToolInput
contentAttributes.bash_command = bashInput.command
// Also capture output if available
if ('output' in result.data) {
contentAttributes.output = String(result.data.output)
}
}
if (Object.keys(contentAttributes).length > 0) {
addToolContentEvent('tool.output', contentAttributes)
}
}
// Capture structured output from tool result if present
if (typeof result === 'object' && 'structured_output' in result) {
// Store the structured output in an attachment message
@@ -1279,14 +1173,6 @@ async function checkPermissionsAndCallTool(
})
}
endToolExecutionSpan({ success: true })
// Pass tool result for new_context logging
const toolResultStr =
result.data && typeof result.data === 'object'
? jsonStringify(result.data)
: String(result.data ?? '')
endToolSpan(toolResultStr)
// Map the tool result to API format once and cache it. This block is reused
// by addToolResult (skipping the remap) and measured here for analytics.
const mappedToolResultBlock = tool.mapToolResultToToolResultBlockParam(
@@ -1373,27 +1259,10 @@ async function checkPermissionsAndCallTool(
}
}
// Log tool result event for OTLP with tool parameters and decision context
const mcpServerScope = isMcpTool(tool)
? getMcpServerScopeFromToolName(tool.name)
: null
void logOTelEvent('tool_result', {
tool_name: sanitizeToolNameForAnalytics(tool.name),
success: 'true',
duration_ms: String(durationMs),
...(Object.keys(toolParameters).length > 0 && {
tool_parameters: jsonStringify(toolParameters),
}),
...(telemetryToolInput && { tool_input: telemetryToolInput }),
tool_result_size_bytes: String(toolResultSizeBytes),
...(decisionInfo && {
decision_source: decisionInfo.source,
decision_type: decisionInfo.decision,
}),
...(mcpServerScope && { mcp_server_scope: mcpServerScope }),
})
// Run PostToolUse hooks
let toolOutput = result.data
const hookResults = []
@@ -1590,12 +1459,6 @@ async function checkPermissionsAndCallTool(
const durationMs = Date.now() - startTime
addToToolDuration(durationMs)
endToolExecutionSpan({
success: false,
error: errorMessage(error),
})
endToolSpan()
// Handle MCP auth errors by updating the client status to 'needs-auth'
// This updates the /mcp display to show the server needs re-authorization
if (error instanceof McpAuthError) {
@@ -1666,27 +1529,9 @@ async function checkPermissionsAndCallTool(
mcpServerBaseUrl,
),
})
// Log tool result error event for OTLP with tool parameters and decision context
const mcpServerScope = isMcpTool(tool)
? getMcpServerScopeFromToolName(tool.name)
: null
void logOTelEvent('tool_result', {
tool_name: sanitizeToolNameForAnalytics(tool.name),
use_id: toolUseID,
success: 'false',
duration_ms: String(durationMs),
error: errorMessage(error),
...(Object.keys(toolParameters).length > 0 && {
tool_parameters: jsonStringify(toolParameters),
}),
...(telemetryToolInput && { tool_input: telemetryToolInput }),
...(decisionInfo && {
decision_source: decisionInfo.source,
decision_type: decisionInfo.decision,
}),
...(mcpServerScope && { mcp_server_scope: mcpServerScope }),
})
}
const content = formatError(error)

View File

@@ -174,7 +174,7 @@ export async function connectVoiceStream(
const url = `${wsBaseUrl}${VOICE_STREAM_PATH}?${params.toString()}`
logForDebugging(`[voice_stream] Connecting to ${url}`)
logForDebugging('[voice_stream] Connecting to voice stream websocket')
const headers: Record<string, string> = {
Authorization: `Bearer ${tokens.accessToken}`,
@@ -357,7 +357,7 @@ export async function connectVoiceStream(
ws.on('message', (raw: Buffer | string) => {
const text = raw.toString()
logForDebugging(
`[voice_stream] Message received (${String(text.length)} chars): ${text.slice(0, 200)}`,
`[voice_stream] Message received (${String(text.length)} chars)`,
)
let msg: VoiceStreamMessage
try {
@@ -369,7 +369,9 @@ export async function connectVoiceStream(
switch (msg.type) {
case 'TranscriptText': {
const transcript = msg.data
logForDebugging(`[voice_stream] TranscriptText: "${transcript ?? ''}"`)
logForDebugging(
`[voice_stream] TranscriptText received (${String((transcript ?? '').length)} chars)`,
)
// Data arrived after CloseStream — disarm the no-data timer so
// a slow-but-real flush isn't cut off. Only disarm once finalized
// (CloseStream sent); pre-CloseStream data racing the deferred
@@ -403,7 +405,7 @@ export async function connectVoiceStream(
!prev.startsWith(next)
) {
logForDebugging(
`[voice_stream] Auto-finalizing previous segment (new segment detected): "${lastTranscriptText}"`,
'[voice_stream] Auto-finalizing previous segment (new segment detected)',
)
callbacks.onTranscript(lastTranscriptText, true)
}
@@ -416,7 +418,7 @@ export async function connectVoiceStream(
}
case 'TranscriptEndpoint': {
logForDebugging(
`[voice_stream] TranscriptEndpoint received, lastTranscriptText="${lastTranscriptText}"`,
`[voice_stream] TranscriptEndpoint received (hasBufferedTranscript=${Boolean(lastTranscriptText)})`,
)
// The server signals the end of an utterance. Emit the last
// TranscriptText as a final transcript so the caller can commit it.
@@ -441,7 +443,9 @@ export async function connectVoiceStream(
case 'TranscriptError': {
const desc =
msg.description ?? msg.error_code ?? 'unknown transcription error'
logForDebugging(`[voice_stream] TranscriptError: ${desc}`)
logForDebugging(
`[voice_stream] TranscriptError received (${msg.error_code ?? 'unknown'})`,
)
if (!finalizing) {
callbacks.onError(desc)
}
@@ -449,7 +453,7 @@ export async function connectVoiceStream(
}
case 'error': {
const errorDetail = msg.message ?? jsonStringify(msg)
logForDebugging(`[voice_stream] Server error: ${errorDetail}`)
logForDebugging('[voice_stream] Server error received')
if (!finalizing) {
callbacks.onError(errorDetail)
}

View File

@@ -368,13 +368,10 @@ export async function setup(
) // Start team memory sync watcher
}
}
initSinks() // Attach error log + analytics sinks and drain queued events
initSinks() // Attach the shared error-log sink
// Session-success-rate denominator. Emit immediately after the analytics
// sink is attached — before any parsing, fetching, or I/O that could throw.
// inc-3694 (P0 CHANGELOG crash) threw at checkForReleaseNotes below; every
// event after this point was dead. This beacon is the earliest reliable
// "process started" signal for release health monitoring.
// Keep the startup compatibility event as early as possible, before any
// parsing, fetching, or I/O that could throw.
logEvent('tengu_started', {})
void prefetchApiKeyFromApiKeyHelperIfSafe(getIsNonInteractiveSession()) // Prefetch safely - only executes if trust already confirmed

View File

@@ -1,197 +0,0 @@
import { mkdir, readdir, readFile, unlink, writeFile } from 'fs/promises'
import { join } from 'path'
import { z } from 'zod/v4'
import { getCwd } from '../../utils/cwd.js'
import { logForDebugging } from '../../utils/debug.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { jsonParse, jsonStringify } from '../../utils/slowOperations.js'
import { type AgentMemoryScope, getAgentMemoryDir } from './agentMemory.js'
const SNAPSHOT_BASE = 'agent-memory-snapshots'
const SNAPSHOT_JSON = 'snapshot.json'
const SYNCED_JSON = '.snapshot-synced.json'
const snapshotMetaSchema = lazySchema(() =>
z.object({
updatedAt: z.string().min(1),
}),
)
const syncedMetaSchema = lazySchema(() =>
z.object({
syncedFrom: z.string().min(1),
}),
)
type SyncedMeta = z.infer<ReturnType<typeof syncedMetaSchema>>
/**
* Returns the path to the snapshot directory for an agent in the current project.
* e.g., <cwd>/.claude/agent-memory-snapshots/<agentType>/
*/
export function getSnapshotDirForAgent(agentType: string): string {
return join(getCwd(), '.claude', SNAPSHOT_BASE, agentType)
}
function getSnapshotJsonPath(agentType: string): string {
return join(getSnapshotDirForAgent(agentType), SNAPSHOT_JSON)
}
function getSyncedJsonPath(agentType: string, scope: AgentMemoryScope): string {
return join(getAgentMemoryDir(agentType, scope), SYNCED_JSON)
}
async function readJsonFile<T>(
path: string,
schema: z.ZodType<T>,
): Promise<T | null> {
try {
const content = await readFile(path, { encoding: 'utf-8' })
const result = schema.safeParse(jsonParse(content))
return result.success ? result.data : null
} catch {
return null
}
}
async function copySnapshotToLocal(
agentType: string,
scope: AgentMemoryScope,
): Promise<void> {
const snapshotMemDir = getSnapshotDirForAgent(agentType)
const localMemDir = getAgentMemoryDir(agentType, scope)
await mkdir(localMemDir, { recursive: true })
try {
const files = await readdir(snapshotMemDir, { withFileTypes: true })
for (const dirent of files) {
if (!dirent.isFile() || dirent.name === SNAPSHOT_JSON) continue
const content = await readFile(join(snapshotMemDir, dirent.name), {
encoding: 'utf-8',
})
await writeFile(join(localMemDir, dirent.name), content)
}
} catch (e) {
logForDebugging(`Failed to copy snapshot to local agent memory: ${e}`)
}
}
async function saveSyncedMeta(
agentType: string,
scope: AgentMemoryScope,
snapshotTimestamp: string,
): Promise<void> {
const syncedPath = getSyncedJsonPath(agentType, scope)
const localMemDir = getAgentMemoryDir(agentType, scope)
await mkdir(localMemDir, { recursive: true })
const meta: SyncedMeta = { syncedFrom: snapshotTimestamp }
try {
await writeFile(syncedPath, jsonStringify(meta))
} catch (e) {
logForDebugging(`Failed to save snapshot sync metadata: ${e}`)
}
}
/**
* Check if a snapshot exists and whether it's newer than what we last synced.
*/
export async function checkAgentMemorySnapshot(
agentType: string,
scope: AgentMemoryScope,
): Promise<{
action: 'none' | 'initialize' | 'prompt-update'
snapshotTimestamp?: string
}> {
const snapshotMeta = await readJsonFile(
getSnapshotJsonPath(agentType),
snapshotMetaSchema(),
)
if (!snapshotMeta) {
return { action: 'none' }
}
const localMemDir = getAgentMemoryDir(agentType, scope)
let hasLocalMemory = false
try {
const dirents = await readdir(localMemDir, { withFileTypes: true })
hasLocalMemory = dirents.some(d => d.isFile() && d.name.endsWith('.md'))
} catch {
// Directory doesn't exist
}
if (!hasLocalMemory) {
return { action: 'initialize', snapshotTimestamp: snapshotMeta.updatedAt }
}
const syncedMeta = await readJsonFile(
getSyncedJsonPath(agentType, scope),
syncedMetaSchema(),
)
if (
!syncedMeta ||
new Date(snapshotMeta.updatedAt) > new Date(syncedMeta.syncedFrom)
) {
return {
action: 'prompt-update',
snapshotTimestamp: snapshotMeta.updatedAt,
}
}
return { action: 'none' }
}
/**
* Initialize local agent memory from a snapshot (first-time setup).
*/
export async function initializeFromSnapshot(
agentType: string,
scope: AgentMemoryScope,
snapshotTimestamp: string,
): Promise<void> {
logForDebugging(
`Initializing agent memory for ${agentType} from project snapshot`,
)
await copySnapshotToLocal(agentType, scope)
await saveSyncedMeta(agentType, scope, snapshotTimestamp)
}
/**
* Replace local agent memory with the snapshot.
*/
export async function replaceFromSnapshot(
agentType: string,
scope: AgentMemoryScope,
snapshotTimestamp: string,
): Promise<void> {
logForDebugging(
`Replacing agent memory for ${agentType} with project snapshot`,
)
// Remove existing .md files before copying to avoid orphans
const localMemDir = getAgentMemoryDir(agentType, scope)
try {
const existing = await readdir(localMemDir, { withFileTypes: true })
for (const dirent of existing) {
if (dirent.isFile() && dirent.name.endsWith('.md')) {
await unlink(join(localMemDir, dirent.name))
}
}
} catch {
// Directory may not exist yet
}
await copySnapshotToLocal(agentType, scope)
await saveSyncedMeta(agentType, scope, snapshotTimestamp)
}
/**
* Mark the current snapshot as synced without changing local memory.
*/
export async function markSnapshotSynced(
agentType: string,
scope: AgentMemoryScope,
snapshotTimestamp: string,
): Promise<void> {
await saveSyncedMeta(agentType, scope, snapshotTimestamp)
}

View File

@@ -47,10 +47,6 @@ import {
setAgentColor,
} from './agentColorManager.js'
import { type AgentMemoryScope, loadAgentMemoryPrompt } from './agentMemory.js'
import {
checkAgentMemorySnapshot,
initializeFromSnapshot,
} from './agentMemorySnapshot.js'
import { getBuiltInAgents } from './builtInAgents.js'
// Type for MCP server specification in agent definitions
@@ -255,41 +251,14 @@ export function filterAgentsByMcpRequirements(
}
/**
* Check for and initialize agent memory from project snapshots.
* For agents with memory enabled, copies snapshot to local if no local memory exists.
* For agents with newer snapshots, logs a debug message (user prompt TODO).
* Agent memory snapshot sync is disabled in this fork to avoid copying
* project-scoped memory into persistent user/local agent memory.
*/
async function initializeAgentMemorySnapshots(
agents: CustomAgentDefinition[],
_agents: CustomAgentDefinition[],
): Promise<void> {
await Promise.all(
agents.map(async agent => {
if (agent.memory !== 'user') return
const result = await checkAgentMemorySnapshot(
agent.agentType,
agent.memory,
)
switch (result.action) {
case 'initialize':
logForDebugging(
`Initializing ${agent.agentType} memory from project snapshot`,
)
await initializeFromSnapshot(
agent.agentType,
agent.memory,
result.snapshotTimestamp!,
)
break
case 'prompt-update':
agent.pendingSnapshotUpdate = {
snapshotTimestamp: result.snapshotTimestamp!,
}
logForDebugging(
`Newer snapshot available for ${agent.agentType} memory (snapshot: ${result.snapshotTimestamp})`,
)
break
}
}),
logForDebugging(
'[loadAgentsDir] Agent memory snapshot sync is disabled in this build',
)
}

View File

@@ -72,11 +72,6 @@ import {
asSystemPrompt,
type SystemPrompt,
} from '../../utils/systemPromptType.js'
import {
isPerfettoTracingEnabled,
registerAgent as registerPerfettoAgent,
unregisterAgent as unregisterPerfettoAgent,
} from '../../utils/telemetry/perfettoTracing.js'
import type { ContentReplacementState } from '../../utils/toolResultStorage.js'
import { createAgentId } from '../../utils/uuid.js'
import { resolveAgentTools } from './agentToolUtils.js'
@@ -352,12 +347,6 @@ export async function* runAgent({
setAgentTranscriptSubdir(agentId, transcriptSubdir)
}
// Register agent in Perfetto trace for hierarchy visualization
if (isPerfettoTracingEnabled()) {
const parentId = toolUseContext.agentId ?? getSessionId()
registerPerfettoAgent(agentId, agentDefinition.agentType, parentId)
}
// Log API calls path for subagents (ant-only)
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
@@ -828,8 +817,6 @@ export async function* runAgent({
agentToolUseContext.readFileState.clear()
// Release the cloned fork context messages
initialMessages.length = 0
// Release perfetto agent registry entry
unregisterPerfettoAgent(agentId)
// Release transcript subdir mapping
clearAgentTranscriptSubdir(agentId)
// Release this agent's todos entry. Without this, every subagent that

View File

@@ -57,6 +57,47 @@ function debug(msg: string): void {
logForDebugging(`[brief:upload] ${msg}`)
}
function summarizeUploadError(error: unknown): string {
const summary: Record<string, boolean | number | string> = {}
if (error instanceof Error) {
summary.errorType = error.constructor.name
summary.errorName = error.name
summary.hasMessage = error.message.length > 0
} else {
summary.errorType = typeof error
summary.hasValue = error !== undefined && error !== null
}
if (axios.isAxiosError(error)) {
summary.errorType = 'AxiosError'
if (error.code) {
summary.axiosCode = error.code
}
if (typeof error.response?.status === 'number') {
summary.httpStatus = error.response.status
}
summary.hasResponseData = error.response?.data !== undefined
}
return jsonStringify(summary)
}
function summarizeUploadResponse(data: unknown): string {
if (data === undefined) return 'undefined'
if (data === null) return 'null'
if (Array.isArray(data)) return `array(${data.length})`
if (typeof data === 'object') {
return jsonStringify({
responseType: 'object',
keys: Object.keys(data as Record<string, unknown>)
.sort()
.slice(0, 10),
})
}
return typeof data
}
/**
* Base URL for uploads. Must match the host the token is valid for.
*
@@ -100,7 +141,9 @@ export async function uploadBriefAttachment(
if (!ctx.replBridgeEnabled) return undefined
if (size > MAX_UPLOAD_BYTES) {
debug(`skip ${fullPath}: ${size} bytes exceeds ${MAX_UPLOAD_BYTES} limit`)
debug(
`skip attachment upload: ${size} bytes exceeds ${MAX_UPLOAD_BYTES} limit`,
)
return undefined
}
@@ -114,7 +157,7 @@ export async function uploadBriefAttachment(
try {
content = await readFile(fullPath)
} catch (e) {
debug(`read failed for ${fullPath}: ${e}`)
debug(`read failed before upload: ${summarizeUploadError(e)}`)
return undefined
}
@@ -150,23 +193,23 @@ export async function uploadBriefAttachment(
if (response.status !== 201) {
debug(
`upload failed for ${fullPath}: status=${response.status} body=${jsonStringify(response.data).slice(0, 200)}`,
`upload failed: status=${response.status} response=${summarizeUploadResponse(
response.data,
)}`,
)
return undefined
}
const parsed = uploadResponseSchema().safeParse(response.data)
if (!parsed.success) {
debug(
`unexpected response shape for ${fullPath}: ${parsed.error.message}`,
)
debug('unexpected upload response shape')
return undefined
}
debug(`uploaded ${fullPath}${parsed.data.file_uuid} (${size} bytes)`)
debug(`uploaded attachment (${size} bytes)`)
return parsed.data.file_uuid
} catch (e) {
debug(`upload threw for ${fullPath}: ${e}`)
debug(`upload threw: ${summarizeUploadError(e)}`)
return undefined
}
}

View File

@@ -29,7 +29,6 @@ import {
fileHistoryEnabled,
fileHistoryTrackEdit,
} from '../../utils/fileHistory.js'
import { logFileOperation } from '../../utils/fileOperationAnalytics.js'
import {
type LineEndingType,
readFileSyncWithMetadata,
@@ -530,12 +529,6 @@ export const FileEditTool = buildTool({
}
countLinesChanged(patch)
logFileOperation({
operation: 'edit',
tool: 'FileEditTool',
filePath: absoluteFilePath,
})
logEvent('tengu_edit_string_lengths', {
oldStringBytes: Buffer.byteLength(old_string, 'utf8'),
newStringBytes: Buffer.byteLength(new_string, 'utf8'),

View File

@@ -37,7 +37,6 @@ import {
getFileModificationTimeAsync,
suggestPathUnderCwd,
} from '../../utils/file.js'
import { logFileOperation } from '../../utils/fileOperationAnalytics.js'
import { formatFileSize } from '../../utils/format.js'
import { getFsImplementation } from '../../utils/fsOperations.js'
import {
@@ -852,13 +851,6 @@ async function callInner(
file: { filePath: file_path, cells },
}
logFileOperation({
operation: 'read',
tool: 'FileReadTool',
filePath: fullFilePath,
content: cellsJson,
})
return { data }
}
@@ -869,13 +861,6 @@ async function callInner(
const data = await readImageWithTokenBudget(resolvedFilePath, maxTokens)
context.nestedMemoryAttachmentTriggers?.add(fullFilePath)
logFileOperation({
operation: 'read',
tool: 'FileReadTool',
filePath: fullFilePath,
content: data.file.base64,
})
const metadataText = data.file.dimensions
? createImageMetadataText(data.file.dimensions)
: null
@@ -907,12 +892,6 @@ async function callInner(
fileSize: extractResult.data.file.originalSize,
hasPageRange: true,
})
logFileOperation({
operation: 'read',
tool: 'FileReadTool',
filePath: fullFilePath,
content: `PDF pages ${pages}`,
})
const entries = await readdir(extractResult.data.file.outputDir)
const imageFiles = entries.filter(f => f.endsWith('.jpg')).sort()
const imageBlocks = await Promise.all(
@@ -989,13 +968,6 @@ async function callInner(
throw new Error(readResult.error.message)
}
const pdfData = readResult.data
logFileOperation({
operation: 'read',
tool: 'FileReadTool',
filePath: fullFilePath,
content: pdfData.file.base64,
})
return {
data: pdfData,
newMessages: [
@@ -1057,13 +1029,6 @@ async function callInner(
memoryFileMtimes.set(data, mtimeMs)
}
logFileOperation({
operation: 'read',
tool: 'FileReadTool',
filePath: fullFilePath,
content,
})
const sessionFileType = detectSessionFileType(fullFilePath)
const analyticsExt = getFileExtensionForAnalytics(fullFilePath)
logEvent('tengu_session_file_read', {

View File

@@ -24,7 +24,6 @@ import {
fileHistoryEnabled,
fileHistoryTrackEdit,
} from '../../utils/fileHistory.js'
import { logFileOperation } from '../../utils/fileOperationAnalytics.js'
import { readFileSyncWithMetadata } from '../../utils/fileRead.js'
import { getFsImplementation } from '../../utils/fsOperations.js'
import {
@@ -380,13 +379,6 @@ export const FileWriteTool = buildTool({
// Track lines added and removed for file updates, right before yielding result
countLinesChanged(patch)
logFileOperation({
operation: 'write',
tool: 'FileWriteTool',
filePath: fullFilePath,
type: 'update',
})
return {
data,
}
@@ -404,13 +396,6 @@ export const FileWriteTool = buildTool({
// For creation of new files, count all lines as additions, right before yielding the result
countLinesChanged([], content)
logFileOperation({
operation: 'write',
tool: 'FileWriteTool',
filePath: fullFilePath,
type: 'create',
})
return {
data,
}

View File

@@ -15,7 +15,6 @@ import type {
ScopedMcpServerConfig,
} from '../../services/mcp/types.js'
import type { Tool } from '../../Tool.js'
import { errorMessage } from '../../utils/errors.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { logMCPDebug, logMCPError } from '../../utils/log.js'
import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js'
@@ -29,9 +28,11 @@ export type McpAuthOutput = {
authUrl?: string
}
function getConfigUrl(config: ScopedMcpServerConfig): string | undefined {
if ('url' in config) return config.url
return undefined
function summarizeMcpAuthToolError(error: unknown): string {
if (error instanceof Error) {
return `${error.name} (hasMessage=${error.message.length > 0})`
}
return `non-Error (${typeof error})`
}
/**
@@ -50,12 +51,10 @@ export function createMcpAuthTool(
serverName: string,
config: ScopedMcpServerConfig,
): Tool<InputSchema, McpAuthOutput> {
const url = getConfigUrl(config)
const transport = config.type ?? 'stdio'
const location = url ? `${transport} at ${url}` : transport
const description =
`The \`${serverName}\` MCP server (${location}) is installed but requires authentication. ` +
`The \`${serverName}\` MCP server (${transport}) is installed but requires authentication. ` +
`Call this tool to start the OAuth flow — you'll receive an authorization URL to share with the user. ` +
`Once the user completes authorization in their browser, the server's real tools will become available automatically.`
@@ -167,7 +166,9 @@ export function createMcpAuthTool(
.catch(err => {
logMCPError(
serverName,
`OAuth flow failed after tool-triggered start: ${errorMessage(err)}`,
`OAuth flow failed after tool-triggered start: ${summarizeMcpAuthToolError(
err,
)}`,
)
})
@@ -199,7 +200,7 @@ export function createMcpAuthTool(
return {
data: {
status: 'error' as const,
message: `Failed to start OAuth flow for ${serverName}: ${errorMessage(err)}. Ask the user to run /mcp and authenticate manually.`,
message: `Failed to start OAuth flow for ${serverName}. Ask the user to run /mcp and authenticate manually.`,
},
}
}

View File

@@ -1,5 +1,4 @@
import { z } from 'zod/v4'
import { getSessionId } from '../../bootstrap/state.js'
import { logEvent } from '../../services/analytics/index.js'
import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/metadata.js'
import type { Tool } from '../../Tool.js'
@@ -159,7 +158,6 @@ export const TeamCreateTool: Tool<InputSchema, Output> = buildTool({
description: _description,
createdAt: Date.now(),
leadAgentId,
leadSessionId: getSessionId(), // Store actual session ID for team discovery
members: [
{
agentId: leadAgentId,
@@ -169,7 +167,6 @@ export const TeamCreateTool: Tool<InputSchema, Output> = buildTool({
joinedAt: Date.now(),
tmuxPaneId: '',
cwd: getCwd(),
subscriptions: [],
},
],
}

View File

@@ -68,18 +68,8 @@ const URL_CACHE = new LRUCache<string, CacheEntry>({
ttl: CACHE_TTL_MS,
})
// Separate cache for preflight domain checks. URL_CACHE is URL-keyed, so
// fetching two paths on the same domain triggers two identical preflight
// HTTP round-trips to api.anthropic.com. This hostname-keyed cache avoids
// that. Only 'allowed' is cached — blocked/failed re-check on next attempt.
const DOMAIN_CHECK_CACHE = new LRUCache<string, true>({
max: 128,
ttl: 5 * 60 * 1000, // 5 minutes — shorter than URL_CACHE TTL
})
export function clearWebFetchCache(): void {
URL_CACHE.clear()
DOMAIN_CHECK_CACHE.clear()
}
// Lazy singleton — defers the turndown → @mixmark-io/domino import (~1.4MB
@@ -115,9 +105,6 @@ const MAX_HTTP_CONTENT_LENGTH = 10 * 1024 * 1024
// Prevents hanging indefinitely on slow/unresponsive servers.
const FETCH_TIMEOUT_MS = 60_000
// Timeout for the domain blocklist preflight check (10 seconds).
const DOMAIN_CHECK_TIMEOUT_MS = 10_000
// Cap same-host redirect hops. Without this a malicious server can return
// a redirect loop (/a → /b → /a …) and the per-request FETCH_TIMEOUT_MS
// resets on every hop, hanging the tool until user interrupt. 10 matches
@@ -174,32 +161,12 @@ type DomainCheckResult =
| { status: 'check_failed'; error: Error }
export async function checkDomainBlocklist(
domain: string,
_domain: string,
): Promise<DomainCheckResult> {
if (DOMAIN_CHECK_CACHE.has(domain)) {
return { status: 'allowed' }
}
try {
const response = await axios.get(
`https://api.anthropic.com/api/web/domain_info?domain=${encodeURIComponent(domain)}`,
{ timeout: DOMAIN_CHECK_TIMEOUT_MS },
)
if (response.status === 200) {
if (response.data.can_fetch === true) {
DOMAIN_CHECK_CACHE.set(domain, true)
return { status: 'allowed' }
}
return { status: 'blocked' }
}
// Non-200 status but didn't throw
return {
status: 'check_failed',
error: new Error(`Domain check returned status ${response.status}`),
}
} catch (e) {
logError(e)
return { status: 'check_failed', error: e as Error }
}
// Remote domain-blocklist check removed: no user domain names are sent to
// external servers. Users explicitly approve each domain via the tool
// permission dialog, which is the primary security boundary.
return { status: 'allowed' }
}
/**

View File

@@ -497,13 +497,11 @@ async function handleSpawnSplitPane(
name: sanitizedName,
agentType: agent_type,
model,
prompt,
color: teammateColor,
planModeRequired: plan_mode_required,
joinedAt: Date.now(),
tmuxPaneId: paneId,
cwd: workingDir,
subscriptions: [],
backendType: detectionResult.backend.type,
})
await writeTeamFileAsync(teamName, teamFile)
@@ -711,13 +709,11 @@ async function handleSpawnSeparateWindow(
name: sanitizedName,
agentType: agent_type,
model,
prompt,
color: teammateColor,
planModeRequired: plan_mode_required,
joinedAt: Date.now(),
tmuxPaneId: paneId,
cwd: workingDir,
subscriptions: [],
backendType: 'tmux', // This handler always uses tmux directly
})
await writeTeamFileAsync(teamName, teamFile)
@@ -997,13 +993,11 @@ async function handleSpawnInProcess(
name: sanitizedName,
agentType: agent_type,
model,
prompt,
color: teammateColor,
planModeRequired: plan_mode_required,
joinedAt: Date.now(),
tmuxPaneId: 'in-process',
cwd: getCwd(),
subscriptions: [],
backendType: 'in-process',
})
await writeTeamFileAsync(teamName, teamFile)

View File

@@ -1,223 +0,0 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.6.1
// protoc unknown
// source: events_mono/growthbook/v1/growthbook_experiment_event.proto
/* eslint-disable */
import { Timestamp } from '../../../google/protobuf/timestamp.js'
import { PublicApiAuth } from '../../common/v1/auth.js'
/**
* GrowthBook experiment assignment event
* This event tracks when a user is exposed to an experiment variant
* See: https://docs.growthbook.io/guide/bigquery
*/
export interface GrowthbookExperimentEvent {
/** Unique event identifier (for deduplication) */
event_id?: string | undefined
/** When user was exposed to experiment (maps to GrowthBook's timestamp column) */
timestamp?: Date | undefined
/** Experiment tracking key (maps to GrowthBook's experiment_id column) */
experiment_id?: string | undefined
/** Variation index: 0=control, 1+=variants (maps to GrowthBook's variation_id column) */
variation_id?: number | undefined
/** Environment where assignment occurred */
environment?: string | undefined
/** User attributes at time of assignment */
user_attributes?: string | undefined
/** Experiment metadata */
experiment_metadata?: string | undefined
/** Device identifier for the client */
device_id?: string | undefined
/** Authentication context automatically injected by the API */
auth?: PublicApiAuth | undefined
/** Session identifier for tracking user sessions */
session_id?: string | undefined
/** Anonymous identifier for unauthenticated users */
anonymous_id?: string | undefined
/** Event metadata variables (automatically populated by internal-tools-common event_logging library) */
event_metadata_vars?: string | undefined
}
function createBaseGrowthbookExperimentEvent(): GrowthbookExperimentEvent {
return {
event_id: '',
timestamp: undefined,
experiment_id: '',
variation_id: 0,
environment: '',
user_attributes: '',
experiment_metadata: '',
device_id: '',
auth: undefined,
session_id: '',
anonymous_id: '',
event_metadata_vars: '',
}
}
export const GrowthbookExperimentEvent: MessageFns<GrowthbookExperimentEvent> =
{
fromJSON(object: any): GrowthbookExperimentEvent {
return {
event_id: isSet(object.event_id)
? globalThis.String(object.event_id)
: '',
timestamp: isSet(object.timestamp)
? fromJsonTimestamp(object.timestamp)
: undefined,
experiment_id: isSet(object.experiment_id)
? globalThis.String(object.experiment_id)
: '',
variation_id: isSet(object.variation_id)
? globalThis.Number(object.variation_id)
: 0,
environment: isSet(object.environment)
? globalThis.String(object.environment)
: '',
user_attributes: isSet(object.user_attributes)
? globalThis.String(object.user_attributes)
: '',
experiment_metadata: isSet(object.experiment_metadata)
? globalThis.String(object.experiment_metadata)
: '',
device_id: isSet(object.device_id)
? globalThis.String(object.device_id)
: '',
auth: isSet(object.auth)
? PublicApiAuth.fromJSON(object.auth)
: undefined,
session_id: isSet(object.session_id)
? globalThis.String(object.session_id)
: '',
anonymous_id: isSet(object.anonymous_id)
? globalThis.String(object.anonymous_id)
: '',
event_metadata_vars: isSet(object.event_metadata_vars)
? globalThis.String(object.event_metadata_vars)
: '',
}
},
toJSON(message: GrowthbookExperimentEvent): unknown {
const obj: any = {}
if (message.event_id !== undefined) {
obj.event_id = message.event_id
}
if (message.timestamp !== undefined) {
obj.timestamp = message.timestamp.toISOString()
}
if (message.experiment_id !== undefined) {
obj.experiment_id = message.experiment_id
}
if (message.variation_id !== undefined) {
obj.variation_id = Math.round(message.variation_id)
}
if (message.environment !== undefined) {
obj.environment = message.environment
}
if (message.user_attributes !== undefined) {
obj.user_attributes = message.user_attributes
}
if (message.experiment_metadata !== undefined) {
obj.experiment_metadata = message.experiment_metadata
}
if (message.device_id !== undefined) {
obj.device_id = message.device_id
}
if (message.auth !== undefined) {
obj.auth = PublicApiAuth.toJSON(message.auth)
}
if (message.session_id !== undefined) {
obj.session_id = message.session_id
}
if (message.anonymous_id !== undefined) {
obj.anonymous_id = message.anonymous_id
}
if (message.event_metadata_vars !== undefined) {
obj.event_metadata_vars = message.event_metadata_vars
}
return obj
},
create<I extends Exact<DeepPartial<GrowthbookExperimentEvent>, I>>(
base?: I,
): GrowthbookExperimentEvent {
return GrowthbookExperimentEvent.fromPartial(base ?? ({} as any))
},
fromPartial<I extends Exact<DeepPartial<GrowthbookExperimentEvent>, I>>(
object: I,
): GrowthbookExperimentEvent {
const message = createBaseGrowthbookExperimentEvent()
message.event_id = object.event_id ?? ''
message.timestamp = object.timestamp ?? undefined
message.experiment_id = object.experiment_id ?? ''
message.variation_id = object.variation_id ?? 0
message.environment = object.environment ?? ''
message.user_attributes = object.user_attributes ?? ''
message.experiment_metadata = object.experiment_metadata ?? ''
message.device_id = object.device_id ?? ''
message.auth =
object.auth !== undefined && object.auth !== null
? PublicApiAuth.fromPartial(object.auth)
: undefined
message.session_id = object.session_id ?? ''
message.anonymous_id = object.anonymous_id ?? ''
message.event_metadata_vars = object.event_metadata_vars ?? ''
return message
},
}
type Builtin =
| Date
| Function
| Uint8Array
| string
| number
| boolean
| undefined
type DeepPartial<T> = T extends Builtin
? T
: T extends globalThis.Array<infer U>
? globalThis.Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U>
? ReadonlyArray<DeepPartial<U>>
: T extends {}
? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>
type KeysOfUnion<T> = T extends T ? keyof T : never
type Exact<P, I extends P> = P extends Builtin
? P
: P & { [K in keyof P]: Exact<P[K], I[K]> } & {
[K in Exclude<keyof I, KeysOfUnion<P>>]: never
}
function fromTimestamp(t: Timestamp): Date {
let millis = (t.seconds || 0) * 1_000
millis += (t.nanos || 0) / 1_000_000
return new globalThis.Date(millis)
}
function fromJsonTimestamp(o: any): Date {
if (o instanceof globalThis.Date) {
return o
} else if (typeof o === 'string') {
return new globalThis.Date(o)
} else {
return fromTimestamp(Timestamp.fromJSON(o))
}
}
function isSet(value: any): boolean {
return value !== null && value !== undefined
}
interface MessageFns<T> {
fromJSON(object: any): T
toJSON(message: T): unknown
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T
}

View File

@@ -40,7 +40,7 @@ export function maybePersistTokenForSubprocesses(
mkdirSync(CCR_TOKEN_DIR, { recursive: true, mode: 0o700 })
// eslint-disable-next-line custom-rules/no-sync-fs -- one-shot startup write in CCR, caller is sync
writeFileSync(path, token, { encoding: 'utf8', mode: 0o600 })
logForDebugging(`Persisted ${tokenName} to ${path} for subprocess access`)
logForDebugging(`Persisted ${tokenName} for subprocess access`)
} catch (error) {
logForDebugging(
`Failed to persist ${tokenName} to disk (non-fatal): ${errorMessage(error)}`,
@@ -65,7 +65,7 @@ export function readTokenFromWellKnownFile(
if (!token) {
return null
}
logForDebugging(`Read ${tokenName} from well-known file ${path}`)
logForDebugging(`Read ${tokenName} from well-known file`)
return token
} catch (error) {
// ENOENT is the expected outcome outside CCR — stay silent. Anything
@@ -73,7 +73,7 @@ export function readTokenFromWellKnownFile(
// debug log so subprocess auth failures aren't mysterious.
if (!isENOENT(error)) {
logForDebugging(
`Failed to read ${tokenName} from ${path}: ${errorMessage(error)}`,
`Failed to read ${tokenName} from well-known file: ${errorMessage(error)}`,
{ level: 'debug' },
)
}
@@ -124,7 +124,7 @@ function getCredentialFromFd({
const fd = parseInt(fdEnv, 10)
if (Number.isNaN(fd)) {
logForDebugging(
`${envVar} must be a valid file descriptor number, got: ${fdEnv}`,
`${envVar} must be a valid file descriptor number`,
{ level: 'error' },
)
setCached(null)
@@ -148,13 +148,13 @@ function getCredentialFromFd({
setCached(null)
return null
}
logForDebugging(`Successfully read ${label} from file descriptor ${fd}`)
logForDebugging(`Successfully read ${label} from file descriptor`)
setCached(token)
maybePersistTokenForSubprocesses(wellKnownPath, token, label)
return token
} catch (error) {
logForDebugging(
`Failed to read ${label} from file descriptor ${fd}: ${errorMessage(error)}`,
`Failed to read ${label} from file descriptor: ${errorMessage(error)}`,
{ level: 'error' },
)
// FD env var was set but read failed — typically a subprocess that

View File

@@ -6,14 +6,11 @@ import {
} from '@ant/claude-for-chrome-mcp'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { format } from 'util'
import { shutdownDatadog } from '../../services/analytics/datadog.js'
import { shutdown1PEventLogging } from '../../services/analytics/firstPartyEventLogger.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../../services/analytics/index.js'
import { initializeAnalyticsSink } from '../../services/analytics/sink.js'
import { getClaudeAIOAuthTokens } from '../auth.js'
import { enableConfigs, getGlobalConfig, saveGlobalConfig } from '../config.js'
import { logForDebugging } from '../debug.js'
@@ -225,7 +222,7 @@ export function createChromeContext(
} = {}
if (metadata) {
for (const [key, value] of Object.entries(metadata)) {
// Rename 'status' to 'bridge_status' to avoid Datadog's reserved field
// Keep the status field namespaced to avoid downstream collisions.
const safeKey = key === 'status' ? 'bridge_status' : key
if (typeof value === 'boolean' || typeof value === 'number') {
safeMetadata[safeKey] = value
@@ -247,22 +244,18 @@ export function createChromeContext(
export async function runClaudeInChromeMcpServer(): Promise<void> {
enableConfigs()
initializeAnalyticsSink()
const context = createChromeContext()
const server = createClaudeForChromeMcpServer(context)
const transport = new StdioServerTransport()
// Exit when parent process dies (stdin pipe closes).
// Flush analytics before exiting so final-batch events (e.g. disconnect) aren't lost.
let exiting = false
const shutdownAndExit = async (): Promise<void> => {
const shutdownAndExit = (): void => {
if (exiting) {
return
}
exiting = true
await shutdown1PEventLogging()
await shutdownDatadog()
// eslint-disable-next-line custom-rules/no-process-exit
process.exit(0)
}

View File

@@ -83,53 +83,22 @@ function convertToOpenAIMessage(message: Message): OpenAIMessage {
}
/**
* Make a request to OpenAI Codex API
* fetchCodexResponse is disabled: sending conversation content to
* api.openai.com would leak user data to a third-party service.
* This function is retained as a stub to avoid breaking any call sites.
*/
export async function fetchCodexResponse(
messages: Message[],
model: string,
options: {
_messages: Message[],
_model: string,
_options: {
apiKey?: string
baseUrl?: string
stream?: boolean
} = {}
): Promise<OpenAIResponse> {
const { apiKey, baseUrl = 'https://api.openai.com/v1', stream = false } = options
if (!apiKey) {
throw new Error('OpenAI API key is required for Codex requests')
}
const openAIMessages = messages.map(convertToOpenAIMessage)
const requestBody = {
model,
messages: openAIMessages,
stream,
temperature: 0.7,
max_tokens: 4096,
}
try {
const response = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify(requestBody),
})
if (!response.ok) {
throw new Error(`OpenAI API error: ${response.status} ${response.statusText}`)
}
const data = await response.json() as OpenAIResponse
return data
} catch (error) {
logError(error)
throw error
}
throw new Error(
'OpenAI Codex API calls are disabled for privacy. External data forwarding has been removed.',
)
}
/**

View File

@@ -6,9 +6,6 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import { homedir } from 'os'
import { shutdownDatadog } from '../../services/analytics/datadog.js'
import { shutdown1PEventLogging } from '../../services/analytics/firstPartyEventLogger.js'
import { initializeAnalyticsSink } from '../../services/analytics/sink.js'
import { enableConfigs } from '../config.js'
import { logForDebugging } from '../debug.js'
import { filterAppsForDescription } from './appNames.js'
@@ -80,20 +77,18 @@ export async function createComputerUseMcpServerForCli(): Promise<
/**
* Subprocess entrypoint for `--computer-use-mcp`. Mirror of
* `runClaudeInChromeMcpServer` — stdio transport, exit on stdin close,
* flush analytics before exit.
* and exit promptly when the parent process closes stdin.
*/
export async function runComputerUseMcpServer(): Promise<void> {
enableConfigs()
initializeAnalyticsSink()
const server = await createComputerUseMcpServerForCli()
const transport = new StdioServerTransport()
let exiting = false
const shutdownAndExit = async (): Promise<void> => {
const shutdownAndExit = (): void => {
if (exiting) return
exiting = true
await Promise.all([shutdown1PEventLogging(), shutdownDatadog()])
// eslint-disable-next-line custom-rules/no-process-exit
process.exit(0)
}

View File

@@ -17,12 +17,19 @@ import {
filterExistingPaths,
getKnownPathsForRepo,
} from '../githubRepoPathMapping.js'
import { jsonStringify } from '../slowOperations.js'
import { readLastFetchTime } from './banner.js'
import { parseDeepLink } from './parseDeepLink.js'
import { MACOS_BUNDLE_ID } from './registerProtocol.js'
import { launchInTerminal } from './terminalLauncher.js'
function summarizeDeepLinkAction(action: {
query?: string
cwd?: string
repo?: string
}): string {
return `hasQuery=${Boolean(action.query)} hasCwd=${Boolean(action.cwd)} hasRepo=${Boolean(action.repo)}`
}
/**
* Handle an incoming deep link URI.
*
@@ -34,7 +41,7 @@ import { launchInTerminal } from './terminalLauncher.js'
* @returns exit code (0 = success)
*/
export async function handleDeepLinkUri(uri: string): Promise<number> {
logForDebugging(`Handling deep link URI: ${uri}`)
logForDebugging('Handling deep link URI')
let action
try {
@@ -46,7 +53,7 @@ export async function handleDeepLinkUri(uri: string): Promise<number> {
return 1
}
logForDebugging(`Parsed deep link action: ${jsonStringify(action)}`)
logForDebugging(`Parsed deep link action (${summarizeDeepLinkAction(action)})`)
// Always the running executable — no PATH lookup. The OS launched us via
// an absolute path (bundle symlink / .desktop Exec= / registry command)
@@ -125,11 +132,11 @@ async function resolveCwd(action: {
const known = getKnownPathsForRepo(action.repo)
const existing = await filterExistingPaths(known)
if (existing[0]) {
logForDebugging(`Resolved repo ${action.repo}${existing[0]}`)
logForDebugging('Resolved repo deep link to local clone')
return { cwd: existing[0], resolvedRepo: action.repo }
}
logForDebugging(
`No local clone found for repo ${action.repo}, falling back to home`,
'No local clone found for repo deep link, falling back to home',
)
}
return { cwd: homedir() }

View File

@@ -116,7 +116,6 @@ function appendToLog(path: string, message: object): void {
const messageWithTimestamp = {
timestamp: new Date().toISOString(),
...message,
cwd: getFsImplementation().cwd(),
userType: process.env.USER_TYPE,
sessionId: getSessionId(),
version: MACRO.VERSION,
@@ -125,25 +124,12 @@ function appendToLog(path: string, message: object): void {
getLogWriter(path).write(messageWithTimestamp)
}
function extractServerMessage(data: unknown): string | undefined {
if (typeof data === 'string') {
return data
function summarizeUrlForLogs(url: string): string | undefined {
try {
return new URL(url).host || undefined
} catch {
return undefined
}
if (data && typeof data === 'object') {
const obj = data as Record<string, unknown>
if (typeof obj.message === 'string') {
return obj.message
}
if (
typeof obj.error === 'object' &&
obj.error &&
'message' in obj.error &&
typeof (obj.error as Record<string, unknown>).message === 'string'
) {
return (obj.error as Record<string, unknown>).message as string
}
}
return undefined
}
/**
@@ -155,15 +141,15 @@ function logErrorImpl(error: Error): void {
// Enrich axios errors with request URL, status, and server message for debugging
let context = ''
if (axios.isAxiosError(error) && error.config?.url) {
const parts = [`url=${error.config.url}`]
const parts: string[] = []
const host = summarizeUrlForLogs(error.config.url)
if (host) {
parts.push(`host=${host}`)
}
if (error.response?.status !== undefined) {
parts.push(`status=${error.response.status}`)
}
const serverMessage = extractServerMessage(error.response?.data)
if (serverMessage) {
parts.push(`body=${serverMessage}`)
}
context = `[${parts.join(',')}] `
context = parts.length > 0 ? `[${parts.join(',')}] ` : ''
}
logForDebugging(`${error.name}: ${context}${errorStr}`, { level: 'error' })
@@ -188,7 +174,6 @@ function logMCPErrorImpl(serverName: string, error: unknown): void {
error: errorStr,
timestamp: new Date().toISOString(),
sessionId: getSessionId(),
cwd: getFsImplementation().cwd(),
}
getLogWriter(logFile).write(errorInfo)
@@ -206,7 +191,6 @@ function logMCPDebugImpl(serverName: string, message: string): void {
debug: message,
timestamp: new Date().toISOString(),
sessionId: getSessionId(),
cwd: getFsImplementation().cwd(),
}
getLogWriter(logFile).write(debugInfo)
@@ -218,8 +202,6 @@ function logMCPDebugImpl(serverName: string, message: string): void {
* Call this during app startup to attach the error logging backend.
* Any errors logged before this is called will be queued and drained.
*
* Should be called BEFORE initializeAnalyticsSink() in the startup sequence.
*
* Idempotent: safe to call multiple times (subsequent calls are no-ops).
*/
export function initializeErrorLogSink(): void {

View File

@@ -1,71 +0,0 @@
import { createHash } from 'crypto'
import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from 'src/services/analytics/index.js'
import { logEvent } from 'src/services/analytics/index.js'
/**
* Creates a truncated SHA256 hash (16 chars) for file paths
* Used for privacy-preserving analytics on file operations
*/
function hashFilePath(
filePath: string,
): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS {
return createHash('sha256')
.update(filePath)
.digest('hex')
.slice(0, 16) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
}
/**
* Creates a full SHA256 hash (64 chars) for file contents
* Used for deduplication and change detection analytics
*/
function hashFileContent(
content: string,
): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS {
return createHash('sha256')
.update(content)
.digest('hex') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
}
// Maximum content size to hash (100KB)
// Prevents memory exhaustion when hashing large files (e.g., base64-encoded images)
const MAX_CONTENT_HASH_SIZE = 100 * 1024
/**
* Logs file operation analytics to Statsig
*/
export function logFileOperation(params: {
operation: 'read' | 'write' | 'edit'
tool: 'FileReadTool' | 'FileWriteTool' | 'FileEditTool'
filePath: string
content?: string
type?: 'create' | 'update'
}): void {
const metadata: Record<
string,
| AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
| number
| boolean
> = {
operation:
params.operation as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
tool: params.tool as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
filePathHash: hashFilePath(params.filePath),
}
// Only hash content if it's provided and below size limit
// This prevents memory exhaustion from hashing large files (e.g., base64-encoded images)
if (
params.content !== undefined &&
params.content.length <= MAX_CONTENT_HASH_SIZE
) {
metadata.contentHash = hashFileContent(params.content)
}
if (params.type !== undefined) {
metadata.type =
params.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
}
logEvent('tengu_file_operation', metadata)
}

View File

@@ -29,8 +29,6 @@ import {
supportsTabStatus,
wrapForMultiplexer,
} from '../ink/termio/osc.js'
import { shutdownDatadog } from '../services/analytics/datadog.js'
import { shutdown1PEventLogging } from '../services/analytics/firstPartyEventLogger.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
@@ -41,7 +39,6 @@ import { logForDebugging } from './debug.js'
import { logForDiagnosticsNoPII } from './diagLogs.js'
import { isEnvTruthy } from './envUtils.js'
import { getCurrentSessionTitle, sessionIdExists } from './sessionStorage.js'
import { sleep } from './sleep.js'
import { profileReport } from './startupProfiler.js'
/**
@@ -301,7 +298,7 @@ export const setupGracefulShutdown = memoize(() => {
process.on('uncaughtException', error => {
logForDiagnosticsNoPII('error', 'uncaught_exception', {
error_name: error.name,
error_message: error.message.slice(0, 2000),
has_message: error.message.length > 0,
})
logEvent('tengu_uncaught_exception', {
error_name:
@@ -321,10 +318,10 @@ export const setupGracefulShutdown = memoize(() => {
reason instanceof Error
? {
error_name: reason.name,
error_message: reason.message.slice(0, 2000),
error_stack: reason.stack?.slice(0, 4000),
has_message: reason.message.length > 0,
has_stack: Boolean(reason.stack),
}
: { error_message: String(reason).slice(0, 2000) }
: { reason_type: typeof reason }
logForDiagnosticsNoPII('error', 'unhandled_rejection', errorInfo)
logEvent('tengu_unhandled_rejection', {
error_name:
@@ -413,7 +410,7 @@ export async function gracefulShutdown(
// Failsafe: guarantee process exits even if cleanup hangs (e.g., MCP connections).
// Runs cleanupTerminalModes first so a hung cleanup doesn't leave the terminal dirty.
// Budget = max(5s, hook budget + 3.5s headroom for cleanup + analytics flush).
// Budget = max(5s, hook budget + 3.5s headroom for remaining cleanup).
failsafeTimer = setTimeout(
code => {
cleanupTerminalModes()
@@ -487,7 +484,7 @@ export async function gracefulShutdown(
}
// Signal to inference that this session's cache can be evicted.
// Fires before analytics flush so the event makes it to the pipeline.
// Emit before the final forced-exit path runs.
const lastRequestId = getLastMainRequestId()
if (lastRequestId) {
logEvent('tengu_cache_eviction_hint', {
@@ -498,18 +495,6 @@ export async function gracefulShutdown(
})
}
// Flush analytics — capped at 500ms. Previously unbounded: the 1P exporter
// awaits all pending axios POSTs (10s each), eating the full failsafe budget.
// Lost analytics on slow networks are acceptable; a hanging exit is not.
try {
await Promise.race([
Promise.all([shutdown1PEventLogging(), shutdownDatadog()]),
sleep(500),
])
} catch {
// Ignore analytics shutdown errors
}
if (options?.finalMessage) {
try {
// eslint-disable-next-line custom-rules/no-sync-fs -- must flush before forceExit

View File

@@ -55,13 +55,7 @@ import {
logEvent,
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
} from 'src/services/analytics/index.js'
import { logOTelEvent } from './telemetry/events.js'
import { ALLOWED_OFFICIAL_MARKETPLACE_NAMES } from './plugins/schemas.js'
import {
startHookSpan,
endHookSpan,
isBetaTracingEnabled,
} from './telemetry/sessionTracing.js'
import {
hookJSONOutputSchema,
promptRequestSchema,
@@ -2066,31 +2060,6 @@ async function* executeHooks({
return
}
// Collect hook definitions for beta tracing telemetry
const hookDefinitionsJson = isBetaTracingEnabled()
? jsonStringify(getHookDefinitionsForTelemetry(matchingHooks))
: '[]'
// Log hook execution start to OTEL (only for beta tracing)
if (isBetaTracingEnabled()) {
void logOTelEvent('hook_execution_start', {
hook_event: hookEvent,
hook_name: hookName,
num_hooks: String(matchingHooks.length),
managed_only: String(shouldAllowManagedHooksOnly()),
hook_definitions: hookDefinitionsJson,
hook_source: shouldAllowManagedHooksOnly() ? 'policySettings' : 'merged',
})
}
// Start hook span for beta tracing
const hookSpan = startHookSpan(
hookEvent,
hookName,
matchingHooks.length,
hookDefinitionsJson,
)
// Yield progress messages for each hook before execution
for (const { hook } of matchingHooks) {
yield {
@@ -2943,32 +2912,6 @@ async function* executeHooks({
totalDurationMs,
})
// Log hook execution completion to OTEL (only for beta tracing)
if (isBetaTracingEnabled()) {
const hookDefinitionsComplete =
getHookDefinitionsForTelemetry(matchingHooks)
void logOTelEvent('hook_execution_complete', {
hook_event: hookEvent,
hook_name: hookName,
num_hooks: String(matchingHooks.length),
num_success: String(outcomes.success),
num_blocking: String(outcomes.blocking),
num_non_blocking_error: String(outcomes.non_blocking_error),
num_cancelled: String(outcomes.cancelled),
managed_only: String(shouldAllowManagedHooksOnly()),
hook_definitions: jsonStringify(hookDefinitionsComplete),
hook_source: shouldAllowManagedHooksOnly() ? 'policySettings' : 'merged',
})
}
// End hook span for beta tracing
endHookSpan(hookSpan, {
numSuccess: outcomes.success,
numBlocking: outcomes.blocking,
numNonBlockingError: outcomes.non_blocking_error,
numCancelled: outcomes.cancelled,
})
}
export type HookOutsideReplResult = {
@@ -5001,22 +4944,3 @@ export async function executeWorktreeRemoveHook(
return true
}
function getHookDefinitionsForTelemetry(
matchedHooks: MatchedHook[],
): Array<{ type: string; command?: string; prompt?: string; name?: string }> {
return matchedHooks.map(({ hook }) => {
if (hook.type === 'command') {
return { type: 'command', command: hook.command }
} else if (hook.type === 'prompt') {
return { type: 'prompt', prompt: hook.prompt }
} else if (hook.type === 'http') {
return { type: 'http', command: hook.url }
} else if (hook.type === 'function') {
return { type: 'function', name: 'function' }
} else if (hook.type === 'callback') {
return { type: 'callback', name: 'callback' }
}
return { type: 'unknown' }
})
}

View File

@@ -1,135 +0,0 @@
/**
* Telemetry for plugin/marketplace fetches that hit the network.
*
* Added for inc-5046 (GitHub complained about claude-plugins-official load).
* Before this, fetch operations only had logForDebugging — no way to measure
* actual network volume. This surfaces what's hitting GitHub vs GCS vs
* user-hosted so we can see the GCS migration take effect and catch future
* hot-path regressions before GitHub emails us again.
*
* Volume: these fire at startup (install-counts 24h-TTL)
* and on explicit user action (install/update). NOT per-interaction. Similar
* envelope to tengu_binary_download_*.
*/
import {
logEvent,
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS as SafeString,
} from '../../services/analytics/index.js'
import { OFFICIAL_MARKETPLACE_NAME } from './officialMarketplace.js'
export type PluginFetchSource =
| 'install_counts'
| 'marketplace_clone'
| 'marketplace_pull'
| 'marketplace_url'
| 'plugin_clone'
| 'mcpb'
export type PluginFetchOutcome = 'success' | 'failure' | 'cache_hit'
// Allowlist of public hosts we report by name. Anything else (enterprise
// git, self-hosted, internal) is bucketed as 'other' — we don't want
// internal hostnames (git.mycorp.internal) landing in telemetry. Bounded
// cardinality also keeps the dashboard host-breakdown tractable.
const KNOWN_PUBLIC_HOSTS = new Set([
'github.com',
'raw.githubusercontent.com',
'objects.githubusercontent.com',
'gist.githubusercontent.com',
'gitlab.com',
'bitbucket.org',
'codeberg.org',
'dev.azure.com',
'ssh.dev.azure.com',
'storage.googleapis.com', // GCS — where Dickson's migration points
])
/**
* Extract hostname from a URL or git spec and bucket to the allowlist.
* Handles `https://host/...`, `git@host:path`, `ssh://host/...`.
* Returns a known public host, 'other' (parseable but not allowlisted —
* don't leak private hostnames), or 'unknown' (unparseable / local path).
*/
function extractHost(urlOrSpec: string): string {
let host: string
const scpMatch = /^[^@/]+@([^:/]+):/.exec(urlOrSpec)
if (scpMatch) {
host = scpMatch[1]!
} else {
try {
host = new URL(urlOrSpec).hostname
} catch {
return 'unknown'
}
}
const normalized = host.toLowerCase()
return KNOWN_PUBLIC_HOSTS.has(normalized) ? normalized : 'other'
}
/**
* True if the URL/spec points at anthropics/claude-plugins-official — the
* repo GitHub complained about. Lets the dashboard separate "our problem"
* traffic from user-configured marketplaces.
*/
function isOfficialRepo(urlOrSpec: string): boolean {
return urlOrSpec.includes(`anthropics/${OFFICIAL_MARKETPLACE_NAME}`)
}
export function logPluginFetch(
source: PluginFetchSource,
urlOrSpec: string | undefined,
outcome: PluginFetchOutcome,
durationMs: number,
errorKind?: string,
): void {
// String values are bounded enums / hostname-only — no code, no paths,
// no raw error messages. Same privacy envelope as tengu_web_fetch_host.
logEvent('tengu_plugin_remote_fetch', {
source: source as SafeString,
host: (urlOrSpec ? extractHost(urlOrSpec) : 'unknown') as SafeString,
is_official: urlOrSpec ? isOfficialRepo(urlOrSpec) : false,
outcome: outcome as SafeString,
duration_ms: Math.round(durationMs),
...(errorKind && { error_kind: errorKind as SafeString }),
})
}
/**
* Classify an error into a stable bucket for the error_kind field. Keeps
* cardinality bounded — raw error messages would explode dashboard grouping.
*
* Handles both axios Error objects (Node.js error codes like ENOTFOUND) and
* git stderr strings (human phrases like "Could not resolve host"). DNS
* checked BEFORE timeout because gitClone's error enhancement at
* marketplaceManager.ts:~950 rewrites DNS failures to include the word
* "timeout" — ordering the other way would misclassify git DNS as timeout.
*/
export function classifyFetchError(error: unknown): string {
const msg = String((error as { message?: unknown })?.message ?? error)
if (
/ENOTFOUND|ECONNREFUSED|EAI_AGAIN|Could not resolve host|Connection refused/i.test(
msg,
)
) {
return 'dns_or_refused'
}
if (/ETIMEDOUT|timed out|timeout/i.test(msg)) return 'timeout'
if (
/ECONNRESET|socket hang up|Connection reset by peer|remote end hung up/i.test(
msg,
)
) {
return 'conn_reset'
}
if (/403|401|authentication|permission denied/i.test(msg)) return 'auth'
if (/404|not found|repository not found/i.test(msg)) return 'not_found'
if (/certificate|SSL|TLS|unable to get local issuer/i.test(msg)) return 'tls'
// Schema validation throws "Invalid response format" (install_counts) —
// distinguish from true unknowns so the dashboard can
// see "server sent garbage" separately.
if (/Invalid response format|Invalid marketplace schema/i.test(msg)) {
return 'invalid_schema'
}
return 'other'
}

View File

@@ -17,7 +17,6 @@ import { errorMessage, getErrnoCode } from '../errors.js'
import { getFsImplementation } from '../fsOperations.js'
import { logError } from '../log.js'
import { jsonParse, jsonStringify } from '../slowOperations.js'
import { classifyFetchError, logPluginFetch } from './fetchTelemetry.js'
import { getPluginsDirectory } from './pluginDirectories.js'
const INSTALL_COUNTS_CACHE_VERSION = 1
@@ -196,21 +195,8 @@ async function fetchInstallCountsFromGitHub(): Promise<
throw new Error('Invalid response format from install counts API')
}
logPluginFetch(
'install_counts',
INSTALL_COUNTS_URL,
'success',
performance.now() - started,
)
return response.data.plugins
} catch (error) {
logPluginFetch(
'install_counts',
INSTALL_COUNTS_URL,
'failure',
performance.now() - started,
classifyFetchError(error),
)
throw error
}
}
@@ -227,7 +213,6 @@ export async function getInstallCounts(): Promise<Map<string, number> | null> {
const cache = await loadInstallCountsCache()
if (cache) {
logForDebugging('Using cached install counts')
logPluginFetch('install_counts', INSTALL_COUNTS_URL, 'cache_hit', 0)
const map = new Map<string, number>()
for (const entry of cache.counts) {
map.set(entry.plugin, entry.unique_installs)

View File

@@ -53,7 +53,6 @@ import {
getAddDirExtraMarketplaces,
} from './addDirPluginSettings.js'
import { markPluginVersionOrphaned } from './cacheUtils.js'
import { classifyFetchError, logPluginFetch } from './fetchTelemetry.js'
import { removeAllPluginsForMarketplace } from './installedPluginsManager.js'
import {
extractHostFromSource,
@@ -1110,13 +1109,7 @@ async function cacheMarketplaceFromGit(
disableCredentialHelper: options?.disableCredentialHelper,
sparsePaths,
})
logPluginFetch(
'marketplace_pull',
gitUrl,
pullResult.code === 0 ? 'success' : 'failure',
performance.now() - pullStarted,
pullResult.code === 0 ? undefined : classifyFetchError(pullResult.stderr),
)
void pullStarted
if (pullResult.code === 0) return
logForDebugging(`git pull failed, will re-clone: ${pullResult.stderr}`, {
level: 'warn',
@@ -1156,13 +1149,7 @@ async function cacheMarketplaceFromGit(
)
const cloneStarted = performance.now()
const result = await gitClone(gitUrl, cachePath, ref, sparsePaths)
logPluginFetch(
'marketplace_clone',
gitUrl,
result.code === 0 ? 'success' : 'failure',
performance.now() - cloneStarted,
result.code === 0 ? undefined : classifyFetchError(result.stderr),
)
void cloneStarted
if (result.code !== 0) {
// Clean up any partial directory created by the failed clone so the next
// attempt starts fresh. Best-effort: if this fails, the stale dir will be
@@ -1284,13 +1271,6 @@ async function cacheMarketplaceFromUrl(
headers,
})
} catch (error) {
logPluginFetch(
'marketplace_url',
url,
'failure',
performance.now() - fetchStarted,
classifyFetchError(error),
)
if (axios.isAxiosError(error)) {
if (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND') {
throw new Error(
@@ -1317,25 +1297,13 @@ async function cacheMarketplaceFromUrl(
// Validate the response is a valid marketplace
const result = PluginMarketplaceSchema().safeParse(response.data)
if (!result.success) {
logPluginFetch(
'marketplace_url',
url,
'failure',
performance.now() - fetchStarted,
'invalid_schema',
)
throw new ConfigParseError(
`Invalid marketplace schema from URL: ${result.error.issues.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')}`,
redactedUrl,
response.data,
)
}
logPluginFetch(
'marketplace_url',
url,
'success',
performance.now() - fetchStarted,
)
void fetchStarted
safeCallProgress(onProgress, 'Saving marketplace to cache')
// Ensure cache directory exists

View File

@@ -20,7 +20,6 @@ import {
} from '../settings/settings.js'
import { jsonParse, jsonStringify } from '../slowOperations.js'
import { getSystemDirectories } from '../systemDirectories.js'
import { classifyFetchError, logPluginFetch } from './fetchTelemetry.js'
/**
* User configuration values for MCPB
*/
@@ -490,7 +489,6 @@ async function downloadMcpb(
}
const started = performance.now()
let fetchTelemetryFired = false
try {
const response = await axios.get(url, {
timeout: 120000, // 2 minute timeout
@@ -507,11 +505,6 @@ async function downloadMcpb(
})
const data = new Uint8Array(response.data)
// Fire telemetry before writeFile — the event measures the network
// fetch, not disk I/O. A writeFile EACCES would otherwise match
// classifyFetchError's /permission denied/ → misreport as auth.
logPluginFetch('mcpb', url, 'success', performance.now() - started)
fetchTelemetryFired = true
// Save to disk (binary data)
await writeFile(destPath, Buffer.from(data))
@@ -523,15 +516,7 @@ async function downloadMcpb(
return data
} catch (error) {
if (!fetchTelemetryFired) {
logPluginFetch(
'mcpb',
url,
'failure',
performance.now() - started,
classifyFetchError(error),
)
}
void started
const errorMsg = errorMessage(error)
const fullError = new Error(
`Failed to download MCPB file from ${url}: ${errorMsg}`,

View File

@@ -85,7 +85,6 @@ import { SettingsSchema } from '../settings/types.js'
import { jsonParse, jsonStringify } from '../slowOperations.js'
import { getAddDirEnabledPlugins } from './addDirPluginSettings.js'
import { verifyAndDemote } from './dependencyResolver.js'
import { classifyFetchError, logPluginFetch } from './fetchTelemetry.js'
import { checkGitAvailable } from './gitAvailability.js'
import { getInMemoryInstalledPlugins } from './installedPluginsManager.js'
import { getManagedPluginNames } from './managedPlugins.js'
@@ -563,13 +562,6 @@ export async function gitClone(
const cloneResult = await execFileNoThrow(gitExe(), args)
if (cloneResult.code !== 0) {
logPluginFetch(
'plugin_clone',
gitUrl,
'failure',
performance.now() - cloneStarted,
classifyFetchError(cloneResult.stderr),
)
throw new Error(`Failed to clone repository: ${cloneResult.stderr}`)
}
@@ -595,13 +587,6 @@ export async function gitClone(
)
if (unshallowResult.code !== 0) {
logPluginFetch(
'plugin_clone',
gitUrl,
'failure',
performance.now() - cloneStarted,
classifyFetchError(unshallowResult.stderr),
)
throw new Error(
`Failed to fetch commit ${sha}: ${unshallowResult.stderr}`,
)
@@ -616,27 +601,12 @@ export async function gitClone(
)
if (checkoutResult.code !== 0) {
logPluginFetch(
'plugin_clone',
gitUrl,
'failure',
performance.now() - cloneStarted,
classifyFetchError(checkoutResult.stderr),
)
throw new Error(
`Failed to checkout commit ${sha}: ${checkoutResult.stderr}`,
)
}
}
// Fire success only after ALL network ops (clone + optional SHA fetch)
// complete — same telemetry-scope discipline as mcpb and marketplace_url.
logPluginFetch(
'plugin_clone',
gitUrl,
'success',
performance.now() - cloneStarted,
)
void cloneStarted
}
/**

File diff suppressed because one or more lines are too long

View File

@@ -9,8 +9,6 @@ import type {
import { logEvent } from '../../services/analytics/index.js'
import type { PermissionMode } from '../../types/permissions.js'
import { createUserMessage } from '../messages.js'
import { logOTelEvent, redactIfDisabled } from '../telemetry/events.js'
import { startInteractionSpan } from '../telemetry/sessionTracing.js'
import {
matchesKeepGoingKeyword,
matchesNegativeKeyword,
@@ -35,26 +33,6 @@ export function processTextPrompt(
typeof input === 'string'
? input
: input.find(block => block.type === 'text')?.text || ''
startInteractionSpan(userPromptText)
// Emit user_prompt OTEL event for both string (CLI) and array (SDK/VS Code)
// input shapes. Previously gated on `typeof input === 'string'`, so VS Code
// sessions never emitted user_prompt (anthropics/claude-code#33301).
// For array input, use the LAST text block: createUserContent pushes the
// user's message last (after any <ide_selection>/attachment context blocks),
// so .findLast gets the actual prompt. userPromptText (first block) is kept
// unchanged for startInteractionSpan to preserve existing span attributes.
const otelPromptText =
typeof input === 'string'
? input
: input.findLast(block => block.type === 'text')?.text || ''
if (otelPromptText) {
void logOTelEvent('user_prompt', {
prompt_length: String(otelPromptText.length),
prompt: redactIfDisabled(otelPromptText),
'prompt.id': promptId,
})
}
const isNegative = matchesNegativeKeyword(userPromptText)
const isKeepGoing = matchesKeepGoingKeyword(userPromptText)

View File

@@ -78,13 +78,13 @@ export async function getSessionEnvironmentScript(): Promise<string | null> {
if (envScript) {
scripts.push(envScript)
logForDebugging(
`Session environment loaded from CLAUDE_ENV_FILE: ${envFile} (${envScript.length} chars)`,
`Session environment loaded from CLAUDE_ENV_FILE (${envScript.length} chars)`,
)
}
} catch (e: unknown) {
const code = getErrnoCode(e)
if (code !== 'ENOENT') {
logForDebugging(`Failed to read CLAUDE_ENV_FILE: ${errorMessage(e)}`)
logForDebugging('Failed to read CLAUDE_ENV_FILE')
}
}
}
@@ -109,9 +109,7 @@ export async function getSessionEnvironmentScript(): Promise<string | null> {
} catch (e: unknown) {
const code = getErrnoCode(e)
if (code !== 'ENOENT') {
logForDebugging(
`Failed to read hook file ${filePath}: ${errorMessage(e)}`,
)
logForDebugging(`Failed to read hook env file ${file}`)
}
}
}

View File

@@ -37,7 +37,7 @@ function getTokenFromFileDescriptor(): string | null {
const fd = parseInt(fdEnv, 10)
if (Number.isNaN(fd)) {
logForDebugging(
`CLAUDE_CODE_WEBSOCKET_AUTH_FILE_DESCRIPTOR must be a valid file descriptor number, got: ${fdEnv}`,
'CLAUDE_CODE_WEBSOCKET_AUTH_FILE_DESCRIPTOR must be a valid file descriptor number',
{ level: 'error' },
)
setSessionIngressToken(null)
@@ -61,7 +61,7 @@ function getTokenFromFileDescriptor(): string | null {
setSessionIngressToken(null)
return null
}
logForDebugging(`Successfully read token from file descriptor ${fd}`)
logForDebugging('Successfully read token from file descriptor')
setSessionIngressToken(token)
maybePersistTokenForSubprocesses(
CCR_SESSION_INGRESS_TOKEN_PATH,
@@ -71,7 +71,7 @@ function getTokenFromFileDescriptor(): string | null {
return token
} catch (error) {
logForDebugging(
`Failed to read token from file descriptor ${fd}: ${errorMessage(error)}`,
`Failed to read token from file descriptor: ${errorMessage(error)}`,
{ level: 'error' },
)
// FD env var was set but read failed — typically a subprocess that

View File

@@ -1344,7 +1344,11 @@ class Project {
setRemoteIngressUrl(url: string): void {
this.remoteIngressUrl = url
logForDebugging(`Remote persistence enabled with URL: ${url}`)
logForDebugging(
url
? 'Remote persistence enabled (remote ingress configured)'
: 'Remote persistence disabled',
)
if (url) {
// If using CCR, don't delay messages by any more than 10ms.
this.FLUSH_INTERVAL_MS = REMOTE_FLUSH_INTERVAL_MS

View File

@@ -1,16 +1,12 @@
import { initializeAnalyticsSink } from '../services/analytics/sink.js'
import { initializeErrorLogSink } from './errorLogSink.js'
/**
* Attach error log and analytics sinks, draining any events queued before
* attachment. Both inits are idempotent. Called from setup() for the default
* command; other entrypoints (subcommands, daemon, bridge) call this directly
* since they bypass setup().
* Attach startup sinks used by all entrypoints. The error-log init is
* idempotent, so callers that bypass setup() can safely invoke this too.
*
* Leaf module — kept out of setup.ts to avoid the setup → commands → bridge
* → setup import cycle.
*/
export function initSinks(): void {
initializeErrorLogSink()
initializeAnalyticsSink()
}

View File

@@ -96,7 +96,6 @@ import {
readMailbox,
writeToMailbox,
} from '../teammateMailbox.js'
import { unregisterAgent as unregisterPerfettoAgent } from '../telemetry/perfettoTracing.js'
import { createContentReplacementState } from '../toolResultStorage.js'
import { TEAM_LEAD_NAME } from './constants.js'
import {
@@ -1460,7 +1459,6 @@ export async function runInProcessTeammate(
})
}
unregisterPerfettoAgent(identity.agentId)
return { success: true, messages: allMessages }
} catch (error) {
const errorMessage =
@@ -1524,7 +1522,6 @@ export async function runInProcessTeammate(
},
)
unregisterPerfettoAgent(identity.agentId)
return {
success: false,
error: errorMessage,

View File

@@ -18,16 +18,10 @@
* 6. Worker polls mailbox for responses and continues execution
*/
import { mkdir, readdir, readFile, unlink, writeFile } from 'fs/promises'
import { join } from 'path'
import { z } from 'zod/v4'
import { logForDebugging } from '../debug.js'
import { getErrnoCode } from '../errors.js'
import { lazySchema } from '../lazySchema.js'
import * as lockfile from '../lockfile.js'
import { logError } from '../log.js'
import type { PermissionUpdate } from '../permissions/PermissionUpdateSchema.js'
import { jsonParse, jsonStringify } from '../slowOperations.js'
import { jsonStringify } from '../slowOperations.js'
import {
getAgentId,
getAgentName,
@@ -41,53 +35,44 @@ import {
createSandboxPermissionResponseMessage,
writeToMailbox,
} from '../teammateMailbox.js'
import { getTeamDir, readTeamFileAsync } from './teamHelpers.js'
import { readTeamFileAsync } from './teamHelpers.js'
/**
* Full request schema for a permission request from a worker to the leader
*/
export const SwarmPermissionRequestSchema = lazySchema(() =>
z.object({
/** Unique identifier for this request */
id: z.string(),
/** Worker's CLAUDE_CODE_AGENT_ID */
workerId: z.string(),
/** Worker's CLAUDE_CODE_AGENT_NAME */
workerName: z.string(),
/** Worker's CLAUDE_CODE_AGENT_COLOR */
workerColor: z.string().optional(),
/** Team name for routing */
teamName: z.string(),
/** Tool name requiring permission (e.g., "Bash", "Edit") */
toolName: z.string(),
/** Original toolUseID from worker's context */
toolUseId: z.string(),
/** Human-readable description of the tool use */
description: z.string(),
/** Serialized tool input */
input: z.record(z.string(), z.unknown()),
/** Suggested permission rules from the permission result */
permissionSuggestions: z.array(z.unknown()),
/** Status of the request */
status: z.enum(['pending', 'approved', 'rejected']),
/** Who resolved the request */
resolvedBy: z.enum(['worker', 'leader']).optional(),
/** Timestamp when resolved */
resolvedAt: z.number().optional(),
/** Rejection feedback message */
feedback: z.string().optional(),
/** Modified input if changed by resolver */
updatedInput: z.record(z.string(), z.unknown()).optional(),
/** "Always allow" rules applied during resolution */
permissionUpdates: z.array(z.unknown()).optional(),
/** Timestamp when request was created */
createdAt: z.number(),
}),
)
export type SwarmPermissionRequest = z.infer<
ReturnType<typeof SwarmPermissionRequestSchema>
>
export type SwarmPermissionRequest = {
/** Unique identifier for this request */
id: string
/** Worker's CLAUDE_CODE_AGENT_ID */
workerId: string
/** Worker's CLAUDE_CODE_AGENT_NAME */
workerName: string
/** Worker's CLAUDE_CODE_AGENT_COLOR */
workerColor?: string
/** Team name for routing */
teamName: string
/** Tool name requiring permission (e.g., "Bash", "Edit") */
toolName: string
/** Original toolUseID from worker's context */
toolUseId: string
/** Human-readable description of the tool use */
description: string
/** Serialized tool input */
input: Record<string, unknown>
/** Suggested permission rules from the permission result */
permissionSuggestions: unknown[]
/** Status of the request */
status: 'pending' | 'approved' | 'rejected'
/** Who resolved the request */
resolvedBy?: 'worker' | 'leader'
/** Timestamp when resolved */
resolvedAt?: number
/** Rejection feedback message */
feedback?: string
/** Modified input if changed by resolver */
updatedInput?: Record<string, unknown>
/** "Always allow" rules applied during resolution */
permissionUpdates?: unknown[]
/** Timestamp when request was created */
createdAt: number
}
/**
* Resolution data returned when leader/worker resolves a request
@@ -105,55 +90,6 @@ export type PermissionResolution = {
permissionUpdates?: PermissionUpdate[]
}
/**
* Get the base directory for a team's permission requests
* Path: ~/.claude/teams/{teamName}/permissions/
*/
export function getPermissionDir(teamName: string): string {
return join(getTeamDir(teamName), 'permissions')
}
/**
* Get the pending directory for a team
*/
function getPendingDir(teamName: string): string {
return join(getPermissionDir(teamName), 'pending')
}
/**
* Get the resolved directory for a team
*/
function getResolvedDir(teamName: string): string {
return join(getPermissionDir(teamName), 'resolved')
}
/**
* Ensure the permissions directory structure exists (async)
*/
async function ensurePermissionDirsAsync(teamName: string): Promise<void> {
const permDir = getPermissionDir(teamName)
const pendingDir = getPendingDir(teamName)
const resolvedDir = getResolvedDir(teamName)
for (const dir of [permDir, pendingDir, resolvedDir]) {
await mkdir(dir, { recursive: true })
}
}
/**
* Get the path to a pending request file
*/
function getPendingRequestPath(teamName: string, requestId: string): string {
return join(getPendingDir(teamName), `${requestId}.json`)
}
/**
* Get the path to a resolved request file
*/
function getResolvedRequestPath(teamName: string, requestId: string): string {
return join(getResolvedDir(teamName), `${requestId}.json`)
}
/**
* Generate a unique request ID
*/
@@ -206,375 +142,6 @@ export function createPermissionRequest(params: {
}
}
/**
* Write a permission request to the pending directory with file locking
* Called by worker agents when they need permission approval from the leader
*
* @returns The written request
*/
export async function writePermissionRequest(
request: SwarmPermissionRequest,
): Promise<SwarmPermissionRequest> {
await ensurePermissionDirsAsync(request.teamName)
const pendingPath = getPendingRequestPath(request.teamName, request.id)
const lockDir = getPendingDir(request.teamName)
// Create a directory-level lock file for atomic writes
const lockFilePath = join(lockDir, '.lock')
await writeFile(lockFilePath, '', 'utf-8')
let release: (() => Promise<void>) | undefined
try {
release = await lockfile.lock(lockFilePath)
// Write the request file
await writeFile(pendingPath, jsonStringify(request, null, 2), 'utf-8')
logForDebugging(
`[PermissionSync] Wrote pending request ${request.id} from ${request.workerName} for ${request.toolName}`,
)
return request
} catch (error) {
logForDebugging(
`[PermissionSync] Failed to write permission request: ${error}`,
)
logError(error)
throw error
} finally {
if (release) {
await release()
}
}
}
/**
* Read all pending permission requests for a team
* Called by the team leader to see what requests need attention
*/
export async function readPendingPermissions(
teamName?: string,
): Promise<SwarmPermissionRequest[]> {
const team = teamName || getTeamName()
if (!team) {
logForDebugging('[PermissionSync] No team name available')
return []
}
const pendingDir = getPendingDir(team)
let files: string[]
try {
files = await readdir(pendingDir)
} catch (e: unknown) {
const code = getErrnoCode(e)
if (code === 'ENOENT') {
return []
}
logForDebugging(`[PermissionSync] Failed to read pending requests: ${e}`)
logError(e)
return []
}
const jsonFiles = files.filter(f => f.endsWith('.json') && f !== '.lock')
const results = await Promise.all(
jsonFiles.map(async file => {
const filePath = join(pendingDir, file)
try {
const content = await readFile(filePath, 'utf-8')
const parsed = SwarmPermissionRequestSchema().safeParse(
jsonParse(content),
)
if (parsed.success) {
return parsed.data
}
logForDebugging(
`[PermissionSync] Invalid request file ${file}: ${parsed.error.message}`,
)
return null
} catch (err) {
logForDebugging(
`[PermissionSync] Failed to read request file ${file}: ${err}`,
)
return null
}
}),
)
const requests = results.filter(r => r !== null)
// Sort by creation time (oldest first)
requests.sort((a, b) => a.createdAt - b.createdAt)
return requests
}
/**
* Read a resolved permission request by ID
* Called by workers to check if their request has been resolved
*
* @returns The resolved request, or null if not yet resolved
*/
export async function readResolvedPermission(
requestId: string,
teamName?: string,
): Promise<SwarmPermissionRequest | null> {
const team = teamName || getTeamName()
if (!team) {
return null
}
const resolvedPath = getResolvedRequestPath(team, requestId)
try {
const content = await readFile(resolvedPath, 'utf-8')
const parsed = SwarmPermissionRequestSchema().safeParse(jsonParse(content))
if (parsed.success) {
return parsed.data
}
logForDebugging(
`[PermissionSync] Invalid resolved request ${requestId}: ${parsed.error.message}`,
)
return null
} catch (e: unknown) {
const code = getErrnoCode(e)
if (code === 'ENOENT') {
return null
}
logForDebugging(
`[PermissionSync] Failed to read resolved request ${requestId}: ${e}`,
)
logError(e)
return null
}
}
/**
* Resolve a permission request
* Called by the team leader (or worker in self-resolution cases)
*
* Writes the resolution to resolved/, removes from pending/
*/
export async function resolvePermission(
requestId: string,
resolution: PermissionResolution,
teamName?: string,
): Promise<boolean> {
const team = teamName || getTeamName()
if (!team) {
logForDebugging('[PermissionSync] No team name available')
return false
}
await ensurePermissionDirsAsync(team)
const pendingPath = getPendingRequestPath(team, requestId)
const resolvedPath = getResolvedRequestPath(team, requestId)
const lockFilePath = join(getPendingDir(team), '.lock')
await writeFile(lockFilePath, '', 'utf-8')
let release: (() => Promise<void>) | undefined
try {
release = await lockfile.lock(lockFilePath)
// Read the pending request
let content: string
try {
content = await readFile(pendingPath, 'utf-8')
} catch (e: unknown) {
const code = getErrnoCode(e)
if (code === 'ENOENT') {
logForDebugging(
`[PermissionSync] Pending request not found: ${requestId}`,
)
return false
}
throw e
}
const parsed = SwarmPermissionRequestSchema().safeParse(jsonParse(content))
if (!parsed.success) {
logForDebugging(
`[PermissionSync] Invalid pending request ${requestId}: ${parsed.error.message}`,
)
return false
}
const request = parsed.data
// Update the request with resolution data
const resolvedRequest: SwarmPermissionRequest = {
...request,
status: resolution.decision === 'approved' ? 'approved' : 'rejected',
resolvedBy: resolution.resolvedBy,
resolvedAt: Date.now(),
feedback: resolution.feedback,
updatedInput: resolution.updatedInput,
permissionUpdates: resolution.permissionUpdates,
}
// Write to resolved directory
await writeFile(
resolvedPath,
jsonStringify(resolvedRequest, null, 2),
'utf-8',
)
// Remove from pending directory
await unlink(pendingPath)
logForDebugging(
`[PermissionSync] Resolved request ${requestId} with ${resolution.decision}`,
)
return true
} catch (error) {
logForDebugging(`[PermissionSync] Failed to resolve request: ${error}`)
logError(error)
return false
} finally {
if (release) {
await release()
}
}
}
/**
* Clean up old resolved permission files
* Called periodically to prevent file accumulation
*
* @param teamName - Team name
* @param maxAgeMs - Maximum age in milliseconds (default: 1 hour)
*/
export async function cleanupOldResolutions(
teamName?: string,
maxAgeMs = 3600000,
): Promise<number> {
const team = teamName || getTeamName()
if (!team) {
return 0
}
const resolvedDir = getResolvedDir(team)
let files: string[]
try {
files = await readdir(resolvedDir)
} catch (e: unknown) {
const code = getErrnoCode(e)
if (code === 'ENOENT') {
return 0
}
logForDebugging(`[PermissionSync] Failed to cleanup resolutions: ${e}`)
logError(e)
return 0
}
const now = Date.now()
const jsonFiles = files.filter(f => f.endsWith('.json'))
const cleanupResults = await Promise.all(
jsonFiles.map(async file => {
const filePath = join(resolvedDir, file)
try {
const content = await readFile(filePath, 'utf-8')
const request = jsonParse(content) as SwarmPermissionRequest
// Check if the resolution is old enough to clean up
// Use >= to handle edge case where maxAgeMs is 0 (clean up everything)
const resolvedAt = request.resolvedAt || request.createdAt
if (now - resolvedAt >= maxAgeMs) {
await unlink(filePath)
logForDebugging(`[PermissionSync] Cleaned up old resolution: ${file}`)
return 1
}
return 0
} catch {
// If we can't parse it, clean it up anyway
try {
await unlink(filePath)
return 1
} catch {
// Ignore deletion errors
return 0
}
}
}),
)
const cleanedCount = cleanupResults.reduce<number>((sum, n) => sum + n, 0)
if (cleanedCount > 0) {
logForDebugging(
`[PermissionSync] Cleaned up ${cleanedCount} old resolutions`,
)
}
return cleanedCount
}
/**
* Legacy response type for worker polling
* Used for backward compatibility with worker integration code
*/
export type PermissionResponse = {
/** ID of the request this responds to */
requestId: string
/** Decision: approved or denied */
decision: 'approved' | 'denied'
/** Timestamp when response was created */
timestamp: string
/** Optional feedback message if denied */
feedback?: string
/** Optional updated input if the resolver modified it */
updatedInput?: Record<string, unknown>
/** Permission updates to apply (e.g., "always allow" rules) */
permissionUpdates?: unknown[]
}
/**
* Poll for a permission response (worker-side convenience function)
* Converts the resolved request into a simpler response format
*
* @returns The permission response, or null if not yet resolved
*/
export async function pollForResponse(
requestId: string,
_agentName?: string,
teamName?: string,
): Promise<PermissionResponse | null> {
const resolved = await readResolvedPermission(requestId, teamName)
if (!resolved) {
return null
}
return {
requestId: resolved.id,
decision: resolved.status === 'approved' ? 'approved' : 'denied',
timestamp: resolved.resolvedAt
? new Date(resolved.resolvedAt).toISOString()
: new Date(resolved.createdAt).toISOString(),
feedback: resolved.feedback,
updatedInput: resolved.updatedInput,
permissionUpdates: resolved.permissionUpdates,
}
}
/**
* Remove a worker's response after processing
* This is an alias for deleteResolvedPermission for backward compatibility
*/
export async function removeWorkerResponse(
requestId: string,
_agentName?: string,
teamName?: string,
): Promise<void> {
await deleteResolvedPermission(requestId, teamName)
}
/**
* Check if the current agent is a team leader
*/
@@ -600,46 +167,6 @@ export function isSwarmWorker(): boolean {
return !!teamName && !!agentId && !isTeamLeader()
}
/**
* Delete a resolved permission file
* Called after a worker has processed the resolution
*/
export async function deleteResolvedPermission(
requestId: string,
teamName?: string,
): Promise<boolean> {
const team = teamName || getTeamName()
if (!team) {
return false
}
const resolvedPath = getResolvedRequestPath(team, requestId)
try {
await unlink(resolvedPath)
logForDebugging(
`[PermissionSync] Deleted resolved permission: ${requestId}`,
)
return true
} catch (e: unknown) {
const code = getErrnoCode(e)
if (code === 'ENOENT') {
return false
}
logForDebugging(
`[PermissionSync] Failed to delete resolved permission: ${e}`,
)
logError(e)
return false
}
}
/**
* Submit a permission request (alias for writePermissionRequest)
* Provided for backward compatibility with worker integration code
*/
export const submitPermissionRequest = writePermissionRequest
// ============================================================================
// Mailbox-Based Permission System
// ============================================================================

View File

@@ -35,11 +35,6 @@ import {
STOPPED_DISPLAY_MS,
} from '../task/framework.js'
import { createTeammateContext } from '../teammateContext.js'
import {
isPerfettoTracingEnabled,
registerAgent as registerPerfettoAgent,
unregisterAgent as unregisterPerfettoAgent,
} from '../telemetry/perfettoTracing.js'
import { removeMemberByAgentId } from './teamHelpers.js'
type SetAppStateFn = (updater: (prev: AppState) => AppState) => void
@@ -146,11 +141,6 @@ export async function spawnInProcessTeammate(
abortController,
})
// Register agent in Perfetto trace for hierarchy visualization
if (isPerfettoTracingEnabled()) {
registerPerfettoAgent(agentId, name, parentSessionId)
}
// Create task state
const description = `${name}: ${prompt.substring(0, 50)}${prompt.length > 50 ? '...' : ''}`
@@ -319,10 +309,5 @@ export function killInProcessTeammate(
)
}
// Release perfetto agent registry entry
if (agentId) {
unregisterPerfettoAgent(agentId)
}
return killed
}

View File

@@ -66,7 +66,6 @@ export type TeamFile = {
description?: string
createdAt: number
leadAgentId: string
leadSessionId?: string // Actual session UUID of the leader (for discovery)
hiddenPaneIds?: string[] // Pane IDs that are currently hidden from the UI
teamAllowedPaths?: TeamAllowedPath[] // Paths all teammates can edit without asking
members: Array<{
@@ -74,15 +73,13 @@ export type TeamFile = {
name: string
agentType?: string
model?: string
prompt?: string
prompt?: string // Legacy field; stripped from persisted configs
color?: string
planModeRequired?: boolean
joinedAt: number
tmuxPaneId: string
cwd: string
worktreePath?: string
sessionId?: string
subscriptions: string[]
backendType?: BackendType
isActive?: boolean // false when idle, undefined/true when active
mode?: PermissionMode // Current permission mode for this teammate
@@ -123,6 +120,42 @@ export function getTeamFilePath(teamName: string): string {
return join(getTeamDir(teamName), 'config.json')
}
function sanitizeTeamFileForPersistence(teamFile: TeamFile): TeamFile {
return {
name: teamFile.name,
...(teamFile.description ? { description: teamFile.description } : {}),
createdAt: teamFile.createdAt,
leadAgentId: teamFile.leadAgentId,
...(teamFile.hiddenPaneIds && teamFile.hiddenPaneIds.length > 0
? { hiddenPaneIds: [...teamFile.hiddenPaneIds] }
: {}),
...(teamFile.teamAllowedPaths && teamFile.teamAllowedPaths.length > 0
? {
teamAllowedPaths: teamFile.teamAllowedPaths.map(path => ({
...path,
})),
}
: {}),
members: teamFile.members.map(member => ({
agentId: member.agentId,
name: member.name,
...(member.agentType ? { agentType: member.agentType } : {}),
...(member.model ? { model: member.model } : {}),
...(member.color ? { color: member.color } : {}),
...(member.planModeRequired !== undefined
? { planModeRequired: member.planModeRequired }
: {}),
joinedAt: member.joinedAt,
tmuxPaneId: member.tmuxPaneId,
cwd: member.cwd,
...(member.worktreePath ? { worktreePath: member.worktreePath } : {}),
...(member.backendType ? { backendType: member.backendType } : {}),
...(member.isActive !== undefined ? { isActive: member.isActive } : {}),
...(member.mode ? { mode: member.mode } : {}),
})),
}
}
/**
* Reads a team file by name (sync — for sync contexts like React render paths)
* @internal Exported for team discovery UI
@@ -131,7 +164,7 @@ export function getTeamFilePath(teamName: string): string {
export function readTeamFile(teamName: string): TeamFile | null {
try {
const content = readFileSync(getTeamFilePath(teamName), 'utf-8')
return jsonParse(content) as TeamFile
return sanitizeTeamFileForPersistence(jsonParse(content) as TeamFile)
} catch (e) {
if (getErrnoCode(e) === 'ENOENT') return null
logForDebugging(
@@ -149,7 +182,7 @@ export async function readTeamFileAsync(
): Promise<TeamFile | null> {
try {
const content = await readFile(getTeamFilePath(teamName), 'utf-8')
return jsonParse(content) as TeamFile
return sanitizeTeamFileForPersistence(jsonParse(content) as TeamFile)
} catch (e) {
if (getErrnoCode(e) === 'ENOENT') return null
logForDebugging(
@@ -166,7 +199,10 @@ export async function readTeamFileAsync(
function writeTeamFile(teamName: string, teamFile: TeamFile): void {
const teamDir = getTeamDir(teamName)
mkdirSync(teamDir, { recursive: true })
writeFileSync(getTeamFilePath(teamName), jsonStringify(teamFile, null, 2))
writeFileSync(
getTeamFilePath(teamName),
jsonStringify(sanitizeTeamFileForPersistence(teamFile), null, 2),
)
}
/**
@@ -178,7 +214,10 @@ export async function writeTeamFileAsync(
): Promise<void> {
const teamDir = getTeamDir(teamName)
await mkdir(teamDir, { recursive: true })
await writeFile(getTeamFilePath(teamName), jsonStringify(teamFile, null, 2))
await writeFile(
getTeamFilePath(teamName),
jsonStringify(sanitizeTeamFileForPersistence(teamFile), null, 2),
)
}
/**

View File

@@ -20,7 +20,6 @@ export type TeammateStatus = {
agentId: string
agentType?: string
model?: string
prompt?: string
status: 'running' | 'idle' | 'unknown'
color?: string
idleSince?: string // ISO timestamp from idle notification
@@ -60,7 +59,6 @@ export function getTeammateStatuses(teamName: string): TeammateStatus[] {
agentId: member.agentId,
agentType: member.agentType,
model: member.model,
prompt: member.prompt,
status,
color: member.color,
tmuxPaneId: member.tmuxPaneId,

View File

@@ -15,7 +15,6 @@ import { PermissionModeSchema } from '../entrypoints/sdk/coreSchemas.js'
import { SEND_MESSAGE_TOOL_NAME } from '../tools/SendMessageTool/constants.js'
import type { Message } from '../types/message.js'
import { generateRequestId } from './agentId.js'
import { count } from './array.js'
import { logForDebugging } from './debug.js'
import { getTeamsDir } from './envUtils.js'
import { getErrnoCode } from './errors.js'
@@ -58,11 +57,7 @@ export function getInboxPath(agentName: string, teamName?: string): string {
const safeTeam = sanitizePathComponent(team)
const safeAgentName = sanitizePathComponent(agentName)
const inboxDir = join(getTeamsDir(), safeTeam, 'inboxes')
const fullPath = join(inboxDir, `${safeAgentName}.json`)
logForDebugging(
`[TeammateMailbox] getInboxPath: agent=${agentName}, team=${team}, fullPath=${fullPath}`,
)
return fullPath
return join(inboxDir, `${safeAgentName}.json`)
}
/**
@@ -73,7 +68,7 @@ async function ensureInboxDir(teamName?: string): Promise<void> {
const safeTeam = sanitizePathComponent(team)
const inboxDir = join(getTeamsDir(), safeTeam, 'inboxes')
await mkdir(inboxDir, { recursive: true })
logForDebugging(`[TeammateMailbox] Ensured inbox directory: ${inboxDir}`)
logForDebugging('[TeammateMailbox] Ensured inbox directory')
}
/**
@@ -86,7 +81,6 @@ export async function readMailbox(
teamName?: string,
): Promise<TeammateMessage[]> {
const inboxPath = getInboxPath(agentName, teamName)
logForDebugging(`[TeammateMailbox] readMailbox: path=${inboxPath}`)
try {
const content = await readFile(inboxPath, 'utf-8')
@@ -101,7 +95,7 @@ export async function readMailbox(
logForDebugging(`[TeammateMailbox] readMailbox: file does not exist`)
return []
}
logForDebugging(`Failed to read inbox for ${agentName}: ${error}`)
logForDebugging(`[TeammateMailbox] Failed to read inbox for ${agentName}`)
logError(error)
return []
}
@@ -142,7 +136,7 @@ export async function writeToMailbox(
const lockFilePath = `${inboxPath}.lock`
logForDebugging(
`[TeammateMailbox] writeToMailbox: recipient=${recipientName}, from=${message.from}, path=${inboxPath}`,
`[TeammateMailbox] writeToMailbox: recipient=${recipientName}, from=${message.from}`,
)
// Ensure the inbox file exists before locking (proper-lockfile requires the file to exist)
@@ -153,7 +147,7 @@ export async function writeToMailbox(
const code = getErrnoCode(error)
if (code !== 'EEXIST') {
logForDebugging(
`[TeammateMailbox] writeToMailbox: failed to create inbox file: ${error}`,
`[TeammateMailbox] writeToMailbox: failed to create inbox file`,
)
logError(error)
return
@@ -182,7 +176,9 @@ export async function writeToMailbox(
`[TeammateMailbox] Wrote message to ${recipientName}'s inbox from ${message.from}`,
)
} catch (error) {
logForDebugging(`Failed to write to inbox for ${recipientName}: ${error}`)
logForDebugging(
`[TeammateMailbox] Failed to write to inbox for ${recipientName}`,
)
logError(error)
} finally {
if (release) {
@@ -192,8 +188,8 @@ export async function writeToMailbox(
}
/**
* Mark a specific message in a teammate's inbox as read by index
* Uses file locking to prevent race conditions
* Remove a specific processed message from a teammate's inbox by index.
* Uses file locking to prevent race conditions.
* @param agentName - The agent name to mark message as read for
* @param teamName - Optional team name
* @param messageIndex - Index of the message to mark as read
@@ -205,7 +201,7 @@ export async function markMessageAsReadByIndex(
): Promise<void> {
const inboxPath = getInboxPath(agentName, teamName)
logForDebugging(
`[TeammateMailbox] markMessageAsReadByIndex called: agentName=${agentName}, teamName=${teamName}, index=${messageIndex}, path=${inboxPath}`,
`[TeammateMailbox] markMessageAsReadByIndex called: agentName=${agentName}, index=${messageIndex}`,
)
const lockFilePath = `${inboxPath}.lock`
@@ -242,22 +238,26 @@ export async function markMessageAsReadByIndex(
return
}
messages[messageIndex] = { ...message, read: true }
const updatedMessages = messages.filter(
(currentMessage, index) => index !== messageIndex && !currentMessage.read,
)
await writeFile(inboxPath, jsonStringify(messages, null, 2), 'utf-8')
await writeFile(
inboxPath,
jsonStringify(updatedMessages, null, 2),
'utf-8',
)
logForDebugging(
`[TeammateMailbox] markMessageAsReadByIndex: marked message at index ${messageIndex} as read`,
`[TeammateMailbox] markMessageAsReadByIndex: removed message at index ${messageIndex} from inbox`,
)
} catch (error) {
const code = getErrnoCode(error)
if (code === 'ENOENT') {
logForDebugging(
`[TeammateMailbox] markMessageAsReadByIndex: file does not exist at ${inboxPath}`,
)
logForDebugging(`[TeammateMailbox] markMessageAsReadByIndex: file missing`)
return
}
logForDebugging(
`[TeammateMailbox] markMessageAsReadByIndex FAILED for ${agentName}: ${error}`,
`[TeammateMailbox] markMessageAsReadByIndex failed for ${agentName}`,
)
logError(error)
} finally {
@@ -270,77 +270,6 @@ export async function markMessageAsReadByIndex(
}
}
/**
* Mark all messages in a teammate's inbox as read
* Uses file locking to prevent race conditions
* @param agentName - The agent name to mark messages as read for
* @param teamName - Optional team name
*/
export async function markMessagesAsRead(
agentName: string,
teamName?: string,
): Promise<void> {
const inboxPath = getInboxPath(agentName, teamName)
logForDebugging(
`[TeammateMailbox] markMessagesAsRead called: agentName=${agentName}, teamName=${teamName}, path=${inboxPath}`,
)
const lockFilePath = `${inboxPath}.lock`
let release: (() => Promise<void>) | undefined
try {
logForDebugging(`[TeammateMailbox] markMessagesAsRead: acquiring lock...`)
release = await lockfile.lock(inboxPath, {
lockfilePath: lockFilePath,
...LOCK_OPTIONS,
})
logForDebugging(`[TeammateMailbox] markMessagesAsRead: lock acquired`)
// Re-read messages after acquiring lock to get the latest state
const messages = await readMailbox(agentName, teamName)
logForDebugging(
`[TeammateMailbox] markMessagesAsRead: read ${messages.length} messages after lock`,
)
if (messages.length === 0) {
logForDebugging(
`[TeammateMailbox] markMessagesAsRead: no messages to mark`,
)
return
}
const unreadCount = count(messages, m => !m.read)
logForDebugging(
`[TeammateMailbox] markMessagesAsRead: ${unreadCount} unread of ${messages.length} total`,
)
// messages comes from jsonParse — fresh, unshared objects safe to mutate
for (const m of messages) m.read = true
await writeFile(inboxPath, jsonStringify(messages, null, 2), 'utf-8')
logForDebugging(
`[TeammateMailbox] markMessagesAsRead: WROTE ${unreadCount} message(s) as read to ${inboxPath}`,
)
} catch (error) {
const code = getErrnoCode(error)
if (code === 'ENOENT') {
logForDebugging(
`[TeammateMailbox] markMessagesAsRead: file does not exist at ${inboxPath}`,
)
return
}
logForDebugging(
`[TeammateMailbox] markMessagesAsRead FAILED for ${agentName}: ${error}`,
)
logError(error)
} finally {
if (release) {
await release()
logForDebugging(`[TeammateMailbox] markMessagesAsRead: lock released`)
}
}
}
/**
* Clear a teammate's inbox (delete all messages)
* @param agentName - The agent name to clear inbox for
@@ -362,7 +291,7 @@ export async function clearMailbox(
if (code === 'ENOENT') {
return
}
logForDebugging(`Failed to clear inbox for ${agentName}: ${error}`)
logForDebugging(`[TeammateMailbox] Failed to clear inbox for ${agentName}`)
logError(error)
}
}
@@ -1095,8 +1024,8 @@ export function isStructuredProtocolMessage(messageText: string): boolean {
}
/**
* Marks only messages matching a predicate as read, leaving others unread.
* Uses the same file-locking mechanism as markMessagesAsRead.
* Removes only messages matching a predicate, leaving the rest unread.
* Uses the same file-locking mechanism as the other mailbox update helpers.
*/
export async function markMessagesAsReadByPredicate(
agentName: string,
@@ -1119,8 +1048,8 @@ export async function markMessagesAsReadByPredicate(
return
}
const updatedMessages = messages.map(m =>
!m.read && predicate(m) ? { ...m, read: true } : m,
const updatedMessages = messages.filter(
m => !m.read && !predicate(m),
)
await writeFile(inboxPath, jsonStringify(updatedMessages, null, 2), 'utf-8')
@@ -1174,7 +1103,7 @@ export function getLastPeerDmSummary(messages: Message[]): string | undefined {
const summary =
'summary' in block.input && typeof block.input.summary === 'string'
? block.input.summary
: block.input.message.slice(0, 80)
: 'sent update'
return `[to ${to}] ${summary}`
}
}

View File

@@ -1,491 +0,0 @@
/**
* Beta Session Tracing for Claude Code
*
* This module contains beta tracing features enabled when
* ENABLE_BETA_TRACING_DETAILED=1 and BETA_TRACING_ENDPOINT are set.
*
* For external users, tracing is enabled in SDK/headless mode, or in
* interactive mode when the org is allowlisted via the
* tengu_trace_lantern GrowthBook gate.
* For ant users, tracing is enabled in all modes.
*
* Visibility Rules:
* | Content | External | Ant |
* |------------------|----------|------|
* | System prompts | ✅ | ✅ |
* | Model output | ✅ | ✅ |
* | Thinking output | ❌ | ✅ |
* | Tools | ✅ | ✅ |
* | new_context | ✅ | ✅ |
*
* Features:
* - Per-agent message tracking with hash-based deduplication
* - System prompt logging (once per unique hash)
* - Hook execution spans
* - Detailed new_context attributes for LLM requests
*/
import type { Span } from '@opentelemetry/api'
import { createHash } from 'crypto'
import { getIsNonInteractiveSession } from '../../bootstrap/state.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
import { sanitizeToolNameForAnalytics } from '../../services/analytics/metadata.js'
import type { AssistantMessage, UserMessage } from '../../types/message.js'
import { isEnvTruthy } from '../envUtils.js'
import { jsonParse, jsonStringify } from '../slowOperations.js'
import { logOTelEvent } from './events.js'
// Message type for API calls (UserMessage or AssistantMessage)
type APIMessage = UserMessage | AssistantMessage
/**
* Track hashes we've already logged this session (system prompts, tools, etc).
*
* WHY: System prompts and tool schemas are large and rarely change within a session.
* Sending full content on every request would be wasteful. Instead, we hash and
* only log the full content once per unique hash.
*/
const seenHashes = new Set<string>()
/**
* Track the last reported message hash per querySource (agent) for incremental context.
*
* WHY: When debugging traces, we want to see what NEW information was added each turn,
* not the entire conversation history (which can be huge). By tracking the last message
* we reported per agent, we can compute and send only the delta (new messages since
* the last request). This is tracked per-agent (querySource) because different agents
* (main thread, subagents, warmup requests) have independent conversation contexts.
*/
const lastReportedMessageHash = new Map<string, string>()
/**
* Clear tracking state after compaction.
* Old hashes are irrelevant once messages have been replaced.
*/
export function clearBetaTracingState(): void {
seenHashes.clear()
lastReportedMessageHash.clear()
}
const MAX_CONTENT_SIZE = 60 * 1024 // 60KB (Honeycomb limit is 64KB, staying safe)
/**
* Check if beta detailed tracing is enabled.
* - Requires ENABLE_BETA_TRACING_DETAILED=1 and BETA_TRACING_ENDPOINT
* - For external users, enabled in SDK/headless mode OR when org is
* allowlisted via the tengu_trace_lantern GrowthBook gate
*/
export function isBetaTracingEnabled(): boolean {
const baseEnabled =
isEnvTruthy(process.env.ENABLE_BETA_TRACING_DETAILED) &&
Boolean(process.env.BETA_TRACING_ENDPOINT)
if (!baseEnabled) {
return false
}
// For external users, enable in SDK/headless mode OR when org is allowlisted.
// Gate reads from disk cache, so first run after allowlisting returns false;
// works from second run onward (same behavior as enhanced_telemetry_beta).
if (process.env.USER_TYPE !== 'ant') {
return (
getIsNonInteractiveSession() ||
getFeatureValue_CACHED_MAY_BE_STALE('tengu_trace_lantern', false)
)
}
return true
}
/**
* Truncate content to fit within Honeycomb limits.
*/
export function truncateContent(
content: string,
maxSize: number = MAX_CONTENT_SIZE,
): { content: string; truncated: boolean } {
if (content.length <= maxSize) {
return { content, truncated: false }
}
return {
content:
content.slice(0, maxSize) +
'\n\n[TRUNCATED - Content exceeds 60KB limit]',
truncated: true,
}
}
/**
* Generate a short hash (first 12 hex chars of SHA-256).
*/
function shortHash(content: string): string {
return createHash('sha256').update(content).digest('hex').slice(0, 12)
}
/**
* Generate a hash for a system prompt.
*/
function hashSystemPrompt(systemPrompt: string): string {
return `sp_${shortHash(systemPrompt)}`
}
/**
* Generate a hash for a message based on its content.
*/
function hashMessage(message: APIMessage): string {
const content = jsonStringify(message.message.content)
return `msg_${shortHash(content)}`
}
// Regex to detect content wrapped in <system-reminder> tags
const SYSTEM_REMINDER_REGEX =
/^<system-reminder>\n?([\s\S]*?)\n?<\/system-reminder>$/
/**
* Check if text is entirely a system reminder (wrapped in <system-reminder> tags).
* Returns the inner content if it is, null otherwise.
*/
function extractSystemReminderContent(text: string): string | null {
const match = text.trim().match(SYSTEM_REMINDER_REGEX)
return match && match[1] ? match[1].trim() : null
}
/**
* Result of formatting messages - separates regular content from system reminders.
*/
interface FormattedMessages {
contextParts: string[]
systemReminders: string[]
}
/**
* Format user messages for new_context display, separating system reminders.
* Only handles user messages (assistant messages are filtered out before this is called).
*/
function formatMessagesForContext(messages: UserMessage[]): FormattedMessages {
const contextParts: string[] = []
const systemReminders: string[] = []
for (const message of messages) {
const content = message.message.content
if (typeof content === 'string') {
const reminderContent = extractSystemReminderContent(content)
if (reminderContent) {
systemReminders.push(reminderContent)
} else {
contextParts.push(`[USER]\n${content}`)
}
} else if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'text') {
const reminderContent = extractSystemReminderContent(block.text)
if (reminderContent) {
systemReminders.push(reminderContent)
} else {
contextParts.push(`[USER]\n${block.text}`)
}
} else if (block.type === 'tool_result') {
const resultContent =
typeof block.content === 'string'
? block.content
: jsonStringify(block.content)
// Tool results can also contain system reminders (e.g., malware warning)
const reminderContent = extractSystemReminderContent(resultContent)
if (reminderContent) {
systemReminders.push(reminderContent)
} else {
contextParts.push(
`[TOOL RESULT: ${block.tool_use_id}]\n${resultContent}`,
)
}
}
}
}
}
return { contextParts, systemReminders }
}
export interface LLMRequestNewContext {
/** System prompt (typically only on first request or if changed) */
systemPrompt?: string
/** Query source identifying the agent/purpose (e.g., 'repl_main_thread', 'agent:builtin') */
querySource?: string
/** Tool schemas sent with the request */
tools?: string
}
/**
* Add beta attributes to an interaction span.
* Adds new_context with the user prompt.
*/
export function addBetaInteractionAttributes(
span: Span,
userPrompt: string,
): void {
if (!isBetaTracingEnabled()) {
return
}
const { content: truncatedPrompt, truncated } = truncateContent(
`[USER PROMPT]\n${userPrompt}`,
)
span.setAttributes({
new_context: truncatedPrompt,
...(truncated && {
new_context_truncated: true,
new_context_original_length: userPrompt.length,
}),
})
}
/**
* Add beta attributes to an LLM request span.
* Handles system prompt logging and new_context computation.
*/
export function addBetaLLMRequestAttributes(
span: Span,
newContext?: LLMRequestNewContext,
messagesForAPI?: APIMessage[],
): void {
if (!isBetaTracingEnabled()) {
return
}
// Add system prompt info to the span
if (newContext?.systemPrompt) {
const promptHash = hashSystemPrompt(newContext.systemPrompt)
const preview = newContext.systemPrompt.slice(0, 500)
// Always add hash, preview, and length to the span
span.setAttribute('system_prompt_hash', promptHash)
span.setAttribute('system_prompt_preview', preview)
span.setAttribute('system_prompt_length', newContext.systemPrompt.length)
// Log the full system prompt only once per unique hash this session
if (!seenHashes.has(promptHash)) {
seenHashes.add(promptHash)
// Truncate for the log if needed
const { content: truncatedPrompt, truncated } = truncateContent(
newContext.systemPrompt,
)
void logOTelEvent('system_prompt', {
system_prompt_hash: promptHash,
system_prompt: truncatedPrompt,
system_prompt_length: String(newContext.systemPrompt.length),
...(truncated && { system_prompt_truncated: 'true' }),
})
}
}
// Add tools info to the span
if (newContext?.tools) {
try {
const toolsArray = jsonParse(newContext.tools) as Record<
string,
unknown
>[]
// Build array of {name, hash} for each tool
const toolsWithHashes = toolsArray.map(tool => {
const toolJson = jsonStringify(tool)
const toolHash = shortHash(toolJson)
return {
name: typeof tool.name === 'string' ? tool.name : 'unknown',
hash: toolHash,
json: toolJson,
}
})
// Set span attribute with array of name/hash pairs
span.setAttribute(
'tools',
jsonStringify(
toolsWithHashes.map(({ name, hash }) => ({ name, hash })),
),
)
span.setAttribute('tools_count', toolsWithHashes.length)
// Log each tool's full description once per unique hash
for (const { name, hash, json } of toolsWithHashes) {
if (!seenHashes.has(`tool_${hash}`)) {
seenHashes.add(`tool_${hash}`)
const { content: truncatedTool, truncated } = truncateContent(json)
void logOTelEvent('tool', {
tool_name: sanitizeToolNameForAnalytics(name),
tool_hash: hash,
tool: truncatedTool,
...(truncated && { tool_truncated: 'true' }),
})
}
}
} catch {
// If parsing fails, log the raw tools string
span.setAttribute('tools_parse_error', true)
}
}
// Add new_context using hash-based tracking (visible to all users)
if (messagesForAPI && messagesForAPI.length > 0 && newContext?.querySource) {
const querySource = newContext.querySource
const lastHash = lastReportedMessageHash.get(querySource)
// Find where the last reported message is in the array
let startIndex = 0
if (lastHash) {
for (let i = 0; i < messagesForAPI.length; i++) {
const msg = messagesForAPI[i]
if (msg && hashMessage(msg) === lastHash) {
startIndex = i + 1 // Start after the last reported message
break
}
}
// If lastHash not found, startIndex stays 0 (send everything)
}
// Get new messages (filter out assistant messages - we only want user input/tool results)
const newMessages = messagesForAPI
.slice(startIndex)
.filter((m): m is UserMessage => m.type === 'user')
if (newMessages.length > 0) {
// Format new messages, separating system reminders from regular content
const { contextParts, systemReminders } =
formatMessagesForContext(newMessages)
// Set new_context (regular user content and tool results)
if (contextParts.length > 0) {
const fullContext = contextParts.join('\n\n---\n\n')
const { content: truncatedContext, truncated } =
truncateContent(fullContext)
span.setAttributes({
new_context: truncatedContext,
new_context_message_count: newMessages.length,
...(truncated && {
new_context_truncated: true,
new_context_original_length: fullContext.length,
}),
})
}
// Set system_reminders as a separate attribute
if (systemReminders.length > 0) {
const fullReminders = systemReminders.join('\n\n---\n\n')
const { content: truncatedReminders, truncated: remindersTruncated } =
truncateContent(fullReminders)
span.setAttributes({
system_reminders: truncatedReminders,
system_reminders_count: systemReminders.length,
...(remindersTruncated && {
system_reminders_truncated: true,
system_reminders_original_length: fullReminders.length,
}),
})
}
// Update last reported hash to the last message in the array
const lastMessage = messagesForAPI[messagesForAPI.length - 1]
if (lastMessage) {
lastReportedMessageHash.set(querySource, hashMessage(lastMessage))
}
}
}
}
/**
* Add beta attributes to endLLMRequestSpan.
* Handles model_output and thinking_output truncation.
*/
export function addBetaLLMResponseAttributes(
endAttributes: Record<string, string | number | boolean>,
metadata?: {
modelOutput?: string
thinkingOutput?: string
},
): void {
if (!isBetaTracingEnabled() || !metadata) {
return
}
// Add model_output (text content) - visible to all users
if (metadata.modelOutput !== undefined) {
const { content: modelOutput, truncated: outputTruncated } =
truncateContent(metadata.modelOutput)
endAttributes['response.model_output'] = modelOutput
if (outputTruncated) {
endAttributes['response.model_output_truncated'] = true
endAttributes['response.model_output_original_length'] =
metadata.modelOutput.length
}
}
// Add thinking_output - ant-only
if (
process.env.USER_TYPE === 'ant' &&
metadata.thinkingOutput !== undefined
) {
const { content: thinkingOutput, truncated: thinkingTruncated } =
truncateContent(metadata.thinkingOutput)
endAttributes['response.thinking_output'] = thinkingOutput
if (thinkingTruncated) {
endAttributes['response.thinking_output_truncated'] = true
endAttributes['response.thinking_output_original_length'] =
metadata.thinkingOutput.length
}
}
}
/**
* Add beta attributes to startToolSpan.
* Adds tool_input with the serialized tool input.
*/
export function addBetaToolInputAttributes(
span: Span,
toolName: string,
toolInput: string,
): void {
if (!isBetaTracingEnabled()) {
return
}
const { content: truncatedInput, truncated } = truncateContent(
`[TOOL INPUT: ${toolName}]\n${toolInput}`,
)
span.setAttributes({
tool_input: truncatedInput,
...(truncated && {
tool_input_truncated: true,
tool_input_original_length: toolInput.length,
}),
})
}
/**
* Add beta attributes to endToolSpan.
* Adds new_context with the tool result.
*/
export function addBetaToolResultAttributes(
endAttributes: Record<string, string | number | boolean>,
toolName: string | number | boolean,
toolResult: string,
): void {
if (!isBetaTracingEnabled()) {
return
}
const { content: truncatedResult, truncated } = truncateContent(
`[TOOL RESULT: ${toolName}]\n${toolResult}`,
)
endAttributes['new_context'] = truncatedResult
if (truncated) {
endAttributes['new_context_truncated'] = true
endAttributes['new_context_original_length'] = toolResult.length
}
}

View File

@@ -1,252 +0,0 @@
import type { Attributes, HrTime } from '@opentelemetry/api'
import { type ExportResult, ExportResultCode } from '@opentelemetry/core'
import {
AggregationTemporality,
type MetricData,
type DataPoint as OTelDataPoint,
type PushMetricExporter,
type ResourceMetrics,
} from '@opentelemetry/sdk-metrics'
import axios from 'axios'
import { checkMetricsEnabled } from 'src/services/api/metricsOptOut.js'
import { getIsNonInteractiveSession } from '../../bootstrap/state.js'
import { getSubscriptionType, isClaudeAISubscriber } from '../auth.js'
import { checkHasTrustDialogAccepted } from '../config.js'
import { logForDebugging } from '../debug.js'
import { errorMessage, toError } from '../errors.js'
import { getAuthHeaders } from '../http.js'
import { logError } from '../log.js'
import { jsonStringify } from '../slowOperations.js'
import { getClaudeCodeUserAgent } from '../userAgent.js'
type DataPoint = {
attributes: Record<string, string>
value: number
timestamp: string
}
type Metric = {
name: string
description?: string
unit?: string
data_points: DataPoint[]
}
type InternalMetricsPayload = {
resource_attributes: Record<string, string>
metrics: Metric[]
}
export class BigQueryMetricsExporter implements PushMetricExporter {
private readonly endpoint: string
private readonly timeout: number
private pendingExports: Promise<void>[] = []
private isShutdown = false
constructor(options: { timeout?: number } = {}) {
const defaultEndpoint = 'https://api.anthropic.com/api/claude_code/metrics'
if (
process.env.USER_TYPE === 'ant' &&
process.env.ANT_CLAUDE_CODE_METRICS_ENDPOINT
) {
this.endpoint =
process.env.ANT_CLAUDE_CODE_METRICS_ENDPOINT +
'/api/claude_code/metrics'
} else {
this.endpoint = defaultEndpoint
}
this.timeout = options.timeout || 5000
}
async export(
metrics: ResourceMetrics,
resultCallback: (result: ExportResult) => void,
): Promise<void> {
if (this.isShutdown) {
resultCallback({
code: ExportResultCode.FAILED,
error: new Error('Exporter has been shutdown'),
})
return
}
const exportPromise = this.doExport(metrics, resultCallback)
this.pendingExports.push(exportPromise)
// Clean up completed exports
void exportPromise.finally(() => {
const index = this.pendingExports.indexOf(exportPromise)
if (index > -1) {
void this.pendingExports.splice(index, 1)
}
})
}
private async doExport(
metrics: ResourceMetrics,
resultCallback: (result: ExportResult) => void,
): Promise<void> {
try {
// Skip if trust not established in interactive mode
// This prevents triggering apiKeyHelper before trust dialog
const hasTrust =
checkHasTrustDialogAccepted() || getIsNonInteractiveSession()
if (!hasTrust) {
logForDebugging(
'BigQuery metrics export: trust not established, skipping',
)
resultCallback({ code: ExportResultCode.SUCCESS })
return
}
// Check organization-level metrics opt-out
const metricsStatus = await checkMetricsEnabled()
if (!metricsStatus.enabled) {
logForDebugging('Metrics export disabled by organization setting')
resultCallback({ code: ExportResultCode.SUCCESS })
return
}
const payload = this.transformMetricsForInternal(metrics)
const authResult = getAuthHeaders()
if (authResult.error) {
logForDebugging(`Metrics export failed: ${authResult.error}`)
resultCallback({
code: ExportResultCode.FAILED,
error: new Error(authResult.error),
})
return
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'User-Agent': getClaudeCodeUserAgent(),
...authResult.headers,
}
const response = await axios.post(this.endpoint, payload, {
timeout: this.timeout,
headers,
})
logForDebugging('BigQuery metrics exported successfully')
logForDebugging(
`BigQuery API Response: ${jsonStringify(response.data, null, 2)}`,
)
resultCallback({ code: ExportResultCode.SUCCESS })
} catch (error) {
logForDebugging(`BigQuery metrics export failed: ${errorMessage(error)}`)
logError(error)
resultCallback({
code: ExportResultCode.FAILED,
error: toError(error),
})
}
}
private transformMetricsForInternal(
metrics: ResourceMetrics,
): InternalMetricsPayload {
const attrs = metrics.resource.attributes
const resourceAttributes: Record<string, string> = {
'service.name': (attrs['service.name'] as string) || 'claude-code',
'service.version': (attrs['service.version'] as string) || 'unknown',
'os.type': (attrs['os.type'] as string) || 'unknown',
'os.version': (attrs['os.version'] as string) || 'unknown',
'host.arch': (attrs['host.arch'] as string) || 'unknown',
'aggregation.temporality':
this.selectAggregationTemporality() === AggregationTemporality.DELTA
? 'delta'
: 'cumulative',
}
// Only add wsl.version if it exists (omit instead of default)
if (attrs['wsl.version']) {
resourceAttributes['wsl.version'] = attrs['wsl.version'] as string
}
// Add customer type and subscription type
if (isClaudeAISubscriber()) {
resourceAttributes['user.customer_type'] = 'claude_ai'
const subscriptionType = getSubscriptionType()
if (subscriptionType) {
resourceAttributes['user.subscription_type'] = subscriptionType
}
} else {
resourceAttributes['user.customer_type'] = 'api'
}
const transformed = {
resource_attributes: resourceAttributes,
metrics: metrics.scopeMetrics.flatMap(scopeMetric =>
scopeMetric.metrics.map(metric => ({
name: metric.descriptor.name,
description: metric.descriptor.description,
unit: metric.descriptor.unit,
data_points: this.extractDataPoints(metric),
})),
),
}
return transformed
}
private extractDataPoints(metric: MetricData): DataPoint[] {
const dataPoints = metric.dataPoints || []
return dataPoints
.filter(
(point): point is OTelDataPoint<number> =>
typeof point.value === 'number',
)
.map(point => ({
attributes: this.convertAttributes(point.attributes),
value: point.value,
timestamp: this.hrTimeToISOString(
point.endTime || point.startTime || [Date.now() / 1000, 0],
),
}))
}
async shutdown(): Promise<void> {
this.isShutdown = true
await this.forceFlush()
logForDebugging('BigQuery metrics exporter shutdown complete')
}
async forceFlush(): Promise<void> {
await Promise.all(this.pendingExports)
logForDebugging('BigQuery metrics exporter flush complete')
}
private convertAttributes(
attributes: Attributes | undefined,
): Record<string, string> {
const result: Record<string, string> = {}
if (attributes) {
for (const [key, value] of Object.entries(attributes)) {
if (value !== undefined && value !== null) {
result[key] = String(value)
}
}
}
return result
}
private hrTimeToISOString(hrTime: HrTime): string {
const [seconds, nanoseconds] = hrTime
const date = new Date(seconds * 1000 + nanoseconds / 1000000)
return date.toISOString()
}
selectAggregationTemporality(): AggregationTemporality {
// DO NOT CHANGE THIS TO CUMULATIVE
// It would mess up the aggregation of metrics
// for CC Productivity metrics dashboard
return AggregationTemporality.DELTA
}
}

View File

@@ -1,75 +0,0 @@
import type { Attributes } from '@opentelemetry/api'
import { getEventLogger, getPromptId } from 'src/bootstrap/state.js'
import { logForDebugging } from '../debug.js'
import { isEnvTruthy } from '../envUtils.js'
import { getTelemetryAttributes } from '../telemetryAttributes.js'
// Monotonically increasing counter for ordering events within a session
let eventSequence = 0
// Track whether we've already warned about a null event logger to avoid spamming
let hasWarnedNoEventLogger = false
function isUserPromptLoggingEnabled() {
return isEnvTruthy(process.env.OTEL_LOG_USER_PROMPTS)
}
export function redactIfDisabled(content: string): string {
return isUserPromptLoggingEnabled() ? content : '<REDACTED>'
}
export async function logOTelEvent(
eventName: string,
metadata: { [key: string]: string | undefined } = {},
): Promise<void> {
const eventLogger = getEventLogger()
if (!eventLogger) {
if (!hasWarnedNoEventLogger) {
hasWarnedNoEventLogger = true
logForDebugging(
`[3P telemetry] Event dropped (no event logger initialized): ${eventName}`,
{ level: 'warn' },
)
}
return
}
// Skip logging in test environment
if (process.env.NODE_ENV === 'test') {
return
}
const attributes: Attributes = {
...getTelemetryAttributes(),
'event.name': eventName,
'event.timestamp': new Date().toISOString(),
'event.sequence': eventSequence++,
}
// Add prompt ID to events (but not metrics, where it would cause unbounded cardinality)
const promptId = getPromptId()
if (promptId) {
attributes['prompt.id'] = promptId
}
// Workspace directory from the desktop app (host path). Events only —
// filesystem paths are too high-cardinality for metric dimensions, and
// the BQ metrics pipeline must never see them.
const workspaceDir = process.env.CLAUDE_CODE_WORKSPACE_HOST_PATHS
if (workspaceDir) {
attributes['workspace.host_paths'] = workspaceDir.split('|')
}
// Add metadata as attributes - all values are already strings
for (const [key, value] of Object.entries(metadata)) {
if (value !== undefined) {
attributes[key] = value
}
}
// Emit log record as an event
eventLogger.emit({
body: `claude_code.${eventName}`,
attributes,
})
}

Some files were not shown because too many files have changed in this diff Show More