Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ Search "IronBee" in Settings:
## Privacy

Sign-in tokens are stored in the editor's encrypted secret storage. Telemetry, when enabled, is
anonymous and never includes your email or account id.
keyed to an anonymous id; while you're signed in, your account email is attached so usage can be
tied to your account. Turn it off anytime with the **Telemetry** setting.

## License

Expand Down
47 changes: 37 additions & 10 deletions docs/ironbee-vscode-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,9 +246,14 @@ CLI-1..CLI-5 are all **verifications that passed** (no required work). CLI-OPT-1

- **[CLI-1 — ✔ VERIFIED] Devtools override already exists.** `config.ironbeeDevTools.mcp`
(full command/args/env replacement) + `config.ironbeeDevTools.env` (`config.ts:2148-2151,
2181-2208`). The extension writes this block into **global** `~/.ironbee/config.json` before
install (global-only — see EXT-5 / CLI-2b) — **no CLI change**. Do NOT invent
`devtools.mcpCommand`/`mcpArgs` (fictional).
2181-2208`). **PER-PROJECT (not global):** rather than writing the bundled `mcp` block into the
shared `~/.ironbee/config.json` (which affects every project + goes stale on upgrade), the
extension passes it as the **`IRONBEE_DEVTOOLS_MCP`** env (a full JSON `{command,args,env}`) to
each `ironbee install` spawn. The CLI's `resolveDevToolsEntryFromEnv` (`config.ts:2748`) gives
that env top precedence and bakes it into THIS project's own `.cursor/mcp.json` — zero global
writes. Empirically verified. The extension still `clearDevtoolsMcp()`s any stale global block a
prior version left behind (migration). npx/universal mode keeps the generic (non-stale)
browser-suppress `ironbeeDevTools.env`. Do NOT invent `devtools.mcpCommand`/`mcpArgs` (fictional).
- **[CLI-2 — ✔ VERIFIED] The override round-trips into the written project MCP entries** — the
load-bearing check for the whole bundling approach, and it holds. All clients compute the
entry at write time via `getComposeDevToolsMcpEntry(projectDir)` and serialize it: Cursor →
Expand Down Expand Up @@ -578,17 +583,39 @@ could disrupt projects the user never intended to touch, and verification settin
optionally server-side collector tokens whose name carries the `ironbee-vscode:` prefix
(to avoid 10-cap pollution). **Do NOT** delete `~/.ironbee/config.json`'s collector token by
default (shared with the CLI), and **do NOT** touch the shared
`~/.ironbee-devtools/config.json` — its anonymous id is shared with `ironbee-devtools-vscode`
(EXT-9), so there is no ironbee-vscode-specific state to remove there.
`~/.ironbee/telemetry.json` — its anonymous id is shared with all IronBee tools (CLI, devtools,
the editor extensions) (EXT-9), so there is no ironbee-vscode-specific state to remove there.
- **Self-update:** best-effort, non-blocking poll of the OpenVSX API with backoff; offer to
update on a newer version (mirror devtools-vscode).

**[EXT-9] Telemetry**
- Show the notice on first run **before** any event is emitted; respect
`ironbee.telemetry.enable` (and the shared anonymous-id file). Events contain only an
anonymous id + event name — **no email/account id** (which would de-anonymize). Storage
path: reuse `~/.ironbee-devtools/config.json` for a shared anonymous id (single decision;
do not split into a second file). Emit `sign_in`, `install`, `switch_account` events.
- On by default (opt-out via `ironbee.telemetry.enable`); no consent notice. `distinct_id` is the
shared anonymous id from `~/.ironbee/telemetry.json` (single decision — all IronBee tools, incl.
the CLI, read/write it; do not split into a second file). When the user is signed in, their email
rides along as the PostHog **person property** via `properties.$set.email` (the reserved key
PostHog recognizes — not a custom prop), read from an in-memory cache populated by `refreshStatus`
(never a per-event API call) and cleared on sign-out. Plus coarse env props (source, extension/
node version, os platform/arch, timezone). Transport: raw HTTPS `POST /i/v0/e/` to
`us.i.posthog.com` (no posthog-node client).
- **All** event names are prefixed `cursor_ext_`: lifecycle `cursor_ext_installed` /
`_activated` / `_deactivated` / `_uninstalled` / `_error`, product `cursor_ext_sign_in` /
`cursor_ext_switch_account`, project `cursor_ext_project_setup` / `cursor_ext_project_uninstall`
(+ their `_failed` variants). **Every** caught failure is reported as `cursor_ext_error`
(fire-and-forget, non-blocking) with a `context` label + `error_type`/`error_message` and a
`surfaced` flag: `reportError` (surfaced=true) shows the message + an "Open issue on GitHub"
action deep-linking to the repo's prefilled new-issue form; `logError` (surfaced=false) only
writes to the output channel — for best-effort/background failures we don't interrupt the user
over. Expected control-flow catches (universal build has no bundled devtools, fs existence
checks) are NOT errors and stay silent. A safety-net wraps every command handler (VS Code
swallows a handler's rejected promise) and `activate`, so no uncaught error escapes unreported.
- **Product/diagnostic signals** (not errors): `cursor_ext_devtools_mode` (bundled vs npx),
`cursor_ext_devtools_prewarm`, `cursor_ext_browser_install` /
`cursor_ext_browser_system_fallback_accepted`, `cursor_ext_setup_cancelled` (with `at` stage),
`cursor_ext_account_switch_noop`, `cursor_ext_collector_token_rotated`,
`cursor_ext_token_cap_recovered` / `cursor_ext_token_cap_blocked`,
`cursor_ext_signin_provider_link_retry`. `AccountManager`/`AuthManager` take an optional injected
telemetry sink (`event`/`error`) so their internal rotations, cap-handling, and otherwise-swallowed
errors surface without importing VS Code.

**[EXT-10] Packaging & publishing**
- `.vscodeignore`: keep `@ironbee-ai/cli`, `@ironbee-ai/devtools (≥0.17.0)`,
Expand Down
25 changes: 21 additions & 4 deletions src/accounts/accountManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ const TOKEN_ROTATE_SKEW_MS: number = 7 * 24 * 60 * 60 * 1000;
/** Reuse the cached token, rotate it (near/at expiry), or mint fresh (gone/revoked). */
type TokenStatus = 'usable' | 'rotate' | 'gone';

/** Fire-and-forget telemetry sink: product signals + otherwise-swallowed error reporting. */
export interface AccountTelemetry {
event(name: string, props?: Record<string, unknown>): void;
error(context: string, err: unknown): void;
}

export interface AccountManagerDeps {
console: ConsoleClient;
store: TokenStore;
Expand All @@ -21,6 +27,8 @@ export interface AccountManagerDeps {
refreshSession: () => Promise<void>;
/** Injectable clock (for expiry checks/tests). */
now?: () => number;
/** Optional telemetry (never throws); records rotations/cap-handling + swallowed errors. */
telemetry?: AccountTelemetry;
}

/**
Expand Down Expand Up @@ -76,6 +84,7 @@ export class AccountManager {
if (previous === targetAccountId) {
// Already active server-side, but the local session/token may be stale — refresh the
// claim before minting/writing so we never act against a stale custom:account_id.
this.deps.telemetry?.event('account_switch_noop');
await this.deps.refreshSession();
await this.doEnsureCollectorToken(targetAccountId);
return;
Expand All @@ -98,10 +107,11 @@ export class AccountManager {
try {
await this.deps.console.switchAccount(previousAccountId);
await this.deps.refreshSession();
} catch {
} catch (err) {
// Rollback failed (e.g. dead session). Local view diverges from server —
// mark dirty so the next operation re-fetches (and re-refreshes) before trusting state.
this.dirty = true;
this.deps.telemetry?.error('account-switch-rollback', err);
}
}

Expand All @@ -126,8 +136,11 @@ export class AccountManager {
return;
}
if (status === 'rotate') {
this.deps.telemetry?.event('collector_token_rotated', { reason: 'near_expiry' });
// Delete the near-expiry token first so rotations don't pile up toward the 10-cap.
await this.deps.console.deleteAccessToken(cached.id).catch((): void => {});
await this.deps.console
.deleteAccessToken(cached.id)
.catch((e: unknown): void => this.deps.telemetry?.error('token-rotate-delete-old', e));
}
}
const minted: MintedToken = await this.mintWithCapHandling();
Expand All @@ -139,8 +152,9 @@ export class AccountManager {
let list: AccessTokenRecord[];
try {
list = await this.deps.console.listAccessTokens();
} catch {
} catch (err) {
// Transient list failure — don't force a needless mint; reuse the cached token.
this.deps.telemetry?.error('token-status-check (reused cached token)', err);
return 'usable';
}
const found: AccessTokenRecord | undefined = list.find((t: AccessTokenRecord): boolean => t.id === id);
Expand All @@ -167,13 +181,16 @@ export class AccountManager {
const list: AccessTokenRecord[] = await this.deps.console.listAccessTokens();
const owned: AccessTokenRecord | undefined = list.find((t: AccessTokenRecord): boolean => t.name.startsWith(TOKEN_LABEL_PREFIX));
if (!owned) {
this.deps.telemetry?.event('token_cap_blocked'); // at cap, nothing of ours to reclaim
throw new Error(
'This account has reached its 10-token limit and none belong to IronBee for VS Code. ' +
'Remove an access token from the IronBee console, then try again.',
);
}
await this.deps.console.deleteAccessToken(owned.id);
return await this.deps.console.mintAccessToken(label);
const reminted: MintedToken = await this.deps.console.mintAccessToken(label);
this.deps.telemetry?.event('token_cap_recovered'); // reclaimed an owned token + re-minted
return reminted;
}
throw err;
}
Expand Down
3 changes: 3 additions & 0 deletions src/auth/authManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export interface AuthManagerDeps {
openUrl: (url: string) => Promise<boolean | void>;
fetchFn?: typeof fetch;
now?: () => number;
/** Optional fire-and-forget product-signal sink (never throws). */
onEvent?: (name: string, props?: Record<string, unknown>) => void;
}

/** Owns the Cognito session: PKCE loopback sign-in, refresh, sign-out. */
Expand Down Expand Up @@ -56,6 +58,7 @@ export class AuthManager {
// First social sign-in for an existing user links the identity and cancels that attempt;
// the second attempt succeeds. Retry once, automatically (per the backend hand-off).
if (err instanceof CognitoCallbackError && err.isProviderLinkRetry() && !signal?.aborted) {
this.deps.onEvent?.('signin_provider_link_retry');
await this.attemptSignIn(timeoutMs, signal);
return;
}
Expand Down
44 changes: 44 additions & 0 deletions src/config/ironbeeConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,50 @@ export async function writeDevtoolsMcp(
await atomicWriteFile(configPath, JSON.stringify(cfg, null, 2) + '\n');
}

export interface DevtoolsMcpEntry {
command: string;
args: string[];
env?: Record<string, string>;
}

/**
* True when a global `ironbeeDevTools.mcp` block was (almost certainly) written by a PRIOR version of
* THIS extension — i.e. its args point inside an editor extensions dir owned by us
* (`…/extensions/ironbee-ai.ironbee-vscode-…`). Used to migrate away from the old global-write
* behavior WITHOUT clobbering an override a user set by hand or via the CLI for standalone use.
*/
export function isExtensionOwnedDevtoolsMcp(mcp: DevtoolsMcpEntry | undefined): boolean {
if (mcp === undefined || !Array.isArray(mcp.args)) {
return false;
}
return mcp.args.some(
(a: unknown): boolean => typeof a === 'string' && /[/\\]extensions[/\\]ironbee-ai\.ironbee-vscode/i.test(a),
);
}

/**
* Remove a stale `ironbeeDevTools.mcp` override so the CLI falls back to its default. The extension
* no longer writes this block to the SHARED global config (it passes the bundled entry per-project
* via `IRONBEE_DEVTOOLS_MCP` at install time instead), so on activation it migrates away any block a
* prior version left in global. Pass `shouldClear` to remove ONLY entries we own — never a user's
* own hand-set/CLI override (which would break their standalone CLI usage). No-op when nothing matches.
*/
export async function clearDevtoolsMcp(
configPath: string = homeIronbeeConfigPath(),
shouldClear?: (mcp: DevtoolsMcpEntry) => boolean,
): Promise<void> {
const cfg: IronbeeGlobalConfig = await readGlobalConfig(configPath);
const devtools: NonNullable<IronbeeGlobalConfig['ironbeeDevTools']> | undefined = cfg.ironbeeDevTools;
if (devtools?.mcp === undefined) {
return;
}
if (shouldClear !== undefined && !shouldClear(devtools.mcp)) {
return; // present but not ours — leave the user's override untouched
}
delete devtools.mcp;
await atomicWriteFile(configPath, JSON.stringify(cfg, null, 2) + '\n');
}

/** True when a usable collector credential is already present (skip-if-authed, EXT-1). */
export function hasCollectorToken(cfg: IronbeeGlobalConfig): boolean {
const t: string | undefined = cfg.collector?.oauthToken;
Expand Down
Loading
Loading