Files
software-mansion__argent/packages/telemetry/test/cloud-agent-detect.test.ts
filip131311 1aaa94d118 feat(telemetry): detect cloud/remote AI-agent environment and tag every event (#418)
## What

Adds a **`cloud_agent`** field to the base telemetry props — alongside
`is_ci` — naming the **cloud/remote AI coding-agent environment** the
process is running in, or `null`. Since it lives in the base props, it's
attached to **every** telemetry event automatically.

Crucially this is **cloud-only**: it fires for vendor-hosted / remote
autonomous runs, **not** for a developer running an agent CLI locally. A
laptop `cursor-agent`/`claude` session returns `null`; only a genuine
remote environment resolves to a slug. (Generic cloud/CI execution stays
`is_ci`; this names the specific agent.)

## How

- **New `packages/telemetry/src/cloud-agent-detect.ts`** —
`detectCloudAgent(env?)` returns a canonical slug or `null`. Modelled on
`ci-detect.ts`: takes `env` lazily for tests, runs nothing at import
time, and only ever emits a fixed allowlist slug — a raw env value is
never copied into telemetry.
- **`base-props.ts`** — adds `cloud_agent: CloudAgent | null` next to
`is_ci`, computed once in the memoized invariant block (no per-event
cost in the long-lived tool-server).

## Signals (only high-confidence remote markers; local CLI use → `null`)

| Slug | Cloud marker | Basis |
|---|---|---|
| `claude_code` | `CLAUDE_CODE_ENVIRONMENT_KIND ∈ {byoc,
anthropic_cloud}`, a `remote_*`/`claude-in-teams` entrypoint, or
`CLAUDE_CODE_REMOTE_SESSION_ID` | Verified against the Claude Code
binary's own internal cloud check |
| `cursor` | `CURSOR_AGENT_WORKER_ID` / `CURSOR_WORKER_POOL_NAME`
(worker/pool exec) | Verified `cursor-agent` reads it from env; the
local CLI sets `CURSOR_AGENT` + `CURSOR_AGENT_CLI_LOCAL_MODE` instead |
| `copilot` | `GITHUB_ACTIONS` + `copilot-swe-agent` actor/workflow |
Confirmed actor identity (also `is_ci`) |
| `replit` | `REPLIT_AGENT` | Agent-specific (the broad `REPL_ID` fires
in any Repl and is **not** used) |
| `devin` | `/opt/.devin` VM marker | Confirmed (`@vercel/detect-agent`)
|
| `jules` | `/opt/environment_summary.sh` VM marker | Confirmed
(official Jules docs) |

### Deliberately excluded
- **Codex cloud** — no reliable cloud-specific marker exists
(`CODEX_SANDBOX` is the *local* sandbox signal; OpenAI declined an
`AGENT=codex` flag in `openai/codex#13416`). Not detected rather than
guessed.
- **Local agent CLIs** — `CURSOR_AGENT`, `CLAUDECODE` with
`entrypoint=cli`, plain `REPL_ID` workspaces: intentionally `null`.

## Privacy
Reads only a fixed allowlist of env-var **names** plus two filesystem
markers (`/opt/.devin`, `/opt/environment_summary.sh`); emits only a
fixed slug. The filesystem checks are injectable and guarded so a
permission error can never break telemetry.

## Tests
- New `test/cloud-agent-detect.test.ts` — every vendor signal, plus the
key **negatives** (local Claude `entrypoint=cli`, local `cursor-agent`,
plain Repl `REPL_ID`, normal CI run) asserting `null`.
- Updated `test/base-props.test.ts` for the renamed key.
- Full telemetry suite **191 passing**, typecheck + build clean. Live
check confirms this local Claude Code session resolves to `null` while a
simulated `byoc`/remote env resolves to `claude_code`.

---

> Scope note: started as a broad "is an agent in the loop" flag
(`agent_env`); narrowed to cloud-only per review — a human using an
agent locally shouldn't look like a cloud run.
2026-06-26 16:33:32 +02:00

138 lines
4.7 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { CLOUD_AGENT_SLUGS, detectCloudAgent } from "../src/cloud-agent-detect.js";
// detectCloudAgent takes env explicitly, so every case is hermetic regardless of
// whatever environment the test suite itself runs under. The default no-file stub
// keeps the Devin/Jules filesystem markers from leaking real state into env cases.
const noFile = { fileExists: () => false };
describe("cloud-agent-detect", () => {
it("returns null when no cloud-agent signal is present", () => {
expect(detectCloudAgent({}, noFile)).toBeNull();
});
describe("Claude Code (remote/cloud)", () => {
it.each(["byoc", "anthropic_cloud"])("detects environment kind %s", (kind) => {
expect(detectCloudAgent({ CLAUDE_CODE_ENVIRONMENT_KIND: kind }, noFile)).toBe("claude_code");
});
it.each([
"remote",
"remote_baku",
"remote_cowork",
"remote_desktop",
"remote_mobile",
"claude-in-teams",
])("detects remote entrypoint %s", (entrypoint) => {
expect(detectCloudAgent({ CLAUDE_CODE_ENTRYPOINT: entrypoint }, noFile)).toBe("claude_code");
});
it("detects a remote session id", () => {
expect(detectCloudAgent({ CLAUDE_CODE_REMOTE_SESSION_ID: "rs_1" }, noFile)).toBe(
"claude_code"
);
});
it.each(["cli", "claude-vscode", "sdk-cli"])(
"does NOT treat local entrypoint %s as cloud",
(entrypoint) => {
expect(
detectCloudAgent({ CLAUDECODE: "1", CLAUDE_CODE_ENTRYPOINT: entrypoint }, noFile)
).toBeNull();
}
);
it("does NOT treat an unknown environment kind as cloud", () => {
expect(detectCloudAgent({ CLAUDE_CODE_ENVIRONMENT_KIND: "local" }, noFile)).toBeNull();
});
});
describe("Cursor (worker/cloud)", () => {
it("detects a worker id", () => {
expect(detectCloudAgent({ CURSOR_AGENT_WORKER_ID: "w-1" }, noFile)).toBe("cursor");
});
it("detects a worker pool name", () => {
expect(detectCloudAgent({ CURSOR_WORKER_POOL_NAME: "lab" }, noFile)).toBe("cursor");
});
it("does NOT treat the local Cursor CLI as cloud", () => {
expect(
detectCloudAgent({ CURSOR_AGENT: "1", CURSOR_AGENT_CLI_LOCAL_MODE: "true" }, noFile)
).toBeNull();
});
});
describe("GitHub Copilot coding agent", () => {
it("detects the copilot-swe-agent actor inside Actions", () => {
expect(
detectCloudAgent({ GITHUB_ACTIONS: "true", GITHUB_ACTOR: "copilot-swe-agent[bot]" }, noFile)
).toBe("copilot");
});
it("detects via the workflow ref", () => {
expect(
detectCloudAgent(
{
GITHUB_ACTIONS: "true",
GITHUB_WORKFLOW_REF: "owner/repo/.github/workflows/copilot-swe-agent/copilot",
},
noFile
)
).toBe("copilot");
});
it("does NOT treat a normal CI run as a copilot agent", () => {
expect(
detectCloudAgent({ GITHUB_ACTIONS: "true", GITHUB_ACTOR: "octocat" }, noFile)
).toBeNull();
});
it("does NOT match a copilot actor outside Actions", () => {
expect(detectCloudAgent({ GITHUB_ACTOR: "copilot" }, noFile)).toBeNull();
});
});
describe("Replit Agent", () => {
it("detects REPLIT_AGENT", () => {
expect(detectCloudAgent({ REPLIT_AGENT: "1" }, noFile)).toBe("replit");
});
it("does NOT treat a plain Repl workspace (REPL_ID only) as an agent", () => {
expect(detectCloudAgent({ REPL_ID: "abc", REPL_SLUG: "my-repl" }, noFile)).toBeNull();
});
});
describe("filesystem-marker agents", () => {
it("detects Devin from /opt/.devin", () => {
expect(detectCloudAgent({}, { fileExists: (p) => p === "/opt/.devin" })).toBe("devin");
});
it("detects Jules from /opt/environment_summary.sh", () => {
expect(detectCloudAgent({}, { fileExists: (p) => p === "/opt/environment_summary.sh" })).toBe(
"jules"
);
});
it("never throws if the filesystem check fails", () => {
const fileExists = () => {
throw new Error("EACCES");
};
expect(detectCloudAgent({}, { fileExists })).toBeNull();
});
});
it("ignores explicitly-empty signal env vars", () => {
expect(detectCloudAgent({ REPLIT_AGENT: "", CURSOR_AGENT_WORKER_ID: "" }, noFile)).toBeNull();
});
it("prefers an env signal over the filesystem markers", () => {
expect(detectCloudAgent({ REPLIT_AGENT: "1" }, { fileExists: () => true })).toBe("replit");
});
it("only ever emits slugs from the published allowlist", () => {
const result = detectCloudAgent({ CLAUDE_CODE_ENVIRONMENT_KIND: "byoc" }, noFile);
expect(result !== null && (CLOUD_AGENT_SLUGS as readonly string[]).includes(result)).toBe(true);
});
});