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: 1 addition & 1 deletion src/__tests__/update-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-31T10:00:00.000Z'));
vi.stubEnv('NODE_ENV', '');
vi.stubEnv('CI', '');
vi.stubEnv('CI', '0');
vi.stubEnv('AGENT_DEVICE_NO_UPDATE_NOTIFIER', '');
Object.defineProperty(process.stderr, 'isTTY', {
configurable: true,
Expand Down
10 changes: 4 additions & 6 deletions src/cli/auth-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import path from 'node:path';
import { runCmd } from '../utils/exec.ts';
import { AppError } from '../kernel/errors.ts';
import type { CliFlags } from './parser/cli-flags.ts';
import type { EnvMap } from '../utils/env-map.ts';
import { isInteractive, type EnvMap } from '../utils/env-map.ts';
import { readCloudJsonResponse } from './cloud-response.ts';

const DEFAULT_CLOUD_BASE_URL = 'https://cloud.agent-device.dev';
Expand Down Expand Up @@ -477,15 +477,13 @@ function detectAuthMode(
): 'local-browser' | 'device-code' | 'non-interactive' {
const stdinIsTTY = io?.stdinIsTTY ?? process.stdin.isTTY;
const stdoutIsTTY = io?.stdoutIsTTY ?? process.stdout.isTTY;
if (isCi(env) || !stdinIsTTY || !stdoutIsTTY) return 'non-interactive';
if (!isInteractive({ isTTY: stdinIsTTY }, env) || !isInteractive({ isTTY: stdoutIsTTY }, env)) {
return 'non-interactive';
}
if (isRemoteShell(env)) return 'device-code';
return 'local-browser';
}

function isCi(env: EnvMap): boolean {
return env.CI === 'true' || env.GITHUB_ACTIONS === 'true' || env.BUILDKITE === 'true';
}

function isRemoteShell(env: EnvMap): boolean {
return Boolean(
env.SSH_TTY ||
Expand Down
4 changes: 3 additions & 1 deletion src/daemon/client/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
} from '../types.ts';
import type { RequestProgressSink } from '../request-progress.ts';
import { createRequestId, emitDiagnostic, withDiagnosticTimer } from '../../utils/diagnostics.ts';
import { isInteractive } from '../../utils/env-map.ts';
import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts';
import { prepareRemoteRequestArtifacts } from '../../remote/daemon-artifacts.ts';
import {
Expand Down Expand Up @@ -107,7 +108,8 @@ export async function sendToDaemon(
}

function writeInstallInProgressNotice(command: string | undefined): void {
if (!isInstallLikeCommand(command) || process.stderr.isTTY !== true || process.env.CI) return;
if (!isInstallLikeCommand(command)) return;
if (!isInteractive(process.stderr)) return;
process.stderr.write(
command === PUBLIC_COMMANDS.reinstall ? 'Reinstalling...\n' : 'Installing...\n',
);
Expand Down
2 changes: 1 addition & 1 deletion src/replay/test/__tests__/reporters-default.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ function withCiEnv<T>(value: string | undefined, run: () => T): T {
}

test('default replay test reporter hides and restores cursor for tty progress', () => {
withCiEnv(undefined, () => {
withCiEnv('0', () => {
const reporter = createDefaultReplayTestReporter();
const { context, stderr, stdout } = createReporterContext({ stderrIsTty: true });

Expand Down
13 changes: 6 additions & 7 deletions src/replay/test/reporters/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
REPLAY_TEST_PROGRESS_SPINNER_INTERVAL_MS,
} from '../progress.ts';
import { formatDurationSeconds } from '../../../utils/duration-format.ts';
import { isInteractive } from '../../../utils/env-map.ts';
import { colorize, supportsColor } from '../../../utils/output.ts';
import type {
ReplayTestReporter,
Expand Down Expand Up @@ -41,15 +42,16 @@ export function createDefaultReplayTestReporter(): ReplayTestReporter {
) => {
stopLiveProgressInterval();
latestLiveProgressEvent = event.type === 'test-step' ? event : undefined;
const liveProgress = isInteractive(context.stderr);
progressRenderer ??= createReplayTestProgressRenderer({
verbose: context.verbose,
liveProgress: shouldUseLiveProgress(context),
liveProgress,
columns: context.stderr.columns,
});
const output = progressRenderer.render(event);
if (!output) return;
context.stderr.write(output.newline ? `${output.text}\n` : output.text);
if (event.type === 'test-step' && shouldUseLiveProgress(context)) {
if (event.type === 'test-step' && liveProgress) {
startLiveProgressInterval(context);
}
};
Expand All @@ -68,7 +70,8 @@ export function createDefaultReplayTestReporter(): ReplayTestReporter {
progressInterval = undefined;
};
const hideCursor = (context: ReplayTestReporterContext) => {
if (cursorHidden || !shouldUseLiveProgress(context)) return;
if (cursorHidden) return;
if (!isInteractive(context.stderr)) return;
context.stderr.write(HIDE_CURSOR);
cursorHidden = true;
};
Expand Down Expand Up @@ -99,10 +102,6 @@ export function createDefaultReplayTestReporter(): ReplayTestReporter {
};
}

function shouldUseLiveProgress(context: ReplayTestReporterContext): boolean {
return context.stderr.isTTY && !process.env.CI;
}

function renderReplayTestSummary(
data: ReplaySuiteResult,
context: ReplayTestReporterContext,
Expand Down
3 changes: 2 additions & 1 deletion src/upload-progress.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Transform } from 'node:stream';
import { isInteractive } from './utils/env-map.ts';

const DEFAULT_UPLOAD_PROGRESS_STEP_BYTES = 8 * 1024 * 1024;
const DEFAULT_UPLOAD_PROGRESS_STEP_RATIO = 0.05;
Expand Down Expand Up @@ -59,7 +60,7 @@ export function createUploadProgressTransform(options: {
}

export function createStderrUploadProgressReporter(): UploadProgressSink | undefined {
if (process.stderr.isTTY !== true || process.env.CI) return undefined;
if (!isInteractive(process.stderr)) return undefined;
return (event) => {
process.stderr.write(`${formatUploadProgressEvent(event)}\n`);
};
Expand Down
2 changes: 1 addition & 1 deletion src/utils/__tests__/daemon-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ async function captureInteractiveStderr(callback: () => Promise<void>): Promise<
const originalCi = process.env.CI;
let output = '';
Object.defineProperty(stderr, 'isTTY', { configurable: true, value: true });
delete process.env.CI;
process.env.CI = '0';
(process.stderr as any).write = ((chunk: unknown) => {
output += String(chunk);
return true;
Expand Down
25 changes: 25 additions & 0 deletions src/utils/__tests__/env-map.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { isInteractive } from '../env-map.ts';

test('isInteractive requires a tty and detects CI environment markers', () => {
assert.equal(isInteractive({ isTTY: true }, {}), true);
assert.equal(isInteractive({ isTTY: false }, {}), false);
assert.equal(isInteractive({}, {}), false);
assert.equal(isInteractive({ isTTY: true }, { CI: '' }), true);
assert.equal(isInteractive({ isTTY: true }, { CI: ' ' }), false);
assert.equal(isInteractive({ isTTY: true }, { CI: 'false' }), true);
assert.equal(isInteractive({ isTTY: true }, { CI: '0' }), true);
assert.equal(isInteractive({ isTTY: true }, { CI: 'false', GITHUB_ACTIONS: 'true' }), true);
assert.equal(isInteractive({ isTTY: true }, { CI: '0', GITHUB_ACTIONS: 'true' }), true);
assert.equal(isInteractive({ isTTY: true }, { CI: 'true' }), false);
assert.equal(isInteractive({ isTTY: true }, { CI: '1' }), false);
assert.equal(isInteractive({ isTTY: true }, { BUILD_NUMBER: '123' }), false);
assert.equal(isInteractive({ isTTY: true }, { CONTINUOUS_INTEGRATION: 'true' }), false);
assert.equal(isInteractive({ isTTY: true }, { GITHUB_ACTIONS: 'true' }), false);
assert.equal(isInteractive({ isTTY: true }, { BUILDKITE: 'true' }), false);
assert.equal(
isInteractive({ isTTY: true }, { GITHUB_ACTIONS: 'false', BUILDKITE: 'false' }),
true,
);
});
27 changes: 27 additions & 0 deletions src/utils/env-map.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,29 @@
export type EnvMap = Record<string, string | undefined>;
export type DefinedEnvMap = Record<string, string>;
export type TtyLike = { isTTY?: boolean | undefined };

const CI_ENV_MARKERS = [
'BUILD_ID',
'BUILD_NUMBER',
'CI',
'CI_APP_ID',
'CI_BUILD_ID',
'CI_BUILD_NUMBER',
'CI_NAME',
'CONTINUOUS_INTEGRATION',
'RUN_ID',
] as const;

const CI_TRUE_MARKERS = ['GITHUB_ACTIONS', 'BUILDKITE'] as const;

function isCI(env: EnvMap = process.env): boolean {
if (env.CI === 'false' || env.CI === '0') return false;
return (
CI_ENV_MARKERS.some((name) => Boolean(env[name])) ||
CI_TRUE_MARKERS.some((name) => env[name] === 'true')
);
Comment on lines +19 to +24

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 CI='false' now overrides all other CI markers, changing auth behavior on GitHub Actions

The new isCI function at src/utils/env-map.ts:20 returns false immediately when CI='false' or CI='0', before checking any other markers like GITHUB_ACTIONS or BUILDKITE. This means { CI: 'false', GITHUB_ACTIONS: 'true' } is now treated as non-CI (tests at src/utils/__tests__/env-map.test.ts:13-14 confirm this). The old isCi in src/cli/auth-session.ts (removed at old lines 485-487) checked env.GITHUB_ACTIONS === 'true' independently, so it would have returned true (CI) in that scenario. In practice this means if a GitHub Actions workflow explicitly sets CI=false (some do for build tools), detectAuthMode would now return 'local-browser' or 'device-code' instead of 'non-interactive', potentially attempting to open a browser on a CI runner. The tests assert this is intentional, but it's a meaningful behavioral change worth confirming with the team.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}

export function isInteractive(stream: TtyLike, env: EnvMap = process.env): boolean {
return !isCI(env) && stream.isTTY === true;
}
4 changes: 2 additions & 2 deletions src/utils/update-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { runCmdDetached } from './exec.ts';
import { isInteractive } from './env-map.ts';

const PACKAGE_NAME = 'agent-device';
const UPDATE_CHECK_INTERVAL_MS = 14 * 24 * 60 * 60 * 1000;
Expand Down Expand Up @@ -82,10 +83,9 @@ function shouldEnableUpgradeNotifier(options: UpgradeNotifierOptions): boolean {
if (!options.command) return false;
if (options.command === 'help' || options.command === 'test') return false;
if (options.flags.help || options.flags.version || options.flags.json) return false;
if (process.env.CI?.trim()) return false;
if (process.env.NODE_ENV === 'test') return false;
if (process.env.AGENT_DEVICE_NO_UPDATE_NOTIFIER?.trim()) return false;
return Boolean(process.stderr.isTTY);
return isInteractive(process.stderr);
}

function shouldShowUpgradeNotice(cache: UpdateCheckCache, currentVersion: string): boolean {
Expand Down
Loading