Compare commits
39 Commits
e8da02f5bc
...
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 | |||
| 20523234e0 | |||
| ecec8b6ba7 | |||
| 23a61be07b | |||
| fa9c74e7b7 |
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.
|
- `node_modules/`, `dist/`, and generated CLI binaries are ignored by Git.
|
||||||
- `bun.lock` is kept in the repository for reproducible installs.
|
- `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,
|
SDKPermissionDenial,
|
||||||
SDKStatus,
|
SDKStatus,
|
||||||
SDKUserMessageReplay,
|
SDKUserMessageReplay,
|
||||||
} from 'src/entrypoints/agentSdkTypes.js'
|
} from 'src/entrypoints/agentSdkTypes.ts'
|
||||||
import { accumulateUsage, updateUsage } from 'src/services/api/claude.js'
|
import { accumulateUsage, updateUsage } from 'src/services/api/claude.js'
|
||||||
import type { NonNullableUsage } from 'src/services/api/logging.js'
|
import type { NonNullableUsage } from 'src/services/api/logging.js'
|
||||||
import { EMPTY_USAGE } 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 { SpinnerMode } from './components/Spinner.js'
|
||||||
import type { QuerySource } from './constants/querySource.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 { AppState } from './state/AppState.js'
|
||||||
import type {
|
import type {
|
||||||
HookProgress,
|
HookProgress,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { getOauthConfig } from '../constants/oauth.js'
|
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 { logForDebugging } from '../utils/debug.js'
|
||||||
import { getOAuthHeaders, prepareApiRequest } from '../utils/teleport/api.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 { realpathSync } from 'fs'
|
||||||
import sumBy from 'lodash-es/sumBy.js'
|
import sumBy from 'lodash-es/sumBy.js'
|
||||||
import { cwd } from 'process'
|
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 { AgentColorName } from 'src/tools/AgentTool/agentColorManager.js'
|
||||||
import type { HookCallbackMatcher } from 'src/types/hooks.js'
|
import type { HookCallbackMatcher } from 'src/types/hooks.js'
|
||||||
// Indirection for browser-sdk build (package.json "browser" field swaps
|
// 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.
|
* tokens don't refresh, so 401 goes straight to BridgeFatalError.
|
||||||
*/
|
*/
|
||||||
onAuth401?: (staleAccessToken: string) => Promise<boolean>
|
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'
|
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 {
|
export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient {
|
||||||
function debug(msg: string): void {
|
function debug(msg: string): void {
|
||||||
deps.onDebug?.(msg)
|
deps.onDebug?.(msg)
|
||||||
@@ -74,18 +94,13 @@ export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient {
|
|||||||
const EMPTY_POLL_LOG_INTERVAL = 100
|
const EMPTY_POLL_LOG_INTERVAL = 100
|
||||||
|
|
||||||
function getHeaders(accessToken: string): Record<string, string> {
|
function getHeaders(accessToken: string): Record<string, string> {
|
||||||
const headers: Record<string, string> = {
|
return {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'anthropic-version': '2023-06-01',
|
'anthropic-version': '2023-06-01',
|
||||||
'anthropic-beta': BETA_HEADER,
|
'anthropic-beta': BETA_HEADER,
|
||||||
'x-environment-runner-version': deps.runnerVersion,
|
'x-environment-runner-version': deps.runnerVersion,
|
||||||
}
|
}
|
||||||
const deviceToken = deps.getTrustedDeviceToken?.()
|
|
||||||
if (deviceToken) {
|
|
||||||
headers['X-Trusted-Device-Token'] = deviceToken
|
|
||||||
}
|
|
||||||
return headers
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveAuth(): string {
|
function resolveAuth(): string {
|
||||||
@@ -154,10 +169,6 @@ export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient {
|
|||||||
}>(
|
}>(
|
||||||
`${deps.baseUrl}/v1/environments/bridge`,
|
`${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
|
// Advertise session capacity so claude.ai/code can show
|
||||||
// "2/4 sessions" badges and only block the picker when
|
// "2/4 sessions" badges and only block the picker when
|
||||||
// actually at capacity. Backends that don't yet accept
|
// 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')
|
handleErrorStatus(response.status, response.data, 'Registration')
|
||||||
debug(
|
debug(
|
||||||
`[bridge:api] POST /v1/environments/bridge -> ${response.status} environment_id=${response.data.environment_id}`,
|
`[bridge:api] POST /v1/environments/bridge -> ${response.status}`,
|
||||||
)
|
)
|
||||||
debug(
|
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
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -240,9 +253,11 @@ export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
debug(
|
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
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -446,7 +461,9 @@ export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient {
|
|||||||
`[bridge:api] POST /v1/sessions/${sessionId}/events -> ${response.status}`,
|
`[bridge:api] POST /v1/sessions/${sessionId}/events -> ${response.status}`,
|
||||||
)
|
)
|
||||||
debug(`[bridge:api] >>> ${debugBody({ events: [event] })}`)
|
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 { feature } from 'bun:bundle'
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
import { hostname, tmpdir } from 'os'
|
import { tmpdir } from 'os'
|
||||||
import { basename, join, resolve } from 'path'
|
import { basename, join, resolve } from 'path'
|
||||||
import { getRemoteSessionUrl } from '../constants/product.js'
|
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 { checkGate_CACHED_OR_BLOCKING } from '../services/analytics/growthbook.js'
|
||||||
import {
|
import {
|
||||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||||
@@ -30,12 +28,11 @@ import {
|
|||||||
import { formatDuration } from './bridgeStatusUtil.js'
|
import { formatDuration } from './bridgeStatusUtil.js'
|
||||||
import { createBridgeLogger } from './bridgeUI.js'
|
import { createBridgeLogger } from './bridgeUI.js'
|
||||||
import { createCapacityWake } from './capacityWake.js'
|
import { createCapacityWake } from './capacityWake.js'
|
||||||
import { describeAxiosError } from './debugUtils.js'
|
import { describeAxiosError, summarizeBridgeErrorForDebug } from './debugUtils.js'
|
||||||
import { createTokenRefreshScheduler } from './jwtUtils.js'
|
import { createTokenRefreshScheduler } from './jwtUtils.js'
|
||||||
import { getPollIntervalConfig } from './pollConfig.js'
|
import { getPollIntervalConfig } from './pollConfig.js'
|
||||||
import { toCompatSessionId, toInfraSessionId } from './sessionIdCompat.js'
|
import { toCompatSessionId, toInfraSessionId } from './sessionIdCompat.js'
|
||||||
import { createSessionSpawner, safeFilenameId } from './sessionRunner.js'
|
import { createSessionSpawner, safeFilenameId } from './sessionRunner.js'
|
||||||
import { getTrustedDeviceToken } from './trustedDevice.js'
|
|
||||||
import {
|
import {
|
||||||
BRIDGE_LOGIN_ERROR,
|
BRIDGE_LOGIN_ERROR,
|
||||||
type BridgeApiClient,
|
type BridgeApiClient,
|
||||||
@@ -2042,16 +2039,15 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
|||||||
)
|
)
|
||||||
enableConfigs()
|
enableConfigs()
|
||||||
|
|
||||||
// Initialize analytics and error reporting sinks. The bridge bypasses the
|
// Initialize shared sinks. The bridge bypasses setup(), so it attaches the
|
||||||
// setup() init flow, so we call initSinks() directly to attach sinks here.
|
// local error-log sink directly here.
|
||||||
const { initSinks } = await import('../utils/sinks.js')
|
const { initSinks } = await import('../utils/sinks.js')
|
||||||
initSinks()
|
initSinks()
|
||||||
|
|
||||||
// Gate-aware validation: --spawn / --capacity / --create-session-in-dir require
|
// Gate-aware validation: --spawn / --capacity / --create-session-in-dir require
|
||||||
// the multi-session gate. parseArgs has already validated flag combinations;
|
// the multi-session gate. parseArgs has already validated flag combinations;
|
||||||
// here we only check the gate since that requires an async GrowthBook call.
|
// here we only check the gate since that requires an async GrowthBook call.
|
||||||
// Runs after enableConfigs() (GrowthBook cache reads global config) and after
|
// Runs after enableConfigs() because GrowthBook cache reads global config.
|
||||||
// initSinks() so the denial event can be enqueued.
|
|
||||||
const multiSessionEnabled = await isMultiSessionSpawnEnabled()
|
const multiSessionEnabled = await isMultiSessionSpawnEnabled()
|
||||||
if (usedMultiSessionFeature && !multiSessionEnabled) {
|
if (usedMultiSessionFeature && !multiSessionEnabled) {
|
||||||
await logEventAsync('tengu_bridge_multi_session_denied', {
|
await logEventAsync('tengu_bridge_multi_session_denied', {
|
||||||
@@ -2059,14 +2055,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
|||||||
used_capacity: parsedCapacity !== undefined,
|
used_capacity: parsedCapacity !== undefined,
|
||||||
used_create_session_in_dir: parsedCreateSessionInDir !== 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
|
// biome-ignore lint/suspicious/noConsole: intentional error output
|
||||||
console.error(
|
console.error(
|
||||||
'Error: Multi-session Remote Control is not enabled for your account yet.',
|
'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
|
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
||||||
: baseUrl
|
: baseUrl
|
||||||
|
|
||||||
const { getBranch, getRemoteUrl, findGitRoot } = await import(
|
const { findGitRoot } = await import('../utils/git.js')
|
||||||
'../utils/git.js'
|
|
||||||
)
|
|
||||||
|
|
||||||
// Precheck worktree availability for the first-run dialog and the `w`
|
// Precheck worktree availability for the first-run dialog and the `w`
|
||||||
// toggle. Unconditional so we know upfront whether worktree is an option.
|
// 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)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const branch = await getBranch()
|
|
||||||
const gitRepoUrl = await getRemoteUrl()
|
|
||||||
const machineName = hostname()
|
|
||||||
const bridgeId = randomUUID()
|
const bridgeId = randomUUID()
|
||||||
|
|
||||||
const { handleOAuth401Error } = await import('../utils/auth.js')
|
const { handleOAuth401Error } = await import('../utils/auth.js')
|
||||||
@@ -2349,7 +2332,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
|||||||
runnerVersion: MACRO.VERSION,
|
runnerVersion: MACRO.VERSION,
|
||||||
onDebug: logForDebugging,
|
onDebug: logForDebugging,
|
||||||
onAuth401: handleOAuth401Error,
|
onAuth401: handleOAuth401Error,
|
||||||
getTrustedDeviceToken,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// When resuming a session via --session-id, fetch it to learn its
|
// 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 = {
|
const config: BridgeConfig = {
|
||||||
dir,
|
dir,
|
||||||
machineName,
|
|
||||||
branch,
|
|
||||||
gitRepoUrl,
|
|
||||||
maxSessions,
|
maxSessions,
|
||||||
spawnMode,
|
spawnMode,
|
||||||
verbose,
|
verbose,
|
||||||
@@ -2435,7 +2414,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logForDebugging(
|
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(
|
logForDebugging(
|
||||||
`[bridge:init] apiBaseUrl=${baseUrl} sessionIngressUrl=${sessionIngressUrl}`,
|
`[bridge:init] apiBaseUrl=${baseUrl} sessionIngressUrl=${sessionIngressUrl}`,
|
||||||
@@ -2591,11 +2570,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const logger = createBridgeLogger({ verbose })
|
const logger = createBridgeLogger({ verbose })
|
||||||
const { parseGitHubRepository } = await import('../utils/detectRepository.js')
|
logger.setRepoInfo(basename(dir), '')
|
||||||
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)
|
|
||||||
|
|
||||||
// `w` toggle is available iff we're in a multi-session mode AND worktree
|
// `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.
|
// 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,
|
environmentId,
|
||||||
title: name,
|
title: name,
|
||||||
events: [],
|
events: [],
|
||||||
gitRepoUrl,
|
|
||||||
branch,
|
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
getAccessToken: getBridgeAccessToken,
|
getAccessToken: getBridgeAccessToken,
|
||||||
@@ -2856,9 +2829,7 @@ export async function runBridgeHeadless(
|
|||||||
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
||||||
: baseUrl
|
: baseUrl
|
||||||
|
|
||||||
const { getBranch, getRemoteUrl, findGitRoot } = await import(
|
const { findGitRoot } = await import('../utils/git.js')
|
||||||
'../utils/git.js'
|
|
||||||
)
|
|
||||||
const { hasWorktreeCreateHook } = await import('../utils/hooks.js')
|
const { hasWorktreeCreateHook } = await import('../utils/hooks.js')
|
||||||
|
|
||||||
if (opts.spawnMode === 'worktree') {
|
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 bridgeId = randomUUID()
|
||||||
|
|
||||||
const config: BridgeConfig = {
|
const config: BridgeConfig = {
|
||||||
dir,
|
dir,
|
||||||
machineName,
|
|
||||||
branch,
|
|
||||||
gitRepoUrl,
|
|
||||||
maxSessions: opts.capacity,
|
maxSessions: opts.capacity,
|
||||||
spawnMode: opts.spawnMode,
|
spawnMode: opts.spawnMode,
|
||||||
verbose: false,
|
verbose: false,
|
||||||
@@ -2899,7 +2864,6 @@ export async function runBridgeHeadless(
|
|||||||
runnerVersion: MACRO.VERSION,
|
runnerVersion: MACRO.VERSION,
|
||||||
onDebug: log,
|
onDebug: log,
|
||||||
onAuth401: opts.onAuth401,
|
onAuth401: opts.onAuth401,
|
||||||
getTrustedDeviceToken,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
let environmentId: string
|
let environmentId: string
|
||||||
@@ -2934,8 +2898,6 @@ export async function runBridgeHeadless(
|
|||||||
environmentId,
|
environmentId,
|
||||||
title: opts.name,
|
title: opts.name,
|
||||||
events: [],
|
events: [],
|
||||||
gitRepoUrl,
|
|
||||||
branch,
|
|
||||||
signal,
|
signal,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
getAccessToken: opts.getAccessToken,
|
getAccessToken: opts.getAccessToken,
|
||||||
|
|||||||
@@ -11,21 +11,21 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
import type { SDKMessage } from '../entrypoints/agentSdkTypes.ts'
|
||||||
import type {
|
import type {
|
||||||
SDKControlRequest,
|
SDKControlRequest,
|
||||||
SDKControlResponse,
|
SDKControlResponse,
|
||||||
} from '../entrypoints/sdk/controlTypes.js'
|
} from '../entrypoints/sdk/controlTypes.ts'
|
||||||
import type { SDKResultSuccess } from '../entrypoints/sdk/coreTypes.js'
|
import type { SDKResultSuccess } from '../entrypoints/sdk/coreTypes.ts'
|
||||||
import { logEvent } from '../services/analytics/index.js'
|
import { logEvent } from '../services/analytics/index.js'
|
||||||
import { EMPTY_USAGE } from '../services/api/emptyUsage.js'
|
import { EMPTY_USAGE } from '../services/api/emptyUsage.js'
|
||||||
import type { Message } from '../types/message.js'
|
import type { Message } from '../types/message.js'
|
||||||
import { normalizeControlMessageKeys } from '../utils/controlMessageCompat.js'
|
import { normalizeControlMessageKeys } from '../utils/controlMessageCompat.js'
|
||||||
import { logForDebugging } from '../utils/debug.js'
|
import { logForDebugging } from '../utils/debug.js'
|
||||||
import { stripDisplayTagsAllowEmpty } from '../utils/displayTags.js'
|
import { stripDisplayTagsAllowEmpty } from '../utils/displayTags.js'
|
||||||
import { errorMessage } from '../utils/errors.js'
|
|
||||||
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
|
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
|
||||||
import { jsonParse } from '../utils/slowOperations.js'
|
import { jsonParse } from '../utils/slowOperations.js'
|
||||||
|
import { summarizeBridgeErrorForDebug } from './debugUtils.js'
|
||||||
import type { ReplBridgeTransport } from './replBridgeTransport.js'
|
import type { ReplBridgeTransport } from './replBridgeTransport.js'
|
||||||
|
|
||||||
// ─── Type guards ─────────────────────────────────────────────────────────────
|
// ─── Type guards ─────────────────────────────────────────────────────────────
|
||||||
@@ -179,13 +179,13 @@ export function handleIngressMessage(
|
|||||||
// receiving any frames, etc).
|
// receiving any frames, etc).
|
||||||
if (uuid && recentInboundUUIDs.has(uuid)) {
|
if (uuid && recentInboundUUIDs.has(uuid)) {
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[bridge:repl] Ignoring re-delivered inbound: type=${parsed.type} uuid=${uuid}`,
|
`[bridge:repl] Ignoring re-delivered inbound: type=${parsed.type}`,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[bridge:repl] Ingress message type=${parsed.type}${uuid ? ` uuid=${uuid}` : ''}`,
|
`[bridge:repl] Ingress message type=${parsed.type}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (parsed.type === 'user') {
|
if (parsed.type === 'user') {
|
||||||
@@ -202,7 +202,9 @@ export function handleIngressMessage(
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logForDebugging(
|
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 }
|
const event = { ...response, session_id: sessionId }
|
||||||
void transport.write(event)
|
void transport.write(event)
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[bridge:repl] Rejected ${request.request.subtype} (outbound-only) request_id=${request.request_id}`,
|
`[bridge:repl] Rejected ${request.request.subtype} (outbound-only)`,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -386,7 +388,7 @@ export function handleServerControlRequest(
|
|||||||
const event = { ...response, session_id: sessionId }
|
const event = { ...response, session_id: sessionId }
|
||||||
void transport.write(event)
|
void transport.write(event)
|
||||||
logForDebugging(
|
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 axios from 'axios'
|
||||||
import { logForDebugging } from '../utils/debug.js'
|
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 { jsonStringify } from '../utils/slowOperations.js'
|
||||||
import { extractErrorDetail } from './debugUtils.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(
|
export async function createCodeSession(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
@@ -47,7 +103,9 @@ export async function createCodeSession(
|
|||||||
)
|
)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[code-session] Session create request failed: ${errorMessage(err)}`,
|
`[code-session] Session create request failed: ${summarizeCodeSessionErrorForDebug(
|
||||||
|
err,
|
||||||
|
)}`,
|
||||||
)
|
)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -72,7 +130,9 @@ export async function createCodeSession(
|
|||||||
!data.session.id.startsWith('cse_')
|
!data.session.id.startsWith('cse_')
|
||||||
) {
|
) {
|
||||||
logForDebugging(
|
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
|
return null
|
||||||
}
|
}
|
||||||
@@ -95,27 +155,24 @@ export async function fetchRemoteCredentials(
|
|||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
trustedDeviceToken?: string,
|
|
||||||
): Promise<RemoteCredentials | null> {
|
): Promise<RemoteCredentials | null> {
|
||||||
const url = `${baseUrl}/v1/code/sessions/${sessionId}/bridge`
|
const url = `${baseUrl}/v1/code/sessions/${sessionId}/bridge`
|
||||||
const headers = oauthHeaders(accessToken)
|
|
||||||
if (trustedDeviceToken) {
|
|
||||||
headers['X-Trusted-Device-Token'] = trustedDeviceToken
|
|
||||||
}
|
|
||||||
let response
|
let response
|
||||||
try {
|
try {
|
||||||
response = await axios.post(
|
response = await axios.post(
|
||||||
url,
|
url,
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
headers,
|
headers: oauthHeaders(accessToken),
|
||||||
timeout: timeoutMs,
|
timeout: timeoutMs,
|
||||||
validateStatus: s => s < 500,
|
validateStatus: s => s < 500,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[code-session] /bridge request failed: ${errorMessage(err)}`,
|
`[code-session] /bridge request failed: ${summarizeCodeSessionErrorForDebug(
|
||||||
|
err,
|
||||||
|
)}`,
|
||||||
)
|
)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -141,7 +198,9 @@ export async function fetchRemoteCredentials(
|
|||||||
!('worker_epoch' in data)
|
!('worker_epoch' in data)
|
||||||
) {
|
) {
|
||||||
logForDebugging(
|
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
|
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 { logForDebugging } from '../utils/debug.js'
|
||||||
import { errorMessage } from '../utils/errors.js'
|
import { errorMessage } from '../utils/errors.js'
|
||||||
import { extractErrorDetail } from './debugUtils.js'
|
import { extractErrorDetail } from './debugUtils.js'
|
||||||
import { toCompatSessionId } from './sessionIdCompat.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
|
// Events must be wrapped in { type: 'event', data: <sdk_message> } for the
|
||||||
// POST /v1/sessions endpoint (discriminated union format).
|
// POST /v1/sessions endpoint (discriminated union format).
|
||||||
type SessionEvent = {
|
type SessionEvent = {
|
||||||
@@ -35,8 +24,6 @@ export async function createBridgeSession({
|
|||||||
environmentId,
|
environmentId,
|
||||||
title,
|
title,
|
||||||
events,
|
events,
|
||||||
gitRepoUrl,
|
|
||||||
branch,
|
|
||||||
signal,
|
signal,
|
||||||
baseUrl: baseUrlOverride,
|
baseUrl: baseUrlOverride,
|
||||||
getAccessToken,
|
getAccessToken,
|
||||||
@@ -45,8 +32,6 @@ export async function createBridgeSession({
|
|||||||
environmentId: string
|
environmentId: string
|
||||||
title?: string
|
title?: string
|
||||||
events: SessionEvent[]
|
events: SessionEvent[]
|
||||||
gitRepoUrl: string | null
|
|
||||||
branch: string
|
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
getAccessToken?: () => string | undefined
|
getAccessToken?: () => string | undefined
|
||||||
@@ -56,8 +41,6 @@ export async function createBridgeSession({
|
|||||||
const { getOrganizationUUID } = await import('../services/oauth/client.js')
|
const { getOrganizationUUID } = await import('../services/oauth/client.js')
|
||||||
const { getOauthConfig } = await import('../constants/oauth.js')
|
const { getOauthConfig } = await import('../constants/oauth.js')
|
||||||
const { getOAuthHeaders } = await import('../utils/teleport/api.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 { getMainLoopModel } = await import('../utils/model/model.js')
|
||||||
const { default: axios } = await import('axios')
|
const { default: axios } = await import('axios')
|
||||||
|
|
||||||
@@ -74,60 +57,12 @@ export async function createBridgeSession({
|
|||||||
return null
|
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 = {
|
const requestBody = {
|
||||||
...(title !== undefined && { title }),
|
...(title !== undefined && { title }),
|
||||||
events,
|
events,
|
||||||
session_context: {
|
session_context: {
|
||||||
sources: gitSource ? [gitSource] : [],
|
sources: [],
|
||||||
outcomes: gitOutcome ? [gitOutcome] : [],
|
outcomes: [],
|
||||||
model: getMainLoopModel(),
|
model: getMainLoopModel(),
|
||||||
},
|
},
|
||||||
environment_id: environmentId,
|
environment_id: environmentId,
|
||||||
|
|||||||
@@ -21,15 +21,10 @@ const SECRET_PATTERN = new RegExp(
|
|||||||
'g',
|
'g',
|
||||||
)
|
)
|
||||||
|
|
||||||
const REDACT_MIN_LENGTH = 16
|
|
||||||
|
|
||||||
export function redactSecrets(s: string): string {
|
export function redactSecrets(s: string): string {
|
||||||
return s.replace(SECRET_PATTERN, (_match, field: string, value: string) => {
|
return s.replace(SECRET_PATTERN, (_match, field: string, value: string) => {
|
||||||
if (value.length < REDACT_MIN_LENGTH) {
|
void value
|
||||||
return `"${field}":"[REDACTED]"`
|
return `"${field}":"[REDACTED]"`
|
||||||
}
|
|
||||||
const redacted = `${value.slice(0, 8)}...${value.slice(-4)}`
|
|
||||||
return `"${field}":"${redacted}"`
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +47,73 @@ export function debugBody(data: unknown): string {
|
|||||||
return s.slice(0, DEBUG_MSG_LIMIT) + `... (${s.length} chars)`
|
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).
|
* Extract a descriptive error message from an axios error (or any error).
|
||||||
* For HTTP errors, appends the server's response body message if available,
|
* For HTTP errors, appends the server's response body message if available,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type {
|
|||||||
ImageBlockParam,
|
ImageBlockParam,
|
||||||
} from '@anthropic-ai/sdk/resources/messages.mjs'
|
} from '@anthropic-ai/sdk/resources/messages.mjs'
|
||||||
import type { UUID } from 'crypto'
|
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'
|
import { detectImageFormatFromBase64 } from '../utils/imageResizer.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -14,10 +14,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { feature } from 'bun:bundle'
|
import { feature } from 'bun:bundle'
|
||||||
import { hostname } from 'os'
|
|
||||||
import { getOriginalCwd, getSessionId } from '../bootstrap/state.js'
|
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.js'
|
import type { SDKControlResponse } from '../entrypoints/sdk/controlTypes.ts'
|
||||||
import { getFeatureValue_CACHED_WITH_REFRESH } from '../services/analytics/growthbook.js'
|
import { getFeatureValue_CACHED_WITH_REFRESH } from '../services/analytics/growthbook.js'
|
||||||
import { getOrganizationUUID } from '../services/oauth/client.js'
|
import { getOrganizationUUID } from '../services/oauth/client.js'
|
||||||
import {
|
import {
|
||||||
@@ -34,7 +33,6 @@ import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'
|
|||||||
import { logForDebugging } from '../utils/debug.js'
|
import { logForDebugging } from '../utils/debug.js'
|
||||||
import { stripDisplayTagsAllowEmpty } from '../utils/displayTags.js'
|
import { stripDisplayTagsAllowEmpty } from '../utils/displayTags.js'
|
||||||
import { errorMessage } from '../utils/errors.js'
|
import { errorMessage } from '../utils/errors.js'
|
||||||
import { getBranch, getRemoteUrl } from '../utils/git.js'
|
|
||||||
import { toSDKMessages } from '../utils/messages/mappers.js'
|
import { toSDKMessages } from '../utils/messages/mappers.js'
|
||||||
import {
|
import {
|
||||||
getContentText,
|
getContentText,
|
||||||
@@ -460,10 +458,6 @@ export async function initReplBridge(
|
|||||||
return null
|
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 =
|
const sessionIngressUrl =
|
||||||
process.env.USER_TYPE === 'ant' &&
|
process.env.USER_TYPE === 'ant' &&
|
||||||
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
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.
|
// so no adapter needed — just the narrower type on the way out.
|
||||||
return initBridgeCore({
|
return initBridgeCore({
|
||||||
dir: getOriginalCwd(),
|
dir: getOriginalCwd(),
|
||||||
machineName: hostname(),
|
|
||||||
branch,
|
|
||||||
gitRepoUrl,
|
|
||||||
title,
|
title,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
sessionIngressUrl,
|
sessionIngressUrl,
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export function createTokenRefreshScheduler({
|
|||||||
// (such as the follow-up refresh set by doRefresh) so the refresh
|
// (such as the follow-up refresh set by doRefresh) so the refresh
|
||||||
// chain is not broken.
|
// chain is not broken.
|
||||||
logForDebugging(
|
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
|
return
|
||||||
}
|
}
|
||||||
@@ -209,7 +209,7 @@ export function createTokenRefreshScheduler({
|
|||||||
failureCounts.delete(sessionId)
|
failureCounts.delete(sessionId)
|
||||||
|
|
||||||
logForDebugging(
|
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', {})
|
logEvent('tengu_bridge_token_refreshed', {})
|
||||||
onRefresh(sessionId, oauthToken)
|
onRefresh(sessionId, oauthToken)
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ import { buildCCRv2SdkUrl } from './workSecret.js'
|
|||||||
import { toCompatSessionId } from './sessionIdCompat.js'
|
import { toCompatSessionId } from './sessionIdCompat.js'
|
||||||
import { FlushGate } from './flushGate.js'
|
import { FlushGate } from './flushGate.js'
|
||||||
import { createTokenRefreshScheduler } from './jwtUtils.js'
|
import { createTokenRefreshScheduler } from './jwtUtils.js'
|
||||||
import { getTrustedDeviceToken } from './trustedDevice.js'
|
|
||||||
import {
|
import {
|
||||||
getEnvLessBridgeConfig,
|
getEnvLessBridgeConfig,
|
||||||
type EnvLessBridgeConfig,
|
type EnvLessBridgeConfig,
|
||||||
@@ -51,7 +50,10 @@ import {
|
|||||||
extractTitleText,
|
extractTitleText,
|
||||||
BoundedUUIDSet,
|
BoundedUUIDSet,
|
||||||
} from './bridgeMessaging.js'
|
} from './bridgeMessaging.js'
|
||||||
import { logBridgeSkip } from './debugUtils.js'
|
import {
|
||||||
|
logBridgeSkip,
|
||||||
|
summarizeBridgeErrorForDebug,
|
||||||
|
} from './debugUtils.js'
|
||||||
import { logForDebugging } from '../utils/debug.js'
|
import { logForDebugging } from '../utils/debug.js'
|
||||||
import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
|
import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
|
||||||
import { isInProtectedNamespace } from '../utils/envUtils.js'
|
import { isInProtectedNamespace } from '../utils/envUtils.js'
|
||||||
@@ -64,11 +66,11 @@ import {
|
|||||||
} from '../services/analytics/index.js'
|
} from '../services/analytics/index.js'
|
||||||
import type { ReplBridgeHandle, BridgeState } from './replBridge.js'
|
import type { ReplBridgeHandle, BridgeState } from './replBridge.js'
|
||||||
import type { Message } from '../types/message.js'
|
import type { Message } from '../types/message.js'
|
||||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
import type { SDKMessage } from '../entrypoints/agentSdkTypes.ts'
|
||||||
import type {
|
import type {
|
||||||
SDKControlRequest,
|
SDKControlRequest,
|
||||||
SDKControlResponse,
|
SDKControlResponse,
|
||||||
} from '../entrypoints/sdk/controlTypes.js'
|
} from '../entrypoints/sdk/controlTypes.ts'
|
||||||
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
|
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
|
||||||
|
|
||||||
const ANTHROPIC_VERSION = '2023-06-01'
|
const ANTHROPIC_VERSION = '2023-06-01'
|
||||||
@@ -182,7 +184,7 @@ export async function initEnvLessBridgeCore(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const sessionId: string = createdSessionId
|
const sessionId: string = createdSessionId
|
||||||
logForDebugging(`[remote-bridge] Created session ${sessionId}`)
|
logForDebugging('[remote-bridge] Created remote bridge session')
|
||||||
logForDiagnosticsNoPII('info', 'bridge_repl_v2_session_created')
|
logForDiagnosticsNoPII('info', 'bridge_repl_v2_session_created')
|
||||||
|
|
||||||
// ── 2. Fetch bridge credentials (POST /bridge → worker_jwt, expires_in, api_base_url) ──
|
// ── 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) ────────────────────
|
// ── 3. Build v2 transport (SSETransport + CCRClient) ────────────────────
|
||||||
const sessionUrl = buildCCRv2SdkUrl(credentials.api_base_url, sessionId)
|
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
|
let transport: ReplBridgeTransport
|
||||||
try {
|
try {
|
||||||
@@ -236,10 +238,12 @@ export async function initEnvLessBridgeCore(
|
|||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[remote-bridge] v2 transport setup failed: ${errorMessage(err)}`,
|
`[remote-bridge] v2 transport setup failed: ${summarizeBridgeErrorForDebug(
|
||||||
|
err,
|
||||||
|
)}`,
|
||||||
{ level: 'error' },
|
{ level: 'error' },
|
||||||
)
|
)
|
||||||
onStateChange?.('failed', `Transport setup failed: ${errorMessage(err)}`)
|
onStateChange?.('failed', 'Transport setup failed')
|
||||||
logBridgeSkip('v2_transport_setup_failed', undefined, true)
|
logBridgeSkip('v2_transport_setup_failed', undefined, true)
|
||||||
void archiveSession(
|
void archiveSession(
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -357,7 +361,9 @@ export async function initEnvLessBridgeCore(
|
|||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[remote-bridge] Proactive refresh rebuild failed: ${errorMessage(err)}`,
|
`[remote-bridge] Proactive refresh rebuild failed: ${summarizeBridgeErrorForDebug(
|
||||||
|
err,
|
||||||
|
)}`,
|
||||||
{ level: 'error' },
|
{ level: 'error' },
|
||||||
)
|
)
|
||||||
logForDiagnosticsNoPII(
|
logForDiagnosticsNoPII(
|
||||||
@@ -365,7 +371,7 @@ export async function initEnvLessBridgeCore(
|
|||||||
'bridge_repl_v2_proactive_refresh_failed',
|
'bridge_repl_v2_proactive_refresh_failed',
|
||||||
)
|
)
|
||||||
if (!tornDown) {
|
if (!tornDown) {
|
||||||
onStateChange?.('failed', `Refresh failed: ${errorMessage(err)}`)
|
onStateChange?.('failed', 'Refresh failed')
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
authRecoveryInFlight = false
|
authRecoveryInFlight = false
|
||||||
@@ -395,9 +401,13 @@ export async function initEnvLessBridgeCore(
|
|||||||
// (Same guard pattern as replBridge.ts:1119.)
|
// (Same guard pattern as replBridge.ts:1119.)
|
||||||
const flushTransport = transport
|
const flushTransport = transport
|
||||||
void flushHistory(initialMessages)
|
void flushHistory(initialMessages)
|
||||||
.catch(e =>
|
.catch(e => {
|
||||||
logForDebugging(`[remote-bridge] flushHistory failed: ${e}`),
|
logForDebugging(
|
||||||
|
`[remote-bridge] flushHistory failed: ${summarizeBridgeErrorForDebug(
|
||||||
|
e,
|
||||||
|
)}`,
|
||||||
)
|
)
|
||||||
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
// authRecoveryInFlight catches the v1-vs-v2 asymmetry: v1 nulls
|
// authRecoveryInFlight catches the v1-vs-v2 asymmetry: v1 nulls
|
||||||
// transport synchronously in setOnClose (replBridge.ts:1175), so
|
// transport synchronously in setOnClose (replBridge.ts:1175), so
|
||||||
@@ -577,12 +587,14 @@ export async function initEnvLessBridgeCore(
|
|||||||
logForDebugging('[remote-bridge] Transport rebuilt after 401')
|
logForDebugging('[remote-bridge] Transport rebuilt after 401')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[remote-bridge] 401 recovery failed: ${errorMessage(err)}`,
|
`[remote-bridge] 401 recovery failed: ${summarizeBridgeErrorForDebug(
|
||||||
|
err,
|
||||||
|
)}`,
|
||||||
{ level: 'error' },
|
{ level: 'error' },
|
||||||
)
|
)
|
||||||
logForDiagnosticsNoPII('error', 'bridge_repl_v2_jwt_refresh_failed')
|
logForDiagnosticsNoPII('error', 'bridge_repl_v2_jwt_refresh_failed')
|
||||||
if (!tornDown) {
|
if (!tornDown) {
|
||||||
onStateChange?.('failed', `JWT refresh failed: ${errorMessage(err)}`)
|
onStateChange?.('failed', 'JWT refresh failed')
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
authRecoveryInFlight = false
|
authRecoveryInFlight = false
|
||||||
@@ -707,7 +719,9 @@ export async function initEnvLessBridgeCore(
|
|||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[remote-bridge] Teardown 401 retry threw: ${errorMessage(err)}`,
|
`[remote-bridge] Teardown 401 retry threw: ${summarizeBridgeErrorForDebug(
|
||||||
|
err,
|
||||||
|
)}`,
|
||||||
{ level: 'error' },
|
{ level: 'error' },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -824,7 +838,7 @@ export async function initEnvLessBridgeCore(
|
|||||||
sendControlRequest(request: SDKControlRequest) {
|
sendControlRequest(request: SDKControlRequest) {
|
||||||
if (authRecoveryInFlight) {
|
if (authRecoveryInFlight) {
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[remote-bridge] Dropping control_request during 401 recovery: ${request.request_id}`,
|
'[remote-bridge] Dropping control_request during 401 recovery',
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -833,9 +847,7 @@ export async function initEnvLessBridgeCore(
|
|||||||
transport.reportState('requires_action')
|
transport.reportState('requires_action')
|
||||||
}
|
}
|
||||||
void transport.write(event)
|
void transport.write(event)
|
||||||
logForDebugging(
|
logForDebugging('[remote-bridge] Sent control_request')
|
||||||
`[remote-bridge] Sent control_request request_id=${request.request_id}`,
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
sendControlResponse(response: SDKControlResponse) {
|
sendControlResponse(response: SDKControlResponse) {
|
||||||
if (authRecoveryInFlight) {
|
if (authRecoveryInFlight) {
|
||||||
@@ -852,7 +864,7 @@ export async function initEnvLessBridgeCore(
|
|||||||
sendControlCancelRequest(requestId: string) {
|
sendControlCancelRequest(requestId: string) {
|
||||||
if (authRecoveryInFlight) {
|
if (authRecoveryInFlight) {
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[remote-bridge] Dropping control_cancel_request during 401 recovery: ${requestId}`,
|
'[remote-bridge] Dropping control_cancel_request during 401 recovery',
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -866,9 +878,7 @@ export async function initEnvLessBridgeCore(
|
|||||||
// those paths, so without this the server stays on requires_action.
|
// those paths, so without this the server stays on requires_action.
|
||||||
transport.reportState('running')
|
transport.reportState('running')
|
||||||
void transport.write(event)
|
void transport.write(event)
|
||||||
logForDebugging(
|
logForDebugging('[remote-bridge] Sent control_cancel_request')
|
||||||
`[remote-bridge] Sent control_cancel_request request_id=${requestId}`,
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
sendResult() {
|
sendResult() {
|
||||||
if (authRecoveryInFlight) {
|
if (authRecoveryInFlight) {
|
||||||
@@ -877,7 +887,7 @@ export async function initEnvLessBridgeCore(
|
|||||||
}
|
}
|
||||||
transport.reportState('idle')
|
transport.reportState('idle')
|
||||||
void transport.write(makeResultMessage(sessionId))
|
void transport.write(makeResultMessage(sessionId))
|
||||||
logForDebugging(`[remote-bridge] Sent result`)
|
logForDebugging('[remote-bridge] Sent result')
|
||||||
},
|
},
|
||||||
async teardown() {
|
async teardown() {
|
||||||
unregister()
|
unregister()
|
||||||
@@ -925,9 +935,8 @@ import {
|
|||||||
} from './codeSessionApi.js'
|
} from './codeSessionApi.js'
|
||||||
import { getBridgeBaseUrlOverride } from './bridgeConfig.js'
|
import { getBridgeBaseUrlOverride } from './bridgeConfig.js'
|
||||||
|
|
||||||
// CLI-side wrapper that applies the CLAUDE_BRIDGE_BASE_URL dev override and
|
// CLI-side wrapper that applies the CLAUDE_BRIDGE_BASE_URL dev override while
|
||||||
// injects the trusted-device token (both are env/GrowthBook reads that the
|
// keeping the SDK-facing codeSessionApi.ts export free of CLI config reads.
|
||||||
// SDK-facing codeSessionApi.ts export must stay free of).
|
|
||||||
export async function fetchRemoteCredentials(
|
export async function fetchRemoteCredentials(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
@@ -939,7 +948,6 @@ export async function fetchRemoteCredentials(
|
|||||||
baseUrl,
|
baseUrl,
|
||||||
accessToken,
|
accessToken,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
getTrustedDeviceToken(),
|
|
||||||
)
|
)
|
||||||
if (!creds) return null
|
if (!creds) return null
|
||||||
return getBridgeBaseUrlOverride()
|
return getBridgeBaseUrlOverride()
|
||||||
@@ -995,12 +1003,13 @@ async function archiveSession(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[remote-bridge] Archive ${compatId} status=${response.status}`,
|
`[remote-bridge] Archive status=${response.status}`,
|
||||||
)
|
)
|
||||||
return response.status
|
return response.status
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = errorMessage(err)
|
logForDebugging(
|
||||||
logForDebugging(`[remote-bridge] Archive failed: ${msg}`)
|
`[remote-bridge] Archive failed: ${summarizeBridgeErrorForDebug(err)}`,
|
||||||
|
)
|
||||||
return axios.isAxiosError(err) && err.code === 'ECONNABORTED'
|
return axios.isAxiosError(err) && err.code === 'ECONNABORTED'
|
||||||
? 'timeout'
|
? 'timeout'
|
||||||
: 'error'
|
: 'error'
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import {
|
|||||||
} from './workSecret.js'
|
} from './workSecret.js'
|
||||||
import { toCompatSessionId, toInfraSessionId } from './sessionIdCompat.js'
|
import { toCompatSessionId, toInfraSessionId } from './sessionIdCompat.js'
|
||||||
import { updateSessionBridgeId } from '../utils/concurrentSessions.js'
|
import { updateSessionBridgeId } from '../utils/concurrentSessions.js'
|
||||||
import { getTrustedDeviceToken } from './trustedDevice.js'
|
|
||||||
import { HybridTransport } from '../cli/transports/HybridTransport.js'
|
import { HybridTransport } from '../cli/transports/HybridTransport.js'
|
||||||
import {
|
import {
|
||||||
type ReplBridgeTransport,
|
type ReplBridgeTransport,
|
||||||
@@ -44,14 +43,15 @@ import {
|
|||||||
describeAxiosError,
|
describeAxiosError,
|
||||||
extractHttpStatus,
|
extractHttpStatus,
|
||||||
logBridgeSkip,
|
logBridgeSkip,
|
||||||
|
summarizeBridgeErrorForDebug,
|
||||||
} from './debugUtils.js'
|
} from './debugUtils.js'
|
||||||
import type { Message } from '../types/message.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 { PermissionMode } from '../utils/permissions/PermissionMode.js'
|
||||||
import type {
|
import type {
|
||||||
SDKControlRequest,
|
SDKControlRequest,
|
||||||
SDKControlResponse,
|
SDKControlResponse,
|
||||||
} from '../entrypoints/sdk/controlTypes.js'
|
} from '../entrypoints/sdk/controlTypes.ts'
|
||||||
import { createCapacityWake, type CapacitySignal } from './capacityWake.js'
|
import { createCapacityWake, type CapacitySignal } from './capacityWake.js'
|
||||||
import { FlushGate } from './flushGate.js'
|
import { FlushGate } from './flushGate.js'
|
||||||
import {
|
import {
|
||||||
@@ -84,15 +84,12 @@ export type BridgeState = 'ready' | 'connected' | 'reconnecting' | 'failed'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Explicit-param input to initBridgeCore. Everything initReplBridge reads
|
* 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
|
* A daemon caller (Agent SDK, PR 4) that never runs main.tsx fills these
|
||||||
* in itself.
|
* in itself.
|
||||||
*/
|
*/
|
||||||
export type BridgeCoreParams = {
|
export type BridgeCoreParams = {
|
||||||
dir: string
|
dir: string
|
||||||
machineName: string
|
|
||||||
branch: string
|
|
||||||
gitRepoUrl: string | null
|
|
||||||
title: string
|
title: string
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
sessionIngressUrl: string
|
sessionIngressUrl: string
|
||||||
@@ -113,14 +110,12 @@ export type BridgeCoreParams = {
|
|||||||
* Daemon wrapper passes `createBridgeSessionLean` from `sessionApi.ts`
|
* Daemon wrapper passes `createBridgeSessionLean` from `sessionApi.ts`
|
||||||
* (HTTP-only, orgUUID+model supplied by the daemon caller).
|
* (HTTP-only, orgUUID+model supplied by the daemon caller).
|
||||||
*
|
*
|
||||||
* Receives `gitRepoUrl`+`branch` so the REPL wrapper can build the git
|
* Receives the registered environment ID and session title. Daemon callers
|
||||||
* source/outcome for claude.ai's session card. Daemon ignores them.
|
* may supply their own lean session-creation implementation.
|
||||||
*/
|
*/
|
||||||
createSession: (opts: {
|
createSession: (opts: {
|
||||||
environmentId: string
|
environmentId: string
|
||||||
title: string
|
title: string
|
||||||
gitRepoUrl: string | null
|
|
||||||
branch: string
|
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
}) => Promise<string | null>
|
}) => Promise<string | null>
|
||||||
/**
|
/**
|
||||||
@@ -262,9 +257,6 @@ export async function initBridgeCore(
|
|||||||
): Promise<BridgeCoreHandle | null> {
|
): Promise<BridgeCoreHandle | null> {
|
||||||
const {
|
const {
|
||||||
dir,
|
dir,
|
||||||
machineName,
|
|
||||||
branch,
|
|
||||||
gitRepoUrl,
|
|
||||||
title,
|
title,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
sessionIngressUrl,
|
sessionIngressUrl,
|
||||||
@@ -312,7 +304,7 @@ export async function initBridgeCore(
|
|||||||
const prior = rawPrior?.source === 'repl' ? rawPrior : null
|
const prior = rawPrior?.source === 'repl' ? rawPrior : null
|
||||||
|
|
||||||
logForDebugging(
|
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
|
// 5. Register bridge environment
|
||||||
@@ -322,7 +314,6 @@ export async function initBridgeCore(
|
|||||||
runnerVersion: MACRO.VERSION,
|
runnerVersion: MACRO.VERSION,
|
||||||
onDebug: logForDebugging,
|
onDebug: logForDebugging,
|
||||||
onAuth401,
|
onAuth401,
|
||||||
getTrustedDeviceToken,
|
|
||||||
})
|
})
|
||||||
// Ant-only: interpose so /bridge-kick can inject poll/register/heartbeat
|
// Ant-only: interpose so /bridge-kick can inject poll/register/heartbeat
|
||||||
// failures. Zero cost in external builds (rawApi passes through unchanged).
|
// failures. Zero cost in external builds (rawApi passes through unchanged).
|
||||||
@@ -331,9 +322,6 @@ export async function initBridgeCore(
|
|||||||
|
|
||||||
const bridgeConfig: BridgeConfig = {
|
const bridgeConfig: BridgeConfig = {
|
||||||
dir,
|
dir,
|
||||||
machineName,
|
|
||||||
branch,
|
|
||||||
gitRepoUrl,
|
|
||||||
maxSessions: 1,
|
maxSessions: 1,
|
||||||
spawnMode: 'single-session',
|
spawnMode: 'single-session',
|
||||||
verbose: false,
|
verbose: false,
|
||||||
@@ -355,7 +343,9 @@ export async function initBridgeCore(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
logBridgeSkip(
|
logBridgeSkip(
|
||||||
'registration_failed',
|
'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
|
// Stale pointer may be the cause (expired/deleted env) — clear it so
|
||||||
// the next start doesn't retry the same dead ID.
|
// the next start doesn't retry the same dead ID.
|
||||||
@@ -366,7 +356,7 @@ export async function initBridgeCore(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
logForDebugging(`[bridge:repl] Environment registered: ${environmentId}`)
|
logForDebugging('[bridge:repl] Environment registered')
|
||||||
logForDiagnosticsNoPII('info', 'bridge_repl_env_registered')
|
logForDiagnosticsNoPII('info', 'bridge_repl_env_registered')
|
||||||
logEvent('tengu_bridge_repl_env_registered', {})
|
logEvent('tengu_bridge_repl_env_registered', {})
|
||||||
|
|
||||||
@@ -384,7 +374,7 @@ export async function initBridgeCore(
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (environmentId !== requestedEnvId) {
|
if (environmentId !== requestedEnvId) {
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[bridge:repl] Env mismatch (requested ${requestedEnvId}, got ${environmentId}) — cannot reconnect in place`,
|
'[bridge:repl] Env mismatch — cannot reconnect in place',
|
||||||
)
|
)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -402,13 +392,13 @@ export async function initBridgeCore(
|
|||||||
for (const id of candidates) {
|
for (const id of candidates) {
|
||||||
try {
|
try {
|
||||||
await api.reconnectSession(environmentId, id)
|
await api.reconnectSession(environmentId, id)
|
||||||
logForDebugging(
|
logForDebugging('[bridge:repl] Reconnected existing session in place')
|
||||||
`[bridge:repl] Reconnected session ${id} in place on env ${environmentId}`,
|
|
||||||
)
|
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logForDebugging(
|
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({
|
const createdSessionId = await createSession({
|
||||||
environmentId,
|
environmentId,
|
||||||
title,
|
title,
|
||||||
gitRepoUrl,
|
|
||||||
branch,
|
|
||||||
signal: AbortSignal.timeout(15_000),
|
signal: AbortSignal.timeout(15_000),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -694,7 +682,9 @@ export async function initBridgeCore(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
bridgeConfig.reuseEnvironmentId = undefined
|
bridgeConfig.reuseEnvironmentId = undefined
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[bridge:repl] Environment re-registration failed: ${errorMessage(err)}`,
|
`[bridge:repl] Environment re-registration failed: ${summarizeBridgeErrorForDebug(
|
||||||
|
err,
|
||||||
|
)}`,
|
||||||
)
|
)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -703,7 +693,7 @@ export async function initBridgeCore(
|
|||||||
bridgeConfig.reuseEnvironmentId = undefined
|
bridgeConfig.reuseEnvironmentId = undefined
|
||||||
|
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[bridge:repl] Re-registered: requested=${requestedEnvId} got=${environmentId}`,
|
'[bridge:repl] Re-registered environment',
|
||||||
)
|
)
|
||||||
|
|
||||||
// Bail out if teardown started while we were registering
|
// Bail out if teardown started while we were registering
|
||||||
@@ -764,8 +754,6 @@ export async function initBridgeCore(
|
|||||||
const newSessionId = await createSession({
|
const newSessionId = await createSession({
|
||||||
environmentId,
|
environmentId,
|
||||||
title: currentTitle,
|
title: currentTitle,
|
||||||
gitRepoUrl,
|
|
||||||
branch,
|
|
||||||
signal: AbortSignal.timeout(15_000),
|
signal: AbortSignal.timeout(15_000),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1001,7 +989,7 @@ export async function initBridgeCore(
|
|||||||
injectFault: injectBridgeFault,
|
injectFault: injectBridgeFault,
|
||||||
wakePollLoop,
|
wakePollLoop,
|
||||||
describe: () =>
|
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)
|
.stopWork(environmentId, currentWorkId, false)
|
||||||
.catch((e: unknown) => {
|
.catch((e: unknown) => {
|
||||||
logForDebugging(
|
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 sessionUrl = buildCCRv2SdkUrl(baseUrl, workSessionId)
|
||||||
const thisGen = v2Generation
|
const thisGen = v2Generation
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[bridge:repl] CCR v2: sessionUrl=${sessionUrl} session=${workSessionId} gen=${thisGen}`,
|
`[bridge:repl] CCR v2: creating transport gen=${thisGen}`,
|
||||||
)
|
)
|
||||||
void createV2ReplTransport({
|
void createV2ReplTransport({
|
||||||
sessionUrl,
|
sessionUrl,
|
||||||
@@ -1416,7 +1406,9 @@ export async function initBridgeCore(
|
|||||||
},
|
},
|
||||||
(err: unknown) => {
|
(err: unknown) => {
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[bridge:repl] CCR v2: createV2ReplTransport failed: ${errorMessage(err)}`,
|
`[bridge:repl] CCR v2: createV2ReplTransport failed: ${summarizeBridgeErrorForDebug(
|
||||||
|
err,
|
||||||
|
)}`,
|
||||||
{ level: 'error' },
|
{ level: 'error' },
|
||||||
)
|
)
|
||||||
logEvent('tengu_bridge_repl_ccr_v2_init_failed', {})
|
logEvent('tengu_bridge_repl_ccr_v2_init_failed', {})
|
||||||
@@ -1431,7 +1423,9 @@ export async function initBridgeCore(
|
|||||||
.stopWork(environmentId, currentWorkId, false)
|
.stopWork(environmentId, currentWorkId, false)
|
||||||
.catch((e: unknown) => {
|
.catch((e: unknown) => {
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[bridge:repl] stopWork after v2 init failure: ${errorMessage(e)}`,
|
`[bridge:repl] stopWork after v2 init failure: ${summarizeBridgeErrorForDebug(
|
||||||
|
e,
|
||||||
|
)}`,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
currentWorkId = null
|
currentWorkId = null
|
||||||
@@ -1452,10 +1446,8 @@ export async function initBridgeCore(
|
|||||||
// secret. refreshHeaders picks up the latest OAuth token on each
|
// secret. refreshHeaders picks up the latest OAuth token on each
|
||||||
// WS reconnect attempt.
|
// WS reconnect attempt.
|
||||||
const wsUrl = buildSdkUrl(sessionIngressUrl, workSessionId)
|
const wsUrl = buildSdkUrl(sessionIngressUrl, workSessionId)
|
||||||
logForDebugging(`[bridge:repl] Ingress URL: ${wsUrl}`)
|
logForDebugging('[bridge:repl] Using session ingress WebSocket endpoint')
|
||||||
logForDebugging(
|
logForDebugging('[bridge:repl] Creating HybridTransport')
|
||||||
`[bridge:repl] Creating HybridTransport: session=${workSessionId}`,
|
|
||||||
)
|
|
||||||
// v1OauthToken was validated non-null above (we'd have returned early).
|
// v1OauthToken was validated non-null above (we'd have returned early).
|
||||||
const oauthToken = v1OauthToken ?? ''
|
const oauthToken = v1OauthToken ?? ''
|
||||||
wireTransport(
|
wireTransport(
|
||||||
@@ -1540,7 +1532,9 @@ export async function initBridgeCore(
|
|||||||
logForDebugging('[bridge:repl] keep_alive sent')
|
logForDebugging('[bridge:repl] keep_alive sent')
|
||||||
void transport.write({ type: 'keep_alive' }).catch((err: unknown) => {
|
void transport.write({ type: 'keep_alive' }).catch((err: unknown) => {
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[bridge:repl] keep_alive write failed: ${errorMessage(err)}`,
|
`[bridge:repl] keep_alive write failed: ${summarizeBridgeErrorForDebug(
|
||||||
|
err,
|
||||||
|
)}`,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}, keepAliveIntervalMs)
|
}, keepAliveIntervalMs)
|
||||||
@@ -1553,15 +1547,13 @@ export async function initBridgeCore(
|
|||||||
doTeardownImpl = async (): Promise<void> => {
|
doTeardownImpl = async (): Promise<void> => {
|
||||||
if (teardownStarted) {
|
if (teardownStarted) {
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[bridge:repl] Teardown already in progress, skipping duplicate call env=${environmentId} session=${currentSessionId}`,
|
'[bridge:repl] Teardown already in progress, skipping duplicate call',
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
teardownStarted = true
|
teardownStarted = true
|
||||||
const teardownStart = Date.now()
|
const teardownStart = Date.now()
|
||||||
logForDebugging(
|
logForDebugging('[bridge:repl] Teardown starting')
|
||||||
`[bridge:repl] Teardown starting: env=${environmentId} session=${currentSessionId} workId=${currentWorkId ?? 'none'} transportState=${transport?.getStateLabel() ?? 'null'}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (pointerRefreshTimer !== null) {
|
if (pointerRefreshTimer !== null) {
|
||||||
clearInterval(pointerRefreshTimer)
|
clearInterval(pointerRefreshTimer)
|
||||||
@@ -1610,7 +1602,7 @@ export async function initBridgeCore(
|
|||||||
source: 'repl',
|
source: 'repl',
|
||||||
})
|
})
|
||||||
logForDebugging(
|
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
|
return
|
||||||
}
|
}
|
||||||
@@ -1636,7 +1628,9 @@ export async function initBridgeCore(
|
|||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[bridge:repl] Teardown stopWork failed: ${errorMessage(err)}`,
|
`[bridge:repl] Teardown stopWork failed: ${summarizeBridgeErrorForDebug(
|
||||||
|
err,
|
||||||
|
)}`,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
: Promise.resolve()
|
: Promise.resolve()
|
||||||
@@ -1653,7 +1647,9 @@ export async function initBridgeCore(
|
|||||||
|
|
||||||
await api.deregisterEnvironment(environmentId).catch((err: unknown) => {
|
await api.deregisterEnvironment(environmentId).catch((err: unknown) => {
|
||||||
logForDebugging(
|
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)
|
await clearBridgePointer(dir)
|
||||||
|
|
||||||
logForDebugging(
|
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
|
// 8. Register cleanup for graceful shutdown
|
||||||
const unregister = registerCleanup(() => doTeardownImpl?.())
|
const unregister = registerCleanup(() => doTeardownImpl?.())
|
||||||
|
|
||||||
logForDebugging(
|
logForDebugging('[bridge:repl] Ready')
|
||||||
`[bridge:repl] Ready: env=${environmentId} session=${currentSessionId}`,
|
|
||||||
)
|
|
||||||
onStateChange?.('ready')
|
onStateChange?.('ready')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1730,7 +1724,7 @@ export async function initBridgeCore(
|
|||||||
if (!transport) {
|
if (!transport) {
|
||||||
const types = filtered.map(m => m.type).join(',')
|
const types = filtered.map(m => m.type).join(',')
|
||||||
logForDebugging(
|
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' },
|
{ level: 'warn' },
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -1765,7 +1759,7 @@ export async function initBridgeCore(
|
|||||||
if (filtered.length === 0) return
|
if (filtered.length === 0) return
|
||||||
if (!transport) {
|
if (!transport) {
|
||||||
logForDebugging(
|
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' },
|
{ level: 'warn' },
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -1785,9 +1779,7 @@ export async function initBridgeCore(
|
|||||||
}
|
}
|
||||||
const event = { ...request, session_id: currentSessionId }
|
const event = { ...request, session_id: currentSessionId }
|
||||||
void transport.write(event)
|
void transport.write(event)
|
||||||
logForDebugging(
|
logForDebugging('[bridge:repl] Sent control_request')
|
||||||
`[bridge:repl] Sent control_request request_id=${request.request_id}`,
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
sendControlResponse(response: SDKControlResponse) {
|
sendControlResponse(response: SDKControlResponse) {
|
||||||
if (!transport) {
|
if (!transport) {
|
||||||
@@ -1813,21 +1805,17 @@ export async function initBridgeCore(
|
|||||||
session_id: currentSessionId,
|
session_id: currentSessionId,
|
||||||
}
|
}
|
||||||
void transport.write(event)
|
void transport.write(event)
|
||||||
logForDebugging(
|
logForDebugging('[bridge:repl] Sent control_cancel_request')
|
||||||
`[bridge:repl] Sent control_cancel_request request_id=${requestId}`,
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
sendResult() {
|
sendResult() {
|
||||||
if (!transport) {
|
if (!transport) {
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[bridge:repl] sendResult: skipping, transport not configured session=${currentSessionId}`,
|
'[bridge:repl] sendResult: skipping, transport not configured',
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
void transport.write(makeResultMessage(currentSessionId))
|
void transport.write(makeResultMessage(currentSessionId))
|
||||||
logForDebugging(
|
logForDebugging('[bridge:repl] Sent result')
|
||||||
`[bridge:repl] Sent result for session=${currentSessionId}`,
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
async teardown() {
|
async teardown() {
|
||||||
unregister()
|
unregister()
|
||||||
@@ -1920,7 +1908,7 @@ async function startWorkPollLoop({
|
|||||||
const MAX_ENVIRONMENT_RECREATIONS = 3
|
const MAX_ENVIRONMENT_RECREATIONS = 3
|
||||||
|
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[bridge:repl] Starting work poll loop for env=${getCredentials().environmentId}`,
|
'[bridge:repl] Starting work poll loop',
|
||||||
)
|
)
|
||||||
|
|
||||||
let consecutiveErrors = 0
|
let consecutiveErrors = 0
|
||||||
@@ -2023,7 +2011,9 @@ async function startWorkPollLoop({
|
|||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[bridge:repl:heartbeat] Failed: ${errorMessage(err)}`,
|
`[bridge:repl:heartbeat] Failed: ${summarizeBridgeErrorForDebug(
|
||||||
|
err,
|
||||||
|
)}`,
|
||||||
)
|
)
|
||||||
if (err instanceof BridgeFatalError) {
|
if (err instanceof BridgeFatalError) {
|
||||||
cap.cleanup()
|
cap.cleanup()
|
||||||
@@ -2141,7 +2131,9 @@ async function startWorkPollLoop({
|
|||||||
secret = decodeWorkSecret(work.secret)
|
secret = decodeWorkSecret(work.secret)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logForDebugging(
|
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', {})
|
logEvent('tengu_bridge_repl_work_secret_failed', {})
|
||||||
// Can't ack (needs the JWT we failed to decode). stopWork uses OAuth.
|
// 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:
|
// Explicitly acknowledge to prevent redelivery. Non-fatal on failure:
|
||||||
// server re-delivers, and the onWorkReceived callback handles dedup.
|
// server re-delivers, and the onWorkReceived callback handles dedup.
|
||||||
logForDebugging(`[bridge:repl] Acknowledging workId=${work.id}`)
|
logForDebugging('[bridge:repl] Acknowledging work item')
|
||||||
try {
|
try {
|
||||||
await api.acknowledgeWork(envId, work.id, secret.session_ingress_token)
|
await api.acknowledgeWork(envId, work.id, secret.session_ingress_token)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logForDebugging(
|
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
|
const currentEnvId = getCredentials().environmentId
|
||||||
if (envId !== currentEnvId) {
|
if (envId !== currentEnvId) {
|
||||||
logForDebugging(
|
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
|
consecutiveErrors = 0
|
||||||
firstErrorTime = null
|
firstErrorTime = null
|
||||||
@@ -2255,9 +2249,7 @@ async function startWorkPollLoop({
|
|||||||
consecutiveErrors = 0
|
consecutiveErrors = 0
|
||||||
firstErrorTime = null
|
firstErrorTime = null
|
||||||
onStateChange?.('ready')
|
onStateChange?.('ready')
|
||||||
logForDebugging(
|
logForDebugging('[bridge:repl] Re-registered environment')
|
||||||
`[bridge:repl] Re-registered environment: ${newCreds.environmentId}`,
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2393,7 +2385,7 @@ async function startWorkPollLoop({
|
|||||||
}
|
}
|
||||||
|
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[bridge:repl] Work poll loop ended (aborted=${signal.aborted}) env=${getCredentials().environmentId}`,
|
`[bridge:repl] Work poll loop ended (aborted=${signal.aborted})`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { StdoutMessage } from 'src/entrypoints/sdk/controlTypes.js'
|
import type { StdoutMessage } from 'src/entrypoints/sdk/controlTypes.ts'
|
||||||
import { CCRClient } from '../cli/transports/ccrClient.js'
|
import { CCRClient } from '../cli/transports/ccrClient.js'
|
||||||
import type { HybridTransport } from '../cli/transports/HybridTransport.js'
|
import type { HybridTransport } from '../cli/transports/HybridTransport.js'
|
||||||
import { SSETransport } from '../cli/transports/SSETransport.js'
|
import { SSETransport } from '../cli/transports/SSETransport.js'
|
||||||
import { logForDebugging } from '../utils/debug.js'
|
import { logForDebugging } from '../utils/debug.js'
|
||||||
import { errorMessage } from '../utils/errors.js'
|
|
||||||
import { updateSessionIngressAuthToken } from '../utils/sessionIngressAuth.js'
|
import { updateSessionIngressAuthToken } from '../utils/sessionIngressAuth.js'
|
||||||
import type { SessionState } from '../utils/sessionState.js'
|
import type { SessionState } from '../utils/sessionState.js'
|
||||||
|
import { summarizeBridgeErrorForDebug } from './debugUtils.js'
|
||||||
import { registerWorker } from './workSecret.js'
|
import { registerWorker } from './workSecret.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,8 +54,6 @@ export type ReplBridgeTransport = {
|
|||||||
* (user watches the REPL locally); multi-session worker callers do.
|
* (user watches the REPL locally); multi-session worker callers do.
|
||||||
*/
|
*/
|
||||||
reportState(state: SessionState): void
|
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
|
* 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
|
* CCR's processing_at/processed_at columns. `received` is auto-fired by
|
||||||
@@ -96,7 +94,6 @@ export function createV1ReplTransport(
|
|||||||
return hybrid.droppedBatchCount
|
return hybrid.droppedBatchCount
|
||||||
},
|
},
|
||||||
reportState: () => {},
|
reportState: () => {},
|
||||||
reportMetadata: () => {},
|
|
||||||
reportDelivery: () => {},
|
reportDelivery: () => {},
|
||||||
flush: () => Promise.resolve(),
|
flush: () => Promise.resolve(),
|
||||||
}
|
}
|
||||||
@@ -182,7 +179,7 @@ export async function createV2ReplTransport(opts: {
|
|||||||
|
|
||||||
const epoch = opts.epoch ?? (await registerWorker(sessionUrl, ingressToken))
|
const epoch = opts.epoch ?? (await registerWorker(sessionUrl, ingressToken))
|
||||||
logForDebugging(
|
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
|
// Derive SSE stream URL. Same logic as transportUtils.ts:26-33 but
|
||||||
@@ -220,7 +217,9 @@ export async function createV2ReplTransport(opts: {
|
|||||||
onCloseCb?.(4090)
|
onCloseCb?.(4090)
|
||||||
} catch (closeErr: unknown) {
|
} catch (closeErr: unknown) {
|
||||||
logForDebugging(
|
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' },
|
{ level: 'error' },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -324,9 +323,6 @@ export async function createV2ReplTransport(opts: {
|
|||||||
reportState(state) {
|
reportState(state) {
|
||||||
ccr.reportState(state)
|
ccr.reportState(state)
|
||||||
},
|
},
|
||||||
reportMetadata(metadata) {
|
|
||||||
ccr.reportMetadata(metadata)
|
|
||||||
},
|
|
||||||
reportDelivery(eventId, status) {
|
reportDelivery(eventId, status) {
|
||||||
ccr.reportDelivery(eventId, status)
|
ccr.reportDelivery(eventId, status)
|
||||||
},
|
},
|
||||||
@@ -353,7 +349,9 @@ export async function createV2ReplTransport(opts: {
|
|||||||
},
|
},
|
||||||
(err: unknown) => {
|
(err: unknown) => {
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[bridge:repl] CCR v2 initialize failed: ${errorMessage(err)}`,
|
`[bridge:repl] CCR v2 initialize failed: ${summarizeBridgeErrorForDebug(
|
||||||
|
err,
|
||||||
|
)}`,
|
||||||
{ level: 'error' },
|
{ level: 'error' },
|
||||||
)
|
)
|
||||||
// Close transport resources and notify replBridge via onClose
|
// Close transport resources and notify replBridge via onClose
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { type ChildProcess, spawn } from 'child_process'
|
import { type ChildProcess, spawn } from 'child_process'
|
||||||
import { createWriteStream, type WriteStream } from 'fs'
|
import { createWriteStream, type WriteStream } from 'fs'
|
||||||
import { tmpdir } from 'os'
|
import { tmpdir } from 'os'
|
||||||
import { dirname, join } from 'path'
|
import { basename, dirname, join } from 'path'
|
||||||
import { createInterface } from 'readline'
|
import { createInterface } from 'readline'
|
||||||
import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
|
import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
|
||||||
import { debugTruncate } from './debugUtils.js'
|
|
||||||
import type {
|
import type {
|
||||||
SessionActivity,
|
SessionActivity,
|
||||||
SessionDoneStatus,
|
SessionDoneStatus,
|
||||||
@@ -25,6 +24,61 @@ export function safeFilenameId(id: string): string {
|
|||||||
return id.replace(/[^a-zA-Z0-9_-]/g, '_')
|
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
|
* A control_request emitted by the child CLI when it needs permission to
|
||||||
* execute a **specific** tool invocation (not a general capability check).
|
* execute a **specific** tool invocation (not a general capability check).
|
||||||
@@ -144,9 +198,7 @@ function extractActivities(
|
|||||||
summary,
|
summary,
|
||||||
timestamp: now,
|
timestamp: now,
|
||||||
})
|
})
|
||||||
onDebug(
|
onDebug(`[bridge:activity] tool_use name=${name}`)
|
||||||
`[bridge:activity] sessionId=${sessionId} tool_use name=${name} ${inputPreview(input)}`,
|
|
||||||
)
|
|
||||||
} else if (b.type === 'text') {
|
} else if (b.type === 'text') {
|
||||||
const text = (b.text as string) ?? ''
|
const text = (b.text as string) ?? ''
|
||||||
if (text.length > 0) {
|
if (text.length > 0) {
|
||||||
@@ -156,7 +208,7 @@ function extractActivities(
|
|||||||
timestamp: now,
|
timestamp: now,
|
||||||
})
|
})
|
||||||
onDebug(
|
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',
|
summary: 'Session completed',
|
||||||
timestamp: now,
|
timestamp: now,
|
||||||
})
|
})
|
||||||
onDebug(
|
onDebug('[bridge:activity] result subtype=success')
|
||||||
`[bridge:activity] sessionId=${sessionId} result subtype=success`,
|
|
||||||
)
|
|
||||||
} else if (subtype) {
|
} else if (subtype) {
|
||||||
const errors = msg.errors as string[] | undefined
|
const errors = msg.errors as string[] | undefined
|
||||||
const errorSummary = errors?.[0] ?? `Error: ${subtype}`
|
const errorSummary = errors?.[0] ?? `Error: ${subtype}`
|
||||||
@@ -182,13 +232,9 @@ function extractActivities(
|
|||||||
summary: errorSummary,
|
summary: errorSummary,
|
||||||
timestamp: now,
|
timestamp: now,
|
||||||
})
|
})
|
||||||
onDebug(
|
onDebug(`[bridge:activity] result subtype=${subtype}`)
|
||||||
`[bridge:activity] sessionId=${sessionId} result subtype=${subtype} error="${errorSummary}"`,
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
onDebug(
|
onDebug('[bridge:activity] result subtype=undefined')
|
||||||
`[bridge:activity] sessionId=${sessionId} result subtype=undefined`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -233,18 +279,6 @@ function extractUserMessageText(
|
|||||||
return text ? text : undefined
|
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 {
|
export function createSessionSpawner(deps: SessionSpawnerDeps): SessionSpawner {
|
||||||
return {
|
return {
|
||||||
spawn(opts: SessionSpawnOpts, dir: string): SessionHandle {
|
spawn(opts: SessionSpawnOpts, dir: string): SessionHandle {
|
||||||
@@ -277,11 +311,15 @@ export function createSessionSpawner(deps: SessionSpawnerDeps): SessionSpawner {
|
|||||||
transcriptStream = createWriteStream(transcriptPath, { flags: 'a' })
|
transcriptStream = createWriteStream(transcriptPath, { flags: 'a' })
|
||||||
transcriptStream.on('error', err => {
|
transcriptStream.on('error', err => {
|
||||||
deps.onDebug(
|
deps.onDebug(
|
||||||
`[bridge:session] Transcript write error: ${err.message}`,
|
`[bridge:session] Transcript write error: ${summarizeSessionRunnerErrorForDebug(
|
||||||
|
err,
|
||||||
|
)}`,
|
||||||
)
|
)
|
||||||
transcriptStream = null
|
transcriptStream = null
|
||||||
})
|
})
|
||||||
deps.onDebug(`[bridge:session] Transcript log: ${transcriptPath}`)
|
deps.onDebug(
|
||||||
|
`[bridge:session] Transcript log configured (${basename(transcriptPath)})`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = [
|
const args = [
|
||||||
@@ -323,11 +361,15 @@ export function createSessionSpawner(deps: SessionSpawnerDeps): SessionSpawner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deps.onDebug(
|
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) {
|
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,
|
// Pipe all three streams: stdin for control, stdout for NDJSON parsing,
|
||||||
@@ -339,9 +381,7 @@ export function createSessionSpawner(deps: SessionSpawnerDeps): SessionSpawner {
|
|||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
deps.onDebug(
|
deps.onDebug('[bridge:session] Child process started')
|
||||||
`[bridge:session] sessionId=${opts.sessionId} pid=${child.pid}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
const activities: SessionActivity[] = []
|
const activities: SessionActivity[] = []
|
||||||
let currentActivity: SessionActivity | null = null
|
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
|
// Log all messages flowing from the child CLI to the bridge
|
||||||
deps.onDebug(
|
deps.onDebug(
|
||||||
`[bridge:ws] sessionId=${opts.sessionId} <<< ${debugTruncate(line)}`,
|
`[bridge:ws] <<< ${summarizeSessionRunnerFrameForDebug(line)}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
// In verbose mode, forward raw output to stderr
|
// In verbose mode, forward raw output to stderr
|
||||||
@@ -455,25 +495,23 @@ export function createSessionSpawner(deps: SessionSpawnerDeps): SessionSpawner {
|
|||||||
|
|
||||||
if (signal === 'SIGTERM' || signal === 'SIGINT') {
|
if (signal === 'SIGTERM' || signal === 'SIGINT') {
|
||||||
deps.onDebug(
|
deps.onDebug(
|
||||||
`[bridge:session] sessionId=${opts.sessionId} interrupted signal=${signal} pid=${child.pid}`,
|
`[bridge:session] interrupted signal=${signal ?? 'unknown'}`,
|
||||||
)
|
)
|
||||||
resolve('interrupted')
|
resolve('interrupted')
|
||||||
} else if (code === 0) {
|
} else if (code === 0) {
|
||||||
deps.onDebug(
|
deps.onDebug('[bridge:session] completed exit_code=0')
|
||||||
`[bridge:session] sessionId=${opts.sessionId} completed exit_code=0 pid=${child.pid}`,
|
|
||||||
)
|
|
||||||
resolve('completed')
|
resolve('completed')
|
||||||
} else {
|
} else {
|
||||||
deps.onDebug(
|
deps.onDebug(`[bridge:session] failed exit_code=${code}`)
|
||||||
`[bridge:session] sessionId=${opts.sessionId} failed exit_code=${code} pid=${child.pid}`,
|
|
||||||
)
|
|
||||||
resolve('failed')
|
resolve('failed')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
child.on('error', err => {
|
child.on('error', err => {
|
||||||
deps.onDebug(
|
deps.onDebug(
|
||||||
`[bridge:session] sessionId=${opts.sessionId} spawn error: ${err.message}`,
|
`[bridge:session] spawn error: ${summarizeSessionRunnerErrorForDebug(
|
||||||
|
err,
|
||||||
|
)}`,
|
||||||
)
|
)
|
||||||
resolve('failed')
|
resolve('failed')
|
||||||
})
|
})
|
||||||
@@ -490,9 +528,7 @@ export function createSessionSpawner(deps: SessionSpawnerDeps): SessionSpawner {
|
|||||||
},
|
},
|
||||||
kill(): void {
|
kill(): void {
|
||||||
if (!child.killed) {
|
if (!child.killed) {
|
||||||
deps.onDebug(
|
deps.onDebug('[bridge:session] Sending SIGTERM to child process')
|
||||||
`[bridge:session] Sending SIGTERM to sessionId=${opts.sessionId} pid=${child.pid}`,
|
|
||||||
)
|
|
||||||
// On Windows, child.kill('SIGTERM') throws; use default signal.
|
// On Windows, child.kill('SIGTERM') throws; use default signal.
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
child.kill()
|
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.
|
// not when the process exits. We need to send SIGKILL even after SIGTERM.
|
||||||
if (!sigkillSent && child.pid) {
|
if (!sigkillSent && child.pid) {
|
||||||
sigkillSent = true
|
sigkillSent = true
|
||||||
deps.onDebug(
|
deps.onDebug('[bridge:session] Sending SIGKILL to child process')
|
||||||
`[bridge:session] Sending SIGKILL to sessionId=${opts.sessionId} pid=${child.pid}`,
|
|
||||||
)
|
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
child.kill()
|
child.kill()
|
||||||
} else {
|
} else {
|
||||||
@@ -519,7 +553,7 @@ export function createSessionSpawner(deps: SessionSpawnerDeps): SessionSpawner {
|
|||||||
writeStdin(data: string): void {
|
writeStdin(data: string): void {
|
||||||
if (child.stdin && !child.stdin.destroyed) {
|
if (child.stdin && !child.stdin.destroyed) {
|
||||||
deps.onDebug(
|
deps.onDebug(
|
||||||
`[bridge:ws] sessionId=${opts.sessionId} >>> ${debugTruncate(data)}`,
|
`[bridge:ws] >>> ${summarizeSessionRunnerFrameForDebug(data)}`,
|
||||||
)
|
)
|
||||||
child.stdin.write(data)
|
child.stdin.write(data)
|
||||||
}
|
}
|
||||||
@@ -536,9 +570,7 @@ export function createSessionSpawner(deps: SessionSpawnerDeps): SessionSpawner {
|
|||||||
variables: { CLAUDE_CODE_SESSION_ACCESS_TOKEN: token },
|
variables: { CLAUDE_CODE_SESSION_ACCESS_TOKEN: token },
|
||||||
}) + '\n',
|
}) + '\n',
|
||||||
)
|
)
|
||||||
deps.onDebug(
|
deps.onDebug('[bridge:session] Sent token refresh via stdin')
|
||||||
`[bridge:session] Sent token refresh via stdin for sessionId=${opts.sessionId}`,
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { 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 { 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).
|
* This fork disables trusted-device enrollment and header emission. The
|
||||||
* The server gates ConnectBridgeWorker on its own flag
|
* remaining helpers only clear any previously stored token during login/logout
|
||||||
* (sessions_elevated_auth_enforcement in Anthropic Main); this CLI-side
|
* so old state is not carried forward.
|
||||||
* 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).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
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 {
|
export function clearTrustedDeviceTokenCache(): void {
|
||||||
readStoredToken.cache?.clear?.()
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear the stored trusted device token from secure storage and the memo cache.
|
* Clear any stored trusted-device token from secure storage.
|
||||||
* 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).
|
|
||||||
*/
|
*/
|
||||||
export function clearTrustedDeviceToken(): void {
|
export function clearTrustedDeviceToken(): void {
|
||||||
if (!isGateEnabled()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const secureStorage = getSecureStorage()
|
const secureStorage = getSecureStorage()
|
||||||
try {
|
try {
|
||||||
const data = secureStorage.read()
|
const data = secureStorage.read()
|
||||||
@@ -83,128 +27,14 @@ export function clearTrustedDeviceToken(): void {
|
|||||||
} catch {
|
} catch {
|
||||||
// Best-effort — don't block login if storage is inaccessible
|
// 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
|
* Trusted-device enrollment is disabled in this build. Keep the no-op entry
|
||||||
* to keychain. Best-effort — logs and returns on failure so callers
|
* point so callers can continue to invoke it without branching.
|
||||||
* (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.
|
|
||||||
*/
|
*/
|
||||||
export async function enrollTrustedDevice(): Promise<void> {
|
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(
|
logForDebugging(
|
||||||
`[trusted-device] Gate ${TRUSTED_DEVICE_GATE} is off, skipping enrollment`,
|
'[trusted-device] Enrollment disabled in this build; skipping trusted device registration',
|
||||||
)
|
|
||||||
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)}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -80,9 +80,6 @@ export type BridgeWorkerType = 'claude_code' | 'claude_code_assistant'
|
|||||||
|
|
||||||
export type BridgeConfig = {
|
export type BridgeConfig = {
|
||||||
dir: string
|
dir: string
|
||||||
machineName: string
|
|
||||||
branch: string
|
|
||||||
gitRepoUrl: string | null
|
|
||||||
maxSessions: number
|
maxSessions: number
|
||||||
spawnMode: SpawnMode
|
spawnMode: SpawnMode
|
||||||
verbose: boolean
|
verbose: boolean
|
||||||
|
|||||||
@@ -2,6 +2,33 @@ import axios from 'axios'
|
|||||||
import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
|
import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
|
||||||
import type { WorkSecret } from './types.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. */
|
/** Decode a base64url-encoded work secret and validate its version. */
|
||||||
export function decodeWorkSecret(secret: string): WorkSecret {
|
export function decodeWorkSecret(secret: string): WorkSecret {
|
||||||
const json = Buffer.from(secret, 'base64url').toString('utf-8')
|
const json = Buffer.from(secret, 'base64url').toString('utf-8')
|
||||||
@@ -120,7 +147,9 @@ export async function registerWorker(
|
|||||||
!Number.isSafeInteger(epoch)
|
!Number.isSafeInteger(epoch)
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`registerWorker: invalid worker_epoch in response: ${jsonStringify(response.data)}`,
|
`registerWorker: invalid worker_epoch in response: ${summarizeRegisterWorkerResponseForDebug(
|
||||||
|
response.data,
|
||||||
|
)}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return epoch
|
return epoch
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export function clearSessionCaches(
|
|||||||
|
|
||||||
// Clear tungsten session usage tracking
|
// Clear tungsten session usage tracking
|
||||||
if (process.env.USER_TYPE === 'ant') {
|
if (process.env.USER_TYPE === 'ant') {
|
||||||
void import('../../tools/TungstenTool/TungstenTool.js').then(
|
void import('../../tools/TungstenTool/TungstenTool.ts').then(
|
||||||
({ clearSessionsWithTungstenUsage, resetInitializationState }) => {
|
({ clearSessionsWithTungstenUsage, resetInitializationState }) => {
|
||||||
clearSessionsWithTungstenUsage()
|
clearSessionsWithTungstenUsage()
|
||||||
resetInitializationState()
|
resetInitializationState()
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { execFileSync } from 'child_process'
|
|
||||||
import { diffLines } from 'diff'
|
import { diffLines } from 'diff'
|
||||||
import { constants as fsConstants } from 'fs'
|
import { constants as fsConstants } from 'fs'
|
||||||
import {
|
import {
|
||||||
@@ -2674,7 +2673,7 @@ export type InsightsExport = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build export data from already-computed values.
|
* Build export data from already-computed values.
|
||||||
* Used by background upload to S3.
|
* Used by the local report writer.
|
||||||
*/
|
*/
|
||||||
export function buildExportData(
|
export function buildExportData(
|
||||||
data: AggregatedData,
|
data: AggregatedData,
|
||||||
@@ -3069,35 +3068,8 @@ const usageReport: Command = {
|
|||||||
{ collectRemote },
|
{ collectRemote },
|
||||||
)
|
)
|
||||||
|
|
||||||
let reportUrl = `file://${htmlPath}`
|
const reportUrl = `file://${htmlPath}`
|
||||||
let uploadHint = ''
|
const 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}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build header with stats
|
// Build header with stats
|
||||||
const sessionLabel =
|
const sessionLabel =
|
||||||
|
|||||||
@@ -34,11 +34,10 @@ export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXComma
|
|||||||
resetUserCache();
|
resetUserCache();
|
||||||
// Refresh GrowthBook after login to get updated feature flags (e.g., for claude.ai MCPs)
|
// Refresh GrowthBook after login to get updated feature flags (e.g., for claude.ai MCPs)
|
||||||
refreshGrowthBookAfterAuthChange();
|
refreshGrowthBookAfterAuthChange();
|
||||||
// Clear any stale trusted device token from a previous account before
|
// Clear any stale trusted-device token from a previous account before
|
||||||
// re-enrolling — prevents sending the old token on bridge calls while
|
// running the disabled enrollment stub so old bridge state is discarded.
|
||||||
// the async enrollTrustedDevice() is in-flight.
|
|
||||||
clearTrustedDeviceToken();
|
clearTrustedDeviceToken();
|
||||||
// Enroll as a trusted device for Remote Control (10-min fresh-session window)
|
// Keep the login flow aligned with builds that still import the helper.
|
||||||
void enrollTrustedDevice();
|
void enrollTrustedDevice();
|
||||||
// Reset killswitch gate checks and re-run with new org
|
// Reset killswitch gate checks and re-run with new org
|
||||||
resetBypassPermissionsCheck();
|
resetBypassPermissionsCheck();
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Text } from '../../ink.js';
|
|||||||
import { refreshGrowthBookAfterAuthChange } from '../../services/analytics/growthbook.js';
|
import { refreshGrowthBookAfterAuthChange } from '../../services/analytics/growthbook.js';
|
||||||
import { getGroveNoticeConfig, getGroveSettings } from '../../services/api/grove.js';
|
import { getGroveNoticeConfig, getGroveSettings } from '../../services/api/grove.js';
|
||||||
import { clearPolicyLimitsCache } from '../../services/policyLimits/index.js';
|
import { clearPolicyLimitsCache } from '../../services/policyLimits/index.js';
|
||||||
// flushTelemetry is loaded lazily to avoid pulling in ~1.1MB of OpenTelemetry at startup
|
|
||||||
import { clearRemoteManagedSettingsCache } from '../../services/remoteManagedSettings/index.js';
|
import { clearRemoteManagedSettingsCache } from '../../services/remoteManagedSettings/index.js';
|
||||||
import { getClaudeAIOAuthTokens, removeApiKey } from '../../utils/auth.js';
|
import { getClaudeAIOAuthTokens, removeApiKey } from '../../utils/auth.js';
|
||||||
import { clearBetasCaches } from '../../utils/betas.js';
|
import { clearBetasCaches } from '../../utils/betas.js';
|
||||||
@@ -16,11 +15,6 @@ import { resetUserCache } from '../../utils/user.js';
|
|||||||
export async function performLogout({
|
export async function performLogout({
|
||||||
clearOnboarding = false
|
clearOnboarding = false
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
// Flush telemetry BEFORE clearing credentials to prevent org data leakage
|
|
||||||
const {
|
|
||||||
flushTelemetry
|
|
||||||
} = await import('../../utils/telemetry/instrumentation.js');
|
|
||||||
await flushTelemetry();
|
|
||||||
await removeApiKey();
|
await removeApiKey();
|
||||||
|
|
||||||
// Wipe all secure storage data on logout
|
// Wipe all secure storage data on logout
|
||||||
|
|||||||
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 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 = {
|
type TranscriptShareResult = {
|
||||||
success: boolean
|
success: boolean
|
||||||
transcriptId?: string
|
transcriptId?: string
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TranscriptShareTrigger =
|
export type TranscriptShareTrigger =
|
||||||
@@ -27,86 +13,12 @@ export type TranscriptShareTrigger =
|
|||||||
| 'memory_survey'
|
| 'memory_survey'
|
||||||
|
|
||||||
export async function submitTranscriptShare(
|
export async function submitTranscriptShare(
|
||||||
messages: Message[],
|
_messages: Message[],
|
||||||
trigger: TranscriptShareTrigger,
|
_trigger: TranscriptShareTrigger,
|
||||||
appearanceId: string,
|
_appearanceId: string,
|
||||||
): Promise<TranscriptShareResult> {
|
): 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 {
|
return {
|
||||||
success: true,
|
success: false,
|
||||||
transcriptId: result?.transcript_id,
|
disabled: true,
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: false }
|
|
||||||
} catch (err) {
|
|
||||||
logForDebugging(errorMessage(err), {
|
|
||||||
level: 'error',
|
|
||||||
})
|
|
||||||
return { success: false }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { isEnvTruthy } from '../../utils/envUtils.js';
|
|||||||
import { getLastAssistantMessage } from '../../utils/messages.js';
|
import { getLastAssistantMessage } from '../../utils/messages.js';
|
||||||
import { getMainLoopModel } from '../../utils/model/model.js';
|
import { getMainLoopModel } from '../../utils/model/model.js';
|
||||||
import { getInitialSettings } from '../../utils/settings/settings.js';
|
import { getInitialSettings } from '../../utils/settings/settings.js';
|
||||||
import { logOTelEvent } from '../../utils/telemetry/events.js';
|
|
||||||
import { submitTranscriptShare, type TranscriptShareTrigger } from './submitTranscriptShare.js';
|
import { submitTranscriptShare, type TranscriptShareTrigger } from './submitTranscriptShare.js';
|
||||||
import type { TranscriptShareResponse } from './TranscriptSharePrompt.js';
|
import type { TranscriptShareResponse } from './TranscriptSharePrompt.js';
|
||||||
import { useSurveyState } from './useSurveyState.js';
|
import { useSurveyState } from './useSurveyState.js';
|
||||||
@@ -99,11 +98,6 @@ export function useFeedbackSurvey(messages: Message[], isLoading: boolean, submi
|
|||||||
last_assistant_message_id: lastAssistantMessageIdRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
last_assistant_message_id: lastAssistantMessageIdRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||||
survey_type: surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
survey_type: surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||||
});
|
});
|
||||||
void logOTelEvent('feedback_survey', {
|
|
||||||
event_type: 'appeared',
|
|
||||||
appearance_id: appearanceId,
|
|
||||||
survey_type: surveyType
|
|
||||||
});
|
|
||||||
}, [updateLastShownTime, surveyType]);
|
}, [updateLastShownTime, surveyType]);
|
||||||
const onSelect = useCallback((appearanceId_0: string, selected: FeedbackSurveyResponse) => {
|
const onSelect = useCallback((appearanceId_0: string, selected: FeedbackSurveyResponse) => {
|
||||||
updateLastShownTime(Date.now(), submitCountRef.current);
|
updateLastShownTime(Date.now(), submitCountRef.current);
|
||||||
@@ -114,12 +108,6 @@ export function useFeedbackSurvey(messages: Message[], isLoading: boolean, submi
|
|||||||
last_assistant_message_id: lastAssistantMessageIdRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
last_assistant_message_id: lastAssistantMessageIdRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||||
survey_type: surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
survey_type: surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||||
});
|
});
|
||||||
void logOTelEvent('feedback_survey', {
|
|
||||||
event_type: 'responded',
|
|
||||||
appearance_id: appearanceId_0,
|
|
||||||
response: selected,
|
|
||||||
survey_type: surveyType
|
|
||||||
});
|
|
||||||
}, [updateLastShownTime, surveyType]);
|
}, [updateLastShownTime, surveyType]);
|
||||||
const shouldShowTranscriptPrompt = useCallback((selected_0: FeedbackSurveyResponse) => {
|
const shouldShowTranscriptPrompt = useCallback((selected_0: FeedbackSurveyResponse) => {
|
||||||
// Only bad and good ratings trigger the transcript ask
|
// Only bad and good ratings trigger the transcript ask
|
||||||
@@ -150,11 +138,6 @@ export function useFeedbackSurvey(messages: Message[], isLoading: boolean, submi
|
|||||||
survey_type: surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
survey_type: surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||||
trigger: trigger as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
trigger: trigger as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||||
});
|
});
|
||||||
void logOTelEvent('feedback_survey', {
|
|
||||||
event_type: 'transcript_prompt_appeared',
|
|
||||||
appearance_id: appearanceId_1,
|
|
||||||
survey_type: surveyType
|
|
||||||
});
|
|
||||||
}, [surveyType]);
|
}, [surveyType]);
|
||||||
const onTranscriptSelect = useCallback(async (appearanceId_2: string, selected_1: TranscriptShareResponse, surveyResponse_0: FeedbackSurveyResponse | null): Promise<boolean> => {
|
const onTranscriptSelect = useCallback(async (appearanceId_2: string, selected_1: TranscriptShareResponse, surveyResponse_0: FeedbackSurveyResponse | null): Promise<boolean> => {
|
||||||
const trigger_0: TranscriptShareTrigger = surveyResponse_0 === 'good' ? 'good_feedback_survey' : 'bad_feedback_survey';
|
const trigger_0: TranscriptShareTrigger = surveyResponse_0 === 'good' ? 'good_feedback_survey' : 'bad_feedback_survey';
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
|
|||||||
import { isEnvTruthy } from '../../utils/envUtils.js';
|
import { isEnvTruthy } from '../../utils/envUtils.js';
|
||||||
import { isAutoManagedMemoryFile } from '../../utils/memoryFileDetection.js';
|
import { isAutoManagedMemoryFile } from '../../utils/memoryFileDetection.js';
|
||||||
import { extractTextContent, getLastAssistantMessage } from '../../utils/messages.js';
|
import { extractTextContent, getLastAssistantMessage } from '../../utils/messages.js';
|
||||||
import { logOTelEvent } from '../../utils/telemetry/events.js';
|
|
||||||
import { submitTranscriptShare } from './submitTranscriptShare.js';
|
import { submitTranscriptShare } from './submitTranscriptShare.js';
|
||||||
import type { TranscriptShareResponse } from './TranscriptSharePrompt.js';
|
import type { TranscriptShareResponse } from './TranscriptSharePrompt.js';
|
||||||
import { useSurveyState } from './useSurveyState.js';
|
import { useSurveyState } from './useSurveyState.js';
|
||||||
@@ -67,11 +66,6 @@ export function useMemorySurvey(messages: Message[], isLoading: boolean, hasActi
|
|||||||
event_type: 'appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
event_type: 'appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||||
appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||||
});
|
});
|
||||||
void logOTelEvent('feedback_survey', {
|
|
||||||
event_type: 'appeared',
|
|
||||||
appearance_id: appearanceId,
|
|
||||||
survey_type: 'memory'
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
const onSelect = useCallback((appearanceId_0: string, selected: FeedbackSurveyResponse) => {
|
const onSelect = useCallback((appearanceId_0: string, selected: FeedbackSurveyResponse) => {
|
||||||
logEvent(MEMORY_SURVEY_EVENT, {
|
logEvent(MEMORY_SURVEY_EVENT, {
|
||||||
@@ -79,12 +73,6 @@ export function useMemorySurvey(messages: Message[], isLoading: boolean, hasActi
|
|||||||
appearance_id: appearanceId_0 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
appearance_id: appearanceId_0 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||||
response: selected as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
response: selected as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||||
});
|
});
|
||||||
void logOTelEvent('feedback_survey', {
|
|
||||||
event_type: 'responded',
|
|
||||||
appearance_id: appearanceId_0,
|
|
||||||
response: selected,
|
|
||||||
survey_type: 'memory'
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
const shouldShowTranscriptPrompt = useCallback((selected_0: FeedbackSurveyResponse) => {
|
const shouldShowTranscriptPrompt = useCallback((selected_0: FeedbackSurveyResponse) => {
|
||||||
if ("external" !== 'ant') {
|
if ("external" !== 'ant') {
|
||||||
@@ -107,11 +95,6 @@ export function useMemorySurvey(messages: Message[], isLoading: boolean, hasActi
|
|||||||
appearance_id: appearanceId_1 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
appearance_id: appearanceId_1 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||||
trigger: TRANSCRIPT_SHARE_TRIGGER as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
trigger: TRANSCRIPT_SHARE_TRIGGER as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||||
});
|
});
|
||||||
void logOTelEvent('feedback_survey', {
|
|
||||||
event_type: 'transcript_prompt_appeared',
|
|
||||||
appearance_id: appearanceId_1,
|
|
||||||
survey_type: 'memory'
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
const onTranscriptSelect = useCallback(async (appearanceId_2: string, selected_1: TranscriptShareResponse): Promise<boolean> => {
|
const onTranscriptSelect = useCallback(async (appearanceId_2: string, selected_1: TranscriptShareResponse): Promise<boolean> => {
|
||||||
logEvent(MEMORY_SURVEY_EVENT, {
|
logEvent(MEMORY_SURVEY_EVENT, {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -7,7 +7,7 @@ import type { Command } from '../commands.js';
|
|||||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||||
import { Box } from '../ink.js';
|
import { Box } from '../ink.js';
|
||||||
import type { Tools } from '../Tool.js';
|
import type { Tools } from '../Tool.js';
|
||||||
import { type ConnectorTextBlock, isConnectorTextBlock } from '../types/connectorText.js';
|
import { type ConnectorTextBlock, isConnectorTextBlock } from '../types/connectorText.ts';
|
||||||
import type { AssistantMessage, AttachmentMessage as AttachmentMessageType, CollapsedReadSearchGroup as CollapsedReadSearchGroupType, GroupedToolUseMessage as GroupedToolUseMessageType, NormalizedUserMessage, ProgressMessage, SystemMessage } from '../types/message.js';
|
import type { AssistantMessage, AttachmentMessage as AttachmentMessageType, CollapsedReadSearchGroup as CollapsedReadSearchGroupType, GroupedToolUseMessage as GroupedToolUseMessageType, NormalizedUserMessage, ProgressMessage, SystemMessage } from '../types/message.js';
|
||||||
import { type AdvisorBlock, isAdvisorBlock } from '../utils/advisor.js';
|
import { type AdvisorBlock, isAdvisorBlock } from '../utils/advisor.js';
|
||||||
import { isFullscreenEnvEnabled } from '../utils/fullscreen.js';
|
import { isFullscreenEnvEnabled } from '../utils/fullscreen.js';
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { ReadMcpResourceTool } from 'src/tools/ReadMcpResourceTool/ReadMcpResour
|
|||||||
import { TaskOutputTool } from 'src/tools/TaskOutputTool/TaskOutputTool.js';
|
import { TaskOutputTool } from 'src/tools/TaskOutputTool/TaskOutputTool.js';
|
||||||
import { TaskStopTool } from 'src/tools/TaskStopTool/TaskStopTool.js';
|
import { TaskStopTool } from 'src/tools/TaskStopTool/TaskStopTool.js';
|
||||||
import { TodoWriteTool } from 'src/tools/TodoWriteTool/TodoWriteTool.js';
|
import { TodoWriteTool } from 'src/tools/TodoWriteTool/TodoWriteTool.js';
|
||||||
import { TungstenTool } from 'src/tools/TungstenTool/TungstenTool.js';
|
import { TungstenTool } from 'src/tools/TungstenTool/TungstenTool.ts';
|
||||||
import { WebFetchTool } from 'src/tools/WebFetchTool/WebFetchTool.js';
|
import { WebFetchTool } from 'src/tools/WebFetchTool/WebFetchTool.js';
|
||||||
import { WebSearchTool } from 'src/tools/WebSearchTool/WebSearchTool.js';
|
import { WebSearchTool } from 'src/tools/WebSearchTool/WebSearchTool.js';
|
||||||
import type { KeyboardEvent } from '../../ink/events/keyboard-event.js';
|
import type { KeyboardEvent } from '../../ink/events/keyboard-event.js';
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { c as _c } from "react/compiler-runtime";
|
|||||||
*/
|
*/
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useCallback, useMemo, useState } 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 { useAppState, useAppStateStore } from 'src/state/AppState.js';
|
||||||
import type { CommandResultDisplay } from '../../commands.js';
|
import type { CommandResultDisplay } from '../../commands.js';
|
||||||
import { useSettingsChange } from '../../hooks/useSettingsChange.js';
|
import { useSettingsChange } from '../../hooks/useSettingsChange.js';
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { c as _c } from "react/compiler-runtime";
|
|||||||
|
|
||||||
import figures from 'figures';
|
import figures from 'figures';
|
||||||
import * as React from 'react';
|
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 type { HookEventMetadata } from 'src/utils/hooks/hooksConfigManager.js';
|
||||||
import { Box, Link, Text } from '../../ink.js';
|
import { Box, Link, Text } from '../../ink.js';
|
||||||
import { plural } from '../../utils/stringUtils.js';
|
import { plural } from '../../utils/stringUtils.js';
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { c as _c } from "react/compiler-runtime";
|
|||||||
* confirmation.
|
* confirmation.
|
||||||
*/
|
*/
|
||||||
import * as React from 'react';
|
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 type { HookEventMetadata } from 'src/utils/hooks/hooksConfigManager.js';
|
||||||
import { Box, Text } from '../../ink.js';
|
import { Box, Text } from '../../ink.js';
|
||||||
import { getHookDisplayText, hookSourceHeaderDisplayString, type IndividualHookConfig } from '../../utils/hooks/hooksSettings.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.
|
* and simply lets the user drill into each matcher to see its hooks.
|
||||||
*/
|
*/
|
||||||
import * as React from 'react';
|
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 { Box, Text } from '../../ink.js';
|
||||||
import { type HookSource, hookSourceInlineDisplayString, type IndividualHookConfig } from '../../utils/hooks/hooksSettings.js';
|
import { type HookSource, hookSourceInlineDisplayString, type IndividualHookConfig } from '../../utils/hooks/hooksSettings.js';
|
||||||
import { plural } from '../../utils/stringUtils.js';
|
import { plural } from '../../utils/stringUtils.js';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { c as _c } from "react/compiler-runtime";
|
import { c as _c } from "react/compiler-runtime";
|
||||||
import * as React from 'react';
|
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 type { buildMessageLookups } from 'src/utils/messages.js';
|
||||||
import { Box, Text } from '../../ink.js';
|
import { Box, Text } from '../../ink.js';
|
||||||
import { MessageResponse } from '../MessageResponse.js';
|
import { MessageResponse } from '../MessageResponse.js';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { c as _c } from "react/compiler-runtime";
|
import { c as _c } from "react/compiler-runtime";
|
||||||
import figures from 'figures';
|
import figures from 'figures';
|
||||||
import React, { useMemo, useState } from 'react';
|
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 { ToolUseContext } from 'src/Tool.js';
|
||||||
import type { DeepImmutable } from 'src/types/utils.js';
|
import type { DeepImmutable } from 'src/types/utils.js';
|
||||||
import type { CommandResultDisplay } from '../../commands.js';
|
import type { CommandResultDisplay } from '../../commands.js';
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import * as React from 'react';
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useInterval } from 'usehooks-ts';
|
import { useInterval } from 'usehooks-ts';
|
||||||
import { useRegisterOverlay } from '../../context/overlayContext.js';
|
import { useRegisterOverlay } from '../../context/overlayContext.js';
|
||||||
import { stringWidth } from '../../ink/stringWidth.js';
|
|
||||||
// eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw j/k/arrow dialog navigation
|
// eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw j/k/arrow dialog navigation
|
||||||
import { Box, Text, useInput } from '../../ink.js';
|
import { Box, Text, useInput } from '../../ink.js';
|
||||||
import { useKeybindings } from '../../keybindings/useKeybinding.js';
|
import { useKeybindings } from '../../keybindings/useKeybinding.js';
|
||||||
@@ -15,7 +14,6 @@ import { getEmptyToolPermissionContext } from '../../Tool.js';
|
|||||||
import { AGENT_COLOR_TO_THEME_COLOR } from '../../tools/AgentTool/agentColorManager.js';
|
import { AGENT_COLOR_TO_THEME_COLOR } from '../../tools/AgentTool/agentColorManager.js';
|
||||||
import { logForDebugging } from '../../utils/debug.js';
|
import { logForDebugging } from '../../utils/debug.js';
|
||||||
import { execFileNoThrow } from '../../utils/execFileNoThrow.js';
|
import { execFileNoThrow } from '../../utils/execFileNoThrow.js';
|
||||||
import { truncateToWidth } from '../../utils/format.js';
|
|
||||||
import { getNextPermissionMode } from '../../utils/permissions/getNextPermissionMode.js';
|
import { getNextPermissionMode } from '../../utils/permissions/getNextPermissionMode.js';
|
||||||
import { getModeColor, type PermissionMode, permissionModeFromString, permissionModeSymbol } from '../../utils/permissions/PermissionMode.js';
|
import { getModeColor, type PermissionMode, permissionModeFromString, permissionModeSymbol } from '../../utils/permissions/PermissionMode.js';
|
||||||
import { jsonStringify } from '../../utils/slowOperations.js';
|
import { jsonStringify } from '../../utils/slowOperations.js';
|
||||||
@@ -381,7 +379,6 @@ function TeammateDetailView(t0) {
|
|||||||
teamName,
|
teamName,
|
||||||
onCancel
|
onCancel
|
||||||
} = t0;
|
} = t0;
|
||||||
const [promptExpanded, setPromptExpanded] = useState(false);
|
|
||||||
const cycleModeShortcut = useShortcutDisplay("confirm:cycleMode", "Confirmation", "shift+tab");
|
const cycleModeShortcut = useShortcutDisplay("confirm:cycleMode", "Confirmation", "shift+tab");
|
||||||
const themeColor = teammate.color ? AGENT_COLOR_TO_THEME_COLOR[teammate.color as keyof typeof AGENT_COLOR_TO_THEME_COLOR] : undefined;
|
const themeColor = teammate.color ? AGENT_COLOR_TO_THEME_COLOR[teammate.color as keyof typeof AGENT_COLOR_TO_THEME_COLOR] : undefined;
|
||||||
let t1;
|
let t1;
|
||||||
@@ -418,18 +415,6 @@ function TeammateDetailView(t0) {
|
|||||||
t3 = $[5];
|
t3 = $[5];
|
||||||
}
|
}
|
||||||
useEffect(t2, t3);
|
useEffect(t2, t3);
|
||||||
let t4;
|
|
||||||
if ($[6] === Symbol.for("react.memo_cache_sentinel")) {
|
|
||||||
t4 = input => {
|
|
||||||
if (input === "p") {
|
|
||||||
setPromptExpanded(_temp);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
$[6] = t4;
|
|
||||||
} else {
|
|
||||||
t4 = $[6];
|
|
||||||
}
|
|
||||||
useInput(t4);
|
|
||||||
const workingPath = teammate.worktreePath || teammate.cwd;
|
const workingPath = teammate.worktreePath || teammate.cwd;
|
||||||
let subtitleParts;
|
let subtitleParts;
|
||||||
if ($[7] !== teammate.model || $[8] !== teammate.worktreePath || $[9] !== workingPath) {
|
if ($[7] !== teammate.model || $[8] !== teammate.worktreePath || $[9] !== workingPath) {
|
||||||
@@ -498,21 +483,11 @@ function TeammateDetailView(t0) {
|
|||||||
} else {
|
} else {
|
||||||
t9 = $[24];
|
t9 = $[24];
|
||||||
}
|
}
|
||||||
let t10;
|
|
||||||
if ($[25] !== promptExpanded || $[26] !== teammate.prompt) {
|
|
||||||
t10 = teammate.prompt && <Box flexDirection="column"><Text bold={true}>Prompt</Text><Text>{promptExpanded ? teammate.prompt : truncateToWidth(teammate.prompt, 80)}{stringWidth(teammate.prompt) > 80 && !promptExpanded && <Text dimColor={true}> (p to expand)</Text>}</Text></Box>;
|
|
||||||
$[25] = promptExpanded;
|
|
||||||
$[26] = teammate.prompt;
|
|
||||||
$[27] = t10;
|
|
||||||
} else {
|
|
||||||
t10 = $[27];
|
|
||||||
}
|
|
||||||
let t11;
|
let t11;
|
||||||
if ($[28] !== onCancel || $[29] !== subtitle || $[30] !== t10 || $[31] !== t9 || $[32] !== title) {
|
if ($[28] !== onCancel || $[29] !== subtitle || $[31] !== t9 || $[32] !== title) {
|
||||||
t11 = <Dialog title={title} subtitle={subtitle} onCancel={onCancel} color="background" hideInputGuide={true}>{t9}{t10}</Dialog>;
|
t11 = <Dialog title={title} subtitle={subtitle} onCancel={onCancel} color="background" hideInputGuide={true}>{t9}</Dialog>;
|
||||||
$[28] = onCancel;
|
$[28] = onCancel;
|
||||||
$[29] = subtitle;
|
$[29] = subtitle;
|
||||||
$[30] = t10;
|
|
||||||
$[31] = t9;
|
$[31] = t9;
|
||||||
$[32] = title;
|
$[32] = title;
|
||||||
$[33] = t11;
|
$[33] = t11;
|
||||||
|
|||||||
@@ -448,9 +448,7 @@ export async function getSystemPrompt(
|
|||||||
mcpClients?: MCPServerConnection[],
|
mcpClients?: MCPServerConnection[],
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) {
|
if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) {
|
||||||
return [
|
return [`You are Claude Code, Anthropic's official CLI for Claude.`]
|
||||||
`You are Claude Code, Anthropic's official CLI for Claude.\n\nCWD: ${getCwd()}\nDate: ${getSessionStartDate()}`,
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const cwd = getCwd()
|
const cwd = getCwd()
|
||||||
@@ -607,8 +605,6 @@ export async function computeEnvInfo(
|
|||||||
modelId: string,
|
modelId: string,
|
||||||
additionalWorkingDirectories?: string[],
|
additionalWorkingDirectories?: string[],
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const [isGit, unameSR] = await Promise.all([getIsGit(), getUnameSR()])
|
|
||||||
|
|
||||||
// Undercover: keep ALL model names/IDs out of the system prompt so nothing
|
// Undercover: keep ALL model names/IDs out of the system prompt so nothing
|
||||||
// internal can leak into public commits/PRs. This includes the public
|
// internal can leak into public commits/PRs. This includes the public
|
||||||
// FRONTIER_MODEL_* constants — if those ever point at an unannounced model,
|
// 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}.`
|
: `You are powered by the model ${modelId}.`
|
||||||
}
|
}
|
||||||
|
|
||||||
const additionalDirsInfo =
|
|
||||||
additionalWorkingDirectories && additionalWorkingDirectories.length > 0
|
|
||||||
? `Additional working directories: ${additionalWorkingDirectories.join(', ')}\n`
|
|
||||||
: ''
|
|
||||||
|
|
||||||
const cutoff = getKnowledgeCutoff(modelId)
|
const cutoff = getKnowledgeCutoff(modelId)
|
||||||
const knowledgeCutoffMessage = cutoff
|
const knowledgeCutoffMessage = cutoff
|
||||||
? `\n\nAssistant knowledge cutoff is ${cutoff}.`
|
? `\n\nAssistant knowledge cutoff is ${cutoff}.`
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
return `Here is useful information about the environment you are running in:
|
return [`# Environment`, `You are Claude Code.`, modelDescription, knowledgeCutoffMessage]
|
||||||
<env>
|
.filter(Boolean)
|
||||||
Working directory: ${getCwd()}
|
.join('\n')
|
||||||
Is directory a git repo: ${isGit ? 'Yes' : 'No'}
|
|
||||||
${additionalDirsInfo}Platform: ${env.platform}
|
|
||||||
${getShellInfoLine()}
|
|
||||||
OS Version: ${unameSR}
|
|
||||||
</env>
|
|
||||||
${modelDescription}${knowledgeCutoffMessage}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function computeSimpleEnvInfo(
|
export async function computeSimpleEnvInfo(
|
||||||
modelId: string,
|
modelId: string,
|
||||||
additionalWorkingDirectories?: string[],
|
additionalWorkingDirectories?: string[],
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const [isGit, unameSR] = await Promise.all([getIsGit(), getUnameSR()])
|
|
||||||
|
|
||||||
// Undercover: strip all model name/ID references. See computeEnvInfo.
|
// Undercover: strip all model name/ID references. See computeEnvInfo.
|
||||||
// DCE: inline the USER_TYPE check at each site — do NOT hoist to a const.
|
// DCE: inline the USER_TYPE check at each site — do NOT hoist to a const.
|
||||||
let modelDescription: string | null = null
|
let modelDescription: string | null = null
|
||||||
@@ -671,42 +654,14 @@ export async function computeSimpleEnvInfo(
|
|||||||
? `Assistant knowledge cutoff is ${cutoff}.`
|
? `Assistant knowledge cutoff is ${cutoff}.`
|
||||||
: null
|
: 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 [
|
return [
|
||||||
`# Environment`,
|
`# Environment`,
|
||||||
`You have been invoked in the following environment: `,
|
`You are Claude Code.`,
|
||||||
...prependBullets(envItems),
|
modelDescription,
|
||||||
].join(`\n`)
|
knowledgeCutoffMessage,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(`\n`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// @[MODEL LAUNCH]: Add a knowledge cutoff date for the new model.
|
// @[MODEL LAUNCH]: Add a knowledge cutoff date for the new model.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { TOOL_SEARCH_TOOL_NAME } from '../tools/ToolSearchTool/prompt.js'
|
|||||||
import { SYNTHETIC_OUTPUT_TOOL_NAME } from '../tools/SyntheticOutputTool/SyntheticOutputTool.js'
|
import { SYNTHETIC_OUTPUT_TOOL_NAME } from '../tools/SyntheticOutputTool/SyntheticOutputTool.js'
|
||||||
import { ENTER_WORKTREE_TOOL_NAME } from '../tools/EnterWorktreeTool/constants.js'
|
import { ENTER_WORKTREE_TOOL_NAME } from '../tools/EnterWorktreeTool/constants.js'
|
||||||
import { EXIT_WORKTREE_TOOL_NAME } from '../tools/ExitWorktreeTool/constants.js'
|
import { EXIT_WORKTREE_TOOL_NAME } from '../tools/ExitWorktreeTool/constants.js'
|
||||||
import { WORKFLOW_TOOL_NAME } from '../tools/WorkflowTool/constants.js'
|
import { WORKFLOW_TOOL_NAME } from '../tools/WorkflowTool/constants.ts'
|
||||||
import {
|
import {
|
||||||
CRON_CREATE_TOOL_NAME,
|
CRON_CREATE_TOOL_NAME,
|
||||||
CRON_DELETE_TOOL_NAME,
|
CRON_DELETE_TOOL_NAME,
|
||||||
|
|||||||
177
src/context.ts
177
src/context.ts
@@ -1,25 +1,7 @@
|
|||||||
import { feature } from 'bun:bundle'
|
|
||||||
import memoize from 'lodash-es/memoize.js'
|
import memoize from 'lodash-es/memoize.js'
|
||||||
import {
|
import { setCachedClaudeMdContent } from './bootstrap/state.js'
|
||||||
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'
|
|
||||||
|
|
||||||
const MAX_STATUS_CHARS = 2000
|
// System prompt injection remains a local cache-busting hook only.
|
||||||
|
|
||||||
// System prompt injection for cache breaking (ant-only, ephemeral debugging state)
|
|
||||||
let systemPromptInjection: string | null = null
|
let systemPromptInjection: string | null = null
|
||||||
|
|
||||||
export function getSystemPromptInjection(): string | null {
|
export function getSystemPromptInjection(): string | null {
|
||||||
@@ -28,162 +10,17 @@ export function getSystemPromptInjection(): string | null {
|
|||||||
|
|
||||||
export function setSystemPromptInjection(value: string | null): void {
|
export function setSystemPromptInjection(value: string | null): void {
|
||||||
systemPromptInjection = value
|
systemPromptInjection = value
|
||||||
// Clear context caches immediately when injection changes
|
|
||||||
getUserContext.cache.clear?.()
|
getUserContext.cache.clear?.()
|
||||||
getSystemContext.cache.clear?.()
|
getSystemContext.cache.clear?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getGitStatus = memoize(async (): Promise<string | null> => {
|
export const getGitStatus = memoize(async (): Promise<string | null> => null)
|
||||||
if (process.env.NODE_ENV === 'test') {
|
|
||||||
// Avoid cycles in tests
|
|
||||||
return 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(
|
export const getSystemContext = memoize(
|
||||||
async (): Promise<{
|
async (): Promise<Record<string, string>> => ({}),
|
||||||
[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}]`,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
export const getUserContext = memoize(async (): Promise<Record<string, string>> => {
|
||||||
* This context is prepended to each conversation, and cached for the duration of the conversation.
|
setCachedClaudeMdContent(null)
|
||||||
*/
|
return {}
|
||||||
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()}.`,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
setCostStateForRestore,
|
setCostStateForRestore,
|
||||||
setHasUnknownModelCost,
|
setHasUnknownModelCost,
|
||||||
} from './bootstrap/state.js'
|
} from './bootstrap/state.js'
|
||||||
import type { ModelUsage } from './entrypoints/agentSdkTypes.js'
|
import type { ModelUsage } from './entrypoints/agentSdkTypes.ts'
|
||||||
import {
|
import {
|
||||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||||
logEvent,
|
logEvent,
|
||||||
|
|||||||
@@ -19,16 +19,16 @@ import type {
|
|||||||
export type {
|
export type {
|
||||||
SDKControlRequest,
|
SDKControlRequest,
|
||||||
SDKControlResponse,
|
SDKControlResponse,
|
||||||
} from './sdk/controlTypes.js'
|
} from './sdk/controlTypes.ts'
|
||||||
// Re-export core types (common serializable types)
|
// Re-export core types (common serializable types)
|
||||||
export * from './sdk/coreTypes.js'
|
export * from './sdk/coreTypes.ts'
|
||||||
// Re-export runtime types (callbacks, interfaces with methods)
|
// Re-export runtime types (callbacks, interfaces with methods)
|
||||||
export * from './sdk/runtimeTypes.js'
|
export * from './sdk/runtimeTypes.ts'
|
||||||
|
|
||||||
// Re-export settings types (generated from settings JSON schema)
|
// Re-export settings types (generated from settings JSON schema)
|
||||||
export type { Settings } from './sdk/settingsTypes.generated.js'
|
export type { Settings } from './sdk/settingsTypes.generated.ts'
|
||||||
// Re-export tool types (all marked @internal until SDK API stabilizes)
|
// Re-export tool types (all marked @internal until SDK API stabilizes)
|
||||||
export * from './sdk/toolTypes.js'
|
export * from './sdk/toolTypes.ts'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Functions
|
// Functions
|
||||||
@@ -39,7 +39,7 @@ import type {
|
|||||||
SDKResultMessage,
|
SDKResultMessage,
|
||||||
SDKSessionInfo,
|
SDKSessionInfo,
|
||||||
SDKUserMessage,
|
SDKUserMessage,
|
||||||
} from './sdk/coreTypes.js'
|
} from './sdk/coreTypes.ts'
|
||||||
// Import types needed for function signatures
|
// Import types needed for function signatures
|
||||||
import type {
|
import type {
|
||||||
AnyZodRawShape,
|
AnyZodRawShape,
|
||||||
@@ -59,7 +59,7 @@ import type {
|
|||||||
SdkMcpToolDefinition,
|
SdkMcpToolDefinition,
|
||||||
SessionMessage,
|
SessionMessage,
|
||||||
SessionMutationOptions,
|
SessionMutationOptions,
|
||||||
} from './sdk/runtimeTypes.js'
|
} from './sdk/runtimeTypes.ts'
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
ListSessionsOptions,
|
ListSessionsOptions,
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
import { feature } from 'bun:bundle';
|
import { feature } from 'bun:bundle';
|
||||||
|
|
||||||
const CLI_MACRO =
|
// Define MACRO global for development (normally injected by bun build --define)
|
||||||
typeof MACRO !== 'undefined'
|
if (typeof MACRO === 'undefined') {
|
||||||
? MACRO
|
(globalThis as typeof globalThis & {
|
||||||
: {
|
MACRO: {
|
||||||
VERSION: 'dev',
|
VERSION: string
|
||||||
BUILD_TIME: '',
|
BUILD_TIME: string
|
||||||
PACKAGE_URL: '@anthropic-ai/claude-code',
|
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:
|
ISSUES_EXPLAINER:
|
||||||
'https://docs.anthropic.com/en/docs/claude-code/feedback',
|
'https://docs.anthropic.com/en/docs/claude-code/feedback',
|
||||||
FEEDBACK_CHANNEL: 'github',
|
FEEDBACK_CHANNEL: 'github',
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Bugfix for corepack auto-pinning, which adds yarnpkg to peoples' package.jsons
|
// Bugfix for corepack auto-pinning, which adds yarnpkg to peoples' package.jsons
|
||||||
// eslint-disable-next-line custom-rules/no-top-level-side-effects
|
// 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')) {
|
if (args.length === 1 && (args[0] === '--version' || args[0] === '-v' || args[0] === '-V')) {
|
||||||
// MACRO.VERSION is inlined at build time
|
// MACRO.VERSION is inlined at build time
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||||
console.log(`${CLI_MACRO.VERSION} (Claude Code)`);
|
console.log(`${MACRO.VERSION} (Claude Code)`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { profileCheckpoint } from '../utils/startupProfiler.js'
|
import { profileCheckpoint } from '../utils/startupProfiler.js'
|
||||||
import '../bootstrap/state.js'
|
import '../bootstrap/state.js'
|
||||||
import '../utils/config.js'
|
import '../utils/config.js'
|
||||||
import type { Attributes, MetricOptions } from '@opentelemetry/api'
|
|
||||||
import memoize from 'lodash-es/memoize.js'
|
import memoize from 'lodash-es/memoize.js'
|
||||||
import { getIsNonInteractiveSession } from 'src/bootstrap/state.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 { shutdownLspServerManager } from '../services/lsp/manager.js'
|
||||||
import { populateOAuthAccountInfoIfNeeded } from '../services/oauth/client.js'
|
import { populateOAuthAccountInfoIfNeeded } from '../services/oauth/client.js'
|
||||||
import {
|
import {
|
||||||
@@ -41,19 +38,9 @@ import {
|
|||||||
ensureScratchpadDir,
|
ensureScratchpadDir,
|
||||||
isScratchpadEnabled,
|
isScratchpadEnabled,
|
||||||
} from '../utils/permissions/filesystem.js'
|
} 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 { configureGlobalAgents } from '../utils/proxy.js'
|
||||||
import { isBetaTracingEnabled } from '../utils/telemetry/betaSessionTracing.js'
|
|
||||||
import { getTelemetryAttributes } from '../utils/telemetryAttributes.js'
|
|
||||||
import { setShellIfWindows } from '../utils/windowsPaths.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> => {
|
export const init = memoize(async (): Promise<void> => {
|
||||||
const initStartTime = Date.now()
|
const initStartTime = Date.now()
|
||||||
logForDiagnosticsNoPII('info', 'init_started')
|
logForDiagnosticsNoPII('info', 'init_started')
|
||||||
@@ -87,22 +74,8 @@ export const init = memoize(async (): Promise<void> => {
|
|||||||
setupGracefulShutdown()
|
setupGracefulShutdown()
|
||||||
profileCheckpoint('init_after_graceful_shutdown')
|
profileCheckpoint('init_after_graceful_shutdown')
|
||||||
|
|
||||||
// Initialize 1P event logging (no security concerns, but deferred to avoid
|
// Telemetry/log export is disabled in this build. Keep the startup
|
||||||
// loading OpenTelemetry sdk-logs at startup). growthbook.js is already in
|
// checkpoint so callers depending on the init timeline still see it.
|
||||||
// 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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
profileCheckpoint('init_after_1p_event_logging')
|
profileCheckpoint('init_after_1p_event_logging')
|
||||||
|
|
||||||
// Populate OAuth account info if it is not already cached in config. This is needed since the
|
// 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,2 +0,0 @@
|
|||||||
export class SDKControlRequest {}
|
|
||||||
export class SDKControlResponse {}
|
|
||||||
325
src/entrypoints/sdk/controlTypes.ts
Normal file
325
src/entrypoints/sdk/controlTypes.ts
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
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 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 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 type SDKControlSetPermissionModeRequest = {
|
||||||
|
subtype: 'set_permission_mode'
|
||||||
|
mode: string
|
||||||
|
ultraplan?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {};
|
|
||||||
@@ -16,7 +16,7 @@ export type {
|
|||||||
SandboxSettings,
|
SandboxSettings,
|
||||||
} from '../sandboxTypes.js'
|
} from '../sandboxTypes.js'
|
||||||
// Re-export all generated types
|
// 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
|
// Re-export utility types that can't be expressed as Zod schemas
|
||||||
export type { NonNullableUsage } from './sdkUtilityTypes.js'
|
export type { NonNullableUsage } from './sdkUtilityTypes.js'
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
export class McpSdkServerConfigWithInstance {}
|
|
||||||
export class SdkMcpToolDefinition {}
|
|
||||||
export class Query {}
|
|
||||||
export class InternalQuery {}
|
|
||||||
export class SDKSession {}
|
|
||||||
export class SDKSessionOptions {}
|
|
||||||
export class InternalOptions {}
|
|
||||||
export class Options {}
|
|
||||||
|
|
||||||
export const AnyZodRawShape = {}
|
|
||||||
export const InferShape = (schema) => schema
|
|
||||||
|
|
||||||
export class ForkSessionOptions {}
|
|
||||||
export class ForkSessionResult {}
|
|
||||||
export class GetSessionInfoOptions {}
|
|
||||||
export class GetSessionMessagesOptions {}
|
|
||||||
export class ListSessionsOptions {}
|
|
||||||
export class SessionMessage {}
|
|
||||||
export class SDKSessionInfo {}
|
|
||||||
export class SDKUserMessage {}
|
|
||||||
export class SDKResultMessage {}
|
|
||||||
export class SessionMutationOptions {}
|
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { CallToolResult, ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'
|
import type { CallToolResult, ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'
|
||||||
import type { ZodTypeAny } from 'zod/v4'
|
import type { ZodTypeAny } from 'zod/v4'
|
||||||
|
import type {
|
||||||
|
SDKMessage,
|
||||||
|
SDKResultMessage,
|
||||||
|
SDKSessionInfo as CoreSDKSessionInfo,
|
||||||
|
SDKUserMessage,
|
||||||
|
} from './coreTypes.ts'
|
||||||
|
|
||||||
export type EffortLevel = 'low' | 'medium' | 'high' | 'max'
|
export type EffortLevel = 'low' | 'medium' | 'high' | 'max'
|
||||||
|
|
||||||
@@ -19,17 +25,33 @@ export type SdkMcpToolDefinition<Schema extends AnyZodRawShape> = {
|
|||||||
alwaysLoad?: boolean
|
alwaysLoad?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type McpSdkServerConfigWithInstance = Record<string, unknown>
|
export type McpSdkServerConfigWithInstance = Record<string, unknown> & {
|
||||||
|
name?: string
|
||||||
export type Options = Record<string, unknown>
|
version?: string
|
||||||
export type InternalOptions = Options
|
instance?: unknown
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export type SDKSessionOptions = Options & {
|
tools?: Array<SdkMcpToolDefinition<any>>
|
||||||
model?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Query = AsyncIterable<unknown>
|
export type Options = Record<string, unknown> & {
|
||||||
export type InternalQuery = AsyncIterable<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 = {
|
export type SessionMutationOptions = {
|
||||||
dir?: string
|
dir?: string
|
||||||
@@ -48,7 +70,10 @@ export type GetSessionMessagesOptions = SessionMutationOptions & {
|
|||||||
includeSystemMessages?: boolean
|
includeSystemMessages?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ForkSessionOptions = SessionMutationOptions
|
export type ForkSessionOptions = SessionMutationOptions & {
|
||||||
|
upToMessageId?: string
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
export type ForkSessionResult = {
|
export type ForkSessionResult = {
|
||||||
sessionId: string
|
sessionId: string
|
||||||
@@ -56,4 +81,13 @@ export type ForkSessionResult = {
|
|||||||
|
|
||||||
export type SDKSession = {
|
export type SDKSession = {
|
||||||
id: string
|
id: string
|
||||||
|
query(
|
||||||
|
prompt: string | AsyncIterable<SDKUserMessage>,
|
||||||
|
options?: Options,
|
||||||
|
): Query
|
||||||
|
interrupt(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SessionMessage = SDKMessage
|
||||||
|
|
||||||
|
export type SDKSessionInfo = CoreSDKSessionInfo
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export {};
|
|
||||||
13
src/entrypoints/sdk/settingsTypes.generated.ts
Normal file
13
src/entrypoints/sdk/settingsTypes.generated.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export type SettingsValue =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null
|
||||||
|
| SettingsObject
|
||||||
|
| SettingsValue[]
|
||||||
|
|
||||||
|
export type SettingsObject = {
|
||||||
|
[key: string]: SettingsValue
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Settings = SettingsObject
|
||||||
@@ -1 +0,0 @@
|
|||||||
export class SdkMcpToolDefinition {}
|
|
||||||
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.
|
// Centralized analytics/telemetry logging for tool permission decisions.
|
||||||
// All permission approve/reject events flow through logPermissionDecision(),
|
// 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 { feature } from 'bun:bundle'
|
||||||
import {
|
import {
|
||||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
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 type { Tool as ToolType, ToolUseContext } from '../../Tool.js'
|
||||||
import { getLanguageName } from '../../utils/cliHighlight.js'
|
import { getLanguageName } from '../../utils/cliHighlight.js'
|
||||||
import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'
|
import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'
|
||||||
import { logOTelEvent } from '../../utils/telemetry/events.js'
|
|
||||||
import type {
|
import type {
|
||||||
PermissionApprovalSource,
|
PermissionApprovalSource,
|
||||||
PermissionRejectionSource,
|
PermissionRejectionSource,
|
||||||
@@ -227,11 +226,6 @@ function logPermissionDecision(
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
})
|
})
|
||||||
|
|
||||||
void logOTelEvent('tool_decision', {
|
|
||||||
decision,
|
|
||||||
source: sourceString,
|
|
||||||
tool_name: sanitizeToolNameForAnalytics(tool.name),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { isCodeEditingTool, buildCodeEditToolAttributes, logPermissionDecision }
|
export { isCodeEditingTool, buildCodeEditToolAttributes, logPermissionDecision }
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ import {
|
|||||||
isShutdownApproved,
|
isShutdownApproved,
|
||||||
isShutdownRequest,
|
isShutdownRequest,
|
||||||
isTeamPermissionUpdate,
|
isTeamPermissionUpdate,
|
||||||
markMessagesAsRead,
|
markMessagesAsReadByPredicate,
|
||||||
readUnreadMessages,
|
readUnreadMessages,
|
||||||
type TeammateMessage,
|
type TeammateMessage,
|
||||||
writeToMailbox,
|
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.
|
// Called after messages are successfully delivered or reliably queued.
|
||||||
|
const deliveredMessageKeys = new Set(
|
||||||
|
unread.map(message => `${message.from}|${message.timestamp}|${message.text}`),
|
||||||
|
)
|
||||||
const markRead = () => {
|
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
|
// Separate permission messages from regular teammate messages
|
||||||
@@ -503,9 +513,7 @@ export function useInboxPoller({
|
|||||||
for (const m of teamPermissionUpdates) {
|
for (const m of teamPermissionUpdates) {
|
||||||
const parsed = isTeamPermissionUpdate(m.text)
|
const parsed = isTeamPermissionUpdate(m.text)
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
logForDebugging(
|
logForDebugging('[InboxPoller] Failed to parse team permission update')
|
||||||
`[InboxPoller] Failed to parse team permission update: ${m.text.substring(0, 100)}`,
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,10 +530,7 @@ export function useInboxPoller({
|
|||||||
|
|
||||||
// Apply the permission update to the teammate's context
|
// Apply the permission update to the teammate's context
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[InboxPoller] Applying team permission update: ${parsed.toolName} allowed in ${parsed.directoryPath}`,
|
`[InboxPoller] Applying team permission update for ${parsed.toolName} (${parsed.permissionUpdate.rules.length} rule(s))`,
|
||||||
)
|
|
||||||
logForDebugging(
|
|
||||||
`[InboxPoller] Permission update rules: ${jsonStringify(parsed.permissionUpdate.rules)}`,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
setAppState(prev => {
|
setAppState(prev => {
|
||||||
@@ -536,7 +541,7 @@ export function useInboxPoller({
|
|||||||
destination: 'session',
|
destination: 'session',
|
||||||
})
|
})
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[InboxPoller] Updated session allow rules: ${jsonStringify(updated.alwaysAllowRules.session)}`,
|
`[InboxPoller] Updated session allow rules (${updated.alwaysAllowRules.session.length} total)`,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
@@ -563,9 +568,7 @@ export function useInboxPoller({
|
|||||||
|
|
||||||
const parsed = isModeSetRequest(m.text)
|
const parsed = isModeSetRequest(m.text)
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
logForDebugging(
|
logForDebugging('[InboxPoller] Failed to parse mode set request')
|
||||||
`[InboxPoller] Failed to parse mode set request: ${m.text.substring(0, 100)}`,
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import type { Command } from '../commands.js';
|
|||||||
import { getSlashCommandToolSkills, isBridgeSafeCommand } from '../commands.js';
|
import { getSlashCommandToolSkills, isBridgeSafeCommand } from '../commands.js';
|
||||||
import { getRemoteSessionUrl } from '../constants/product.js';
|
import { getRemoteSessionUrl } from '../constants/product.js';
|
||||||
import { useNotifications } from '../context/notifications.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.js';
|
import type { SDKControlResponse } from '../entrypoints/sdk/controlTypes.ts';
|
||||||
import { Text } from '../ink.js';
|
import { Text } from '../ink.js';
|
||||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js';
|
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js';
|
||||||
import { useAppState, useAppStateStore, useSetAppState } from '../state/AppState.js';
|
import { useAppState, useAppStateStore, useSetAppState } from '../state/AppState.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
|
* Permission requests/responses now flow entirely through teammate mailboxes.
|
||||||
* as a worker agent in a swarm. When a response is received, it calls the
|
* Workers register callbacks here, and the inbox poller dispatches mailbox
|
||||||
* appropriate callback (onAllow/onReject) to continue execution.
|
* responses back into those callbacks.
|
||||||
*
|
|
||||||
* This hook should be used in conjunction with the worker-side integration
|
|
||||||
* in useCanUseTool.ts, which creates pending requests that this hook monitors.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef } from 'react'
|
|
||||||
import { useInterval } from 'usehooks-ts'
|
|
||||||
import { logForDebugging } from '../utils/debug.js'
|
import { logForDebugging } from '../utils/debug.js'
|
||||||
import { errorMessage } from '../utils/errors.js'
|
|
||||||
import {
|
import {
|
||||||
type PermissionUpdate,
|
type PermissionUpdate,
|
||||||
permissionUpdateSchema,
|
permissionUpdateSchema,
|
||||||
} from '../utils/permissions/PermissionUpdateSchema.js'
|
} 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).
|
* 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
|
* Legacy no-op hook kept for compatibility with older imports.
|
||||||
*/
|
* Mailbox responses are handled by useInboxPoller instead of disk polling.
|
||||||
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
|
|
||||||
*/
|
*/
|
||||||
export function useSwarmPermissionPoller(): void {
|
export function useSwarmPermissionPoller(): void {
|
||||||
const isProcessingRef = useRef(false)
|
// Intentionally empty.
|
||||||
|
|
||||||
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])
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { type ChannelEntry, getAllowedChannels, setAllowedChannels, setHasDevCha
|
|||||||
import type { Command } from './commands.js';
|
import type { Command } from './commands.js';
|
||||||
import { createStatsStore, type StatsStore } from './context/stats.js';
|
import { createStatsStore, type StatsStore } from './context/stats.js';
|
||||||
import { getSystemContext } from './context.js';
|
import { getSystemContext } from './context.js';
|
||||||
import { initializeTelemetryAfterTrust } from './entrypoints/init.js';
|
|
||||||
import { isSynchronizedOutputSupported } from './ink/terminal.js';
|
import { isSynchronizedOutputSupported } from './ink/terminal.js';
|
||||||
import type { RenderOptions, Root, TextProps } from './ink.js';
|
import type { RenderOptions, Root, TextProps } from './ink.js';
|
||||||
import { KeybindingSetup } from './keybindings/KeybindingProviderSetup.js';
|
import { KeybindingSetup } from './keybindings/KeybindingProviderSetup.js';
|
||||||
@@ -183,11 +182,6 @@ export async function showSetupScreens(root: Root, permissionMode: PermissionMod
|
|||||||
// This includes potentially dangerous environment variables from untrusted sources
|
// This includes potentially dangerous environment variables from untrusted sources
|
||||||
applyConfigEnvironmentVariables();
|
applyConfigEnvironmentVariables();
|
||||||
|
|
||||||
// Initialize telemetry after env vars are applied so OTEL endpoint env vars and
|
|
||||||
// otelHeadersHelper (which requires trust to execute) are available.
|
|
||||||
// Defer to next tick so the OTel dynamic import resolves after first render
|
|
||||||
// instead of during the pre-render microtask queue.
|
|
||||||
setImmediate(() => initializeTelemetryAfterTrust());
|
|
||||||
if (await isQualifiedForGrove()) {
|
if (await isQualifiedForGrove()) {
|
||||||
const {
|
const {
|
||||||
GroveDialog
|
GroveDialog
|
||||||
|
|||||||
113
src/main.tsx
113
src/main.tsx
@@ -29,7 +29,7 @@ import React from 'react';
|
|||||||
import { getOauthConfig } from './constants/oauth.js';
|
import { getOauthConfig } from './constants/oauth.js';
|
||||||
import { getRemoteSessionUrl } from './constants/product.js';
|
import { getRemoteSessionUrl } from './constants/product.js';
|
||||||
import { getSystemContext, getUserContext } from './context.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 { addToHistory } from './history.js';
|
||||||
import type { Root } from './ink.js';
|
import type { Root } from './ink.js';
|
||||||
import { launchRepl } from './replLauncher.js';
|
import { launchRepl } from './replLauncher.js';
|
||||||
@@ -49,7 +49,7 @@ import { isAgentSwarmsEnabled } from './utils/agentSwarmsEnabled.js';
|
|||||||
import { count, uniq } from './utils/array.js';
|
import { count, uniq } from './utils/array.js';
|
||||||
import { installAsciicastRecorder } from './utils/asciicast.js';
|
import { installAsciicastRecorder } from './utils/asciicast.js';
|
||||||
import { getSubscriptionType, isClaudeAISubscriber, prefetchAwsCredentialsAndBedRockInfoIfSafe, prefetchGcpCredentialsIfSafe, validateForceLoginOrg } from './utils/auth.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 { seedEarlyInput, stopCapturingEarlyInput } from './utils/earlyInput.js';
|
||||||
import { getInitialEffortSetting, parseEffortValue } from './utils/effort.js';
|
import { getInitialEffortSetting, parseEffortValue } from './utils/effort.js';
|
||||||
import { getInitialFastModeSetting, isFastModeEnabled, prefetchFastModeStatus, resolveFastModeStatusFromCache } from './utils/fastMode.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 { initializeWarningHandler } from './utils/warningHandler.js';
|
||||||
import { isWorktreeModeEnabled } from './utils/worktreeModeEnabled.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
|
// Lazy require to avoid circular dependency: teammate.ts -> AppState.tsx -> ... -> main.tsx
|
||||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
const getTeammateUtils = () => require('./utils/teammate.js') as typeof import('./utils/teammate.js');
|
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 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;
|
const kairosGate = feature('KAIROS') ? require('./assistant/gate.js') as typeof import('./assistant/gate.js') : null;
|
||||||
import { relative, resolve } from 'path';
|
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 { 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 { 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 { getOriginalCwd, setAdditionalDirectoriesForClaudeMd, setIsRemoteMode, setMainLoopModelOverride, setMainThreadAgentType, setTeleportedSessionInfo } from './bootstrap/state.js';
|
||||||
import { filterCommandsForRemoteMode, getCommands } from './commands.js';
|
import { filterCommandsForRemoteMode, getCommands } from './commands.js';
|
||||||
import type { StatsStore } from './context/stats.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 { assertMinVersion } from './utils/autoUpdater.js';
|
||||||
import { CLAUDE_IN_CHROME_SKILL_HINT, CLAUDE_IN_CHROME_SKILL_HINT_WITH_WEBBROWSER } from './utils/claudeInChrome/prompt.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 { setupClaudeInChrome, shouldAutoEnableClaudeInChrome, shouldEnableClaudeInChrome } from './utils/claudeInChrome/setup.js';
|
||||||
import { getContextWindowForModel } from './utils/context.js';
|
|
||||||
import { loadConversationForResume } from './utils/conversationRecovery.js';
|
import { loadConversationForResume } from './utils/conversationRecovery.js';
|
||||||
import { buildDeepLinkBanner } from './utils/deepLink/banner.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 { refreshExampleCommands } from './utils/exampleCommands.js';
|
||||||
import type { FpsMetrics } from './utils/fpsTracker.js';
|
import type { FpsMetrics } from './utils/fpsTracker.js';
|
||||||
import { getWorktreePaths } from './utils/getWorktreePaths.js';
|
import { getWorktreePaths } from './utils/getWorktreePaths.js';
|
||||||
import { findGitRoot, getBranch, getIsGit, getWorktreeCount } from './utils/git.js';
|
import { findGitRoot, getBranch } from './utils/git.js';
|
||||||
import { getGhAuthStatus } from './utils/github/ghAuthStatus.js';
|
|
||||||
import { safeParseJSON } from './utils/json.js';
|
import { safeParseJSON } from './utils/json.js';
|
||||||
import { logError } from './utils/log.js';
|
import { logError } from './utils/log.js';
|
||||||
import { getModelDeprecationWarning } from './utils/model/deprecation.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 { checkAndDisableBypassPermissions, getAutoModeEnabledStateIfCached, initializeToolPermissionContext, initialPermissionModeFromCLI, isDefaultPermissionModeAuto, parseToolListFromCLI, removeDangerousPermissions, stripDangerousPermissionsForAutoMode, verifyAutoModeGateAccess } from './utils/permissions/permissionSetup.js';
|
||||||
import { cleanupOrphanedPluginVersionsInBackground } from './utils/plugins/cacheUtils.js';
|
import { cleanupOrphanedPluginVersionsInBackground } from './utils/plugins/cacheUtils.js';
|
||||||
import { initializeVersionedPlugins } from './utils/plugins/installedPluginsManager.js';
|
import { initializeVersionedPlugins } from './utils/plugins/installedPluginsManager.js';
|
||||||
import { getManagedPluginNames } from './utils/plugins/managedPlugins.js';
|
|
||||||
import { getGlobExclusionsForPluginCache } from './utils/plugins/orphanedPluginFilter.js';
|
import { getGlobExclusionsForPluginCache } from './utils/plugins/orphanedPluginFilter.js';
|
||||||
import { getPluginSeedDirs } from './utils/plugins/pluginDirectories.js';
|
|
||||||
import { countFilesRoundedRg } from './utils/ripgrep.js';
|
import { countFilesRoundedRg } from './utils/ripgrep.js';
|
||||||
import { processSessionStartHooks, processSetupHooks } from './utils/sessionStart.js';
|
import { processSessionStartHooks, processSetupHooks } from './utils/sessionStart.js';
|
||||||
import { cacheSessionTitle, getSessionIdFromLog, loadTranscriptFromFile, saveAgentSetting, saveMode, searchSessionsByCustomTitle, sessionIdExists } from './utils/sessionStorage.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 { resetSettingsCache } from './utils/settings/settingsCache.js';
|
||||||
import type { ValidationError } from './utils/settings/validation.js';
|
import type { ValidationError } from './utils/settings/validation.js';
|
||||||
import { DEFAULT_TASKS_MODE_TASK_LIST_ID, TASK_STATUSES } from './utils/tasks.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 { generateTempFilePath } from './utils/tempfile.js';
|
||||||
import { validateUuid } from './utils/uuid.js';
|
import { validateUuid } from './utils/uuid.js';
|
||||||
// Plugin startup checks are now handled non-blockingly in REPL.tsx
|
// 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 { isInBundledMode, isRunningWithBun } from './utils/bundledMode.js';
|
||||||
import { logForDiagnosticsNoPII } from './utils/diagLogs.js';
|
import { logForDiagnosticsNoPII } from './utils/diagLogs.js';
|
||||||
import { filterExistingPaths, getKnownPathsForRepo } from './utils/githubRepoPathMapping.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 { migrateChangelogFromConfig } from './utils/releaseNotes.js';
|
||||||
import { SandboxManager } from './utils/sandbox/sandbox-adapter.js';
|
import { SandboxManager } from './utils/sandbox/sandbox-adapter.js';
|
||||||
import { fetchSession, prepareApiRequest } from './utils/teleport/api.js';
|
import { fetchSession, prepareApiRequest } from './utils/teleport/api.js';
|
||||||
@@ -282,56 +262,6 @@ if ("external" !== 'ant' && isBeingDebugged()) {
|
|||||||
process.exit(1);
|
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.
|
// @[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.
|
// Bump this when adding a new sync migration so existing users re-run the set.
|
||||||
const CURRENT_MIGRATION_VERSION = 11;
|
const CURRENT_MIGRATION_VERSION = 11;
|
||||||
@@ -425,8 +355,7 @@ export function startDeferredPrefetches(): void {
|
|||||||
}
|
}
|
||||||
void countFilesRoundedRg(getCwd(), AbortSignal.timeout(3000), []);
|
void countFilesRoundedRg(getCwd(), AbortSignal.timeout(3000), []);
|
||||||
|
|
||||||
// Analytics and feature flag initialization
|
// Feature flag initialization
|
||||||
void initializeAnalyticsGates();
|
|
||||||
void prefetchOfficialMcpUrls();
|
void prefetchOfficialMcpUrls();
|
||||||
void refreshModelCapabilities();
|
void refreshModelCapabilities();
|
||||||
|
|
||||||
@@ -935,11 +864,8 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
process.title = 'claude';
|
process.title = 'claude';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach logging sinks so subcommand handlers can use logEvent/logError.
|
// Attach shared sinks for subcommands that bypass setup(). Today this is
|
||||||
// Before PR #11106 logEvent dispatched directly; after, events queue until
|
// just the local error-log sink; analytics/event logging is already inert.
|
||||||
// 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.
|
|
||||||
const {
|
const {
|
||||||
initSinks
|
initSinks
|
||||||
} = await import('./utils/sinks.js');
|
} = await import('./utils/sinks.js');
|
||||||
@@ -2297,11 +2223,8 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
resetUserCache();
|
resetUserCache();
|
||||||
// Refresh GrowthBook after login to get updated feature flags (e.g., for claude.ai MCPs)
|
// Refresh GrowthBook after login to get updated feature flags (e.g., for claude.ai MCPs)
|
||||||
refreshGrowthBookAfterAuthChange();
|
refreshGrowthBookAfterAuthChange();
|
||||||
// Clear any stale trusted device token then enroll for Remote Control.
|
// Clear any stale trusted-device token, then run the no-op enrollment
|
||||||
// Both self-gate on tengu_sessions_elevated_auth_enforcement internally
|
// stub so the disabled bridge path stays consistent after login.
|
||||||
// — enrollTrustedDevice() via checkGate_CACHED_OR_BLOCKING (awaits
|
|
||||||
// the GrowthBook reinit above), clearTrustedDeviceToken() via the
|
|
||||||
// sync cached check (acceptable since clear is idempotent).
|
|
||||||
void import('./bridge/trustedDevice.js').then(m => {
|
void import('./bridge/trustedDevice.js').then(m => {
|
||||||
m.clearTrustedDeviceToken();
|
m.clearTrustedDeviceToken();
|
||||||
return m.enrollTrustedDevice();
|
return m.enrollTrustedDevice();
|
||||||
@@ -2599,15 +2522,10 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
setHasFormattedOutput(true);
|
setHasFormattedOutput(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply full environment variables in print mode since trust dialog is bypassed
|
// Apply full environment variables in print mode since trust dialog is bypassed.
|
||||||
// This includes potentially dangerous environment variables from untrusted sources
|
|
||||||
// but print mode is considered trusted (as documented in help text)
|
// but print mode is considered trusted (as documented in help text)
|
||||||
applyConfigEnvironmentVariables();
|
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
|
// Kick SessionStart hooks now so the subprocess spawn overlaps with
|
||||||
// MCP connect + plugin init + print.ts import below. loadInitialMessages
|
// MCP connect + plugin init + print.ts import below. loadInitialMessages
|
||||||
// joins this at print.ts:4397. Guarded same as 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());
|
void import('./utils/sdkHeapDumpMonitor.js').then(m => m.startSdkMemoryMonitor());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logSessionTelemetry();
|
|
||||||
profileCheckpoint('before_print_import');
|
profileCheckpoint('before_print_import');
|
||||||
const {
|
const {
|
||||||
runHeadless
|
runHeadless
|
||||||
@@ -3055,15 +2972,11 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
|
|
||||||
// Increment numStartups synchronously — first-render readers like
|
// Increment numStartups synchronously — first-render readers like
|
||||||
// shouldShowEffortCallout (via useState initializer) need the updated
|
// shouldShowEffortCallout (via useState initializer) need the updated
|
||||||
// value before setImmediate fires. Defer only telemetry.
|
// value immediately.
|
||||||
saveGlobalConfig(current => ({
|
saveGlobalConfig(current => ({
|
||||||
...current,
|
...current,
|
||||||
numStartups: (current.numStartups ?? 0) + 1
|
numStartups: (current.numStartups ?? 0) + 1
|
||||||
}));
|
}));
|
||||||
setImmediate(() => {
|
|
||||||
void logStartupTelemetry();
|
|
||||||
logSessionTelemetry();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set up per-turn session environment data uploader (ant-only build).
|
// Set up per-turn session environment data uploader (ant-only build).
|
||||||
// Default-enabled for all ant users when working in an Anthropic-owned
|
// Default-enabled for all ant users when working in an Anthropic-owned
|
||||||
@@ -3817,7 +3730,7 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
pendingHookMessages
|
pendingHookMessages
|
||||||
}, renderAndRun);
|
}, 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
|
// Worktree flags
|
||||||
program.option('-w, --worktree [name]', 'Create a new git worktree for this session (optionally specify a name)');
|
program.option('-w, --worktree [name]', 'Create a new git worktree for this session (optionally specify a name)');
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ import {
|
|||||||
stripSignatureBlocks,
|
stripSignatureBlocks,
|
||||||
} from './utils/messages.js'
|
} from './utils/messages.js'
|
||||||
import { generateToolUseSummary } from './services/toolUseSummary/toolUseSummaryGenerator.js'
|
import { generateToolUseSummary } from './services/toolUseSummary/toolUseSummaryGenerator.js'
|
||||||
import { prependUserContext, appendSystemContext } from './utils/api.js'
|
|
||||||
import {
|
import {
|
||||||
createAttachmentMessage,
|
createAttachmentMessage,
|
||||||
filterDuplicateMemoryAttachments,
|
filterDuplicateMemoryAttachments,
|
||||||
@@ -446,9 +445,7 @@ async function* queryLoop(
|
|||||||
messagesForQuery = collapseResult.messages
|
messagesForQuery = collapseResult.messages
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullSystemPrompt = asSystemPrompt(
|
const fullSystemPrompt = asSystemPrompt(systemPrompt)
|
||||||
appendSystemContext(systemPrompt, systemContext),
|
|
||||||
)
|
|
||||||
|
|
||||||
queryCheckpoint('query_autocompact_start')
|
queryCheckpoint('query_autocompact_start')
|
||||||
const { compactionResult, consecutiveFailures } = await deps.autocompact(
|
const { compactionResult, consecutiveFailures } = await deps.autocompact(
|
||||||
@@ -657,7 +654,7 @@ async function* queryLoop(
|
|||||||
let streamingFallbackOccured = false
|
let streamingFallbackOccured = false
|
||||||
queryCheckpoint('query_api_streaming_start')
|
queryCheckpoint('query_api_streaming_start')
|
||||||
for await (const message of deps.callModel({
|
for await (const message of deps.callModel({
|
||||||
messages: prependUserContext(messagesForQuery, userContext),
|
messages: messagesForQuery,
|
||||||
systemPrompt: fullSystemPrompt,
|
systemPrompt: fullSystemPrompt,
|
||||||
thinkingConfig: toolUseContext.options.thinkingConfig,
|
thinkingConfig: toolUseContext.options.thinkingConfig,
|
||||||
tools: toolUseContext.options.tools,
|
tools: toolUseContext.options.tools,
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
import type { SDKMessage } from '../entrypoints/agentSdkTypes.ts'
|
||||||
import type {
|
import type {
|
||||||
SDKControlCancelRequest,
|
SDKControlCancelRequest,
|
||||||
SDKControlPermissionRequest,
|
SDKControlPermissionRequest,
|
||||||
SDKControlRequest,
|
SDKControlRequest,
|
||||||
SDKControlResponse,
|
SDKControlResponse,
|
||||||
} from '../entrypoints/sdk/controlTypes.js'
|
} from '../entrypoints/sdk/controlTypes.ts'
|
||||||
import { logForDebugging } from '../utils/debug.js'
|
import { logForDebugging } from '../utils/debug.js'
|
||||||
import { logError } from '../utils/log.js'
|
import { logError } from '../utils/log.js'
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
import { getOauthConfig } from '../constants/oauth.js'
|
import { getOauthConfig } from '../constants/oauth.js'
|
||||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
import type { SDKMessage } from '../entrypoints/agentSdkTypes.ts'
|
||||||
import type {
|
import type {
|
||||||
SDKControlCancelRequest,
|
SDKControlCancelRequest,
|
||||||
SDKControlRequest,
|
SDKControlRequest,
|
||||||
SDKControlRequestInner,
|
SDKControlRequestInner,
|
||||||
SDKControlResponse,
|
SDKControlResponse,
|
||||||
} from '../entrypoints/sdk/controlTypes.js'
|
} from '../entrypoints/sdk/controlTypes.ts'
|
||||||
import { logForDebugging } from '../utils/debug.js'
|
import { logForDebugging } from '../utils/debug.js'
|
||||||
import { errorMessage } from '../utils/errors.js'
|
|
||||||
import { logError } from '../utils/log.js'
|
import { logError } from '../utils/log.js'
|
||||||
import { getWebSocketTLSOptions } from '../utils/mtls.js'
|
import { getWebSocketTLSOptions } from '../utils/mtls.js'
|
||||||
import { getWebSocketProxyAgent, getWebSocketProxyUrl } from '../utils/proxy.js'
|
import { getWebSocketProxyAgent, getWebSocketProxyUrl } from '../utils/proxy.js'
|
||||||
@@ -54,6 +53,16 @@ function isSessionsMessage(value: unknown): value is SessionsMessage {
|
|||||||
return typeof value.type === 'string'
|
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 = {
|
export type SessionsWebSocketCallbacks = {
|
||||||
onMessage: (message: SessionsMessage) => void
|
onMessage: (message: SessionsMessage) => void
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
@@ -108,7 +117,9 @@ export class SessionsWebSocket {
|
|||||||
const baseUrl = getOauthConfig().BASE_API_URL.replace('https://', 'wss://')
|
const baseUrl = getOauthConfig().BASE_API_URL.replace('https://', 'wss://')
|
||||||
const url = `${baseUrl}/v1/sessions/ws/${this.sessionId}/subscribe?organization_uuid=${this.orgUuid}`
|
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
|
// Get fresh token for each connection attempt
|
||||||
const accessToken = this.getAccessToken()
|
const accessToken = this.getAccessToken()
|
||||||
@@ -152,9 +163,7 @@ export class SessionsWebSocket {
|
|||||||
|
|
||||||
// eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
|
// eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
|
||||||
ws.addEventListener('close', (event: CloseEvent) => {
|
ws.addEventListener('close', (event: CloseEvent) => {
|
||||||
logForDebugging(
|
logForDebugging(`[SessionsWebSocket] Closed: code=${event.code}`)
|
||||||
`[SessionsWebSocket] Closed: code=${event.code} reason=${event.reason}`,
|
|
||||||
)
|
|
||||||
this.handleClose(event.code)
|
this.handleClose(event.code)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -187,14 +196,19 @@ export class SessionsWebSocket {
|
|||||||
})
|
})
|
||||||
|
|
||||||
ws.on('error', (err: Error) => {
|
ws.on('error', (err: Error) => {
|
||||||
logError(new Error(`[SessionsWebSocket] Error: ${err.message}`))
|
logError(
|
||||||
|
new Error(
|
||||||
|
`[SessionsWebSocket] Error: ${summarizeSessionsWebSocketErrorForDebug(
|
||||||
|
err,
|
||||||
|
)}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
this.callbacks.onError?.(err)
|
this.callbacks.onError?.(err)
|
||||||
})
|
})
|
||||||
|
|
||||||
ws.on('close', (code: number, reason: Buffer) => {
|
ws.on('close', (code: number, reason: Buffer) => {
|
||||||
logForDebugging(
|
void reason
|
||||||
`[SessionsWebSocket] Closed: code=${code} reason=${reason.toString()}`,
|
logForDebugging(`[SessionsWebSocket] Closed: code=${code}`)
|
||||||
)
|
|
||||||
this.handleClose(code)
|
this.handleClose(code)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -222,7 +236,9 @@ export class SessionsWebSocket {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(
|
logError(
|
||||||
new Error(
|
new Error(
|
||||||
`[SessionsWebSocket] Failed to parse message: ${errorMessage(error)}`,
|
`[SessionsWebSocket] Failed to parse message: ${summarizeSessionsWebSocketErrorForDebug(
|
||||||
|
error,
|
||||||
|
)}`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
import type { SDKControlPermissionRequest } from '../entrypoints/sdk/controlTypes.js'
|
import type { SDKControlPermissionRequest } from '../entrypoints/sdk/controlTypes.ts'
|
||||||
import type { Tool } from '../Tool.js'
|
import type { Tool } from '../Tool.js'
|
||||||
import type { AssistantMessage } from '../types/message.js'
|
import type { AssistantMessage } from '../types/message.js'
|
||||||
import { jsonStringify } from '../utils/slowOperations.js'
|
import { jsonStringify } from '../utils/slowOperations.js'
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
SDKStatusMessage,
|
SDKStatusMessage,
|
||||||
SDKSystemMessage,
|
SDKSystemMessage,
|
||||||
SDKToolProgressMessage,
|
SDKToolProgressMessage,
|
||||||
} from '../entrypoints/agentSdkTypes.js'
|
} from '../entrypoints/agentSdkTypes.ts'
|
||||||
import type {
|
import type {
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
Message,
|
Message,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* Both files now import from this shared location instead of each other.
|
* 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 { z } from 'zod/v4'
|
||||||
import { lazySchema } from '../utils/lazySchema.js'
|
import { lazySchema } from '../utils/lazySchema.js'
|
||||||
import { SHELL_TYPES } from '../utils/shell/shellProvider.js'
|
import { SHELL_TYPES } from '../utils/shell/shellProvider.js'
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ import { WorkerPendingPermission } from '../components/permissions/WorkerPending
|
|||||||
import { injectUserMessageToTeammate, getAllInProcessTeammateTasks } from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js';
|
import { injectUserMessageToTeammate, getAllInProcessTeammateTasks } from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js';
|
||||||
import { isLocalAgentTask, queuePendingMessage, appendMessageToLocalAgent, type LocalAgentTaskState } from '../tasks/LocalAgentTask/LocalAgentTask.js';
|
import { isLocalAgentTask, queuePendingMessage, appendMessageToLocalAgent, type LocalAgentTaskState } from '../tasks/LocalAgentTask/LocalAgentTask.js';
|
||||||
import { registerLeaderToolUseConfirmQueue, unregisterLeaderToolUseConfirmQueue, registerLeaderSetToolPermissionContext, unregisterLeaderSetToolPermissionContext } from '../utils/swarm/leaderPermissionBridge.js';
|
import { registerLeaderToolUseConfirmQueue, unregisterLeaderToolUseConfirmQueue, registerLeaderSetToolPermissionContext, unregisterLeaderSetToolPermissionContext } from '../utils/swarm/leaderPermissionBridge.js';
|
||||||
import { endInteractionSpan } from '../utils/telemetry/sessionTracing.js';
|
|
||||||
import { useLogMessages } from '../hooks/useLogMessages.js';
|
import { useLogMessages } from '../hooks/useLogMessages.js';
|
||||||
import { useReplBridge } from '../hooks/useReplBridge.js';
|
import { useReplBridge } from '../hooks/useReplBridge.js';
|
||||||
import { type Command, type CommandResultDisplay, type ResumeEntrypoint, getCommandName, isCommandEnabled } from '../commands.js';
|
import { type Command, type CommandResultDisplay, type ResumeEntrypoint, getCommandName, isCommandEnabled } from '../commands.js';
|
||||||
@@ -267,7 +266,7 @@ import { useTeammateLifecycleNotification } from 'src/hooks/notifs/useTeammateSh
|
|||||||
import { useFastModeNotification } from 'src/hooks/notifs/useFastModeNotification.js';
|
import { useFastModeNotification } from 'src/hooks/notifs/useFastModeNotification.js';
|
||||||
import { AutoRunIssueNotification, shouldAutoRunIssue, getAutoRunIssueReasonText, getAutoRunCommand, type AutoRunIssueReason } from '../utils/autoRunIssue.js';
|
import { AutoRunIssueNotification, shouldAutoRunIssue, getAutoRunIssueReasonText, getAutoRunCommand, type AutoRunIssueReason } from '../utils/autoRunIssue.js';
|
||||||
import type { HookProgress } from '../types/hooks.js';
|
import type { HookProgress } from '../types/hooks.js';
|
||||||
import { TungstenLiveMonitor } from '../tools/TungstenTool/TungstenLiveMonitor.js';
|
import { TungstenLiveMonitor } from '../tools/TungstenTool/TungstenLiveMonitor.ts';
|
||||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
const WebBrowserPanelModule = feature('WEB_BROWSER_TOOL') ? require('../tools/WebBrowserTool/WebBrowserPanel.js') as typeof import('../tools/WebBrowserTool/WebBrowserPanel.js') : null;
|
const WebBrowserPanelModule = feature('WEB_BROWSER_TOOL') ? require('../tools/WebBrowserTool/WebBrowserPanel.js') as typeof import('../tools/WebBrowserTool/WebBrowserPanel.js') : null;
|
||||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||||
@@ -1579,7 +1578,6 @@ export function REPL({
|
|||||||
setSpinnerColor(null);
|
setSpinnerColor(null);
|
||||||
setSpinnerShimmerColor(null);
|
setSpinnerShimmerColor(null);
|
||||||
pickNewSpinnerTip();
|
pickNewSpinnerTip();
|
||||||
endInteractionSpan();
|
|
||||||
// Speculative bash classifier checks are only valid for the current
|
// Speculative bash classifier checks are only valid for the current
|
||||||
// turn's commands — clear after each turn to avoid accumulating
|
// turn's commands — clear after each turn to avoid accumulating
|
||||||
// Promise chains for unconsumed checks (denied/aborted paths).
|
// Promise chains for unconsumed checks (denied/aborted paths).
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
/* eslint-disable eslint-plugin-n/no-unsupported-features/node-builtins */
|
/* 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 {
|
import type {
|
||||||
SDKControlPermissionRequest,
|
SDKControlPermissionRequest,
|
||||||
StdoutMessage,
|
StdoutMessage,
|
||||||
} from '../entrypoints/sdk/controlTypes.js'
|
} from '../entrypoints/sdk/controlTypes.ts'
|
||||||
import type { RemotePermissionResponse } from '../remote/RemoteSessionManager.js'
|
import type { RemotePermissionResponse } from '../remote/RemoteSessionManager.js'
|
||||||
import { logForDebugging } from '../utils/debug.js'
|
import { logForDebugging } from '../utils/debug.js'
|
||||||
import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
|
import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Shared analytics configuration
|
* Shared analytics configuration
|
||||||
*
|
*
|
||||||
* Common logic for determining when analytics should be disabled
|
* 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'
|
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||||
@@ -31,7 +31,7 @@ export function isAnalyticsDisabled(): boolean {
|
|||||||
*
|
*
|
||||||
* Unlike isAnalyticsDisabled(), this does NOT block on 3P providers
|
* Unlike isAnalyticsDisabled(), this does NOT block on 3P providers
|
||||||
* (Bedrock/Vertex/Foundry). The survey is a local UI prompt with no
|
* (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 {
|
export function isFeedbackSurveyDisabled(): boolean {
|
||||||
return process.env.NODE_ENV === 'test' || isTelemetryDisabled()
|
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
|
* Analytics service - public API for event logging
|
||||||
*
|
*
|
||||||
* This module serves as the main entry point for analytics events in Claude CLI.
|
* The open build intentionally ships without product telemetry. We keep this
|
||||||
*
|
* module as a compatibility boundary so existing call sites can remain
|
||||||
* DESIGN: This module has NO dependencies to avoid import cycles.
|
* unchanged while all analytics become inert.
|
||||||
* Events are queued until attachAnalyticsSink() is called during app initialization.
|
|
||||||
* The sink handles routing to Datadog and 1P event logging.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,53 +17,22 @@
|
|||||||
export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = never
|
export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = never
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Marker type for values routed to PII-tagged proto columns via `_PROTO_*`
|
* Marker type for values that previously flowed to privileged `_PROTO_*`
|
||||||
* payload keys. The destination BQ column has privileged access controls,
|
* columns. The export remains so existing call sites keep their explicit
|
||||||
* so unredacted values are acceptable — unlike general-access backends.
|
* privacy annotations even though external analytics export is disabled.
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* Usage: `rawName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED`
|
* Usage: `rawName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED`
|
||||||
*/
|
*/
|
||||||
export type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED = never
|
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>(
|
export function stripProtoFields<V>(
|
||||||
metadata: Record<string, V>,
|
metadata: Record<string, V>,
|
||||||
): Record<string, V> {
|
): Record<string, V> {
|
||||||
let result: Record<string, V> | undefined
|
return metadata
|
||||||
for (const key in metadata) {
|
|
||||||
if (key.startsWith('_PROTO_')) {
|
|
||||||
if (result === undefined) {
|
|
||||||
result = { ...metadata }
|
|
||||||
}
|
|
||||||
delete result[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result ?? metadata
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Internal type for logEvent metadata - different from the enriched EventMetadata in metadata.ts
|
|
||||||
type LogEventMetadata = { [key: string]: boolean | number | undefined }
|
type LogEventMetadata = { [key: string]: boolean | number | undefined }
|
||||||
|
|
||||||
type QueuedEvent = {
|
|
||||||
eventName: string
|
|
||||||
metadata: LogEventMetadata
|
|
||||||
async: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sink interface for the analytics backend
|
* Sink interface for the analytics backend
|
||||||
*/
|
*/
|
||||||
@@ -77,97 +44,26 @@ export type AnalyticsSink = {
|
|||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event queue for events logged before sink is attached
|
export function attachAnalyticsSink(_newSink: AnalyticsSink): void {}
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log an event to analytics backends (synchronous)
|
* 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(
|
export function logEvent(
|
||||||
eventName: string,
|
_eventName: string,
|
||||||
// intentionally no strings unless AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
_metadata: LogEventMetadata,
|
||||||
// to avoid accidentally logging code/filepaths
|
): void {}
|
||||||
metadata: LogEventMetadata,
|
|
||||||
): void {
|
|
||||||
if (sink === null) {
|
|
||||||
eventQueue.push({ eventName, metadata, async: false })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sink.logEvent(eventName, metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log an event to analytics backends (asynchronous)
|
* 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(
|
export async function logEventAsync(
|
||||||
eventName: string,
|
_eventName: string,
|
||||||
// intentionally no strings, to avoid accidentally logging code/filepaths
|
_metadata: LogEventMetadata,
|
||||||
metadata: LogEventMetadata,
|
): Promise<void> {}
|
||||||
): Promise<void> {
|
|
||||||
if (sink === null) {
|
|
||||||
eventQueue.push({ eventName, metadata, async: true })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await sink.logEventAsync(eventName, metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset analytics state for testing purposes only.
|
* Reset analytics state for testing purposes only.
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
export function _resetForTesting(): void {
|
export function _resetForTesting(): void {}
|
||||||
sink = null
|
|
||||||
eventQueue.length = 0
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 { extname } from 'path'
|
||||||
import memoize from 'lodash-es/memoize.js'
|
import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from './index.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'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Marker type for verifying analytics metadata doesn't contain sensitive data
|
* Local-only analytics helpers retained for compatibility after telemetry
|
||||||
*
|
* export removal. These helpers only sanitize or classify values in-process.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = never
|
|
||||||
|
|
||||||
/**
|
export type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS }
|
||||||
* 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 function sanitizeToolNameForAnalytics(
|
export function sanitizeToolNameForAnalytics(
|
||||||
toolName: string,
|
toolName: string,
|
||||||
): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS {
|
): 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
|
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 {
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function mcpToolDetailsForAnalytics(): {
|
||||||
* 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,
|
|
||||||
): {
|
|
||||||
mcpServerName?: AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
mcpServerName?: AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||||
mcpToolName?: 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 {}
|
return {}
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
!BUILTIN_MCP_SERVER_NAMES.has(details.serverName) &&
|
|
||||||
!isAnalyticsToolDetailsLoggingEnabled(mcpServerType, mcpServerBaseUrl)
|
|
||||||
) {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
mcpServerName: details.serverName,
|
|
||||||
mcpToolName: details.mcpToolName,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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):
|
export function extractMcpToolDetails(toolName: string):
|
||||||
| {
|
| {
|
||||||
serverName: AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
serverName: AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||||
@@ -183,16 +38,13 @@ export function extractMcpToolDetails(toolName: string):
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format: mcp__<server>__<tool>
|
|
||||||
const parts = toolName.split('__')
|
const parts = toolName.split('__')
|
||||||
if (parts.length < 3) {
|
if (parts.length < 3) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverName = parts[1]
|
const serverName = parts[1]
|
||||||
// Tool name may contain __ so rejoin remaining parts
|
|
||||||
const mcpToolName = parts.slice(2).join('__')
|
const mcpToolName = parts.slice(2).join('__')
|
||||||
|
|
||||||
if (!serverName || !mcpToolName) {
|
if (!serverName || !mcpToolName) {
|
||||||
return undefined
|
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(
|
export function extractSkillName(
|
||||||
toolName: string,
|
toolName: string,
|
||||||
input: unknown,
|
input: unknown,
|
||||||
@@ -233,93 +78,14 @@ export function extractSkillName(
|
|||||||
return undefined
|
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(
|
export function extractToolInputForTelemetry(
|
||||||
input: unknown,
|
_input: unknown,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
if (!isToolDetailsLoggingEnabled()) {
|
|
||||||
return undefined
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
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(
|
export function getFileExtensionForAnalytics(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS | undefined {
|
): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS | undefined {
|
||||||
@@ -328,7 +94,7 @@ export function getFileExtensionForAnalytics(
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const extension = ext.slice(1) // remove leading dot
|
const extension = ext.slice(1)
|
||||||
if (extension.length > MAX_FILE_EXTENSION_LENGTH) {
|
if (extension.length > MAX_FILE_EXTENSION_LENGTH) {
|
||||||
return 'other' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
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
|
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([
|
const FILE_COMMANDS = new Set([
|
||||||
'rm',
|
'rm',
|
||||||
'mv',
|
'mv',
|
||||||
@@ -357,23 +122,16 @@ const FILE_COMMANDS = new Set([
|
|||||||
'sed',
|
'sed',
|
||||||
])
|
])
|
||||||
|
|
||||||
/** Regex to split bash commands on compound operators (&&, ||, ;, |). */
|
|
||||||
const COMPOUND_OPERATOR_REGEX = /\s*(?:&&|\|\||[;|])\s*/
|
const COMPOUND_OPERATOR_REGEX = /\s*(?:&&|\|\||[;|])\s*/
|
||||||
|
|
||||||
/** Regex to split on whitespace. */
|
|
||||||
const WHITESPACE_REGEX = /\s+/
|
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(
|
export function getFileExtensionsFromBashCommand(
|
||||||
command: string,
|
command: string,
|
||||||
simulatedSedEditFilePath?: string,
|
simulatedSedEditFilePath?: string,
|
||||||
): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS | undefined {
|
): 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
|
let result: string | undefined
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
@@ -398,7 +156,7 @@ export function getFileExtensionsFromBashCommand(
|
|||||||
|
|
||||||
for (let i = 1; i < tokens.length; i++) {
|
for (let i = 1; i < tokens.length; i++) {
|
||||||
const arg = tokens[i]!
|
const arg = tokens[i]!
|
||||||
if (arg.charCodeAt(0) === 45 /* - */) continue
|
if (arg.charCodeAt(0) === 45) continue
|
||||||
const ext = getFileExtensionForAnalytics(arg)
|
const ext = getFileExtensionForAnalytics(arg)
|
||||||
if (ext && !seen.has(ext)) {
|
if (ext && !seen.has(ext)) {
|
||||||
seen.add(ext)
|
seen.add(ext)
|
||||||
@@ -407,567 +165,8 @@ export function getFileExtensionsFromBashCommand(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result) return undefined
|
if (!result) {
|
||||||
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 {
|
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
}
|
return result as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -41,7 +41,7 @@ import {
|
|||||||
type ConnectorTextBlock,
|
type ConnectorTextBlock,
|
||||||
type ConnectorTextDelta,
|
type ConnectorTextDelta,
|
||||||
isConnectorTextBlock,
|
isConnectorTextBlock,
|
||||||
} from '../../types/connectorText.js'
|
} from '../../types/connectorText.ts'
|
||||||
import type {
|
import type {
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
Message,
|
Message,
|
||||||
@@ -209,11 +209,6 @@ import {
|
|||||||
stopSessionActivity,
|
stopSessionActivity,
|
||||||
} from '../../utils/sessionActivity.js'
|
} from '../../utils/sessionActivity.js'
|
||||||
import { jsonStringify } from '../../utils/slowOperations.js'
|
import { jsonStringify } from '../../utils/slowOperations.js'
|
||||||
import {
|
|
||||||
isBetaTracingEnabled,
|
|
||||||
type LLMRequestNewContext,
|
|
||||||
startLLMRequestSpan,
|
|
||||||
} from '../../utils/telemetry/sessionTracing.js'
|
|
||||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||||
import {
|
import {
|
||||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||||
@@ -1379,9 +1374,6 @@ async function* queryModel(
|
|||||||
})
|
})
|
||||||
const useBetas = betas.length > 0
|
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 ?? [])]
|
const extraToolSchemas = [...(options.extraToolSchemas ?? [])]
|
||||||
if (advisorModel) {
|
if (advisorModel) {
|
||||||
// Server tools must be in the tools array by API contract. Appended after
|
// 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()
|
const startIncludingRetries = Date.now()
|
||||||
let start = Date.now()
|
let start = Date.now()
|
||||||
let attemptNumber = 0
|
let attemptNumber = 0
|
||||||
@@ -2730,7 +2705,6 @@ async function* queryModel(
|
|||||||
didFallBackToNonStreaming,
|
didFallBackToNonStreaming,
|
||||||
queryTracking: options.queryTracking,
|
queryTracking: options.queryTracking,
|
||||||
querySource: options.querySource,
|
querySource: options.querySource,
|
||||||
llmSpan,
|
|
||||||
fastMode: isFastModeRequest,
|
fastMode: isFastModeRequest,
|
||||||
previousRequestId,
|
previousRequestId,
|
||||||
})
|
})
|
||||||
@@ -2786,7 +2760,6 @@ async function* queryModel(
|
|||||||
didFallBackToNonStreaming,
|
didFallBackToNonStreaming,
|
||||||
queryTracking: options.queryTracking,
|
queryTracking: options.queryTracking,
|
||||||
querySource: options.querySource,
|
querySource: options.querySource,
|
||||||
llmSpan,
|
|
||||||
fastMode: isFastModeRequest,
|
fastMode: isFastModeRequest,
|
||||||
previousRequestId,
|
previousRequestId,
|
||||||
})
|
})
|
||||||
@@ -2874,10 +2847,7 @@ async function* queryModel(
|
|||||||
costUSD,
|
costUSD,
|
||||||
queryTracking: options.queryTracking,
|
queryTracking: options.queryTracking,
|
||||||
permissionMode: permissionContext.mode,
|
permissionMode: permissionContext.mode,
|
||||||
// Pass newMessages for beta tracing - extraction happens in logging.ts
|
|
||||||
// only when beta tracing is enabled
|
|
||||||
newMessages,
|
newMessages,
|
||||||
llmSpan,
|
|
||||||
globalCacheStrategy,
|
globalCacheStrategy,
|
||||||
requestSetupMs: start - startIncludingRetries,
|
requestSetupMs: start - startIncludingRetries,
|
||||||
attemptStartTimes,
|
attemptStartTimes,
|
||||||
|
|||||||
@@ -736,77 +736,27 @@ async function translateCodexStreamToAnthropic(
|
|||||||
|
|
||||||
// ── Main fetch interceptor ──────────────────────────────────────────
|
// ── 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.
|
* createCodexFetch is disabled: routing conversations to chatgpt.com would
|
||||||
* @param accessToken - The Codex access token for authentication
|
* send full user conversation content to OpenAI's backend, which is a
|
||||||
* @returns A fetch function that translates Anthropic requests to Codex format
|
* 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(
|
export function createCodexFetch(
|
||||||
accessToken: string,
|
_accessToken: string,
|
||||||
): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
|
): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
|
||||||
const accountId = extractAccountId(accessToken)
|
return async (_input: RequestInfo | URL, _init?: RequestInit): Promise<Response> => {
|
||||||
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(codexBody),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!codexResponse.ok) {
|
|
||||||
const errorText = await codexResponse.text()
|
|
||||||
const errorBody = {
|
const errorBody = {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
error: {
|
error: {
|
||||||
type: 'api_error',
|
type: 'api_error',
|
||||||
message: `Codex API error (${codexResponse.status}): ${errorText}`,
|
message:
|
||||||
|
'Codex API routing is disabled. External Codex forwarding has been removed for privacy reasons.',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return new Response(JSON.stringify(errorBody), {
|
return new Response(JSON.stringify(errorBody), {
|
||||||
status: codexResponse.status,
|
status: 403,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Translate streaming response
|
|
||||||
return translateCodexStreamToAnthropic(codexResponse, codexModel)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type {
|
|||||||
BetaStopReason,
|
BetaStopReason,
|
||||||
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||||
import { AFK_MODE_BETA_HEADER } from 'src/constants/betas.js'
|
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 {
|
import type {
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
Message,
|
Message,
|
||||||
|
|||||||
@@ -14,9 +14,10 @@ import * as path from 'path'
|
|||||||
import { count } from '../../utils/array.js'
|
import { count } from '../../utils/array.js'
|
||||||
import { getCwd } from '../../utils/cwd.js'
|
import { getCwd } from '../../utils/cwd.js'
|
||||||
import { logForDebugging } from '../../utils/debug.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 { logError } from '../../utils/log.js'
|
||||||
import { sleep } from '../../utils/sleep.js'
|
import { sleep } from '../../utils/sleep.js'
|
||||||
|
import { jsonStringify } from '../../utils/slowOperations.js'
|
||||||
import {
|
import {
|
||||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||||
logEvent,
|
logEvent,
|
||||||
@@ -45,6 +46,37 @@ function logDebug(message: string): void {
|
|||||||
logForDebugging(`[files-api] ${message}`)
|
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
|
* File specification parsed from CLI args
|
||||||
* Format: --file=<file_id>:<relative_path>
|
* Format: --file=<file_id>:<relative_path>
|
||||||
@@ -108,9 +140,7 @@ async function retryWithBackoff<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
lastError = result.error || `${operation} failed`
|
lastError = result.error || `${operation} failed`
|
||||||
logDebug(
|
logDebug(`${operation} attempt ${attempt}/${MAX_RETRIES} failed`)
|
||||||
`${operation} attempt ${attempt}/${MAX_RETRIES} failed: ${lastError}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (attempt < MAX_RETRIES) {
|
if (attempt < MAX_RETRIES) {
|
||||||
const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1)
|
const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1)
|
||||||
@@ -142,7 +172,7 @@ export async function downloadFile(
|
|||||||
'anthropic-beta': FILES_API_BETA_HEADER,
|
'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 () => {
|
return retryWithBackoff(`Download file ${fileId}`, async () => {
|
||||||
try {
|
try {
|
||||||
@@ -191,9 +221,7 @@ export function buildDownloadPath(
|
|||||||
): string | null {
|
): string | null {
|
||||||
const normalized = path.normalize(relativePath)
|
const normalized = path.normalize(relativePath)
|
||||||
if (normalized.startsWith('..')) {
|
if (normalized.startsWith('..')) {
|
||||||
logDebugError(
|
logDebugError('Invalid file path rejected: path traversal is not allowed')
|
||||||
`Invalid file path: ${relativePath}. Path must not traverse above workspace`,
|
|
||||||
)
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +271,7 @@ export async function downloadAndSaveFile(
|
|||||||
// Write the file
|
// Write the file
|
||||||
await fs.writeFile(fullPath, content)
|
await fs.writeFile(fullPath, content)
|
||||||
|
|
||||||
logDebug(`Saved file ${fileId} to ${fullPath} (${content.length} bytes)`)
|
logDebug(`Saved file ${fileId} (${content.length} bytes)`)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fileId,
|
fileId,
|
||||||
@@ -252,10 +280,16 @@ export async function downloadAndSaveFile(
|
|||||||
bytesWritten: content.length,
|
bytesWritten: content.length,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logDebugError(`Failed to download file ${fileId}: ${errorMessage(error)}`)
|
logDebugError(
|
||||||
if (error instanceof Error) {
|
`Failed to download file ${fileId}: ${summarizeFilesApiError(error)}`,
|
||||||
logError(error)
|
)
|
||||||
}
|
logError(
|
||||||
|
new Error(
|
||||||
|
`Files API download failed for ${fileId}: ${summarizeFilesApiError(
|
||||||
|
error,
|
||||||
|
)}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fileId,
|
fileId,
|
||||||
@@ -390,7 +424,7 @@ export async function uploadFile(
|
|||||||
'anthropic-beta': FILES_API_BETA_HEADER,
|
'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)
|
// Read file content first (outside retry loop since it's not a network operation)
|
||||||
let content: Buffer
|
let content: Buffer
|
||||||
@@ -455,7 +489,7 @@ export async function uploadFile(
|
|||||||
const body = Buffer.concat(bodyParts)
|
const body = Buffer.concat(bodyParts)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await retryWithBackoff(`Upload file ${relativePath}`, async () => {
|
return await retryWithBackoff('Upload session file', async () => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(url, body, {
|
const response = await axios.post(url, body, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -476,7 +510,7 @@ export async function uploadFile(
|
|||||||
error: 'Upload succeeded but no file ID returned',
|
error: 'Upload succeeded but no file ID returned',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logDebug(`Uploaded file ${filePath} -> ${fileId} (${fileSize} bytes)`)
|
logDebug(`Uploaded file (${fileSize} bytes)`)
|
||||||
return {
|
return {
|
||||||
done: true,
|
done: true,
|
||||||
value: {
|
value: {
|
||||||
@@ -735,9 +769,7 @@ export function parseFileSpecs(fileSpecs: string[]): File[] {
|
|||||||
const relativePath = spec.substring(colonIndex + 1)
|
const relativePath = spec.substring(colonIndex + 1)
|
||||||
|
|
||||||
if (!fileId || !relativePath) {
|
if (!fileId || !relativePath) {
|
||||||
logDebugError(
|
logDebugError('Invalid file spec: missing file_id or relative path')
|
||||||
`Invalid file spec: ${spec}. Both file_id and path are required`,
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
setLastApiCompletionTimestamp,
|
setLastApiCompletionTimestamp,
|
||||||
} from 'src/bootstrap/state.js'
|
} from 'src/bootstrap/state.js'
|
||||||
import type { QueryChainTracking } from 'src/Tool.js'
|
import type { QueryChainTracking } from 'src/Tool.js'
|
||||||
import { isConnectorTextBlock } from 'src/types/connectorText.js'
|
import { isConnectorTextBlock } from 'src/types/connectorText.ts'
|
||||||
import type { AssistantMessage } from 'src/types/message.js'
|
import type { AssistantMessage } from 'src/types/message.js'
|
||||||
import { logForDebugging } from 'src/utils/debug.js'
|
import { logForDebugging } from 'src/utils/debug.js'
|
||||||
import type { EffortLevel } from 'src/utils/effort.js'
|
import type { EffortLevel } from 'src/utils/effort.js'
|
||||||
@@ -22,12 +22,6 @@ import { logError } from 'src/utils/log.js'
|
|||||||
import { getAPIProviderForStatsig } from 'src/utils/model/providers.js'
|
import { getAPIProviderForStatsig } from 'src/utils/model/providers.js'
|
||||||
import type { PermissionMode } from 'src/utils/permissions/PermissionMode.js'
|
import type { PermissionMode } from 'src/utils/permissions/PermissionMode.js'
|
||||||
import { jsonStringify } from 'src/utils/slowOperations.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 type { NonNullableUsage } from '../../entrypoints/sdk/sdkUtilityTypes.js'
|
||||||
import { consumeInvokingRequestId } from '../../utils/agentContext.js'
|
import { consumeInvokingRequestId } from '../../utils/agentContext.js'
|
||||||
import {
|
import {
|
||||||
@@ -247,7 +241,6 @@ export function logAPIError({
|
|||||||
headers,
|
headers,
|
||||||
queryTracking,
|
queryTracking,
|
||||||
querySource,
|
querySource,
|
||||||
llmSpan,
|
|
||||||
fastMode,
|
fastMode,
|
||||||
previousRequestId,
|
previousRequestId,
|
||||||
}: {
|
}: {
|
||||||
@@ -266,8 +259,6 @@ export function logAPIError({
|
|||||||
headers?: globalThis.Headers
|
headers?: globalThis.Headers
|
||||||
queryTracking?: QueryChainTracking
|
queryTracking?: QueryChainTracking
|
||||||
querySource?: string
|
querySource?: string
|
||||||
/** The span from startLLMRequestSpan - pass this to correctly match responses to requests */
|
|
||||||
llmSpan?: Span
|
|
||||||
fastMode?: boolean
|
fastMode?: boolean
|
||||||
previousRequestId?: string | null
|
previousRequestId?: string | null
|
||||||
}): void {
|
}): void {
|
||||||
@@ -364,24 +355,6 @@ export function logAPIError({
|
|||||||
...getAnthropicEnvMetadata(),
|
...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)
|
// Log first error for teleported sessions (reliability tracking)
|
||||||
const teleportInfo = getTeleportedSessionInfo()
|
const teleportInfo = getTeleportedSessionInfo()
|
||||||
if (teleportInfo?.isTeleported && !teleportInfo.hasLoggedFirstMessage) {
|
if (teleportInfo?.isTeleported && !teleportInfo.hasLoggedFirstMessage) {
|
||||||
@@ -597,7 +570,6 @@ export function logAPISuccessAndDuration({
|
|||||||
queryTracking,
|
queryTracking,
|
||||||
permissionMode,
|
permissionMode,
|
||||||
newMessages,
|
newMessages,
|
||||||
llmSpan,
|
|
||||||
globalCacheStrategy,
|
globalCacheStrategy,
|
||||||
requestSetupMs,
|
requestSetupMs,
|
||||||
attemptStartTimes,
|
attemptStartTimes,
|
||||||
@@ -622,11 +594,7 @@ export function logAPISuccessAndDuration({
|
|||||||
costUSD: number
|
costUSD: number
|
||||||
queryTracking?: QueryChainTracking
|
queryTracking?: QueryChainTracking
|
||||||
permissionMode?: PermissionMode
|
permissionMode?: PermissionMode
|
||||||
/** Assistant messages from the response - used to extract model_output and thinking_output
|
|
||||||
* when beta tracing is enabled */
|
|
||||||
newMessages?: AssistantMessage[]
|
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' */
|
/** Strategy used for global prompt caching: 'tool_based', 'system_prompt', or 'none' */
|
||||||
globalCacheStrategy?: GlobalCacheStrategy
|
globalCacheStrategy?: GlobalCacheStrategy
|
||||||
/** Time spent in pre-request setup before the successful attempt */
|
/** Time spent in pre-request setup before the successful attempt */
|
||||||
@@ -714,68 +682,6 @@ export function logAPISuccessAndDuration({
|
|||||||
previousRequestId,
|
previousRequestId,
|
||||||
betas,
|
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)
|
// Log first successful message for teleported sessions (reliability tracking)
|
||||||
const teleportInfo = getTeleportedSessionInfo()
|
const teleportInfo = getTeleportedSessionInfo()
|
||||||
if (teleportInfo?.isTeleported && !teleportInfo.hasLoggedFirstMessage) {
|
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
|
// Module-level state
|
||||||
const lastUuidMap: Map<string, UUID> = new Map()
|
const lastUuidMap: Map<string, UUID> = new Map()
|
||||||
|
|
||||||
@@ -81,9 +112,7 @@ async function appendSessionLogImpl(
|
|||||||
|
|
||||||
if (response.status === 200 || response.status === 201) {
|
if (response.status === 200 || response.status === 201) {
|
||||||
lastUuidMap.set(sessionId, entry.uuid)
|
lastUuidMap.set(sessionId, entry.uuid)
|
||||||
logForDebugging(
|
logForDebugging('Successfully persisted session log entry')
|
||||||
`Successfully persisted session log entry for session ${sessionId}`,
|
|
||||||
)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +125,7 @@ async function appendSessionLogImpl(
|
|||||||
// Our entry IS the last entry on server - it was stored successfully previously
|
// Our entry IS the last entry on server - it was stored successfully previously
|
||||||
lastUuidMap.set(sessionId, entry.uuid)
|
lastUuidMap.set(sessionId, entry.uuid)
|
||||||
logForDebugging(
|
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')
|
logForDiagnosticsNoPII('info', 'session_persist_recovered_from_409')
|
||||||
return true
|
return true
|
||||||
@@ -108,7 +137,7 @@ async function appendSessionLogImpl(
|
|||||||
if (serverLastUuid) {
|
if (serverLastUuid) {
|
||||||
lastUuidMap.set(sessionId, serverLastUuid as UUID)
|
lastUuidMap.set(sessionId, serverLastUuid as UUID)
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`Session 409: adopting server lastUuid=${serverLastUuid} from header, retrying entry ${entry.uuid}`,
|
'Session 409: adopting server last UUID from header and retrying',
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Server didn't return x-last-uuid (e.g. v1 endpoint). Re-fetch
|
// Server didn't return x-last-uuid (e.g. v1 endpoint). Re-fetch
|
||||||
@@ -118,7 +147,7 @@ async function appendSessionLogImpl(
|
|||||||
if (adoptedUuid) {
|
if (adoptedUuid) {
|
||||||
lastUuidMap.set(sessionId, adoptedUuid)
|
lastUuidMap.set(sessionId, adoptedUuid)
|
||||||
logForDebugging(
|
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 {
|
} else {
|
||||||
// Can't determine server state — give up
|
// Can't determine server state — give up
|
||||||
@@ -127,7 +156,7 @@ async function appendSessionLogImpl(
|
|||||||
errorData.error?.message || 'Concurrent modification detected'
|
errorData.error?.message || 'Concurrent modification detected'
|
||||||
logError(
|
logError(
|
||||||
new Error(
|
new Error(
|
||||||
`Session persistence conflict: UUID mismatch for session ${sessionId}, entry ${entry.uuid}. ${errorMessage}`,
|
`Session persistence conflict: UUID mismatch detected. ${errorMessage}`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
logForDiagnosticsNoPII(
|
logForDiagnosticsNoPII(
|
||||||
@@ -149,7 +178,7 @@ async function appendSessionLogImpl(
|
|||||||
|
|
||||||
// Other 4xx (429, etc.) - retryable
|
// Other 4xx (429, etc.) - retryable
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`Failed to persist session log: ${response.status} ${response.statusText}`,
|
`Failed to persist session log: status=${response.status}`,
|
||||||
)
|
)
|
||||||
logForDiagnosticsNoPII('error', 'session_persist_fail_status', {
|
logForDiagnosticsNoPII('error', 'session_persist_fail_status', {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
@@ -158,7 +187,13 @@ async function appendSessionLogImpl(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Network errors, 5xx - retryable
|
// Network errors, 5xx - retryable
|
||||||
const axiosError = error as AxiosError<SessionIngressError>
|
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', {
|
logForDiagnosticsNoPII('error', 'session_persist_fail_status', {
|
||||||
status: axiosError.status,
|
status: axiosError.status,
|
||||||
attempt,
|
attempt,
|
||||||
@@ -249,7 +284,7 @@ export async function getSessionLogsViaOAuth(
|
|||||||
orgUUID: string,
|
orgUUID: string,
|
||||||
): Promise<Entry[] | null> {
|
): Promise<Entry[] | null> {
|
||||||
const url = `${getOauthConfig().BASE_API_URL}/v1/session_ingress/session/${sessionId}`
|
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 = {
|
const headers = {
|
||||||
...getOAuthHeaders(accessToken),
|
...getOAuthHeaders(accessToken),
|
||||||
'x-organization-uuid': orgUUID,
|
'x-organization-uuid': orgUUID,
|
||||||
@@ -299,7 +334,7 @@ export async function getTeleportEvents(
|
|||||||
'x-organization-uuid': orgUUID,
|
'x-organization-uuid': orgUUID,
|
||||||
}
|
}
|
||||||
|
|
||||||
logForDebugging(`[teleport] Fetching events from: ${baseUrl}`)
|
logForDebugging('[teleport] Fetching session events via teleport endpoint')
|
||||||
|
|
||||||
const all: Entry[] = []
|
const all: Entry[] = []
|
||||||
let cursor: string | undefined
|
let cursor: string | undefined
|
||||||
@@ -346,7 +381,7 @@ export async function getTeleportEvents(
|
|||||||
// 404 mid-pagination (pages > 0) means session was deleted between
|
// 404 mid-pagination (pages > 0) means session was deleted between
|
||||||
// pages — return what we have.
|
// pages — return what we have.
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[teleport] Session ${sessionId} not found (page ${pages})`,
|
`[teleport] Session not found while fetching events (page ${pages})`,
|
||||||
)
|
)
|
||||||
logForDiagnosticsNoPII('warn', 'teleport_events_not_found')
|
logForDiagnosticsNoPII('warn', 'teleport_events_not_found')
|
||||||
return pages === 0 ? null : all
|
return pages === 0 ? null : all
|
||||||
@@ -362,7 +397,9 @@ export async function getTeleportEvents(
|
|||||||
if (response.status !== 200) {
|
if (response.status !== 200) {
|
||||||
logError(
|
logError(
|
||||||
new Error(
|
new Error(
|
||||||
`Teleport events returned ${response.status}: ${jsonStringify(response.data)}`,
|
`Teleport events returned ${response.status}: ${summarizeSessionIngressPayload(
|
||||||
|
response.data,
|
||||||
|
)}`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
logForDiagnosticsNoPII('error', 'teleport_events_bad_status')
|
logForDiagnosticsNoPII('error', 'teleport_events_bad_status')
|
||||||
@@ -373,7 +410,9 @@ export async function getTeleportEvents(
|
|||||||
if (!Array.isArray(data)) {
|
if (!Array.isArray(data)) {
|
||||||
logError(
|
logError(
|
||||||
new Error(
|
new Error(
|
||||||
`Teleport events invalid response shape: ${jsonStringify(response.data)}`,
|
`Teleport events invalid response shape: ${summarizeSessionIngressPayload(
|
||||||
|
response.data,
|
||||||
|
)}`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
logForDiagnosticsNoPII('error', 'teleport_events_invalid_shape')
|
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
|
// Don't fail — return what we have. Better to teleport with a
|
||||||
// truncated transcript than not at all.
|
// truncated transcript than not at all.
|
||||||
logError(
|
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')
|
logForDiagnosticsNoPII('warn', 'teleport_events_page_cap')
|
||||||
}
|
}
|
||||||
|
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[teleport] Fetched ${all.length} events over ${pages} page(s) for ${sessionId}`,
|
`[teleport] Fetched ${all.length} events over ${pages} page(s)`,
|
||||||
)
|
)
|
||||||
return all
|
return all
|
||||||
}
|
}
|
||||||
@@ -439,7 +478,9 @@ async function fetchSessionLogsFromUrl(
|
|||||||
if (!data || typeof data !== 'object' || !Array.isArray(data.loglines)) {
|
if (!data || typeof data !== 'object' || !Array.isArray(data.loglines)) {
|
||||||
logError(
|
logError(
|
||||||
new Error(
|
new Error(
|
||||||
`Invalid session logs response format: ${jsonStringify(data)}`,
|
`Invalid session logs response format: ${summarizeSessionIngressPayload(
|
||||||
|
data,
|
||||||
|
)}`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
logForDiagnosticsNoPII('error', 'session_get_fail_invalid_response')
|
logForDiagnosticsNoPII('error', 'session_get_fail_invalid_response')
|
||||||
@@ -447,14 +488,12 @@ async function fetchSessionLogsFromUrl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const logs = data.loglines as Entry[]
|
const logs = data.loglines as Entry[]
|
||||||
logForDebugging(
|
logForDebugging(`Fetched ${logs.length} session logs`)
|
||||||
`Fetched ${logs.length} session logs for session ${sessionId}`,
|
|
||||||
)
|
|
||||||
return logs
|
return logs
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
logForDebugging(`No existing logs for session ${sessionId}`)
|
logForDebugging('No existing session logs')
|
||||||
logForDiagnosticsNoPII('warn', 'session_get_no_logs_for_session')
|
logForDiagnosticsNoPII('warn', 'session_get_no_logs_for_session')
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -468,7 +507,7 @@ async function fetchSessionLogsFromUrl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`Failed to fetch session logs: ${response.status} ${response.statusText}`,
|
`Failed to fetch session logs: status=${response.status}`,
|
||||||
)
|
)
|
||||||
logForDiagnosticsNoPII('error', 'session_get_fail_status', {
|
logForDiagnosticsNoPII('error', 'session_get_fail_status', {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
@@ -476,7 +515,13 @@ async function fetchSessionLogsFromUrl(
|
|||||||
return null
|
return null
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const axiosError = error as AxiosError<SessionIngressError>
|
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', {
|
logForDiagnosticsNoPII('error', 'session_get_fail_status', {
|
||||||
status: axiosError.status,
|
status: axiosError.status,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { clearSpeculativeChecks } from '../../tools/BashTool/bashPermissions.js'
|
|||||||
import { clearClassifierApprovals } from '../../utils/classifierApprovals.js'
|
import { clearClassifierApprovals } from '../../utils/classifierApprovals.js'
|
||||||
import { resetGetMemoryFilesCache } from '../../utils/claudemd.js'
|
import { resetGetMemoryFilesCache } from '../../utils/claudemd.js'
|
||||||
import { clearSessionMessagesCache } from '../../utils/sessionStorage.js'
|
import { clearSessionMessagesCache } from '../../utils/sessionStorage.js'
|
||||||
import { clearBetaTracingState } from '../../utils/telemetry/betaSessionTracing.js'
|
|
||||||
import { resetMicrocompactState } from './microCompact.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
|
// model still has SkillTool in schema, invoked_skills preserves used
|
||||||
// skills, and dynamic additions are handled by skillChangeDetector /
|
// skills, and dynamic additions are handled by skillChangeDetector /
|
||||||
// cacheUtils resets. See compactConversation() for full rationale.
|
// cacheUtils resets. See compactConversation() for full rationale.
|
||||||
clearBetaTracingState()
|
|
||||||
if (feature('COMMIT_ATTRIBUTION')) {
|
if (feature('COMMIT_ATTRIBUTION')) {
|
||||||
void import('../../utils/attributionHooks.js').then(m =>
|
void import('../../utils/attributionHooks.js').then(m =>
|
||||||
m.sweepFileContentCache(),
|
m.sweepFileContentCache(),
|
||||||
|
|||||||
@@ -8,6 +8,34 @@ import type { DiagnosticFile } from '../diagnosticTracking.js'
|
|||||||
import { registerPendingLSPDiagnostic } from './LSPDiagnosticRegistry.js'
|
import { registerPendingLSPDiagnostic } from './LSPDiagnosticRegistry.js'
|
||||||
import type { LSPServerManager } from './LSPServerManager.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
|
* Map LSP severity to Claude diagnostic severity
|
||||||
*
|
*
|
||||||
@@ -54,7 +82,9 @@ export function formatDiagnosticsForAttachment(
|
|||||||
const err = toError(error)
|
const err = toError(error)
|
||||||
logError(err)
|
logError(err)
|
||||||
logForDebugging(
|
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
|
// Gracefully fallback to original URI - LSP servers may send malformed URIs
|
||||||
uri = params.uri
|
uri = params.uri
|
||||||
@@ -177,14 +207,16 @@ export function registerLSPNotificationHandlers(
|
|||||||
)
|
)
|
||||||
logError(err)
|
logError(err)
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`Invalid diagnostic params from ${serverName}: ${jsonStringify(params)}`,
|
`Invalid diagnostic params from ${serverName}: ${summarizeDiagnosticParamsForDebug(
|
||||||
|
params,
|
||||||
|
)}`,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const diagnosticParams = params as PublishDiagnosticsParams
|
const diagnosticParams = params as PublishDiagnosticsParams
|
||||||
logForDebugging(
|
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)
|
// Convert LSP diagnostics to Claude format (can throw on invalid URIs)
|
||||||
@@ -199,7 +231,7 @@ export function registerLSPNotificationHandlers(
|
|||||||
firstFile.diagnostics.length === 0
|
firstFile.diagnostics.length === 0
|
||||||
) {
|
) {
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`Skipping empty diagnostics from ${serverName} for ${diagnosticParams.uri}`,
|
`Skipping empty diagnostics from ${serverName}`,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -223,9 +255,8 @@ export function registerLSPNotificationHandlers(
|
|||||||
logError(err)
|
logError(err)
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`Error registering LSP diagnostics from ${serverName}: ` +
|
`Error registering LSP diagnostics from ${serverName}: ` +
|
||||||
`URI: ${diagnosticParams.uri}, ` +
|
|
||||||
`Diagnostic count: ${firstFile.diagnostics.length}, ` +
|
`Diagnostic count: ${firstFile.diagnostics.length}, ` +
|
||||||
`Error: ${err.message}`,
|
`Error: ${summarizeLspErrorForDebug(err)}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Track consecutive failures and warn after 3+
|
// Track consecutive failures and warn after 3+
|
||||||
@@ -234,7 +265,7 @@ export function registerLSPNotificationHandlers(
|
|||||||
lastError: '',
|
lastError: '',
|
||||||
}
|
}
|
||||||
failures.count++
|
failures.count++
|
||||||
failures.lastError = err.message
|
failures.lastError = summarizeLspErrorForDebug(err)
|
||||||
diagnosticFailures.set(serverName, failures)
|
diagnosticFailures.set(serverName, failures)
|
||||||
|
|
||||||
if (failures.count >= 3) {
|
if (failures.count >= 3) {
|
||||||
@@ -251,7 +282,9 @@ export function registerLSPNotificationHandlers(
|
|||||||
const err = toError(error)
|
const err = toError(error)
|
||||||
logError(err)
|
logError(err)
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`Unexpected error processing diagnostics from ${serverName}: ${err.message}`,
|
`Unexpected error processing diagnostics from ${serverName}: ${summarizeLspErrorForDebug(
|
||||||
|
err,
|
||||||
|
)}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Track consecutive failures and warn after 3+
|
// Track consecutive failures and warn after 3+
|
||||||
@@ -260,7 +293,7 @@ export function registerLSPNotificationHandlers(
|
|||||||
lastError: '',
|
lastError: '',
|
||||||
}
|
}
|
||||||
failures.count++
|
failures.count++
|
||||||
failures.lastError = err.message
|
failures.lastError = summarizeLspErrorForDebug(err)
|
||||||
diagnosticFailures.set(serverName, failures)
|
diagnosticFailures.set(serverName, failures)
|
||||||
|
|
||||||
if (failures.count >= 3) {
|
if (failures.count >= 3) {
|
||||||
@@ -284,13 +317,13 @@ export function registerLSPNotificationHandlers(
|
|||||||
|
|
||||||
registrationErrors.push({
|
registrationErrors.push({
|
||||||
serverName,
|
serverName,
|
||||||
error: err.message,
|
error: summarizeLspErrorForDebug(err),
|
||||||
})
|
})
|
||||||
|
|
||||||
logError(err)
|
logError(err)
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`Failed to register diagnostics handler for ${serverName}: ` +
|
`Failed to register diagnostics handler for ${serverName}: ` +
|
||||||
`Error: ${err.message}`,
|
`Error: ${summarizeLspErrorForDebug(err)}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,35 +93,77 @@ type MCPOAuthFlowErrorReason =
|
|||||||
|
|
||||||
const MAX_LOCK_RETRIES = 5
|
const MAX_LOCK_RETRIES = 5
|
||||||
|
|
||||||
/**
|
function summarizeHeadersForDebug(
|
||||||
* OAuth query parameters that should be redacted from logs.
|
headers: Record<string, string> | undefined,
|
||||||
* These contain sensitive values that could enable CSRF or session fixation attacks.
|
): {
|
||||||
*/
|
headerCount: number
|
||||||
const SENSITIVE_OAUTH_PARAMS = [
|
headerNames: string[]
|
||||||
'state',
|
hasAuthorization: boolean
|
||||||
'nonce',
|
} {
|
||||||
'code_challenge',
|
if (!headers) {
|
||||||
'code_verifier',
|
return {
|
||||||
'code',
|
headerCount: 0,
|
||||||
]
|
headerNames: [],
|
||||||
|
hasAuthorization: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
const headerNames = Object.keys(headers).sort()
|
||||||
* Redacts sensitive OAuth query parameters from a URL for safe logging.
|
return {
|
||||||
* Prevents exposure of state, nonce, code_challenge, code_verifier, and authorization codes.
|
headerCount: headerNames.length,
|
||||||
*/
|
headerNames,
|
||||||
function redactSensitiveUrlParams(url: string): string {
|
hasAuthorization: headerNames.some(
|
||||||
try {
|
headerName => headerName.toLowerCase() === 'authorization',
|
||||||
const parsedUrl = new URL(url)
|
),
|
||||||
for (const param of SENSITIVE_OAUTH_PARAMS) {
|
|
||||||
if (parsedUrl.searchParams.has(param)) {
|
|
||||||
parsedUrl.searchParams.set(param, '[REDACTED]')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return parsedUrl.toString()
|
|
||||||
} catch {
|
function extractHttpStatusFromErrorMessage(message: string): number | undefined {
|
||||||
// Return as-is if not a valid URL
|
const statusMatch = message.match(/^HTTP (\d{3}):/)
|
||||||
return url
|
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.
|
// to the legacy path-aware retry.
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
serverName,
|
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'
|
: 'client_secret_basic'
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
serverName,
|
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)
|
// Revoke refresh token first (more important - prevents future access token generation)
|
||||||
@@ -537,7 +581,9 @@ export async function revokeServerTokens(
|
|||||||
// Log but continue
|
// Log but continue
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
serverName,
|
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) {
|
} catch (error: unknown) {
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
serverName,
|
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) {
|
} catch (error: unknown) {
|
||||||
// Log error but don't throw - revocation is best-effort
|
// 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 {
|
} else {
|
||||||
logMCPDebug(serverName, 'No tokens to revoke')
|
logMCPDebug(serverName, 'No tokens to revoke')
|
||||||
@@ -696,14 +747,11 @@ async function performMCPXaaAuth(
|
|||||||
const haveKeys = Object.keys(
|
const haveKeys = Object.keys(
|
||||||
getSecureStorage().read()?.mcpOAuthClientConfig ?? {},
|
getSecureStorage().read()?.mcpOAuthClientConfig ?? {},
|
||||||
)
|
)
|
||||||
const headersForLogging = Object.fromEntries(
|
|
||||||
Object.entries(serverConfig.headers ?? {}).map(([k, v]) =>
|
|
||||||
k.toLowerCase() === 'authorization' ? [k, '[REDACTED]'] : [k, v],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
serverName,
|
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(
|
throw new Error(
|
||||||
`XAA: AS client secret not found for '${serverName}'. Re-add with --client-secret.`,
|
`XAA: AS client secret not found for '${serverName}'. Re-add with --client-secret.`,
|
||||||
@@ -923,10 +971,7 @@ export async function performMCPOAuthFlow(
|
|||||||
try {
|
try {
|
||||||
resourceMetadataUrl = new URL(cachedResourceMetadataUrl)
|
resourceMetadataUrl = new URL(cachedResourceMetadataUrl)
|
||||||
} catch {
|
} catch {
|
||||||
logMCPDebug(
|
logMCPDebug(serverName, 'Invalid cached resource metadata URL')
|
||||||
serverName,
|
|
||||||
`Invalid cached resourceMetadataUrl: ${cachedResourceMetadataUrl}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const wwwAuthParams: WWWAuthenticateParams = {
|
const wwwAuthParams: WWWAuthenticateParams = {
|
||||||
@@ -988,13 +1033,15 @@ export async function performMCPOAuthFlow(
|
|||||||
provider.setMetadata(metadata)
|
provider.setMetadata(metadata)
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
serverName,
|
serverName,
|
||||||
`Fetched OAuth metadata with scope: ${getScopeFromMetadata(metadata) || 'NONE'}`,
|
`Fetched OAuth metadata (hasScope=${Boolean(
|
||||||
|
getScopeFromMetadata(metadata),
|
||||||
|
)})`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
serverName,
|
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 () => {
|
server.listen(port, '127.0.0.1', async () => {
|
||||||
try {
|
try {
|
||||||
logMCPDebug(serverName, `Starting SDK auth`)
|
logMCPDebug(
|
||||||
logMCPDebug(serverName, `Server URL: ${serverConfig.url}`)
|
serverName,
|
||||||
|
`Starting SDK auth (transport=${serverConfig.type})`,
|
||||||
|
)
|
||||||
|
|
||||||
// First call to start the auth flow - should redirect
|
// First call to start the auth flow - should redirect
|
||||||
// Pass the scope and resource_metadata from WWW-Authenticate header if available
|
// Pass the scope and resource_metadata from WWW-Authenticate header if available
|
||||||
@@ -1189,7 +1238,10 @@ export async function performMCPOAuthFlow(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logMCPDebug(serverName, `SDK auth error: ${error}`)
|
logMCPDebug(
|
||||||
|
serverName,
|
||||||
|
`SDK auth error: ${summarizeOAuthErrorForDebug(error)}`,
|
||||||
|
)
|
||||||
cleanup()
|
cleanup()
|
||||||
rejectOnce(new Error(`SDK auth failed: ${errorMessage(error)}`))
|
rejectOnce(new Error(`SDK auth failed: ${errorMessage(error)}`))
|
||||||
}
|
}
|
||||||
@@ -1235,9 +1287,13 @@ export async function performMCPOAuthFlow(
|
|||||||
if (savedTokens) {
|
if (savedTokens) {
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
serverName,
|
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', {
|
logEvent('tengu_mcp_oauth_flow_success', {
|
||||||
@@ -1257,7 +1313,10 @@ export async function performMCPOAuthFlow(
|
|||||||
throw new Error('Unexpected auth result: ' + result)
|
throw new Error('Unexpected auth result: ' + result)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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
|
// Determine failure reason for attribution telemetry. The try block covers
|
||||||
// port acquisition, the callback server, the redirect flow, and token
|
// 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
|
// 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
|
// embeds it in the message as "HTTP {status}:" when the response body was
|
||||||
// unparseable. Best-effort extraction.
|
// unparseable. Best-effort extraction.
|
||||||
const statusMatch = error.message.match(/^HTTP (\d{3}):/)
|
const parsedStatus = extractHttpStatusFromErrorMessage(error.message)
|
||||||
if (statusMatch) {
|
if (parsedStatus !== undefined) {
|
||||||
httpStatus = Number(statusMatch[1])
|
httpStatus = parsedStatus
|
||||||
}
|
}
|
||||||
// If client not found, clear the stored client ID and suggest retry
|
// If client not found, clear the stored client ID and suggest retry
|
||||||
if (
|
if (
|
||||||
@@ -1429,7 +1488,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
|
|||||||
metadata.scope = metadataScope
|
metadata.scope = metadataScope
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
this.serverName,
|
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 {
|
get clientMetadataUrl(): string | undefined {
|
||||||
const override = process.env.MCP_OAUTH_CLIENT_METADATA_URL
|
const override = process.env.MCP_OAUTH_CLIENT_METADATA_URL
|
||||||
if (override) {
|
if (override) {
|
||||||
logMCPDebug(this.serverName, `Using CIMD URL from env: ${override}`)
|
logMCPDebug(this.serverName, 'Using CIMD URL from env override')
|
||||||
return override
|
return override
|
||||||
}
|
}
|
||||||
return MCP_CLIENT_METADATA_URL
|
return MCP_CLIENT_METADATA_URL
|
||||||
@@ -1467,7 +1526,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
|
|||||||
*/
|
*/
|
||||||
markStepUpPending(scope: string): void {
|
markStepUpPending(scope: string): void {
|
||||||
this._pendingStepUpScope = scope
|
this._pendingStepUpScope = scope
|
||||||
logMCPDebug(this.serverName, `Marked step-up pending: ${scope}`)
|
logMCPDebug(this.serverName, 'Marked step-up pending')
|
||||||
}
|
}
|
||||||
|
|
||||||
async state(): Promise<string> {
|
async state(): Promise<string> {
|
||||||
@@ -1606,7 +1665,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
this.serverName,
|
this.serverName,
|
||||||
`XAA silent exchange failed: ${errorMessage(e)}`,
|
`XAA silent exchange failed: ${summarizeOAuthErrorForDebug(e)}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Fall through. Either id_token isn't cached (xaaRefresh returned
|
// Fall through. Either id_token isn't cached (xaaRefresh returned
|
||||||
@@ -1632,7 +1691,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
|
|||||||
if (needsStepUp) {
|
if (needsStepUp) {
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
this.serverName,
|
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) {
|
} catch (error) {
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
this.serverName,
|
this.serverName,
|
||||||
`Token refresh error: ${errorMessage(error)}`,
|
`Token refresh error: ${summarizeOAuthErrorForDebug(error)}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1693,10 +1752,15 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
|
|||||||
token_type: 'Bearer',
|
token_type: 'Bearer',
|
||||||
}
|
}
|
||||||
|
|
||||||
logMCPDebug(this.serverName, `Returning tokens`)
|
logMCPDebug(
|
||||||
logMCPDebug(this.serverName, `Token length: ${tokens.access_token?.length}`)
|
this.serverName,
|
||||||
logMCPDebug(this.serverName, `Has refresh token: ${!!tokens.refresh_token}`)
|
`Returning tokens: ${jsonStringify({
|
||||||
logMCPDebug(this.serverName, `Expires in: ${Math.floor(expiresIn)}s`)
|
hasAccessToken: Boolean(tokens.access_token),
|
||||||
|
hasRefreshToken: Boolean(tokens.refresh_token),
|
||||||
|
hasScope: Boolean(tokens.scope),
|
||||||
|
expiresInSec: Math.floor(expiresIn),
|
||||||
|
})}`,
|
||||||
|
)
|
||||||
|
|
||||||
return tokens
|
return tokens
|
||||||
}
|
}
|
||||||
@@ -1707,9 +1771,15 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
|
|||||||
const existingData = storage.read() || {}
|
const existingData = storage.read() || {}
|
||||||
const serverKey = getServerKey(this.serverName, this.serverConfig)
|
const serverKey = getServerKey(this.serverName, this.serverConfig)
|
||||||
|
|
||||||
logMCPDebug(this.serverName, `Saving tokens`)
|
logMCPDebug(
|
||||||
logMCPDebug(this.serverName, `Token expires in: ${tokens.expires_in}`)
|
this.serverName,
|
||||||
logMCPDebug(this.serverName, `Has refresh token: ${!!tokens.refresh_token}`)
|
`Saving tokens: ${jsonStringify({
|
||||||
|
hasAccessToken: Boolean(tokens.access_token),
|
||||||
|
hasRefreshToken: Boolean(tokens.refresh_token),
|
||||||
|
hasScope: Boolean(tokens.scope),
|
||||||
|
expiresInSec: tokens.expires_in,
|
||||||
|
})}`,
|
||||||
|
)
|
||||||
|
|
||||||
const updatedData: SecureStorageData = {
|
const updatedData: SecureStorageData = {
|
||||||
...existingData,
|
...existingData,
|
||||||
@@ -1783,7 +1853,9 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
this.serverName,
|
this.serverName,
|
||||||
`XAA: OIDC discovery failed in silent refresh: ${errorMessage(e)}`,
|
`XAA: OIDC discovery failed in silent refresh: ${summarizeOAuthErrorForDebug(
|
||||||
|
e,
|
||||||
|
)}`,
|
||||||
)
|
)
|
||||||
return undefined
|
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
|
// Extract and store scopes from the authorization URL for later use in token exchange
|
||||||
const scopes = authorizationUrl.searchParams.get('scope')
|
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) {
|
if (scopes) {
|
||||||
this._scopes = scopes
|
this._scopes = scopes
|
||||||
logMCPDebug(
|
logMCPDebug(this.serverName, 'Captured scopes from authorization URL')
|
||||||
this.serverName,
|
|
||||||
`Captured scopes from authorization URL: ${scopes}`,
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
// If no scope in URL, try to get it from metadata
|
// If no scope in URL, try to get it from metadata
|
||||||
const metadataScope = getScopeFromMetadata(this._metadata)
|
const metadataScope = getScopeFromMetadata(this._metadata)
|
||||||
if (metadataScope) {
|
if (metadataScope) {
|
||||||
this._scopes = metadataScope
|
this._scopes = metadataScope
|
||||||
logMCPDebug(
|
logMCPDebug(this.serverName, 'Using scopes from metadata')
|
||||||
this.serverName,
|
|
||||||
`Using scopes from metadata: ${metadataScope}`,
|
|
||||||
)
|
|
||||||
} else {
|
} 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) {
|
if (existing) {
|
||||||
existing.stepUpScope = this._scopes
|
existing.stepUpScope = this._scopes
|
||||||
storage.update(existingData)
|
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`)
|
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,
|
// 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
|
// 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) {
|
if (!this.skipBrowserOpen) {
|
||||||
logMCPDebug(this.serverName, `Opening authorization URL: ${redactedUrl}`)
|
logMCPDebug(this.serverName, 'Opening authorization URL')
|
||||||
|
|
||||||
const success = await openBrowser(urlString)
|
const success = await openBrowser(urlString)
|
||||||
if (!success) {
|
if (!success) {
|
||||||
@@ -1938,7 +1997,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
|
|||||||
} else {
|
} else {
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
this.serverName,
|
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)
|
storage.update(existingData)
|
||||||
logMCPDebug(this.serverName, `Invalidated credentials (scope: ${scope})`)
|
logMCPDebug(this.serverName, `Invalidated credentials (${scope})`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveDiscoveryState(state: OAuthDiscoveryState): Promise<void> {
|
async saveDiscoveryState(state: OAuthDiscoveryState): Promise<void> {
|
||||||
@@ -1999,10 +2058,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
|
|||||||
const existingData = storage.read() || {}
|
const existingData = storage.read() || {}
|
||||||
const serverKey = getServerKey(this.serverName, this.serverConfig)
|
const serverKey = getServerKey(this.serverName, this.serverConfig)
|
||||||
|
|
||||||
logMCPDebug(
|
logMCPDebug(this.serverName, 'Saving discovery state')
|
||||||
this.serverName,
|
|
||||||
`Saving discovery state (authServer: ${state.authorizationServerUrl})`,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Persist only the URLs, NOT the full metadata blobs.
|
// Persist only the URLs, NOT the full metadata blobs.
|
||||||
// authorizationServerMetadata alone is ~1.5-2KB per MCP server (every
|
// 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
|
const cached = data?.mcpOAuth?.[serverKey]?.discoveryState
|
||||||
if (cached?.authorizationServerUrl) {
|
if (cached?.authorizationServerUrl) {
|
||||||
logMCPDebug(
|
logMCPDebug(this.serverName, 'Returning cached discovery state')
|
||||||
this.serverName,
|
|
||||||
`Returning cached discovery state (authServer: ${cached.authorizationServerUrl})`,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
authorizationServerUrl: cached.authorizationServerUrl,
|
authorizationServerUrl: cached.authorizationServerUrl,
|
||||||
@@ -2061,7 +2114,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
|
|||||||
if (metadataUrl) {
|
if (metadataUrl) {
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
this.serverName,
|
this.serverName,
|
||||||
`Fetching metadata from configured URL: ${metadataUrl}`,
|
'Fetching metadata from configured override URL',
|
||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
const metadata = await fetchAuthServerMetadata(
|
const metadata = await fetchAuthServerMetadata(
|
||||||
@@ -2079,7 +2132,9 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
this.serverName,
|
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) {
|
} else if (cached?.authorizationServerUrl) {
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
this.serverName,
|
this.serverName,
|
||||||
`Re-discovering metadata from persisted auth server URL: ${cached.authorizationServerUrl}`,
|
'Re-discovering metadata from persisted auth server URL',
|
||||||
)
|
)
|
||||||
metadata = await discoverAuthorizationServerMetadata(
|
metadata = await discoverAuthorizationServerMetadata(
|
||||||
cached.authorizationServerUrl,
|
cached.authorizationServerUrl,
|
||||||
@@ -2287,10 +2342,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
|
|||||||
// Invalid grant means the refresh token itself is invalid/revoked/expired.
|
// Invalid grant means the refresh token itself is invalid/revoked/expired.
|
||||||
// But another process may have already refreshed successfully — check first.
|
// But another process may have already refreshed successfully — check first.
|
||||||
if (error instanceof InvalidGrantError) {
|
if (error instanceof InvalidGrantError) {
|
||||||
logMCPDebug(
|
logMCPDebug(this.serverName, 'Token refresh failed with invalid_grant')
|
||||||
this.serverName,
|
|
||||||
`Token refresh failed with invalid_grant: ${error.message}`,
|
|
||||||
)
|
|
||||||
clearKeychainCache()
|
clearKeychainCache()
|
||||||
const storage = getSecureStorage()
|
const storage = getSecureStorage()
|
||||||
const data = storage.read()
|
const data = storage.read()
|
||||||
@@ -2337,7 +2389,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
|
|||||||
if (!isRetryable || attempt >= MAX_ATTEMPTS) {
|
if (!isRetryable || attempt >= MAX_ATTEMPTS) {
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
this.serverName,
|
this.serverName,
|
||||||
`Token refresh failed: ${errorMessage(error)}`,
|
`Token refresh failed: ${summarizeOAuthErrorForDebug(error)}`,
|
||||||
)
|
)
|
||||||
emitRefreshEvent(
|
emitRefreshEvent(
|
||||||
'failure',
|
'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:
|
* Shared handler for sse/http/claudeai-proxy auth failures during connect:
|
||||||
* emits tengu_mcp_server_needs_auth, caches the needs-auth entry, and returns
|
* 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`)
|
logMCPDebug(name, `SSE transport initialized, awaiting connection`)
|
||||||
} else if (serverRef.type === 'sse-ide') {
|
} 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
|
// IDE servers don't need authentication
|
||||||
// TODO: Use the auth token provided in the lockfile
|
// TODO: Use the auth token provided in the lockfile
|
||||||
const proxyOptions = getProxyFetchOptions()
|
const proxyOptions = getProxyFetchOptions()
|
||||||
@@ -735,7 +826,7 @@ export const connectToServer = memoize(
|
|||||||
} else if (serverRef.type === 'ws') {
|
} else if (serverRef.type === 'ws') {
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
name,
|
name,
|
||||||
`Initializing WebSocket transport to ${serverRef.url}`,
|
`Initializing WebSocket transport to ${mcpBaseUrlForDebug(serverRef)}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
const combinedHeaders = await getMcpServerHeaders(name, serverRef)
|
const combinedHeaders = await getMcpServerHeaders(name, serverRef)
|
||||||
@@ -749,16 +840,17 @@ export const connectToServer = memoize(
|
|||||||
...combinedHeaders,
|
...combinedHeaders,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redact sensitive headers before logging
|
const wsHeadersForLogging = summarizeHeadersForDebug(
|
||||||
const wsHeadersForLogging = mapValues(wsHeaders, (value, key) =>
|
mapValues(wsHeaders, (_value, key) =>
|
||||||
key.toLowerCase() === 'authorization' ? '[REDACTED]' : value,
|
key.toLowerCase() === 'authorization' ? '[REDACTED]' : '[set]',
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
name,
|
name,
|
||||||
`WebSocket transport options: ${jsonStringify({
|
`WebSocket transport options: ${jsonStringify({
|
||||||
url: serverRef.url,
|
url: mcpBaseUrlForDebug(serverRef),
|
||||||
headers: wsHeadersForLogging,
|
...wsHeadersForLogging,
|
||||||
hasSessionAuth: !!sessionIngressToken,
|
hasSessionAuth: !!sessionIngressToken,
|
||||||
})}`,
|
})}`,
|
||||||
)
|
)
|
||||||
@@ -782,20 +874,17 @@ export const connectToServer = memoize(
|
|||||||
}
|
}
|
||||||
transport = new WebSocketTransport(wsClient)
|
transport = new WebSocketTransport(wsClient)
|
||||||
} else if (serverRef.type === 'http') {
|
} else if (serverRef.type === 'http') {
|
||||||
logMCPDebug(name, `Initializing HTTP transport to ${serverRef.url}`)
|
logMCPDebug(
|
||||||
|
name,
|
||||||
|
`Initializing HTTP transport to ${mcpBaseUrlForDebug(serverRef)}`,
|
||||||
|
)
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
name,
|
name,
|
||||||
`Node version: ${process.version}, Platform: ${process.platform}`,
|
`Node version: ${process.version}, Platform: ${process.platform}`,
|
||||||
)
|
)
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
name,
|
name,
|
||||||
`Environment: ${jsonStringify({
|
`Environment: ${jsonStringify(summarizeProxyEnvForDebug())}`,
|
||||||
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',
|
|
||||||
})}`,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create an auth provider for this server
|
// Create an auth provider for this server
|
||||||
@@ -843,16 +932,16 @@ export const connectToServer = memoize(
|
|||||||
const headersForLogging = transportOptions.requestInit?.headers
|
const headersForLogging = transportOptions.requestInit?.headers
|
||||||
? mapValues(
|
? mapValues(
|
||||||
transportOptions.requestInit.headers as Record<string, string>,
|
transportOptions.requestInit.headers as Record<string, string>,
|
||||||
(value, key) =>
|
(_value, key) =>
|
||||||
key.toLowerCase() === 'authorization' ? '[REDACTED]' : value,
|
key.toLowerCase() === 'authorization' ? '[REDACTED]' : '[set]',
|
||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
name,
|
name,
|
||||||
`HTTP transport options: ${jsonStringify({
|
`HTTP transport options: ${jsonStringify({
|
||||||
url: serverRef.url,
|
url: mcpBaseUrlForDebug(serverRef),
|
||||||
headers: headersForLogging,
|
...summarizeHeadersForDebug(headersForLogging),
|
||||||
hasAuthProvider: !!authProvider,
|
hasAuthProvider: !!authProvider,
|
||||||
timeoutMs: MCP_REQUEST_TIMEOUT_MS,
|
timeoutMs: MCP_REQUEST_TIMEOUT_MS,
|
||||||
})}`,
|
})}`,
|
||||||
@@ -879,7 +968,7 @@ export const connectToServer = memoize(
|
|||||||
const oauthConfig = getOauthConfig()
|
const oauthConfig = getOauthConfig()
|
||||||
const proxyUrl = `${oauthConfig.MCP_PROXY_URL}${oauthConfig.MCP_PROXY_PATH.replace('{server_id}', serverRef.id)}`
|
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
|
// eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
|
||||||
const fetchWithAuth = createClaudeAiProxyFetch(globalThis.fetch)
|
const fetchWithAuth = createClaudeAiProxyFetch(globalThis.fetch)
|
||||||
@@ -1025,23 +1114,28 @@ export const connectToServer = memoize(
|
|||||||
|
|
||||||
// For HTTP transport, try a basic connectivity test first
|
// For HTTP transport, try a basic connectivity test first
|
||||||
if (serverRef.type === 'http') {
|
if (serverRef.type === 'http') {
|
||||||
logMCPDebug(name, `Testing basic HTTP connectivity to ${serverRef.url}`)
|
|
||||||
try {
|
|
||||||
const testUrl = new URL(serverRef.url)
|
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
name,
|
name,
|
||||||
`Parsed URL: host=${testUrl.hostname}, port=${testUrl.port || 'default'}, protocol=${testUrl.protocol}`,
|
`Testing basic HTTP connectivity to ${mcpBaseUrlForDebug(serverRef)}`,
|
||||||
)
|
)
|
||||||
|
try {
|
||||||
|
const testUrl = new URL(serverRef.url)
|
||||||
|
logMCPDebug(name, 'Parsed HTTP endpoint for preflight checks')
|
||||||
|
|
||||||
// Log DNS resolution attempt
|
// Log DNS resolution attempt
|
||||||
if (
|
if (
|
||||||
testUrl.hostname === '127.0.0.1' ||
|
testUrl.hostname === '127.0.0.1' ||
|
||||||
testUrl.hostname === 'localhost'
|
testUrl.hostname === 'localhost'
|
||||||
) {
|
) {
|
||||||
logMCPDebug(name, `Using loopback address: ${testUrl.hostname}`)
|
logMCPDebug(name, 'Using loopback HTTP endpoint')
|
||||||
}
|
}
|
||||||
} catch (urlError) {
|
} 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 {
|
try {
|
||||||
await Promise.race([connectPromise, timeoutPromise])
|
await Promise.race([connectPromise, timeoutPromise])
|
||||||
if (stderrOutput) {
|
if (stderrOutput) {
|
||||||
logMCPError(name, `Server stderr: ${stderrOutput}`)
|
logMCPError(name, summarizeStderrForDebug(stderrOutput))
|
||||||
stderrOutput = '' // Release accumulated string to prevent memory growth
|
stderrOutput = '' // Release accumulated string to prevent memory growth
|
||||||
}
|
}
|
||||||
const elapsed = Date.now() - connectStartTime
|
const elapsed = Date.now() - connectStartTime
|
||||||
@@ -1093,30 +1187,29 @@ export const connectToServer = memoize(
|
|||||||
if (serverRef.type === 'sse' && error instanceof Error) {
|
if (serverRef.type === 'sse' && error instanceof Error) {
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
name,
|
name,
|
||||||
`SSE Connection failed after ${elapsed}ms: ${jsonStringify({
|
`SSE connection failed after ${elapsed}ms: ${summarizeMcpErrorForDebug(
|
||||||
url: serverRef.url,
|
error,
|
||||||
error: error.message,
|
)}`,
|
||||||
errorType: error.constructor.name,
|
)
|
||||||
stack: error.stack,
|
logMCPError(
|
||||||
})}`,
|
name,
|
||||||
|
`SSE connection failed: ${summarizeMcpErrorForDebug(error)}`,
|
||||||
)
|
)
|
||||||
logMCPError(name, error)
|
|
||||||
|
|
||||||
if (error instanceof UnauthorizedError) {
|
if (error instanceof UnauthorizedError) {
|
||||||
return handleRemoteAuthFailure(name, serverRef, 'sse')
|
return handleRemoteAuthFailure(name, serverRef, 'sse')
|
||||||
}
|
}
|
||||||
} else if (serverRef.type === 'http' && error instanceof Error) {
|
} else if (serverRef.type === 'http' && error instanceof Error) {
|
||||||
const errorObj = error as Error & {
|
|
||||||
cause?: unknown
|
|
||||||
code?: string
|
|
||||||
errno?: string | number
|
|
||||||
syscall?: string
|
|
||||||
}
|
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
name,
|
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) {
|
if (error instanceof UnauthorizedError) {
|
||||||
return handleRemoteAuthFailure(name, serverRef, 'http')
|
return handleRemoteAuthFailure(name, serverRef, 'http')
|
||||||
@@ -1127,9 +1220,16 @@ export const connectToServer = memoize(
|
|||||||
) {
|
) {
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
name,
|
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
|
// StreamableHTTPError has a `code` property with the HTTP status
|
||||||
const errorCode = (error as Error & { code?: number }).code
|
const errorCode = (error as Error & { code?: number }).code
|
||||||
@@ -1149,7 +1249,7 @@ export const connectToServer = memoize(
|
|||||||
}
|
}
|
||||||
transport.close().catch(() => {})
|
transport.close().catch(() => {})
|
||||||
if (stderrOutput) {
|
if (stderrOutput) {
|
||||||
logMCPError(name, `Server stderr: ${stderrOutput}`)
|
logMCPError(name, summarizeStderrForDebug(stderrOutput))
|
||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
@@ -1208,7 +1308,9 @@ export const connectToServer = memoize(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logMCPError(
|
logMCPError(
|
||||||
name,
|
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
|
hasTriggeredClose = true
|
||||||
logMCPDebug(name, `Closing transport (${reason})`)
|
logMCPDebug(name, `Closing transport (${reason})`)
|
||||||
void client.close().catch(e => {
|
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`,
|
`Failed to spawn process - check command and permissions`,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
logMCPDebug(name, `Connection error: ${error.message}`)
|
logMCPDebug(
|
||||||
|
name,
|
||||||
|
`Connection error: ${summarizeMcpErrorForDebug(error)}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1407,12 +1515,20 @@ export const connectToServer = memoize(
|
|||||||
try {
|
try {
|
||||||
await inProcessServer.close()
|
await inProcessServer.close()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logMCPDebug(name, `Error closing in-process server: ${error}`)
|
logMCPDebug(
|
||||||
|
name,
|
||||||
|
`Error closing in-process server: ${summarizeMcpErrorForDebug(
|
||||||
|
error,
|
||||||
|
)}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await client.close()
|
await client.close()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logMCPDebug(name, `Error closing client: ${error}`)
|
logMCPDebug(
|
||||||
|
name,
|
||||||
|
`Error closing client: ${summarizeMcpErrorForDebug(error)}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1438,7 +1554,10 @@ export const connectToServer = memoize(
|
|||||||
try {
|
try {
|
||||||
process.kill(childPid, 'SIGINT')
|
process.kill(childPid, 'SIGINT')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logMCPDebug(name, `Error sending SIGINT: ${error}`)
|
logMCPDebug(
|
||||||
|
name,
|
||||||
|
`Error sending SIGINT: ${summarizeMcpErrorForDebug(error)}`,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1492,7 +1611,12 @@ export const connectToServer = memoize(
|
|||||||
try {
|
try {
|
||||||
process.kill(childPid, 'SIGTERM')
|
process.kill(childPid, 'SIGTERM')
|
||||||
} catch (termError) {
|
} catch (termError) {
|
||||||
logMCPDebug(name, `Error sending SIGTERM: ${termError}`)
|
logMCPDebug(
|
||||||
|
name,
|
||||||
|
`Error sending SIGTERM: ${summarizeMcpErrorForDebug(
|
||||||
|
termError,
|
||||||
|
)}`,
|
||||||
|
)
|
||||||
resolved = true
|
resolved = true
|
||||||
clearInterval(checkInterval)
|
clearInterval(checkInterval)
|
||||||
clearTimeout(failsafeTimeout)
|
clearTimeout(failsafeTimeout)
|
||||||
@@ -1525,7 +1649,9 @@ export const connectToServer = memoize(
|
|||||||
} catch (killError) {
|
} catch (killError) {
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
name,
|
name,
|
||||||
`Error sending SIGKILL: ${killError}`,
|
`Error sending SIGKILL: ${summarizeMcpErrorForDebug(
|
||||||
|
killError,
|
||||||
|
)}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1557,7 +1683,12 @@ export const connectToServer = memoize(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (processError) {
|
} catch (processError) {
|
||||||
logMCPDebug(name, `Error terminating process: ${processError}`)
|
logMCPDebug(
|
||||||
|
name,
|
||||||
|
`Error terminating process: ${summarizeMcpErrorForDebug(
|
||||||
|
processError,
|
||||||
|
)}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1565,7 +1696,10 @@ export const connectToServer = memoize(
|
|||||||
try {
|
try {
|
||||||
await client.close()
|
await client.close()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logMCPDebug(name, `Error closing client: ${error}`)
|
logMCPDebug(
|
||||||
|
name,
|
||||||
|
`Error closing client: ${summarizeMcpErrorForDebug(error)}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1622,9 +1756,14 @@ export const connectToServer = memoize(
|
|||||||
})
|
})
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
name,
|
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) {
|
if (inProcessServer) {
|
||||||
inProcessServer.close().catch(() => {})
|
inProcessServer.close().catch(() => {})
|
||||||
@@ -1989,7 +2128,10 @@ export const fetchToolsForClient = memoizeWithLRU(
|
|||||||
})
|
})
|
||||||
.filter(isIncludedMcpTool)
|
.filter(isIncludedMcpTool)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logMCPError(client.name, `Failed to fetch tools: ${errorMessage(error)}`)
|
logMCPError(
|
||||||
|
client.name,
|
||||||
|
`Failed to fetch tools: ${summarizeMcpErrorForDebug(error)}`,
|
||||||
|
)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2021,7 +2163,7 @@ export const fetchResourcesForClient = memoizeWithLRU(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logMCPError(
|
logMCPError(
|
||||||
client.name,
|
client.name,
|
||||||
`Failed to fetch resources: ${errorMessage(error)}`,
|
`Failed to fetch resources: ${summarizeMcpErrorForDebug(error)}`,
|
||||||
)
|
)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -2087,7 +2229,9 @@ export const fetchCommandsForClient = memoizeWithLRU(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logMCPError(
|
logMCPError(
|
||||||
client.name,
|
client.name,
|
||||||
`Error running command '${prompt.name}': ${errorMessage(error)}`,
|
`Error running command '${prompt.name}': ${summarizeMcpErrorForDebug(
|
||||||
|
error,
|
||||||
|
)}`,
|
||||||
)
|
)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
@@ -2097,7 +2241,7 @@ export const fetchCommandsForClient = memoizeWithLRU(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logMCPError(
|
logMCPError(
|
||||||
client.name,
|
client.name,
|
||||||
`Failed to fetch commands: ${errorMessage(error)}`,
|
`Failed to fetch commands: ${summarizeMcpErrorForDebug(error)}`,
|
||||||
)
|
)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -2198,7 +2342,10 @@ export async function reconnectMcpServerImpl(
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Handle errors gracefully - connection might have closed during fetch
|
// 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 with failed status
|
||||||
return {
|
return {
|
||||||
@@ -2373,7 +2520,9 @@ export async function getMcpToolsCommandsAndResources(
|
|||||||
// Handle errors gracefully - connection might have closed during fetch
|
// Handle errors gracefully - connection might have closed during fetch
|
||||||
logMCPError(
|
logMCPError(
|
||||||
name,
|
name,
|
||||||
`Error fetching tools/commands/resources: ${errorMessage(error)}`,
|
`Error fetching tools/commands/resources: ${summarizeMcpErrorForDebug(
|
||||||
|
error,
|
||||||
|
)}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Still update with the client but no tools/commands
|
// Still update with the client but no tools/commands
|
||||||
@@ -2460,7 +2609,7 @@ export function prefetchAllMcpResources(
|
|||||||
}, mcpConfigs).catch(error => {
|
}, mcpConfigs).catch(error => {
|
||||||
logMCPError(
|
logMCPError(
|
||||||
'prefetchAllMcpResources',
|
'prefetchAllMcpResources',
|
||||||
`Failed to get MCP resources: ${errorMessage(error)}`,
|
`Failed to get MCP resources: ${summarizeMcpErrorForDebug(error)}`,
|
||||||
)
|
)
|
||||||
// Still resolve with empty results
|
// Still resolve with empty results
|
||||||
void resolve({
|
void resolve({
|
||||||
@@ -3322,7 +3471,12 @@ export async function setupSdkMcpClients(
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If connection fails, return failed server
|
// 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 {
|
return {
|
||||||
client: {
|
client: {
|
||||||
type: 'failed' as const,
|
type: 'failed' as const,
|
||||||
|
|||||||
@@ -1397,6 +1397,7 @@ export function parseMcpConfigFromFilePath(params: {
|
|||||||
configContent = fs.readFileSync(filePath, { encoding: 'utf8' })
|
configContent = fs.readFileSync(filePath, { encoding: 'utf8' })
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const code = getErrnoCode(error)
|
const code = getErrnoCode(error)
|
||||||
|
const fileName = parse(filePath).base
|
||||||
if (code === 'ENOENT') {
|
if (code === 'ENOENT') {
|
||||||
return {
|
return {
|
||||||
config: null,
|
config: null,
|
||||||
@@ -1415,7 +1416,7 @@ export function parseMcpConfigFromFilePath(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
logForDebugging(
|
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' },
|
{ level: 'error' },
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
@@ -1439,7 +1440,7 @@ export function parseMcpConfigFromFilePath(params: {
|
|||||||
|
|
||||||
if (!parsedJson) {
|
if (!parsedJson) {
|
||||||
logForDebugging(
|
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' },
|
{ level: 'error' },
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -96,6 +96,24 @@ function redactTokens(raw: unknown): string {
|
|||||||
return s.replace(SENSITIVE_TOKEN_RE, (_, k) => `"${k}":"[REDACTED]"`)
|
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 ────────────────────────────────────────────────────────────
|
// ─── Zod Schemas ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const TokenExchangeResponseSchema = lazySchema(() =>
|
const TokenExchangeResponseSchema = lazySchema(() =>
|
||||||
@@ -145,7 +163,7 @@ export async function discoverProtectedResource(
|
|||||||
)
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(
|
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]) {
|
if (!prm.resource || !prm.authorization_servers?.[0]) {
|
||||||
@@ -154,9 +172,7 @@ export async function discoverProtectedResource(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (normalizeUrl(prm.resource) !== normalizeUrl(serverUrl)) {
|
if (normalizeUrl(prm.resource) !== normalizeUrl(serverUrl)) {
|
||||||
throw new Error(
|
throw new Error('XAA: PRM discovery failed: PRM resource mismatch')
|
||||||
`XAA: PRM discovery failed: PRM resource mismatch: expected ${serverUrl}, got ${prm.resource}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
resource: prm.resource,
|
resource: prm.resource,
|
||||||
@@ -183,22 +199,16 @@ export async function discoverAuthorizationServer(
|
|||||||
fetchFn: opts?.fetchFn ?? defaultFetch,
|
fetchFn: opts?.fetchFn ?? defaultFetch,
|
||||||
})
|
})
|
||||||
if (!meta?.issuer || !meta.token_endpoint) {
|
if (!meta?.issuer || !meta.token_endpoint) {
|
||||||
throw new Error(
|
throw new Error('XAA: AS metadata discovery failed: no valid metadata')
|
||||||
`XAA: AS metadata discovery failed: no valid metadata at ${asUrl}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (normalizeUrl(meta.issuer) !== normalizeUrl(asUrl)) {
|
if (normalizeUrl(meta.issuer) !== normalizeUrl(asUrl)) {
|
||||||
throw new Error(
|
throw new Error('XAA: AS metadata discovery failed: issuer mismatch')
|
||||||
`XAA: AS metadata discovery failed: issuer mismatch: expected ${asUrl}, got ${meta.issuer}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
// RFC 8414 §3.3 / RFC 9728 §3 require HTTPS. A PRM-advertised http:// AS
|
// 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
|
// that self-consistently reports an http:// issuer would pass the mismatch
|
||||||
// check above, then we'd POST id_token + client_secret over plaintext.
|
// check above, then we'd POST id_token + client_secret over plaintext.
|
||||||
if (new URL(meta.token_endpoint).protocol !== 'https:') {
|
if (new URL(meta.token_endpoint).protocol !== 'https:') {
|
||||||
throw new Error(
|
throw new Error('XAA: refusing non-HTTPS token endpoint')
|
||||||
`XAA: refusing non-HTTPS token endpoint: ${meta.token_endpoint}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
issuer: meta.issuer,
|
issuer: meta.issuer,
|
||||||
@@ -263,7 +273,7 @@ export async function requestJwtAuthorizationGrant(opts: {
|
|||||||
body: params,
|
body: params,
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
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.
|
// 4xx → id_token rejected (invalid_grant etc.), clear cache.
|
||||||
// 5xx → IdP outage, id_token may still be valid, preserve it.
|
// 5xx → IdP outage, id_token may still be valid, preserve it.
|
||||||
const shouldClear = res.status < 500
|
const shouldClear = res.status < 500
|
||||||
@@ -278,21 +288,25 @@ export async function requestJwtAuthorizationGrant(opts: {
|
|||||||
} catch {
|
} catch {
|
||||||
// Transient network condition (captive portal, proxy) — don't clear id_token.
|
// Transient network condition (captive portal, proxy) — don't clear id_token.
|
||||||
throw new XaaTokenExchangeError(
|
throw new XaaTokenExchangeError(
|
||||||
`XAA: token exchange returned non-JSON (captive portal?) at ${opts.tokenEndpoint}`,
|
'XAA: token exchange returned non-JSON response (captive portal?)',
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const exchangeParsed = TokenExchangeResponseSchema().safeParse(rawExchange)
|
const exchangeParsed = TokenExchangeResponseSchema().safeParse(rawExchange)
|
||||||
if (!exchangeParsed.success) {
|
if (!exchangeParsed.success) {
|
||||||
throw new XaaTokenExchangeError(
|
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,
|
true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const result = exchangeParsed.data
|
const result = exchangeParsed.data
|
||||||
if (!result.access_token) {
|
if (!result.access_token) {
|
||||||
throw new XaaTokenExchangeError(
|
throw new XaaTokenExchangeError(
|
||||||
`XAA: token exchange response missing access_token: ${redactTokens(result)}`,
|
`XAA: token exchange response missing access_token: ${summarizeXaaPayload(
|
||||||
|
redactTokens(result),
|
||||||
|
)}`,
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -373,7 +387,7 @@ export async function exchangeJwtAuthGrant(opts: {
|
|||||||
body: params,
|
body: params,
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
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}`)
|
throw new Error(`XAA: jwt-bearer grant failed: HTTP ${res.status}: ${body}`)
|
||||||
}
|
}
|
||||||
let rawTokens: unknown
|
let rawTokens: unknown
|
||||||
@@ -381,13 +395,15 @@ export async function exchangeJwtAuthGrant(opts: {
|
|||||||
rawTokens = await res.json()
|
rawTokens = await res.json()
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(
|
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)
|
const tokensParsed = JwtBearerResponseSchema().safeParse(rawTokens)
|
||||||
if (!tokensParsed.success) {
|
if (!tokensParsed.success) {
|
||||||
throw new Error(
|
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
|
return tokensParsed.data
|
||||||
@@ -431,11 +447,14 @@ export async function performCrossAppAccess(
|
|||||||
): Promise<XaaResult> {
|
): Promise<XaaResult> {
|
||||||
const fetchFn = makeXaaFetch(abortSignal)
|
const fetchFn = makeXaaFetch(abortSignal)
|
||||||
|
|
||||||
logMCPDebug(serverName, `XAA: discovering PRM for ${serverUrl}`)
|
logMCPDebug(serverName, 'XAA: discovering protected resource metadata')
|
||||||
const prm = await discoverProtectedResource(serverUrl, { fetchFn })
|
const prm = await discoverProtectedResource(serverUrl, { fetchFn })
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
serverName,
|
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
|
// 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 })
|
candidate = await discoverAuthorizationServer(asUrl, { fetchFn })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (abortSignal?.aborted) throw 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
|
continue
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
candidate.grant_types_supported &&
|
candidate.grant_types_supported &&
|
||||||
!candidate.grant_types_supported.includes(JWT_BEARER_GRANT)
|
!candidate.grant_types_supported.includes(JWT_BEARER_GRANT)
|
||||||
) {
|
) {
|
||||||
asErrors.push(
|
asErrors.push('authorization server does not advertise jwt-bearer grant')
|
||||||
`${asUrl}: does not advertise jwt-bearer grant (supported: ${candidate.grant_types_supported.join(', ')})`,
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
asMeta = candidate
|
asMeta = candidate
|
||||||
@@ -466,7 +485,7 @@ export async function performCrossAppAccess(
|
|||||||
}
|
}
|
||||||
if (!asMeta) {
|
if (!asMeta) {
|
||||||
throw new Error(
|
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
|
// Pick auth method from what the AS advertises. We handle
|
||||||
@@ -481,7 +500,7 @@ export async function performCrossAppAccess(
|
|||||||
: 'client_secret_basic'
|
: 'client_secret_basic'
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
serverName,
|
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`)
|
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),
|
signal: AbortSignal.timeout(IDP_REQUEST_TIMEOUT_MS),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(
|
throw new Error(`XAA IdP: OIDC discovery failed (HTTP ${res.status})`)
|
||||||
`XAA IdP: OIDC discovery failed: HTTP ${res.status} at ${url}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
// Captive portals and proxy auth pages return 200 with HTML. res.json()
|
// Captive portals and proxy auth pages return 200 with HTML. res.json()
|
||||||
// throws a raw SyntaxError before safeParse can give a useful message.
|
// throws a raw SyntaxError before safeParse can give a useful message.
|
||||||
@@ -221,17 +219,15 @@ export async function discoverOidc(
|
|||||||
body = await res.json()
|
body = await res.json()
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(
|
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)
|
const parsed = OpenIdProviderDiscoveryMetadataSchema.safeParse(body)
|
||||||
if (!parsed.success) {
|
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:') {
|
if (new URL(parsed.data.token_endpoint).protocol !== 'https:') {
|
||||||
throw new Error(
|
throw new Error('XAA IdP: refusing non-HTTPS token endpoint')
|
||||||
`XAA IdP: refusing non-HTTPS token endpoint: ${parsed.data.token_endpoint}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return parsed.data
|
return parsed.data
|
||||||
}
|
}
|
||||||
@@ -373,7 +369,7 @@ function waitForCallback(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} 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)
|
const cached = getCachedIdpIdToken(idpIssuer)
|
||||||
if (cached) {
|
if (cached) {
|
||||||
logMCPDebug('xaa', `Using cached id_token for ${idpIssuer}`)
|
logMCPDebug('xaa', 'Using cached id_token for configured IdP')
|
||||||
return cached
|
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 metadata = await discoverOidc(idpIssuer)
|
||||||
const port = opts.callbackPort ?? (await findAvailablePort())
|
const port = opts.callbackPort ?? (await findAvailablePort())
|
||||||
@@ -478,10 +474,7 @@ export async function acquireIdpIdToken(
|
|||||||
: Date.now() + (tokens.expires_in ?? 3600) * 1000
|
: Date.now() + (tokens.expires_in ?? 3600) * 1000
|
||||||
|
|
||||||
saveIdpIdToken(idpIssuer, tokens.id_token, expiresAt)
|
saveIdpIdToken(idpIssuer, tokens.id_token, expiresAt)
|
||||||
logMCPDebug(
|
logMCPDebug('xaa', 'Cached id_token for configured IdP')
|
||||||
'xaa',
|
|
||||||
`Cached id_token for ${idpIssuer} (expires ${new Date(expiresAt).toISOString()})`,
|
|
||||||
)
|
|
||||||
|
|
||||||
return tokens.id_token
|
return tokens.id_token
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
extractMcpToolDetails,
|
extractMcpToolDetails,
|
||||||
extractSkillName,
|
extractSkillName,
|
||||||
extractToolInputForTelemetry,
|
|
||||||
getFileExtensionForAnalytics,
|
getFileExtensionForAnalytics,
|
||||||
getFileExtensionsFromBashCommand,
|
getFileExtensionsFromBashCommand,
|
||||||
isToolDetailsLoggingEnabled,
|
isToolDetailsLoggingEnabled,
|
||||||
@@ -87,17 +86,6 @@ import {
|
|||||||
} from '../../utils/sessionActivity.js'
|
} from '../../utils/sessionActivity.js'
|
||||||
import { jsonStringify } from '../../utils/slowOperations.js'
|
import { jsonStringify } from '../../utils/slowOperations.js'
|
||||||
import { Stream } from '../../utils/stream.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 {
|
import {
|
||||||
formatError,
|
formatError,
|
||||||
formatZodValidationError,
|
formatZodValidationError,
|
||||||
@@ -204,7 +192,7 @@ function ruleSourceToOTelSource(
|
|||||||
* Without it, we fall back conservatively: allow → user_temporary,
|
* Without it, we fall back conservatively: allow → user_temporary,
|
||||||
* deny → user_reject.
|
* deny → user_reject.
|
||||||
*/
|
*/
|
||||||
function decisionReasonToOTelSource(
|
function decisionReasonToSource(
|
||||||
reason: PermissionDecisionReason | undefined,
|
reason: PermissionDecisionReason | undefined,
|
||||||
behavior: 'allow' | 'deny',
|
behavior: 'allow' | 'deny',
|
||||||
): string {
|
): 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,
|
// Check whether we have permission to use the tool,
|
||||||
// and ask the user for permission if we don't
|
// and ask the user for permission if we don't
|
||||||
const permissionMode = toolUseContext.getAppState().toolPermissionContext.mode
|
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
|
// Increment the code-edit counter here when the interactive permission path
|
||||||
// permission path didn't already log it (headless mode bypasses permission
|
// did not already log a decision (headless mode bypasses permission logging).
|
||||||
// logging, so we need to emit both the generic event and the code-edit
|
|
||||||
// counter here)
|
|
||||||
if (
|
if (
|
||||||
permissionDecision.behavior !== 'ask' &&
|
permissionDecision.behavior !== 'ask' &&
|
||||||
!toolUseContext.toolDecisions?.has(toolUseID)
|
!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
|
// Increment code-edit tool decision counter for headless mode
|
||||||
if (isCodeEditingTool(tool.name)) {
|
if (isCodeEditingTool(tool.name)) {
|
||||||
void buildCodeEditToolAttributes(
|
void buildCodeEditToolAttributes(
|
||||||
tool,
|
tool,
|
||||||
processedInput,
|
processedInput,
|
||||||
decision,
|
decision,
|
||||||
source,
|
decisionReasonToSource(
|
||||||
|
permissionDecision.decisionReason,
|
||||||
|
permissionDecision.behavior,
|
||||||
|
),
|
||||||
).then(attributes => getCodeEditToolDecisionCounter()?.add(1, attributes))
|
).then(attributes => getCodeEditToolDecisionCounter()?.add(1, attributes))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -994,10 +948,6 @@ async function checkPermissionsAndCallTool(
|
|||||||
|
|
||||||
if (permissionDecision.behavior !== 'allow') {
|
if (permissionDecision.behavior !== 'allow') {
|
||||||
logForDebugging(`${tool.name} tool permission denied`)
|
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', {
|
logEvent('tengu_tool_use_can_use_tool_rejected', {
|
||||||
messageID:
|
messageID:
|
||||||
messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||||
@@ -1131,10 +1081,6 @@ async function checkPermissionsAndCallTool(
|
|||||||
processedInput = permissionDecision.updatedInput
|
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> = {}
|
let toolParameters: Record<string, unknown> = {}
|
||||||
if (isToolDetailsLoggingEnabled()) {
|
if (isToolDetailsLoggingEnabled()) {
|
||||||
if (tool.name === BASH_TOOL_NAME && 'command' in processedInput) {
|
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()
|
const startTime = Date.now()
|
||||||
|
|
||||||
startSessionActivity('tool_exec')
|
startSessionActivity('tool_exec')
|
||||||
@@ -1223,51 +1162,6 @@ async function checkPermissionsAndCallTool(
|
|||||||
const durationMs = Date.now() - startTime
|
const durationMs = Date.now() - startTime
|
||||||
addToToolDuration(durationMs)
|
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
|
// Capture structured output from tool result if present
|
||||||
if (typeof result === 'object' && 'structured_output' in result) {
|
if (typeof result === 'object' && 'structured_output' in result) {
|
||||||
// Store the structured output in an attachment message
|
// 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
|
// 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.
|
// by addToolResult (skipping the remap) and measured here for analytics.
|
||||||
const mappedToolResultBlock = tool.mapToolResultToToolResultBlockParam(
|
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)
|
const mcpServerScope = isMcpTool(tool)
|
||||||
? getMcpServerScopeFromToolName(tool.name)
|
? getMcpServerScopeFromToolName(tool.name)
|
||||||
: null
|
: 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
|
// Run PostToolUse hooks
|
||||||
let toolOutput = result.data
|
let toolOutput = result.data
|
||||||
const hookResults = []
|
const hookResults = []
|
||||||
@@ -1590,12 +1459,6 @@ async function checkPermissionsAndCallTool(
|
|||||||
const durationMs = Date.now() - startTime
|
const durationMs = Date.now() - startTime
|
||||||
addToToolDuration(durationMs)
|
addToToolDuration(durationMs)
|
||||||
|
|
||||||
endToolExecutionSpan({
|
|
||||||
success: false,
|
|
||||||
error: errorMessage(error),
|
|
||||||
})
|
|
||||||
endToolSpan()
|
|
||||||
|
|
||||||
// Handle MCP auth errors by updating the client status to 'needs-auth'
|
// Handle MCP auth errors by updating the client status to 'needs-auth'
|
||||||
// This updates the /mcp display to show the server needs re-authorization
|
// This updates the /mcp display to show the server needs re-authorization
|
||||||
if (error instanceof McpAuthError) {
|
if (error instanceof McpAuthError) {
|
||||||
@@ -1666,27 +1529,9 @@ async function checkPermissionsAndCallTool(
|
|||||||
mcpServerBaseUrl,
|
mcpServerBaseUrl,
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
// Log tool result error event for OTLP with tool parameters and decision context
|
|
||||||
const mcpServerScope = isMcpTool(tool)
|
const mcpServerScope = isMcpTool(tool)
|
||||||
? getMcpServerScopeFromToolName(tool.name)
|
? getMcpServerScopeFromToolName(tool.name)
|
||||||
: null
|
: 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)
|
const content = formatError(error)
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user