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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,8 @@ Run `one update` manually whenever you want to upgrade.

### Telemetry

Every `actions execute` call also tells One which surface and harness made it (`x-one-source: cli`, and `x-one-agent: claude-code` or whichever agent launched the CLI), so the dashboard's Logs page can show which agent ran each action. This is the call's own record in your account, not analytics, and it is sent whether or not telemetry is on. No version, arguments or data ride with it.

The CLI collects **usage analytics** — which command was run, the CLI version, OS/arch, and whether it ran in `--agent` mode — to help us prioritize improvements. Events are linked to your One account (user id, email, name, org) so they line up with your dashboard activity. **Command arguments, inputs, connection data, and secrets are never collected** (only the command name, e.g. `actions execute`). A one-time notice is shown on first run. Commands are aggregated locally and sent as periodic, batched roll-ups in the background, so telemetry never slows down or blocks a command (and stays lightweight even under heavy automation).

To opt out, set any of:
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@withone/cli",
"version": "2.5.0",
"version": "2.6.0",
"description": "CLI for managing One",
"type": "module",
"files": [
Expand Down
76 changes: 76 additions & 0 deletions src/lib/api-caller-headers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, it, afterEach, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { OneApi } from './api.js';
import { callerHeaders } from './caller.js';

const realFetch = globalThis.fetch;
const LAUNCHER_VARS = ['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CODEX_SANDBOX', 'CODEX_CI', 'CODEX_THREAD_ID', 'GEMINI_CLI', 'CURSOR_AGENT', 'WINDSURF_AGENT', 'KIRO_AGENT', 'OPENCLAW_AGENT', 'OPENCLAW_SESSION', 'HERMES_AGENT', 'HERMES_SESSION', 'DEVIN_SESSION_ID'];
const saved: Record<string, string | undefined> = {};

// The suite itself may run under an agent (CLAUDECODE=1 under Claude Code),
// so every launcher marker is cleared and set per test.
beforeEach(() => { for (const v of LAUNCHER_VARS) { saved[v] = process.env[v]; delete process.env[v]; } });
afterEach(() => {
globalThis.fetch = realFetch;
for (const v of LAUNCHER_VARS) { if (saved[v] === undefined) delete process.env[v]; else process.env[v] = saved[v]; }
});

const action = { _id: 'conn_mod_def::1', title: 'List customers', method: 'GET', path: '/v1/customers', tags: [] } as never;

function capture(): { calls: { url: string; headers: Record<string, string> }[] } {
const calls: { url: string; headers: Record<string, string> }[] = [];
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
calls.push({ url: String(url), headers: (init?.headers ?? {}) as Record<string, string> });
return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });
}) as typeof fetch;
return { calls };
}

describe('callerHeaders', () => {
it('always names the CLI as the surface', () => {
assert.deepEqual(callerHeaders({}), { 'x-one-source': 'cli' });
});

it('names the harness that launched the process, without a version', () => {
assert.deepEqual(callerHeaders({ CLAUDECODE: '1' }), { 'x-one-source': 'cli', 'x-one-agent': 'claude-code' });
assert.deepEqual(callerHeaders({ CURSOR_AGENT: '1' }), { 'x-one-source': 'cli', 'x-one-agent': 'cursor' });
});

it('sends no agent for a plain terminal', () => {
assert.equal(callerHeaders({ CURSOR_TRACE_ID: 'editor-only' })['x-one-agent'], undefined);
});
});

describe('OneApi.executePassthroughRequest caller headers', () => {
it('sends the source and the launching harness on a passthrough call', async () => {
process.env.CLAUDECODE = '1';
const { calls } = capture();
await new OneApi('sk_live_k', 'https://api.test/v1').executePassthroughRequest({ platform: 'stripe', actionId: 'conn_mod_def::1', connectionKey: 'live::stripe::default::s' }, action);
assert.equal(calls[0].url, 'https://api.test/v1/passthrough/v1/customers');
assert.equal(calls[0].headers['x-one-source'], 'cli');
assert.equal(calls[0].headers['x-one-agent'], 'claude-code');
assert.equal(calls[0].headers['x-one-secret'], 'sk_live_k');
});

it('sends only the source when no harness launched it', async () => {
const { calls } = capture();
await new OneApi('k', 'https://api.test/v1').executePassthroughRequest({ platform: 'stripe', actionId: 'conn_mod_def::1', connectionKey: 'live::stripe::default::s' }, action);
assert.equal(calls[0].headers['x-one-source'], 'cli');
assert.equal(calls[0].headers['x-one-agent'], undefined);
});

it('lets an explicit --headers value win over the detected harness', async () => {
process.env.CLAUDECODE = '1';
const { calls } = capture();
await new OneApi('k', 'https://api.test/v1').executePassthroughRequest({ platform: 'stripe', actionId: 'conn_mod_def::1', connectionKey: 'live::stripe::default::s', headers: { 'x-one-agent': 'my-bot/1.0' } }, action);
assert.equal(calls[0].headers['x-one-agent'], 'my-bot/1.0');
});

it('does not touch non-passthrough requests', async () => {
process.env.CLAUDECODE = '1';
const { calls } = capture();
await new OneApi('k', 'https://api.test/v1').getOwnAccess().catch(() => undefined);
assert.equal(calls[0].headers['x-one-source'], undefined);
assert.equal(calls[0].headers['x-one-agent'], undefined);
});
});
4 changes: 4 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { callerHeaders } from './caller.js';
import { createWriteStream } from 'node:fs';
import { resolve } from 'node:path';
import { pipeline } from 'node:stream/promises';
Expand Down Expand Up @@ -411,6 +412,9 @@ export class OneApi {
'x-one-action-id': action._id,
'Content-Type': contentType,
Accept: 'application/json, text/event-stream, */*',
// Who is calling, for the Logs page. Before the caller's own headers so
// an explicit --headers value still wins.
...callerHeaders(),
...args.headers,
};

Expand Down
26 changes: 26 additions & 0 deletions src/lib/caller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { detectLauncher } from './install-context.js';

/**
* How a passthrough call names who made it, for the Logs page's Agent column
* (PICA-1017, pica-v2 #992).
*
* `x-one-source: cli` says the call came through the One CLI rather than a
* script holding the same key. `x-one-agent` names the harness that launched
* this process (`claude-code`, `cursor`, …) when one did; a person in a plain
* terminal sends no agent, and the row then shows only the door. Both are
* declared, never authenticated, and the backend drops a value it cannot
* read rather than refusing the call, so a wrong value can never break an
* action. A harness version is not sent: the CLI does not know it, and a
* made-up one would be shown beside the harness name as if it were real.
*
* Only the passthrough route reads these; every other route ignores them.
*/
export const CALLER_SOURCE_HEADER = 'x-one-source';
export const CALLER_AGENT_HEADER = 'x-one-agent';

export function callerHeaders(env: NodeJS.ProcessEnv = process.env): Record<string, string> {
const headers: Record<string, string> = { [CALLER_SOURCE_HEADER]: 'cli' };
const launcher = detectLauncher(env);
if (launcher) headers[CALLER_AGENT_HEADER] = launcher;
return headers;
}
Loading