@tiny-fish/cli 0.33.0 → 0.33.1-next.279

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import spawn from 'cross-spawn';
3
3
  import { CONNECT_ATTEMPT_ENV, isAttemptId, resolvedApiKey, takePendingConnectAttempt, } from './auth.js';
4
4
  import { CLI_INVOCATION_ID, CLI_VERSION } from './constants.js';
5
- import { detectHarness, detectHumanInitiated } from './harness.js';
5
+ import { detectHarness, detectHumanInitiated, detectRuntimeEnv } from './harness.js';
6
6
  import { errLine, sanitizeLine } from './output.js';
7
7
  import { postConnectEvent, telemetryDisabled } from './setup-telemetry.js';
8
8
  import { installSignalGuard } from './signals.js';
@@ -290,6 +290,7 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
290
290
  // "none", not absent — absent has to keep meaning "CLI too old".
291
291
  calling_harness: detectHarness() ?? 'none',
292
292
  cli_invocation_id: CLI_INVOCATION_ID,
293
+ runtime_env: detectRuntimeEnv(),
293
294
  })).catch((error) => {
294
295
  // postConnectEvent is total, so this only fires if something inside deliver starts
295
296
  // throwing. Same channel postConnectEvent uses for its own failures.
@@ -1,6 +1,13 @@
1
1
  type Env = Record<string, string | undefined>;
2
2
  /** Returns the detected harness name, or null when none match. */
3
3
  export declare function detectHarness(env?: Env): string | null;
4
+ export type RuntimeEnv = 'container' | 'desktop';
5
+ interface FsApi {
6
+ existsSync: (path: string) => boolean;
7
+ readFileSync: (path: string, encoding: 'utf8') => string;
8
+ }
9
+ /** Fails open to desktop: a probe error must never break telemetry. */
10
+ export declare function detectRuntimeEnv(env?: Env, fsApi?: FsApi): RuntimeEnv;
4
11
  interface HumanInitiatedStreams {
5
12
  stdin: {
6
13
  isTTY?: boolean;
@@ -7,6 +7,7 @@
7
7
  // Order matters: wrappers that embed another agent must precede the embedded
8
8
  // one. OpenClaw drives Claude Code via `sdk-cli`, so its env carries both
9
9
  // CLAUDECODE=1 and OPENCLAW_MCP_*; it must be checked before claude-code.
10
+ import { existsSync, readFileSync } from 'node:fs';
10
11
  const hasVarWithPrefix = (env, prefix) => Object.keys(env).some((key) => key.startsWith(prefix));
11
12
  // A new name here must also join KNOWN_CALLING_HARNESSES in
12
13
  // frontend/app/api/cli/connect-event/route.ts, or connect telemetry buckets it as `other`.
@@ -34,6 +35,77 @@ const HARNESS_FINGERPRINTS = [
34
35
  export function detectHarness(env = process.env) {
35
36
  return HARNESS_FINGERPRINTS.find((fingerprint) => fingerprint.matches(env))?.name ?? null;
36
37
  }
38
+ // Markers verified live (PF-3778); exact names only — substring "SANDBOX" misfires.
39
+ const SANDBOX_ENV_VARS = [
40
+ // Codex cloud task container.
41
+ 'CODEX_PROXY_CERT',
42
+ // ChatGPT agent-mode container; absent in some sessions, NETWORK below backstops.
43
+ 'CAAS_ARTIFACTORY_BASE_URL',
44
+ 'CLAUDE_CODE_REMOTE_SESSION_ID',
45
+ 'E2B_SANDBOX',
46
+ 'MODAL_TASK_ID',
47
+ 'MODAL_SANDBOX_ID',
48
+ ];
49
+ // Real humans in containerized dev clouds; still container.
50
+ const DEV_CLOUD_ENV_VARS = [
51
+ 'CODESPACES',
52
+ 'GITPOD_WORKSPACE_ID',
53
+ 'CLOUD_SHELL',
54
+ 'REPL_ID',
55
+ 'KUBERNETES_SERVICE_HOST',
56
+ ];
57
+ const CONTAINER_FILES = ['/.dockerenv', '/run/.containerenv'];
58
+ // Container-side line shape only: host mountinfo carries .../containers/<id>/mounts/shm.
59
+ const MOUNTINFO_MARKER = /\/docker\/containers\/[0-9a-f]{64}\/(?:resolv\.conf|hostname|hosts)/;
60
+ const CGROUP_MARKERS = /docker|containerd|kubepods|libpod|lxc/;
61
+ // Per-read isolation: a missing mountinfo must not skip the cgroup probe.
62
+ const safeRead = (fsApi, path) => {
63
+ try {
64
+ return fsApi.readFileSync(path, 'utf8');
65
+ }
66
+ catch {
67
+ return '';
68
+ }
69
+ };
70
+ // One verdict per process, like CLI_INVOCATION_ID: events must never disagree.
71
+ let cachedRuntimeEnv;
72
+ // CODEX_CI / CODEX_SANDBOX* / CLAUDECODE mean agent-spawned, not containerized: never here.
73
+ /** Fails open to desktop: a probe error must never break telemetry. */
74
+ export function detectRuntimeEnv(env, fsApi) {
75
+ // Injected args bypass the cache so tests stay independent.
76
+ if (env || fsApi) {
77
+ return computeRuntimeEnv(env ?? process.env, fsApi ?? { existsSync, readFileSync });
78
+ }
79
+ cachedRuntimeEnv ??= computeRuntimeEnv(process.env, { existsSync, readFileSync });
80
+ return cachedRuntimeEnv;
81
+ }
82
+ function computeRuntimeEnv(env, fsApi) {
83
+ // Docs guarantee it is never true locally.
84
+ if (env['CLAUDE_CODE_REMOTE'] === 'true')
85
+ return 'container';
86
+ // Generic name, so match the value (ChatGPT agent mode).
87
+ if (env['NETWORK'] === 'caas_packages_only')
88
+ return 'container';
89
+ if (SANDBOX_ENV_VARS.some((name) => env[name]))
90
+ return 'container';
91
+ if (hasVarWithPrefix(env, 'CODEX_ENV_'))
92
+ return 'container';
93
+ if (DEV_CLOUD_ENV_VARS.some((name) => env[name]))
94
+ return 'container';
95
+ try {
96
+ if (CONTAINER_FILES.some((file) => fsApi.existsSync(file)))
97
+ return 'container';
98
+ }
99
+ catch {
100
+ // A failed probe is not evidence of a container.
101
+ }
102
+ // Modern docker cgroup is bare 0::/; mountinfo still carries the path.
103
+ if (MOUNTINFO_MARKER.test(safeRead(fsApi, '/proc/self/mountinfo')))
104
+ return 'container';
105
+ if (CGROUP_MARKERS.test(safeRead(fsApi, '/proc/self/cgroup')))
106
+ return 'container';
107
+ return 'desktop';
108
+ }
37
109
  /**
38
110
  * True only when both ends are a real terminal — a human at a prompt. Agents
39
111
  * shell out without a TTY, so this reads false for every harness above and is
@@ -3,7 +3,7 @@ import { z } from 'zod';
3
3
  import { validateKeyFormat } from './auth.js';
4
4
  import { BASE_URL, CLI_INVOCATION_ID, CLI_VERSION } from './constants.js';
5
5
  import { ALL_HARNESSES, AuthMode, Registered } from './harness-detect.js';
6
- import { detectHarness } from './harness.js';
6
+ import { detectHarness, detectRuntimeEnv } from './harness.js';
7
7
  import { captureNotice } from './notice.js';
8
8
  import { errLine } from './output.js';
9
9
  const CONNECT_EVENT_TIMEOUT_MS = 2_000;
@@ -116,6 +116,7 @@ export async function sendSetupStarted(mcpUrl, attemptId, apiKey) {
116
116
  runtime_platform: process.platform,
117
117
  ...(stamped ? { connect_attempt_id: stamped } : {}),
118
118
  cli_invocation_id: CLI_INVOCATION_ID,
119
+ runtime_env: detectRuntimeEnv(),
119
120
  }),
120
121
  attempts: 1,
121
122
  label: 'setup',
@@ -145,6 +146,7 @@ export async function sendUpgradeCompleted(fromVersion, toVersion, outcome, scop
145
146
  outcome,
146
147
  scope,
147
148
  cli_invocation_id: CLI_INVOCATION_ID,
149
+ runtime_env: detectRuntimeEnv(),
148
150
  }),
149
151
  attempts: 1,
150
152
  label: 'upgrade',
@@ -173,6 +175,7 @@ export async function sendSetupCompleted(mcpUrl, harnesses, apiKey, mode, attemp
173
175
  event: 'setup_completed',
174
176
  ...payload,
175
177
  cli_invocation_id: CLI_INVOCATION_ID,
178
+ runtime_env: detectRuntimeEnv(),
176
179
  });
177
180
  }
178
181
  catch (e) {
@@ -237,6 +240,7 @@ export async function sendDoctorCompleted(mcpUrl, payload, apiKey) {
237
240
  node_version: process.version,
238
241
  calling_harness: detectHarness() ?? 'none',
239
242
  cli_invocation_id: CLI_INVOCATION_ID,
243
+ runtime_env: detectRuntimeEnv(),
240
244
  });
241
245
  }
242
246
  catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.33.0",
3
+ "version": "0.33.1-next.279",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {