mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
1aaa94d118
## 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.
138 lines
4.7 KiB
TypeScript
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);
|
|
});
|
|
});
|