Compare commits
35 Commits
20523234e0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7dd3095974 | |||
| 9ba783f10b | |||
| 5af8acb2bb | |||
| 523b8c0a4a | |||
| 2264aea591 | |||
| 86e7dbd1ab | |||
| 5149320afd | |||
| 4d506aabf7 | |||
| 010ded8476 | |||
| 02f22d80bd | |||
| 7cf8afab73 | |||
| 832035a087 | |||
| f06a2c2740 | |||
| f65baebb3c | |||
| eb96764770 | |||
| 3e5461df9b | |||
| ce8f0dfd2b | |||
| 1b4603ed3b | |||
| dccd151718 | |||
| a95f0a540a | |||
| 497f81f4f9 | |||
| 9e7338d54c | |||
| d04ef7e701 | |||
| 3606a51288 | |||
| 2cf6c23fdc | |||
| 4eb053eef5 | |||
| d7bb164a05 | |||
| 108704ef22 | |||
| 04f612db35 | |||
| 64c2b201be | |||
| 9a4bb0e1ca | |||
| af2f2b0e34 | |||
| 097bc753f0 | |||
| f25dc3b6be | |||
| 9410e2f394 |
169
.github/workflows/release.yml
vendored
Normal file
169
.github/workflows/release.yml
vendored
Normal 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
|
||||
22
README.md
22
README.md
@@ -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.
|
||||
|
||||
366
docs/free-code-main-diff-analysis.md
Normal file
366
docs/free-code-main-diff-analysis.md
Normal 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` 的主要价值在于“作为参考基线和资源来源”。
|
||||
|
||||
如果下一步要继续治理代码,最合理的策略不是盲目回滚当前差异,而是先把差异分类,再决定哪些保留、哪些收敛、哪些补测试。
|
||||
423
docs/free-code-main-local-system-info-removal-report.md
Normal file
423
docs/free-code-main-local-system-info-removal-report.md
Normal 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`
|
||||
|
||||
430
docs/local-system-info-egress-audit.md
Normal file
430
docs/local-system-info-egress-audit.md
Normal 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% 抓包确认。
|
||||
- 如果你需要,我下一步可以继续补一版:
|
||||
- 运行时抓包建议
|
||||
- 外发域名清单
|
||||
- 按“默认开启 / 可关闭 / 必须用户触发”生成一张更适合合规审查的表
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
SDKPermissionDenial,
|
||||
SDKStatus,
|
||||
SDKUserMessageReplay,
|
||||
} from 'src/entrypoints/agentSdkTypes.js'
|
||||
} from 'src/entrypoints/agentSdkTypes.ts'
|
||||
import { accumulateUsage, updateUsage } from 'src/services/api/claude.js'
|
||||
import type { NonNullableUsage } from 'src/services/api/logging.js'
|
||||
import { EMPTY_USAGE } from 'src/services/api/logging.js'
|
||||
|
||||
@@ -74,7 +74,7 @@ export type {
|
||||
|
||||
import type { SpinnerMode } from './components/Spinner.js'
|
||||
import type { QuerySource } from './constants/querySource.js'
|
||||
import type { SDKStatus } from './entrypoints/agentSdkTypes.js'
|
||||
import type { SDKStatus } from './entrypoints/agentSdkTypes.ts'
|
||||
import type { AppState } from './state/AppState.js'
|
||||
import type {
|
||||
HookProgress,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios from 'axios'
|
||||
import { getOauthConfig } from '../constants/oauth.js'
|
||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.ts'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { getOAuthHeaders, prepareApiRequest } from '../utils/teleport/api.js'
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'
|
||||
import { realpathSync } from 'fs'
|
||||
import sumBy from 'lodash-es/sumBy.js'
|
||||
import { cwd } from 'process'
|
||||
import type { HookEvent, ModelUsage } from 'src/entrypoints/agentSdkTypes.js'
|
||||
import type { HookEvent, ModelUsage } from 'src/entrypoints/agentSdkTypes.ts'
|
||||
import type { AgentColorName } from 'src/tools/AgentTool/agentColorManager.js'
|
||||
import type { HookCallbackMatcher } from 'src/types/hooks.js'
|
||||
// Indirection for browser-sdk build (package.json "browser" field swaps
|
||||
|
||||
@@ -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 {
|
||||
@@ -154,10 +169,6 @@ export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient {
|
||||
}>(
|
||||
`${deps.baseUrl}/v1/environments/bridge`,
|
||||
{
|
||||
machine_name: config.machineName,
|
||||
directory: config.dir,
|
||||
branch: config.branch,
|
||||
git_repo_url: config.gitRepoUrl,
|
||||
// Advertise session capacity so claude.ai/code can show
|
||||
// "2/4 sessions" badges and only block the picker when
|
||||
// actually at capacity. Backends that don't yet accept
|
||||
@@ -187,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({ machine_name: config.machineName, directory: config.dir, branch: config.branch, git_repo_url: config.gitRepoUrl, max_sessions: config.maxSessions, metadata: { worker_type: config.workerType } })}`,
|
||||
`[bridge:api] >>> ${debugBody({ max_sessions: config.maxSessions, metadata: { worker_type: config.workerType } })}`,
|
||||
)
|
||||
debug(
|
||||
`[bridge:api] <<< ${summarizeBridgeApiPayloadForDebug(response.data)}`,
|
||||
)
|
||||
debug(`[bridge:api] <<< ${debugBody(response.data)}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -240,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
|
||||
},
|
||||
|
||||
@@ -446,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)}`,
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { hostname, tmpdir } from 'os'
|
||||
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.',
|
||||
@@ -2203,9 +2191,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
||||
: baseUrl
|
||||
|
||||
const { getBranch, getRemoteUrl, findGitRoot } = await import(
|
||||
'../utils/git.js'
|
||||
)
|
||||
const { findGitRoot } = await import('../utils/git.js')
|
||||
|
||||
// Precheck worktree availability for the first-run dialog and the `w`
|
||||
// toggle. Unconditional so we know upfront whether worktree is an option.
|
||||
@@ -2337,9 +2323,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const branch = await getBranch()
|
||||
const gitRepoUrl = await getRemoteUrl()
|
||||
const machineName = hostname()
|
||||
const bridgeId = randomUUID()
|
||||
|
||||
const { handleOAuth401Error } = await import('../utils/auth.js')
|
||||
@@ -2349,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
|
||||
@@ -2417,9 +2399,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
|
||||
const config: BridgeConfig = {
|
||||
dir,
|
||||
machineName,
|
||||
branch,
|
||||
gitRepoUrl,
|
||||
maxSessions,
|
||||
spawnMode,
|
||||
verbose,
|
||||
@@ -2435,7 +2414,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`[bridge:init] bridgeId=${bridgeId}${reuseEnvironmentId ? ` reuseEnvironmentId=${reuseEnvironmentId}` : ''} dir=${dir} branch=${branch} gitRepoUrl=${gitRepoUrl} machine=${machineName}`,
|
||||
`[bridge:init] bridgeId=${bridgeId}${reuseEnvironmentId ? ` reuseEnvironmentId=${reuseEnvironmentId}` : ''} dir=${dir}`,
|
||||
)
|
||||
logForDebugging(
|
||||
`[bridge:init] apiBaseUrl=${baseUrl} sessionIngressUrl=${sessionIngressUrl}`,
|
||||
@@ -2591,11 +2570,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
})
|
||||
|
||||
const logger = createBridgeLogger({ verbose })
|
||||
const { parseGitHubRepository } = await import('../utils/detectRepository.js')
|
||||
const ownerRepo = gitRepoUrl ? parseGitHubRepository(gitRepoUrl) : null
|
||||
// Use the repo name from the parsed owner/repo, or fall back to the dir basename
|
||||
const repoName = ownerRepo ? ownerRepo.split('/').pop()! : basename(dir)
|
||||
logger.setRepoInfo(repoName, branch)
|
||||
logger.setRepoInfo(basename(dir), '')
|
||||
|
||||
// `w` toggle is available iff we're in a multi-session mode AND worktree
|
||||
// is a valid option. When unavailable, the mode suffix and hint are hidden.
|
||||
@@ -2678,8 +2653,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
environmentId,
|
||||
title: name,
|
||||
events: [],
|
||||
gitRepoUrl,
|
||||
branch,
|
||||
signal: controller.signal,
|
||||
baseUrl,
|
||||
getAccessToken: getBridgeAccessToken,
|
||||
@@ -2856,9 +2829,7 @@ export async function runBridgeHeadless(
|
||||
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
||||
: baseUrl
|
||||
|
||||
const { getBranch, getRemoteUrl, findGitRoot } = await import(
|
||||
'../utils/git.js'
|
||||
)
|
||||
const { findGitRoot } = await import('../utils/git.js')
|
||||
const { hasWorktreeCreateHook } = await import('../utils/hooks.js')
|
||||
|
||||
if (opts.spawnMode === 'worktree') {
|
||||
@@ -2871,16 +2842,10 @@ export async function runBridgeHeadless(
|
||||
}
|
||||
}
|
||||
|
||||
const branch = await getBranch()
|
||||
const gitRepoUrl = await getRemoteUrl()
|
||||
const machineName = hostname()
|
||||
const bridgeId = randomUUID()
|
||||
|
||||
const config: BridgeConfig = {
|
||||
dir,
|
||||
machineName,
|
||||
branch,
|
||||
gitRepoUrl,
|
||||
maxSessions: opts.capacity,
|
||||
spawnMode: opts.spawnMode,
|
||||
verbose: false,
|
||||
@@ -2899,7 +2864,6 @@ export async function runBridgeHeadless(
|
||||
runnerVersion: MACRO.VERSION,
|
||||
onDebug: log,
|
||||
onAuth401: opts.onAuth401,
|
||||
getTrustedDeviceToken,
|
||||
})
|
||||
|
||||
let environmentId: string
|
||||
@@ -2934,8 +2898,6 @@ export async function runBridgeHeadless(
|
||||
environmentId,
|
||||
title: opts.name,
|
||||
events: [],
|
||||
gitRepoUrl,
|
||||
branch,
|
||||
signal,
|
||||
baseUrl,
|
||||
getAccessToken: opts.getAccessToken,
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.ts'
|
||||
import type {
|
||||
SDKControlRequest,
|
||||
SDKControlResponse,
|
||||
@@ -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}`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,20 +1,9 @@
|
||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.ts'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { errorMessage } from '../utils/errors.js'
|
||||
import { extractErrorDetail } from './debugUtils.js'
|
||||
import { toCompatSessionId } from './sessionIdCompat.js'
|
||||
|
||||
type GitSource = {
|
||||
type: 'git_repository'
|
||||
url: string
|
||||
revision?: string
|
||||
}
|
||||
|
||||
type GitOutcome = {
|
||||
type: 'git_repository'
|
||||
git_info: { type: 'github'; repo: string; branches: string[] }
|
||||
}
|
||||
|
||||
// Events must be wrapped in { type: 'event', data: <sdk_message> } for the
|
||||
// POST /v1/sessions endpoint (discriminated union format).
|
||||
type SessionEvent = {
|
||||
@@ -35,8 +24,6 @@ export async function createBridgeSession({
|
||||
environmentId,
|
||||
title,
|
||||
events,
|
||||
gitRepoUrl,
|
||||
branch,
|
||||
signal,
|
||||
baseUrl: baseUrlOverride,
|
||||
getAccessToken,
|
||||
@@ -45,8 +32,6 @@ export async function createBridgeSession({
|
||||
environmentId: string
|
||||
title?: string
|
||||
events: SessionEvent[]
|
||||
gitRepoUrl: string | null
|
||||
branch: string
|
||||
signal: AbortSignal
|
||||
baseUrl?: string
|
||||
getAccessToken?: () => string | undefined
|
||||
@@ -56,8 +41,6 @@ export async function createBridgeSession({
|
||||
const { getOrganizationUUID } = await import('../services/oauth/client.js')
|
||||
const { getOauthConfig } = await import('../constants/oauth.js')
|
||||
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
|
||||
const { parseGitHubRepository } = await import('../utils/detectRepository.js')
|
||||
const { getDefaultBranch } = await import('../utils/git.js')
|
||||
const { getMainLoopModel } = await import('../utils/model/model.js')
|
||||
const { default: axios } = await import('axios')
|
||||
|
||||
@@ -74,60 +57,12 @@ export async function createBridgeSession({
|
||||
return null
|
||||
}
|
||||
|
||||
// Build git source and outcome context
|
||||
let gitSource: GitSource | null = null
|
||||
let gitOutcome: GitOutcome | null = null
|
||||
|
||||
if (gitRepoUrl) {
|
||||
const { parseGitRemote } = await import('../utils/detectRepository.js')
|
||||
const parsed = parseGitRemote(gitRepoUrl)
|
||||
if (parsed) {
|
||||
const { host, owner, name } = parsed
|
||||
const revision = branch || (await getDefaultBranch()) || undefined
|
||||
gitSource = {
|
||||
type: 'git_repository',
|
||||
url: `https://${host}/${owner}/${name}`,
|
||||
revision,
|
||||
}
|
||||
gitOutcome = {
|
||||
type: 'git_repository',
|
||||
git_info: {
|
||||
type: 'github',
|
||||
repo: `${owner}/${name}`,
|
||||
branches: [`claude/${branch || 'task'}`],
|
||||
},
|
||||
}
|
||||
} else {
|
||||
// Fallback: try parseGitHubRepository for owner/repo format
|
||||
const ownerRepo = parseGitHubRepository(gitRepoUrl)
|
||||
if (ownerRepo) {
|
||||
const [owner, name] = ownerRepo.split('/')
|
||||
if (owner && name) {
|
||||
const revision = branch || (await getDefaultBranch()) || undefined
|
||||
gitSource = {
|
||||
type: 'git_repository',
|
||||
url: `https://github.com/${owner}/${name}`,
|
||||
revision,
|
||||
}
|
||||
gitOutcome = {
|
||||
type: 'git_repository',
|
||||
git_info: {
|
||||
type: 'github',
|
||||
repo: `${owner}/${name}`,
|
||||
branches: [`claude/${branch || 'task'}`],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
...(title !== undefined && { title }),
|
||||
events,
|
||||
session_context: {
|
||||
sources: gitSource ? [gitSource] : [],
|
||||
outcomes: gitOutcome ? [gitOutcome] : [],
|
||||
sources: [],
|
||||
outcomes: [],
|
||||
model: getMainLoopModel(),
|
||||
},
|
||||
environment_id: environmentId,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
ImageBlockParam,
|
||||
} from '@anthropic-ai/sdk/resources/messages.mjs'
|
||||
import type { UUID } from 'crypto'
|
||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.ts'
|
||||
import { detectImageFormatFromBase64 } from '../utils/imageResizer.js'
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,9 +14,8 @@
|
||||
*/
|
||||
|
||||
import { feature } from 'bun:bundle'
|
||||
import { hostname } from 'os'
|
||||
import { getOriginalCwd, getSessionId } from '../bootstrap/state.js'
|
||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.ts'
|
||||
import type { SDKControlResponse } from '../entrypoints/sdk/controlTypes.ts'
|
||||
import { getFeatureValue_CACHED_WITH_REFRESH } from '../services/analytics/growthbook.js'
|
||||
import { getOrganizationUUID } from '../services/oauth/client.js'
|
||||
@@ -34,7 +33,6 @@ import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { stripDisplayTagsAllowEmpty } from '../utils/displayTags.js'
|
||||
import { errorMessage } from '../utils/errors.js'
|
||||
import { getBranch, getRemoteUrl } from '../utils/git.js'
|
||||
import { toSDKMessages } from '../utils/messages/mappers.js'
|
||||
import {
|
||||
getContentText,
|
||||
@@ -460,10 +458,6 @@ export async function initReplBridge(
|
||||
return null
|
||||
}
|
||||
|
||||
// Gather git context — this is the bootstrap-read boundary.
|
||||
// Everything from here down is passed explicitly to bridgeCore.
|
||||
const branch = await getBranch()
|
||||
const gitRepoUrl = await getRemoteUrl()
|
||||
const sessionIngressUrl =
|
||||
process.env.USER_TYPE === 'ant' &&
|
||||
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
||||
@@ -489,9 +483,6 @@ export async function initReplBridge(
|
||||
// so no adapter needed — just the narrower type on the way out.
|
||||
return initBridgeCore({
|
||||
dir: getOriginalCwd(),
|
||||
machineName: hostname(),
|
||||
branch,
|
||||
gitRepoUrl,
|
||||
title,
|
||||
baseUrl,
|
||||
sessionIngressUrl,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
@@ -64,7 +66,7 @@ import {
|
||||
} from '../services/analytics/index.js'
|
||||
import type { ReplBridgeHandle, BridgeState } from './replBridge.js'
|
||||
import type { Message } from '../types/message.js'
|
||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.ts'
|
||||
import type {
|
||||
SDKControlRequest,
|
||||
SDKControlResponse,
|
||||
@@ -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'
|
||||
|
||||
@@ -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,9 +43,10 @@ import {
|
||||
describeAxiosError,
|
||||
extractHttpStatus,
|
||||
logBridgeSkip,
|
||||
summarizeBridgeErrorForDebug,
|
||||
} from './debugUtils.js'
|
||||
import type { Message } from '../types/message.js'
|
||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.ts'
|
||||
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
|
||||
import type {
|
||||
SDKControlRequest,
|
||||
@@ -84,15 +84,12 @@ export type BridgeState = 'ready' | 'connected' | 'reconnecting' | 'failed'
|
||||
|
||||
/**
|
||||
* Explicit-param input to initBridgeCore. Everything initReplBridge reads
|
||||
* from bootstrap state (cwd, session ID, git, OAuth) becomes a field here.
|
||||
* from bootstrap state (cwd, session ID, OAuth) becomes a field here.
|
||||
* A daemon caller (Agent SDK, PR 4) that never runs main.tsx fills these
|
||||
* in itself.
|
||||
*/
|
||||
export type BridgeCoreParams = {
|
||||
dir: string
|
||||
machineName: string
|
||||
branch: string
|
||||
gitRepoUrl: string | null
|
||||
title: string
|
||||
baseUrl: string
|
||||
sessionIngressUrl: string
|
||||
@@ -113,14 +110,12 @@ export type BridgeCoreParams = {
|
||||
* Daemon wrapper passes `createBridgeSessionLean` from `sessionApi.ts`
|
||||
* (HTTP-only, orgUUID+model supplied by the daemon caller).
|
||||
*
|
||||
* Receives `gitRepoUrl`+`branch` so the REPL wrapper can build the git
|
||||
* source/outcome for claude.ai's session card. Daemon ignores them.
|
||||
* Receives the registered environment ID and session title. Daemon callers
|
||||
* may supply their own lean session-creation implementation.
|
||||
*/
|
||||
createSession: (opts: {
|
||||
environmentId: string
|
||||
title: string
|
||||
gitRepoUrl: string | null
|
||||
branch: string
|
||||
signal: AbortSignal
|
||||
}) => Promise<string | null>
|
||||
/**
|
||||
@@ -262,9 +257,6 @@ export async function initBridgeCore(
|
||||
): Promise<BridgeCoreHandle | null> {
|
||||
const {
|
||||
dir,
|
||||
machineName,
|
||||
branch,
|
||||
gitRepoUrl,
|
||||
title,
|
||||
baseUrl,
|
||||
sessionIngressUrl,
|
||||
@@ -312,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
|
||||
@@ -322,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).
|
||||
@@ -331,9 +322,6 @@ export async function initBridgeCore(
|
||||
|
||||
const bridgeConfig: BridgeConfig = {
|
||||
dir,
|
||||
machineName,
|
||||
branch,
|
||||
gitRepoUrl,
|
||||
maxSessions: 1,
|
||||
spawnMode: 'single-session',
|
||||
verbose: false,
|
||||
@@ -355,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.
|
||||
@@ -366,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', {})
|
||||
|
||||
@@ -384,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
|
||||
}
|
||||
@@ -402,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,
|
||||
)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -457,8 +447,6 @@ export async function initBridgeCore(
|
||||
const createdSessionId = await createSession({
|
||||
environmentId,
|
||||
title,
|
||||
gitRepoUrl,
|
||||
branch,
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
|
||||
@@ -694,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
|
||||
}
|
||||
@@ -703,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
|
||||
@@ -764,8 +754,6 @@ export async function initBridgeCore(
|
||||
const newSessionId = await createSession({
|
||||
environmentId,
|
||||
title: currentTitle,
|
||||
gitRepoUrl,
|
||||
branch,
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
|
||||
@@ -1001,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)}`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1055,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,
|
||||
)}`,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1382,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,
|
||||
@@ -1416,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', {})
|
||||
@@ -1431,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
|
||||
@@ -1452,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(
|
||||
@@ -1540,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)
|
||||
@@ -1553,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)
|
||||
@@ -1610,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
|
||||
}
|
||||
@@ -1636,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()
|
||||
@@ -1653,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,
|
||||
)}`,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1663,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 {
|
||||
@@ -1730,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
|
||||
@@ -1765,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
|
||||
@@ -1785,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) {
|
||||
@@ -1813,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()
|
||||
@@ -1920,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
|
||||
@@ -2023,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()
|
||||
@@ -2141,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.
|
||||
@@ -2152,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,
|
||||
)}`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2209,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
|
||||
@@ -2255,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
|
||||
}
|
||||
|
||||
@@ -2393,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})`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,78 +1,22 @@
|
||||
import axios from 'axios'
|
||||
import memoize from 'lodash-es/memoize.js'
|
||||
import { hostname } from 'os'
|
||||
import { getOauthConfig } from '../constants/oauth.js'
|
||||
import {
|
||||
checkGate_CACHED_OR_BLOCKING,
|
||||
getFeatureValue_CACHED_MAY_BE_STALE,
|
||||
} from '../services/analytics/growthbook.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { errorMessage } from '../utils/errors.js'
|
||||
import { isEssentialTrafficOnly } from '../utils/privacyLevel.js'
|
||||
import { getSecureStorage } from '../utils/secureStorage/index.js'
|
||||
import { jsonStringify } from '../utils/slowOperations.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 after enrollment (below) and on logout (clearAuthRelatedCaches).
|
||||
//
|
||||
// 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 before enrollTrustedDevice() during /login so a stale token from the
|
||||
* previous account isn't sent as X-Trusted-Device-Token while enrollment is
|
||||
* in-flight (enrollTrustedDevice is async — bridge API calls between login and
|
||||
* enrollment completion would otherwise still read the old cached token).
|
||||
* Clear any stored trusted-device token from secure storage.
|
||||
*/
|
||||
export function clearTrustedDeviceToken(): void {
|
||||
if (!isGateEnabled()) {
|
||||
return
|
||||
}
|
||||
const secureStorage = getSecureStorage()
|
||||
try {
|
||||
const data = secureStorage.read()
|
||||
@@ -83,128 +27,14 @@ export function clearTrustedDeviceToken(): void {
|
||||
} catch {
|
||||
// Best-effort — don't block login if storage is inaccessible
|
||||
}
|
||||
readStoredToken.cache?.clear?.()
|
||||
}
|
||||
|
||||
/**
|
||||
* Enroll this device via POST /auth/trusted_devices and persist the token
|
||||
* to keychain. Best-effort — logs and returns on failure so callers
|
||||
* (post-login hooks) don't block the login flow.
|
||||
*
|
||||
* The server gates enrollment on account_session.created_at < 10min, so
|
||||
* this must be called immediately after a fresh /login. Calling it later
|
||||
* (e.g. lazy enrollment on /bridge 403) will fail with 403 stale_session.
|
||||
* Trusted-device enrollment is disabled in this build. Keep the no-op entry
|
||||
* point so callers can continue to invoke it without branching.
|
||||
*/
|
||||
export async function enrollTrustedDevice(): Promise<void> {
|
||||
try {
|
||||
// checkGate_CACHED_OR_BLOCKING awaits any in-flight GrowthBook re-init
|
||||
// (triggered by refreshGrowthBookAfterAuthChange in login.tsx) before
|
||||
// reading the gate, so we get the post-refresh value.
|
||||
if (!(await checkGate_CACHED_OR_BLOCKING(TRUSTED_DEVICE_GATE))) {
|
||||
logForDebugging(
|
||||
`[trusted-device] Gate ${TRUSTED_DEVICE_GATE} is off, skipping enrollment`,
|
||||
)
|
||||
return
|
||||
}
|
||||
// If CLAUDE_TRUSTED_DEVICE_TOKEN is set (e.g. by an enterprise wrapper),
|
||||
// skip enrollment — the env var takes precedence in readStoredToken() so
|
||||
// any enrolled token would be shadowed and never used.
|
||||
if (process.env.CLAUDE_TRUSTED_DEVICE_TOKEN) {
|
||||
logForDebugging(
|
||||
'[trusted-device] CLAUDE_TRUSTED_DEVICE_TOKEN env var is set, skipping enrollment (env var takes precedence)',
|
||||
)
|
||||
return
|
||||
}
|
||||
// Lazy require — utils/auth.ts transitively pulls ~1300 modules
|
||||
// (config → file → permissions → sessionStorage → commands). Daemon callers
|
||||
// of getTrustedDeviceToken() don't need this; only /login does.
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const { getClaudeAIOAuthTokens } =
|
||||
require('../utils/auth.js') as typeof import('../utils/auth.js')
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
const accessToken = getClaudeAIOAuthTokens()?.accessToken
|
||||
if (!accessToken) {
|
||||
logForDebugging('[trusted-device] No OAuth token, skipping enrollment')
|
||||
return
|
||||
}
|
||||
// Always re-enroll on /login — the existing token may belong to a
|
||||
// different account (account-switch without /logout). Skipping enrollment
|
||||
// would send the old account's token on the new account's bridge calls.
|
||||
const secureStorage = getSecureStorage()
|
||||
|
||||
if (isEssentialTrafficOnly()) {
|
||||
logForDebugging(
|
||||
'[trusted-device] Essential traffic only, skipping enrollment',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const baseUrl = getOauthConfig().BASE_API_URL
|
||||
let response
|
||||
try {
|
||||
response = await axios.post<{
|
||||
device_token?: string
|
||||
device_id?: string
|
||||
}>(
|
||||
`${baseUrl}/api/auth/trusted_devices`,
|
||||
{ display_name: `Claude Code on ${hostname()} · ${process.platform}` },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 10_000,
|
||||
validateStatus: s => s < 500,
|
||||
},
|
||||
)
|
||||
} catch (err: unknown) {
|
||||
logForDebugging(
|
||||
`[trusted-device] Enrollment request failed: ${errorMessage(err)}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (response.status !== 200 && response.status !== 201) {
|
||||
logForDebugging(
|
||||
`[trusted-device] Enrollment failed ${response.status}: ${jsonStringify(response.data).slice(0, 200)}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const token = response.data?.device_token
|
||||
if (!token || typeof token !== 'string') {
|
||||
logForDebugging(
|
||||
'[trusted-device] Enrollment response missing device_token field',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const storageData = secureStorage.read()
|
||||
if (!storageData) {
|
||||
logForDebugging(
|
||||
'[trusted-device] Cannot read storage, skipping token persist',
|
||||
)
|
||||
return
|
||||
}
|
||||
storageData.trustedDeviceToken = token
|
||||
const result = secureStorage.update(storageData)
|
||||
if (!result.success) {
|
||||
logForDebugging(
|
||||
`[trusted-device] Failed to persist token: ${result.warning ?? 'unknown'}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
readStoredToken.cache?.clear?.()
|
||||
logForDebugging(
|
||||
`[trusted-device] Enrolled device_id=${response.data.device_id ?? 'unknown'}`,
|
||||
)
|
||||
} catch (err: unknown) {
|
||||
logForDebugging(
|
||||
`[trusted-device] Storage write failed: ${errorMessage(err)}`,
|
||||
)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
logForDebugging(`[trusted-device] Enrollment error: ${errorMessage(err)}`)
|
||||
}
|
||||
logForDebugging(
|
||||
'[trusted-device] Enrollment disabled in this build; skipping trusted device registration',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -80,9 +80,6 @@ export type BridgeWorkerType = 'claude_code' | 'claude_code_assistant'
|
||||
|
||||
export type BridgeConfig = {
|
||||
dir: string
|
||||
machineName: string
|
||||
branch: string
|
||||
gitRepoUrl: string | null
|
||||
maxSessions: number
|
||||
spawnMode: SpawnMode
|
||||
verbose: boolean
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { execFileSync } from 'child_process'
|
||||
import { diffLines } from 'diff'
|
||||
import { constants as fsConstants } from 'fs'
|
||||
import {
|
||||
@@ -2674,7 +2673,7 @@ export type InsightsExport = {
|
||||
|
||||
/**
|
||||
* Build export data from already-computed values.
|
||||
* Used by background upload to S3.
|
||||
* Used by the local report writer.
|
||||
*/
|
||||
export function buildExportData(
|
||||
data: AggregatedData,
|
||||
@@ -3069,35 +3068,8 @@ const usageReport: Command = {
|
||||
{ collectRemote },
|
||||
)
|
||||
|
||||
let reportUrl = `file://${htmlPath}`
|
||||
let uploadHint = ''
|
||||
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
// Try to upload to S3
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, '')
|
||||
.replace('T', '_')
|
||||
.slice(0, 15)
|
||||
const username = process.env.SAFEUSER || process.env.USER || 'unknown'
|
||||
const filename = `${username}_insights_${timestamp}.html`
|
||||
const s3Path = `s3://anthropic-serve/atamkin/cc-user-reports/${filename}`
|
||||
const s3Url = `https://s3-frontend.infra.ant.dev/anthropic-serve/atamkin/cc-user-reports/${filename}`
|
||||
|
||||
reportUrl = s3Url
|
||||
try {
|
||||
execFileSync('ff', ['cp', htmlPath, s3Path], {
|
||||
timeout: 60000,
|
||||
stdio: 'pipe', // Suppress output
|
||||
})
|
||||
} catch {
|
||||
// Upload failed - fall back to local file and show upload command
|
||||
reportUrl = `file://${htmlPath}`
|
||||
uploadHint = `\nAutomatic upload failed. Are you on the boron namespace? Try \`use-bo\` and ensure you've run \`sso\`.
|
||||
To share, run: ff cp ${htmlPath} ${s3Path}
|
||||
Then access at: ${s3Url}`
|
||||
}
|
||||
}
|
||||
const reportUrl = `file://${htmlPath}`
|
||||
const uploadHint = ''
|
||||
|
||||
// Build header with stats
|
||||
const sessionLabel =
|
||||
|
||||
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
14
src/components/FeedbackSurvey/submitTranscriptShare.test.ts
Normal file
14
src/components/FeedbackSurvey/submitTranscriptShare.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,23 +1,9 @@
|
||||
import axios from 'axios'
|
||||
import { readFile, stat } from 'fs/promises'
|
||||
import type { Message } from '../../types/message.js'
|
||||
import { checkAndRefreshOAuthTokenIfNeeded } from '../../utils/auth.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { errorMessage } from '../../utils/errors.js'
|
||||
import { getAuthHeaders, getUserAgent } from '../../utils/http.js'
|
||||
import { normalizeMessagesForAPI } from '../../utils/messages.js'
|
||||
import {
|
||||
extractAgentIdsFromMessages,
|
||||
getTranscriptPath,
|
||||
loadSubagentTranscripts,
|
||||
MAX_TRANSCRIPT_READ_BYTES,
|
||||
} from '../../utils/sessionStorage.js'
|
||||
import { jsonStringify } from '../../utils/slowOperations.js'
|
||||
import { redactSensitiveInfo } from '../Feedback.js'
|
||||
|
||||
type TranscriptShareResult = {
|
||||
success: boolean
|
||||
transcriptId?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export type TranscriptShareTrigger =
|
||||
@@ -27,86 +13,12 @@ export type TranscriptShareTrigger =
|
||||
| 'memory_survey'
|
||||
|
||||
export async function submitTranscriptShare(
|
||||
messages: Message[],
|
||||
trigger: TranscriptShareTrigger,
|
||||
appearanceId: string,
|
||||
_messages: Message[],
|
||||
_trigger: TranscriptShareTrigger,
|
||||
_appearanceId: string,
|
||||
): Promise<TranscriptShareResult> {
|
||||
try {
|
||||
logForDebugging('Collecting transcript for sharing', { level: 'info' })
|
||||
|
||||
const transcript = normalizeMessagesForAPI(messages)
|
||||
|
||||
// Collect subagent transcripts
|
||||
const agentIds = extractAgentIdsFromMessages(messages)
|
||||
const subagentTranscripts = await loadSubagentTranscripts(agentIds)
|
||||
|
||||
// Read raw JSONL transcript (with size guard to prevent OOM)
|
||||
let rawTranscriptJsonl: string | undefined
|
||||
try {
|
||||
const transcriptPath = getTranscriptPath()
|
||||
const { size } = await stat(transcriptPath)
|
||||
if (size <= MAX_TRANSCRIPT_READ_BYTES) {
|
||||
rawTranscriptJsonl = await readFile(transcriptPath, 'utf-8')
|
||||
} else {
|
||||
logForDebugging(
|
||||
`Skipping raw transcript read: file too large (${size} bytes)`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// File may not exist
|
||||
}
|
||||
|
||||
const data = {
|
||||
trigger,
|
||||
version: MACRO.VERSION,
|
||||
platform: process.platform,
|
||||
transcript,
|
||||
subagentTranscripts:
|
||||
Object.keys(subagentTranscripts).length > 0
|
||||
? subagentTranscripts
|
||||
: undefined,
|
||||
rawTranscriptJsonl,
|
||||
}
|
||||
|
||||
const content = redactSensitiveInfo(jsonStringify(data))
|
||||
|
||||
await checkAndRefreshOAuthTokenIfNeeded()
|
||||
|
||||
const authResult = getAuthHeaders()
|
||||
if (authResult.error) {
|
||||
return { success: false }
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': getUserAgent(),
|
||||
...authResult.headers,
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
'https://api.anthropic.com/api/claude_code_shared_session_transcripts',
|
||||
{ content, appearance_id: appearanceId },
|
||||
{
|
||||
headers,
|
||||
timeout: 30000,
|
||||
},
|
||||
)
|
||||
|
||||
if (response.status === 200 || response.status === 201) {
|
||||
const result = response.data
|
||||
logForDebugging('Transcript shared successfully', { level: 'info' })
|
||||
return {
|
||||
success: true,
|
||||
transcriptId: result?.transcript_id,
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false }
|
||||
} catch (err) {
|
||||
logForDebugging(errorMessage(err), {
|
||||
level: 'error',
|
||||
})
|
||||
return { success: false }
|
||||
return {
|
||||
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
@@ -13,7 +13,7 @@ import { c as _c } from "react/compiler-runtime";
|
||||
*/
|
||||
import * as React from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js';
|
||||
import type { HookEvent } from 'src/entrypoints/agentSdkTypes.ts';
|
||||
import { useAppState, useAppStateStore } from 'src/state/AppState.js';
|
||||
import type { CommandResultDisplay } from '../../commands.js';
|
||||
import { useSettingsChange } from '../../hooks/useSettingsChange.js';
|
||||
|
||||
@@ -10,7 +10,7 @@ import { c as _c } from "react/compiler-runtime";
|
||||
|
||||
import figures from 'figures';
|
||||
import * as React from 'react';
|
||||
import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js';
|
||||
import type { HookEvent } from 'src/entrypoints/agentSdkTypes.ts';
|
||||
import type { HookEventMetadata } from 'src/utils/hooks/hooksConfigManager.js';
|
||||
import { Box, Link, Text } from '../../ink.js';
|
||||
import { plural } from '../../utils/stringUtils.js';
|
||||
|
||||
@@ -7,7 +7,7 @@ import { c as _c } from "react/compiler-runtime";
|
||||
* confirmation.
|
||||
*/
|
||||
import * as React from 'react';
|
||||
import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js';
|
||||
import type { HookEvent } from 'src/entrypoints/agentSdkTypes.ts';
|
||||
import type { HookEventMetadata } from 'src/utils/hooks/hooksConfigManager.js';
|
||||
import { Box, Text } from '../../ink.js';
|
||||
import { getHookDisplayText, hookSourceHeaderDisplayString, type IndividualHookConfig } from '../../utils/hooks/hooksSettings.js';
|
||||
|
||||
@@ -6,7 +6,7 @@ import { c as _c } from "react/compiler-runtime";
|
||||
* and simply lets the user drill into each matcher to see its hooks.
|
||||
*/
|
||||
import * as React from 'react';
|
||||
import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js';
|
||||
import type { HookEvent } from 'src/entrypoints/agentSdkTypes.ts';
|
||||
import { Box, Text } from '../../ink.js';
|
||||
import { type HookSource, hookSourceInlineDisplayString, type IndividualHookConfig } from '../../utils/hooks/hooksSettings.js';
|
||||
import { plural } from '../../utils/stringUtils.js';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import * as React from 'react';
|
||||
import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js';
|
||||
import type { HookEvent } from 'src/entrypoints/agentSdkTypes.ts';
|
||||
import type { buildMessageLookups } from 'src/utils/messages.js';
|
||||
import { Box, Text } from '../../ink.js';
|
||||
import { MessageResponse } from '../MessageResponse.js';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import figures from 'figures';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import type { SDKMessage } from 'src/entrypoints/agentSdkTypes.js';
|
||||
import type { SDKMessage } from 'src/entrypoints/agentSdkTypes.ts';
|
||||
import type { ToolUseContext } from 'src/Tool.js';
|
||||
import type { DeepImmutable } from 'src/types/utils.js';
|
||||
import type { CommandResultDisplay } from '../../commands.js';
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -448,9 +448,7 @@ export async function getSystemPrompt(
|
||||
mcpClients?: MCPServerConnection[],
|
||||
): Promise<string[]> {
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) {
|
||||
return [
|
||||
`You are Claude Code, Anthropic's official CLI for Claude.\n\nCWD: ${getCwd()}\nDate: ${getSessionStartDate()}`,
|
||||
]
|
||||
return [`You are Claude Code, Anthropic's official CLI for Claude.`]
|
||||
}
|
||||
|
||||
const cwd = getCwd()
|
||||
@@ -607,8 +605,6 @@ export async function computeEnvInfo(
|
||||
modelId: string,
|
||||
additionalWorkingDirectories?: string[],
|
||||
): Promise<string> {
|
||||
const [isGit, unameSR] = await Promise.all([getIsGit(), getUnameSR()])
|
||||
|
||||
// Undercover: keep ALL model names/IDs out of the system prompt so nothing
|
||||
// internal can leak into public commits/PRs. This includes the public
|
||||
// FRONTIER_MODEL_* constants — if those ever point at an unannounced model,
|
||||
@@ -627,33 +623,20 @@ export async function computeEnvInfo(
|
||||
: `You are powered by the model ${modelId}.`
|
||||
}
|
||||
|
||||
const additionalDirsInfo =
|
||||
additionalWorkingDirectories && additionalWorkingDirectories.length > 0
|
||||
? `Additional working directories: ${additionalWorkingDirectories.join(', ')}\n`
|
||||
: ''
|
||||
|
||||
const cutoff = getKnowledgeCutoff(modelId)
|
||||
const knowledgeCutoffMessage = cutoff
|
||||
? `\n\nAssistant knowledge cutoff is ${cutoff}.`
|
||||
: ''
|
||||
|
||||
return `Here is useful information about the environment you are running in:
|
||||
<env>
|
||||
Working directory: ${getCwd()}
|
||||
Is directory a git repo: ${isGit ? 'Yes' : 'No'}
|
||||
${additionalDirsInfo}Platform: ${env.platform}
|
||||
${getShellInfoLine()}
|
||||
OS Version: ${unameSR}
|
||||
</env>
|
||||
${modelDescription}${knowledgeCutoffMessage}`
|
||||
return [`# Environment`, `You are Claude Code.`, modelDescription, knowledgeCutoffMessage]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export async function computeSimpleEnvInfo(
|
||||
modelId: string,
|
||||
additionalWorkingDirectories?: string[],
|
||||
): Promise<string> {
|
||||
const [isGit, unameSR] = await Promise.all([getIsGit(), getUnameSR()])
|
||||
|
||||
// Undercover: strip all model name/ID references. See computeEnvInfo.
|
||||
// DCE: inline the USER_TYPE check at each site — do NOT hoist to a const.
|
||||
let modelDescription: string | null = null
|
||||
@@ -671,42 +654,14 @@ export async function computeSimpleEnvInfo(
|
||||
? `Assistant knowledge cutoff is ${cutoff}.`
|
||||
: null
|
||||
|
||||
const cwd = getCwd()
|
||||
const isWorktree = getCurrentWorktreeSession() !== null
|
||||
|
||||
const envItems = [
|
||||
`Primary working directory: ${cwd}`,
|
||||
isWorktree
|
||||
? `This is a git worktree — an isolated copy of the repository. Run all commands from this directory. Do NOT \`cd\` to the original repository root.`
|
||||
: null,
|
||||
[`Is a git repository: ${isGit}`],
|
||||
additionalWorkingDirectories && additionalWorkingDirectories.length > 0
|
||||
? `Additional working directories:`
|
||||
: null,
|
||||
additionalWorkingDirectories && additionalWorkingDirectories.length > 0
|
||||
? additionalWorkingDirectories
|
||||
: null,
|
||||
`Platform: ${env.platform}`,
|
||||
getShellInfoLine(),
|
||||
`OS Version: ${unameSR}`,
|
||||
modelDescription,
|
||||
knowledgeCutoffMessage,
|
||||
process.env.USER_TYPE === 'ant' && isUndercover()
|
||||
? null
|
||||
: `The most recent Claude model family is Claude 4.5/4.6. Model IDs — Opus 4.6: '${CLAUDE_4_5_OR_4_6_MODEL_IDS.opus}', Sonnet 4.6: '${CLAUDE_4_5_OR_4_6_MODEL_IDS.sonnet}', Haiku 4.5: '${CLAUDE_4_5_OR_4_6_MODEL_IDS.haiku}'. When building AI applications, default to the latest and most capable Claude models.`,
|
||||
process.env.USER_TYPE === 'ant' && isUndercover()
|
||||
? null
|
||||
: `Claude Code is available as a CLI in the terminal, desktop app (Mac/Windows), web app (claude.ai/code), and IDE extensions (VS Code, JetBrains).`,
|
||||
process.env.USER_TYPE === 'ant' && isUndercover()
|
||||
? null
|
||||
: `Fast mode for Claude Code uses the same ${FRONTIER_MODEL_NAME} model with faster output. It does NOT switch to a different model. It can be toggled with /fast.`,
|
||||
].filter(item => item !== null)
|
||||
|
||||
return [
|
||||
`# Environment`,
|
||||
`You have been invoked in the following environment: `,
|
||||
...prependBullets(envItems),
|
||||
].join(`\n`)
|
||||
`You are Claude Code.`,
|
||||
modelDescription,
|
||||
knowledgeCutoffMessage,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(`\n`)
|
||||
}
|
||||
|
||||
// @[MODEL LAUNCH]: Add a knowledge cutoff date for the new model.
|
||||
|
||||
179
src/context.ts
179
src/context.ts
@@ -1,25 +1,7 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import memoize from 'lodash-es/memoize.js'
|
||||
import {
|
||||
getAdditionalDirectoriesForClaudeMd,
|
||||
setCachedClaudeMdContent,
|
||||
} from './bootstrap/state.js'
|
||||
import { getLocalISODate } from './constants/common.js'
|
||||
import {
|
||||
filterInjectedMemoryFiles,
|
||||
getClaudeMds,
|
||||
getMemoryFiles,
|
||||
} from './utils/claudemd.js'
|
||||
import { logForDiagnosticsNoPII } from './utils/diagLogs.js'
|
||||
import { isBareMode, isEnvTruthy } from './utils/envUtils.js'
|
||||
import { execFileNoThrow } from './utils/execFileNoThrow.js'
|
||||
import { getBranch, getDefaultBranch, getIsGit, gitExe } from './utils/git.js'
|
||||
import { shouldIncludeGitInstructions } from './utils/gitSettings.js'
|
||||
import { logError } from './utils/log.js'
|
||||
import { setCachedClaudeMdContent } from './bootstrap/state.js'
|
||||
|
||||
const MAX_STATUS_CHARS = 2000
|
||||
|
||||
// System prompt injection for cache breaking (ant-only, ephemeral debugging state)
|
||||
// System prompt injection remains a local cache-busting hook only.
|
||||
let systemPromptInjection: string | null = null
|
||||
|
||||
export function getSystemPromptInjection(): string | null {
|
||||
@@ -28,162 +10,17 @@ export function getSystemPromptInjection(): string | null {
|
||||
|
||||
export function setSystemPromptInjection(value: string | null): void {
|
||||
systemPromptInjection = value
|
||||
// Clear context caches immediately when injection changes
|
||||
getUserContext.cache.clear?.()
|
||||
getSystemContext.cache.clear?.()
|
||||
}
|
||||
|
||||
export const getGitStatus = memoize(async (): Promise<string | null> => {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
// Avoid cycles in tests
|
||||
return null
|
||||
}
|
||||
export const getGitStatus = memoize(async (): Promise<string | null> => null)
|
||||
|
||||
const startTime = Date.now()
|
||||
logForDiagnosticsNoPII('info', 'git_status_started')
|
||||
|
||||
const isGitStart = Date.now()
|
||||
const isGit = await getIsGit()
|
||||
logForDiagnosticsNoPII('info', 'git_is_git_check_completed', {
|
||||
duration_ms: Date.now() - isGitStart,
|
||||
is_git: isGit,
|
||||
})
|
||||
|
||||
if (!isGit) {
|
||||
logForDiagnosticsNoPII('info', 'git_status_skipped_not_git', {
|
||||
duration_ms: Date.now() - startTime,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const gitCmdsStart = Date.now()
|
||||
const [branch, mainBranch, status, log, userName] = await Promise.all([
|
||||
getBranch(),
|
||||
getDefaultBranch(),
|
||||
execFileNoThrow(gitExe(), ['--no-optional-locks', 'status', '--short'], {
|
||||
preserveOutputOnError: false,
|
||||
}).then(({ stdout }) => stdout.trim()),
|
||||
execFileNoThrow(
|
||||
gitExe(),
|
||||
['--no-optional-locks', 'log', '--oneline', '-n', '5'],
|
||||
{
|
||||
preserveOutputOnError: false,
|
||||
},
|
||||
).then(({ stdout }) => stdout.trim()),
|
||||
execFileNoThrow(gitExe(), ['config', 'user.name'], {
|
||||
preserveOutputOnError: false,
|
||||
}).then(({ stdout }) => stdout.trim()),
|
||||
])
|
||||
|
||||
logForDiagnosticsNoPII('info', 'git_commands_completed', {
|
||||
duration_ms: Date.now() - gitCmdsStart,
|
||||
status_length: status.length,
|
||||
})
|
||||
|
||||
// Check if status exceeds character limit
|
||||
const truncatedStatus =
|
||||
status.length > MAX_STATUS_CHARS
|
||||
? status.substring(0, MAX_STATUS_CHARS) +
|
||||
'\n... (truncated because it exceeds 2k characters. If you need more information, run "git status" using BashTool)'
|
||||
: status
|
||||
|
||||
logForDiagnosticsNoPII('info', 'git_status_completed', {
|
||||
duration_ms: Date.now() - startTime,
|
||||
truncated: status.length > MAX_STATUS_CHARS,
|
||||
})
|
||||
|
||||
return [
|
||||
`This is the git status at the start of the conversation. Note that this status is a snapshot in time, and will not update during the conversation.`,
|
||||
`Current branch: ${branch}`,
|
||||
`Main branch (you will usually use this for PRs): ${mainBranch}`,
|
||||
...(userName ? [`Git user: ${userName}`] : []),
|
||||
`Status:\n${truncatedStatus || '(clean)'}`,
|
||||
`Recent commits:\n${log}`,
|
||||
].join('\n\n')
|
||||
} catch (error) {
|
||||
logForDiagnosticsNoPII('error', 'git_status_failed', {
|
||||
duration_ms: Date.now() - startTime,
|
||||
})
|
||||
logError(error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* This context is prepended to each conversation, and cached for the duration of the conversation.
|
||||
*/
|
||||
export const getSystemContext = memoize(
|
||||
async (): Promise<{
|
||||
[k: string]: string
|
||||
}> => {
|
||||
const startTime = Date.now()
|
||||
logForDiagnosticsNoPII('info', 'system_context_started')
|
||||
|
||||
// Skip git status in CCR (unnecessary overhead on resume) or when git instructions are disabled
|
||||
const gitStatus =
|
||||
isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) ||
|
||||
!shouldIncludeGitInstructions()
|
||||
? null
|
||||
: await getGitStatus()
|
||||
|
||||
// Include system prompt injection if set (for cache breaking, ant-only)
|
||||
const injection = feature('BREAK_CACHE_COMMAND')
|
||||
? getSystemPromptInjection()
|
||||
: null
|
||||
|
||||
logForDiagnosticsNoPII('info', 'system_context_completed', {
|
||||
duration_ms: Date.now() - startTime,
|
||||
has_git_status: gitStatus !== null,
|
||||
has_injection: injection !== null,
|
||||
})
|
||||
|
||||
return {
|
||||
...(gitStatus && { gitStatus }),
|
||||
...(feature('BREAK_CACHE_COMMAND') && injection
|
||||
? {
|
||||
cacheBreaker: `[CACHE_BREAKER: ${injection}]`,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
},
|
||||
async (): Promise<Record<string, string>> => ({}),
|
||||
)
|
||||
|
||||
/**
|
||||
* This context is prepended to each conversation, and cached for the duration of the conversation.
|
||||
*/
|
||||
export const getUserContext = memoize(
|
||||
async (): Promise<{
|
||||
[k: string]: string
|
||||
}> => {
|
||||
const startTime = Date.now()
|
||||
logForDiagnosticsNoPII('info', 'user_context_started')
|
||||
|
||||
// CLAUDE_CODE_DISABLE_CLAUDE_MDS: hard off, always.
|
||||
// --bare: skip auto-discovery (cwd walk), BUT honor explicit --add-dir.
|
||||
// --bare means "skip what I didn't ask for", not "ignore what I asked for".
|
||||
const shouldDisableClaudeMd =
|
||||
isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS) ||
|
||||
(isBareMode() && getAdditionalDirectoriesForClaudeMd().length === 0)
|
||||
// Await the async I/O (readFile/readdir directory walk) so the event
|
||||
// loop yields naturally at the first fs.readFile.
|
||||
const claudeMd = shouldDisableClaudeMd
|
||||
? null
|
||||
: getClaudeMds(filterInjectedMemoryFiles(await getMemoryFiles()))
|
||||
// Cache for the auto-mode classifier (yoloClassifier.ts reads this
|
||||
// instead of importing claudemd.ts directly, which would create a
|
||||
// cycle through permissions/filesystem → permissions → yoloClassifier).
|
||||
setCachedClaudeMdContent(claudeMd || null)
|
||||
|
||||
logForDiagnosticsNoPII('info', 'user_context_completed', {
|
||||
duration_ms: Date.now() - startTime,
|
||||
claudemd_length: claudeMd?.length ?? 0,
|
||||
claudemd_disabled: Boolean(shouldDisableClaudeMd),
|
||||
})
|
||||
|
||||
return {
|
||||
...(claudeMd && { claudeMd }),
|
||||
currentDate: `Today's date is ${getLocalISODate()}.`,
|
||||
}
|
||||
},
|
||||
)
|
||||
export const getUserContext = memoize(async (): Promise<Record<string, string>> => {
|
||||
setCachedClaudeMdContent(null)
|
||||
return {}
|
||||
})
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
setCostStateForRestore,
|
||||
setHasUnknownModelCost,
|
||||
} from './bootstrap/state.js'
|
||||
import type { ModelUsage } from './entrypoints/agentSdkTypes.js'
|
||||
import type { ModelUsage } from './entrypoints/agentSdkTypes.ts'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { feature } from 'bun:bundle';
|
||||
|
||||
const CLI_MACRO =
|
||||
typeof MACRO !== 'undefined'
|
||||
? MACRO
|
||||
: {
|
||||
VERSION: 'dev',
|
||||
BUILD_TIME: '',
|
||||
PACKAGE_URL: '@anthropic-ai/claude-code',
|
||||
ISSUES_EXPLAINER:
|
||||
'https://docs.anthropic.com/en/docs/claude-code/feedback',
|
||||
FEEDBACK_CHANNEL: 'github',
|
||||
};
|
||||
// Define MACRO global for development (normally injected by bun build --define)
|
||||
if (typeof MACRO === 'undefined') {
|
||||
(globalThis as typeof globalThis & {
|
||||
MACRO: {
|
||||
VERSION: string
|
||||
BUILD_TIME: string
|
||||
PACKAGE_URL: string
|
||||
ISSUES_EXPLAINER: string
|
||||
FEEDBACK_CHANNEL: string
|
||||
}
|
||||
}).MACRO = {
|
||||
VERSION: '2.1.88-dev',
|
||||
BUILD_TIME: new Date().toISOString(),
|
||||
PACKAGE_URL: 'claude-code-recover',
|
||||
ISSUES_EXPLAINER:
|
||||
'https://docs.anthropic.com/en/docs/claude-code/feedback',
|
||||
FEEDBACK_CHANNEL: 'github',
|
||||
};
|
||||
}
|
||||
|
||||
// Bugfix for corepack auto-pinning, which adds yarnpkg to peoples' package.jsons
|
||||
// eslint-disable-next-line custom-rules/no-top-level-side-effects
|
||||
@@ -49,7 +57,7 @@ async function main(): Promise<void> {
|
||||
if (args.length === 1 && (args[0] === '--version' || args[0] === '-v' || args[0] === '-V')) {
|
||||
// MACRO.VERSION is inlined at build time
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${CLI_MACRO.VERSION} (Claude Code)`);
|
||||
console.log(`${MACRO.VERSION} (Claude Code)`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export {
|
||||
SDKControlRequest,
|
||||
SDKControlResponse,
|
||||
} from './controlTypes.ts'
|
||||
@@ -1,14 +1,325 @@
|
||||
export type SDKControlRequest = Record<string, unknown> & {
|
||||
subtype?: string
|
||||
import type { SDKMessage } from './coreTypes.ts'
|
||||
|
||||
export type SDKPermissionResponse =
|
||||
| {
|
||||
behavior: 'allow'
|
||||
updatedInput?: Record<string, unknown>
|
||||
message?: string
|
||||
}
|
||||
| {
|
||||
behavior: 'deny' | 'ask'
|
||||
message?: string
|
||||
updatedInput?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type SDKControlInterruptRequest = {
|
||||
subtype: 'interrupt'
|
||||
}
|
||||
|
||||
export type SDKControlResponse = Record<string, unknown> & {
|
||||
type?: string
|
||||
subtype?: string
|
||||
export type SDKControlPermissionRequest = {
|
||||
subtype: 'can_use_tool'
|
||||
tool_name: string
|
||||
input: Record<string, unknown>
|
||||
permission_suggestions?: Array<Record<string, unknown>>
|
||||
blocked_path?: string
|
||||
decision_reason?: string
|
||||
title?: string
|
||||
display_name?: string
|
||||
tool_use_id: string
|
||||
agent_id?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type StdoutMessage = SDKControlResponse
|
||||
export type SDKControlInitializeRequest = {
|
||||
subtype: 'initialize'
|
||||
hooks?: Record<string, Array<Record<string, unknown>>>
|
||||
sdkMcpServers?: string[]
|
||||
jsonSchema?: Record<string, unknown>
|
||||
systemPrompt?: string
|
||||
appendSystemPrompt?: string
|
||||
agents?: Record<string, unknown>
|
||||
promptSuggestions?: boolean
|
||||
agentProgressSummaries?: boolean
|
||||
}
|
||||
|
||||
export class SDKControlRequest {}
|
||||
export type SDKControlSetPermissionModeRequest = {
|
||||
subtype: 'set_permission_mode'
|
||||
mode: string
|
||||
ultraplan?: boolean
|
||||
}
|
||||
|
||||
export class SDKControlResponse {}
|
||||
export type SDKControlSetModelRequest = {
|
||||
subtype: 'set_model'
|
||||
model?: string
|
||||
}
|
||||
|
||||
export type SDKControlSetMaxThinkingTokensRequest = {
|
||||
subtype: 'set_max_thinking_tokens'
|
||||
max_thinking_tokens: number | null
|
||||
}
|
||||
|
||||
export type SDKControlMcpStatusRequest = {
|
||||
subtype: 'mcp_status'
|
||||
}
|
||||
|
||||
export type SDKControlGetContextUsageRequest = {
|
||||
subtype: 'get_context_usage'
|
||||
}
|
||||
|
||||
export type SDKControlRewindFilesRequest = {
|
||||
subtype: 'rewind_files'
|
||||
paths?: string[]
|
||||
}
|
||||
|
||||
export type SDKControlCancelAsyncMessageRequest = {
|
||||
subtype: 'cancel_async_message'
|
||||
message_uuid: string
|
||||
}
|
||||
|
||||
export type SDKControlSeedReadStateRequest = {
|
||||
subtype: 'seed_read_state'
|
||||
path: string
|
||||
mtime: number
|
||||
}
|
||||
|
||||
export type SDKHookCallbackRequest = {
|
||||
subtype: 'hook_callback'
|
||||
callback_id: string
|
||||
input: Record<string, unknown>
|
||||
tool_use_id?: string
|
||||
}
|
||||
|
||||
export type SDKControlMcpMessageRequest = {
|
||||
subtype: 'mcp_message'
|
||||
server_name: string
|
||||
message: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type SDKControlMcpSetServersRequest = {
|
||||
subtype: 'mcp_set_servers'
|
||||
servers: Record<string, Record<string, unknown>>
|
||||
}
|
||||
|
||||
export type SDKControlReloadPluginsRequest = {
|
||||
subtype: 'reload_plugins'
|
||||
}
|
||||
|
||||
export type SDKControlMcpReconnectRequest = {
|
||||
subtype: 'mcp_reconnect'
|
||||
serverName: string
|
||||
}
|
||||
|
||||
export type SDKControlMcpToggleRequest = {
|
||||
subtype: 'mcp_toggle'
|
||||
serverName: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type SDKControlStopTaskRequest = {
|
||||
subtype: 'stop_task'
|
||||
task_id: string
|
||||
}
|
||||
|
||||
export type SDKControlApplyFlagSettingsRequest = {
|
||||
subtype: 'apply_flag_settings'
|
||||
settings?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type SDKControlGetSettingsRequest = {
|
||||
subtype: 'get_settings'
|
||||
}
|
||||
|
||||
export type SDKControlElicitationRequest = {
|
||||
subtype: 'elicitation'
|
||||
prompt?: string
|
||||
spec?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type SDKControlRequestInner =
|
||||
| SDKControlInterruptRequest
|
||||
| SDKControlPermissionRequest
|
||||
| SDKControlInitializeRequest
|
||||
| SDKControlSetPermissionModeRequest
|
||||
| SDKControlSetModelRequest
|
||||
| SDKControlSetMaxThinkingTokensRequest
|
||||
| SDKControlMcpStatusRequest
|
||||
| SDKControlGetContextUsageRequest
|
||||
| SDKHookCallbackRequest
|
||||
| SDKControlRewindFilesRequest
|
||||
| SDKControlCancelAsyncMessageRequest
|
||||
| SDKControlSeedReadStateRequest
|
||||
| SDKControlMcpMessageRequest
|
||||
| SDKControlMcpSetServersRequest
|
||||
| SDKControlReloadPluginsRequest
|
||||
| SDKControlMcpReconnectRequest
|
||||
| SDKControlMcpToggleRequest
|
||||
| SDKControlStopTaskRequest
|
||||
| SDKControlApplyFlagSettingsRequest
|
||||
| SDKControlGetSettingsRequest
|
||||
| SDKControlElicitationRequest
|
||||
| ({
|
||||
subtype: string
|
||||
} & Record<string, unknown>)
|
||||
|
||||
export type SDKControlRequest = {
|
||||
type: 'control_request'
|
||||
request_id: string
|
||||
request: SDKControlRequestInner
|
||||
}
|
||||
|
||||
export type SDKControlSuccessResponse = {
|
||||
subtype: 'success'
|
||||
request_id: string
|
||||
response?: SDKPermissionResponse | Record<string, unknown>
|
||||
}
|
||||
|
||||
export type SDKControlErrorResponse = {
|
||||
subtype: 'error'
|
||||
request_id: string
|
||||
error: string
|
||||
}
|
||||
|
||||
export type SDKControlInitializeResponse = {
|
||||
subtype: 'initialize'
|
||||
request_id: string
|
||||
commands?: Array<Record<string, unknown>>
|
||||
agents?: Array<Record<string, unknown>>
|
||||
output_style?: string
|
||||
available_output_styles?: string[]
|
||||
models?: Array<Record<string, unknown>>
|
||||
account?: Record<string, unknown>
|
||||
pid?: number
|
||||
fast_mode_state?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type SDKControlMcpStatusResponse = {
|
||||
subtype: 'mcp_status'
|
||||
request_id: string
|
||||
mcpServers?: Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export type SDKControlCancelAsyncMessageResponse = {
|
||||
subtype: 'cancel_async_message'
|
||||
request_id: string
|
||||
cancelled: boolean
|
||||
}
|
||||
|
||||
export type SDKControlMcpSetServersResponse = {
|
||||
subtype: 'mcp_set_servers'
|
||||
request_id: string
|
||||
added: string[]
|
||||
removed: string[]
|
||||
errors: Record<string, string>
|
||||
}
|
||||
|
||||
export type SDKControlReloadPluginsResponse = {
|
||||
subtype: 'reload_plugins'
|
||||
request_id: string
|
||||
commands?: Array<Record<string, unknown>>
|
||||
agents?: Array<Record<string, unknown>>
|
||||
plugins?: Array<Record<string, unknown>>
|
||||
mcpServers?: Array<Record<string, unknown>>
|
||||
error_count?: number
|
||||
}
|
||||
|
||||
export type SDKControlGetContextUsageResponse = {
|
||||
subtype: 'get_context_usage'
|
||||
request_id: string
|
||||
categories?: Array<Record<string, unknown>>
|
||||
totalTokens?: number
|
||||
maxTokens?: number
|
||||
rawMaxTokens?: number
|
||||
percentage?: number
|
||||
gridRows?: Array<Array<Record<string, unknown>>>
|
||||
model?: string
|
||||
memoryFiles?: Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export type SDKControlGetSettingsResponse = {
|
||||
subtype: 'get_settings'
|
||||
request_id: string
|
||||
effective: Record<string, unknown>
|
||||
sources: Array<{
|
||||
source:
|
||||
| 'userSettings'
|
||||
| 'projectSettings'
|
||||
| 'localSettings'
|
||||
| 'flagSettings'
|
||||
| 'policySettings'
|
||||
settings: Record<string, unknown>
|
||||
}>
|
||||
applied?: {
|
||||
model: string
|
||||
effort: 'low' | 'medium' | 'high' | 'max' | null
|
||||
}
|
||||
}
|
||||
|
||||
export type SDKControlElicitationResponse = {
|
||||
subtype: 'elicitation'
|
||||
request_id: string
|
||||
response?: {
|
||||
action?: 'accept' | 'decline' | 'cancel'
|
||||
content?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export type SDKControlResponseInner =
|
||||
| SDKControlSuccessResponse
|
||||
| SDKControlErrorResponse
|
||||
| SDKControlInitializeResponse
|
||||
| SDKControlMcpStatusResponse
|
||||
| SDKControlCancelAsyncMessageResponse
|
||||
| SDKControlMcpSetServersResponse
|
||||
| SDKControlReloadPluginsResponse
|
||||
| SDKControlGetContextUsageResponse
|
||||
| SDKControlGetSettingsResponse
|
||||
| SDKControlElicitationResponse
|
||||
| ({
|
||||
subtype: string
|
||||
request_id: string
|
||||
} & Record<string, unknown>)
|
||||
|
||||
export type SDKControlResponse = {
|
||||
type: 'control_response'
|
||||
response: SDKControlResponseInner
|
||||
}
|
||||
|
||||
export type SDKControlCancelRequest = {
|
||||
type: 'control_cancel_request'
|
||||
request_id: string
|
||||
tool_use_id?: string
|
||||
}
|
||||
|
||||
export type KeepAliveMessage = {
|
||||
type: 'keep_alive'
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type SDKUpdateEnvironmentVariablesMessage = {
|
||||
type: 'update_environment_variables'
|
||||
variables: Record<string, string>
|
||||
}
|
||||
|
||||
export type SDKStreamlinedTextMessage = {
|
||||
type: 'streamlined_text'
|
||||
text: string
|
||||
session_id?: string
|
||||
uuid?: string
|
||||
}
|
||||
|
||||
export type SDKStreamlinedToolUseSummaryMessage = {
|
||||
type: 'streamlined_tool_use_summary'
|
||||
tool_summary: string
|
||||
session_id?: string
|
||||
uuid?: string
|
||||
}
|
||||
|
||||
export type StdoutMessage =
|
||||
| SDKMessage
|
||||
| SDKControlRequest
|
||||
| SDKControlResponse
|
||||
| SDKControlCancelRequest
|
||||
| KeepAliveMessage
|
||||
| SDKUpdateEnvironmentVariablesMessage
|
||||
| SDKStreamlinedTextMessage
|
||||
| SDKStreamlinedToolUseSummaryMessage
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './coreTypes.generated.ts'
|
||||
@@ -16,7 +16,7 @@ export type {
|
||||
SandboxSettings,
|
||||
} from '../sandboxTypes.js'
|
||||
// Re-export all generated types
|
||||
export * from './coreTypes.generated.js'
|
||||
export * from './coreTypes.generated.ts'
|
||||
|
||||
// Re-export utility types that can't be expressed as Zod schemas
|
||||
export type { NonNullableUsage } from './sdkUtilityTypes.js'
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './runtimeTypes.ts'
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { CallToolResult, ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'
|
||||
import type { ZodTypeAny } from 'zod/v4'
|
||||
import type {
|
||||
SDKMessage,
|
||||
SDKResultMessage,
|
||||
SDKSessionInfo as CoreSDKSessionInfo,
|
||||
SDKUserMessage,
|
||||
} from './coreTypes.ts'
|
||||
|
||||
export type EffortLevel = 'low' | 'medium' | 'high' | 'max'
|
||||
|
||||
@@ -19,17 +25,33 @@ export type SdkMcpToolDefinition<Schema extends AnyZodRawShape> = {
|
||||
alwaysLoad?: boolean
|
||||
}
|
||||
|
||||
export type McpSdkServerConfigWithInstance = Record<string, unknown>
|
||||
|
||||
export type Options = Record<string, unknown>
|
||||
export type InternalOptions = Options
|
||||
|
||||
export type SDKSessionOptions = Options & {
|
||||
model?: string
|
||||
export type McpSdkServerConfigWithInstance = Record<string, unknown> & {
|
||||
name?: string
|
||||
version?: string
|
||||
instance?: unknown
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
tools?: Array<SdkMcpToolDefinition<any>>
|
||||
}
|
||||
|
||||
export type Query = AsyncIterable<unknown>
|
||||
export type InternalQuery = AsyncIterable<unknown>
|
||||
export type Options = Record<string, unknown> & {
|
||||
cwd?: string
|
||||
model?: string
|
||||
permissionMode?: string
|
||||
maxThinkingTokens?: number | null
|
||||
}
|
||||
|
||||
export type InternalOptions = Options & {
|
||||
systemPrompt?: string
|
||||
appendSystemPrompt?: string
|
||||
}
|
||||
|
||||
export type SDKSessionOptions = Options & {
|
||||
cwd?: string
|
||||
resume?: boolean
|
||||
}
|
||||
|
||||
export type Query = AsyncIterable<SDKMessage>
|
||||
export type InternalQuery = AsyncIterable<SDKMessage>
|
||||
|
||||
export type SessionMutationOptions = {
|
||||
dir?: string
|
||||
@@ -48,7 +70,10 @@ export type GetSessionMessagesOptions = SessionMutationOptions & {
|
||||
includeSystemMessages?: boolean
|
||||
}
|
||||
|
||||
export type ForkSessionOptions = SessionMutationOptions
|
||||
export type ForkSessionOptions = SessionMutationOptions & {
|
||||
upToMessageId?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export type ForkSessionResult = {
|
||||
sessionId: string
|
||||
@@ -56,4 +81,13 @@ export type ForkSessionResult = {
|
||||
|
||||
export type SDKSession = {
|
||||
id: string
|
||||
query(
|
||||
prompt: string | AsyncIterable<SDKUserMessage>,
|
||||
options?: Options,
|
||||
): Query
|
||||
interrupt(): void
|
||||
}
|
||||
|
||||
export type SessionMessage = SDKMessage
|
||||
|
||||
export type SDKSessionInfo = CoreSDKSessionInfo
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './settingsTypes.generated.ts'
|
||||
@@ -1 +1,13 @@
|
||||
export type Settings = Record<string, unknown>
|
||||
export type SettingsValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| SettingsObject
|
||||
| SettingsValue[]
|
||||
|
||||
export type SettingsObject = {
|
||||
[key: string]: SettingsValue
|
||||
}
|
||||
|
||||
export type Settings = SettingsObject
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './toolTypes.ts'
|
||||
23
src/entrypoints/sdk/toolTypes.ts
Executable file → Normal file
23
src/entrypoints/sdk/toolTypes.ts
Executable file → Normal file
@@ -1 +1,22 @@
|
||||
export {}
|
||||
import type { CallToolResult, ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'
|
||||
|
||||
import type { AnyZodRawShape, InferShape, SdkMcpToolDefinition } from './runtimeTypes.ts'
|
||||
|
||||
export type { AnyZodRawShape, InferShape, SdkMcpToolDefinition }
|
||||
|
||||
export type SdkMcpToolHandler<Schema extends AnyZodRawShape> = (
|
||||
args: InferShape<Schema>,
|
||||
extra: unknown,
|
||||
) => Promise<CallToolResult>
|
||||
|
||||
export type SdkMcpToolExtras = {
|
||||
annotations?: ToolAnnotations
|
||||
searchHint?: string
|
||||
alwaysLoad?: boolean
|
||||
}
|
||||
|
||||
export type SdkMcpToolDescriptor<Schema extends AnyZodRawShape> = Pick<
|
||||
SdkMcpToolDefinition<Schema>,
|
||||
'name' | 'description' | 'inputSchema' | 'handler'
|
||||
> &
|
||||
SdkMcpToolExtras
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { Command } from '../commands.js';
|
||||
import { getSlashCommandToolSkills, isBridgeSafeCommand } from '../commands.js';
|
||||
import { getRemoteSessionUrl } from '../constants/product.js';
|
||||
import { useNotifications } from '../context/notifications.js';
|
||||
import type { PermissionMode, SDKMessage } from '../entrypoints/agentSdkTypes.js';
|
||||
import type { PermissionMode, SDKMessage } from '../entrypoints/agentSdkTypes.ts';
|
||||
import type { SDKControlResponse } from '../entrypoints/sdk/controlTypes.ts';
|
||||
import { Text } from '../ink.js';
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js';
|
||||
|
||||
@@ -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
113
src/main.tsx
113
src/main.tsx
@@ -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';
|
||||
@@ -65,18 +65,6 @@ import { computeInitialTeamContext } from './utils/swarm/reconnection.js';
|
||||
import { initializeWarningHandler } from './utils/warningHandler.js';
|
||||
import { isWorktreeModeEnabled } from './utils/worktreeModeEnabled.js';
|
||||
|
||||
const MAIN_MACRO =
|
||||
typeof MACRO !== 'undefined'
|
||||
? MACRO
|
||||
: {
|
||||
VERSION: 'dev',
|
||||
BUILD_TIME: '',
|
||||
PACKAGE_URL: '@anthropic-ai/claude-code',
|
||||
ISSUES_EXPLAINER:
|
||||
'https://docs.anthropic.com/en/docs/claude-code/feedback',
|
||||
FEEDBACK_CHANNEL: 'github',
|
||||
};
|
||||
|
||||
// Lazy require to avoid circular dependency: teammate.ts -> AppState.tsx -> ... -> main.tsx
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const getTeammateUtils = () => require('./utils/teammate.js') as typeof import('./utils/teammate.js');
|
||||
@@ -92,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';
|
||||
@@ -115,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';
|
||||
@@ -133,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';
|
||||
@@ -144,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
|
||||
@@ -208,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';
|
||||
@@ -282,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;
|
||||
@@ -425,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();
|
||||
|
||||
@@ -935,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');
|
||||
@@ -2297,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();
|
||||
@@ -2599,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 —
|
||||
@@ -2832,7 +2750,6 @@ async function run(): Promise<CommanderCommand> {
|
||||
void import('./utils/sdkHeapDumpMonitor.js').then(m => m.startSdkMemoryMonitor());
|
||||
}
|
||||
}
|
||||
logSessionTelemetry();
|
||||
profileCheckpoint('before_print_import');
|
||||
const {
|
||||
runHeadless
|
||||
@@ -3055,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
|
||||
@@ -3817,7 +3730,7 @@ async function run(): Promise<CommanderCommand> {
|
||||
pendingHookMessages
|
||||
}, renderAndRun);
|
||||
}
|
||||
}).version(`${MAIN_MACRO.VERSION} (Claude Code)`, '-v, --version', 'Output the version number');
|
||||
}).version(`${MACRO.VERSION} (Claude Code)`, '-v, --version', 'Output the version number');
|
||||
|
||||
// Worktree flags
|
||||
program.option('-w, --worktree [name]', 'Create a new git worktree for this session (optionally specify a name)');
|
||||
|
||||
@@ -55,7 +55,6 @@ import {
|
||||
stripSignatureBlocks,
|
||||
} from './utils/messages.js'
|
||||
import { generateToolUseSummary } from './services/toolUseSummary/toolUseSummaryGenerator.js'
|
||||
import { prependUserContext, appendSystemContext } from './utils/api.js'
|
||||
import {
|
||||
createAttachmentMessage,
|
||||
filterDuplicateMemoryAttachments,
|
||||
@@ -446,9 +445,7 @@ async function* queryLoop(
|
||||
messagesForQuery = collapseResult.messages
|
||||
}
|
||||
|
||||
const fullSystemPrompt = asSystemPrompt(
|
||||
appendSystemContext(systemPrompt, systemContext),
|
||||
)
|
||||
const fullSystemPrompt = asSystemPrompt(systemPrompt)
|
||||
|
||||
queryCheckpoint('query_autocompact_start')
|
||||
const { compactionResult, consecutiveFailures } = await deps.autocompact(
|
||||
@@ -657,7 +654,7 @@ async function* queryLoop(
|
||||
let streamingFallbackOccured = false
|
||||
queryCheckpoint('query_api_streaming_start')
|
||||
for await (const message of deps.callModel({
|
||||
messages: prependUserContext(messagesForQuery, userContext),
|
||||
messages: messagesForQuery,
|
||||
systemPrompt: fullSystemPrompt,
|
||||
thinkingConfig: toolUseContext.options.thinkingConfig,
|
||||
tools: toolUseContext.options.tools,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.ts'
|
||||
import type {
|
||||
SDKControlCancelRequest,
|
||||
SDKControlPermissionRequest,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { getOauthConfig } from '../constants/oauth.js'
|
||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.ts'
|
||||
import type {
|
||||
SDKControlCancelRequest,
|
||||
SDKControlRequest,
|
||||
@@ -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,
|
||||
)}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
SDKStatusMessage,
|
||||
SDKSystemMessage,
|
||||
SDKToolProgressMessage,
|
||||
} from '../entrypoints/agentSdkTypes.js'
|
||||
} from '../entrypoints/agentSdkTypes.ts'
|
||||
import type {
|
||||
AssistantMessage,
|
||||
Message,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* Both files now import from this shared location instead of each other.
|
||||
*/
|
||||
|
||||
import { HOOK_EVENTS, type HookEvent } from 'src/entrypoints/agentSdkTypes.js'
|
||||
import { HOOK_EVENTS, type HookEvent } from 'src/entrypoints/agentSdkTypes.ts'
|
||||
import { z } from 'zod/v4'
|
||||
import { lazySchema } from '../utils/lazySchema.js'
|
||||
import { SHELL_TYPES } from '../utils/shell/shellProvider.js'
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable eslint-plugin-n/no-unsupported-features/node-builtins */
|
||||
|
||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.ts'
|
||||
import type {
|
||||
SDKControlPermissionRequest,
|
||||
StdoutMessage,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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(() => {})
|
||||
}
|
||||
@@ -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
32
src/services/analytics/index.test.ts
Normal file
32
src/services/analytics/index.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
BetaStopReason,
|
||||
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
import { AFK_MODE_BETA_HEADER } from 'src/constants/betas.js'
|
||||
import type { SDKAssistantMessageError } from 'src/entrypoints/agentSdkTypes.js'
|
||||
import type { SDKAssistantMessageError } from 'src/entrypoints/agentSdkTypes.ts'
|
||||
import type {
|
||||
AssistantMessage,
|
||||
Message,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ToolUseBlock } from '@anthropic-ai/sdk/resources';
|
||||
import { getRemoteSessionUrl } from '../../constants/product.js';
|
||||
import { OUTPUT_FILE_TAG, REMOTE_REVIEW_PROGRESS_TAG, REMOTE_REVIEW_TAG, STATUS_TAG, SUMMARY_TAG, TASK_ID_TAG, TASK_NOTIFICATION_TAG, TASK_TYPE_TAG, TOOL_USE_ID_TAG, ULTRAPLAN_TAG } from '../../constants/xml.js';
|
||||
import type { SDKAssistantMessage, SDKMessage } from '../../entrypoints/agentSdkTypes.js';
|
||||
import type { SDKAssistantMessage, SDKMessage } from '../../entrypoints/agentSdkTypes.ts';
|
||||
import type { SetAppState, Task, TaskContext, TaskStateBase } from '../../Task.js';
|
||||
import { createTaskStateBase, generateTaskId } from '../../Task.js';
|
||||
import { TodoWriteTool } from '../../tools/TodoWriteTool/TodoWriteTool.js';
|
||||
|
||||
@@ -57,7 +57,7 @@ import { TodoWriteTool } from './tools/TodoWriteTool/TodoWriteTool.js'
|
||||
import { ExitPlanModeV2Tool } from './tools/ExitPlanModeTool/ExitPlanModeV2Tool.js'
|
||||
import { TestingPermissionTool } from './tools/testing/TestingPermissionTool.js'
|
||||
import { GrepTool } from './tools/GrepTool/GrepTool.js'
|
||||
import { TungstenTool } from './tools/TungstenTool/TungstenTool.js'
|
||||
import { TungstenTool } from './tools/TungstenTool/TungstenTool.ts'
|
||||
// Lazy require to break circular dependency: tools.ts -> TeamCreateTool/TeamDeleteTool -> ... -> tools.ts
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const getTeamCreateTool = () =>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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',
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user