mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Fix desktop PATH resolution when shell startup fails (#1916)
* fix(desktop): retry login shell env resolution Finder-launched desktop sessions can inherit launchd's minimal PATH if the login shell probe times out or never prints its marker. Retry once with a non-interactive login shell so profile PATH still has a chance to apply. * fix(desktop): cap login shell probe retry budget * fix(desktop): repair csh login env retry * test(desktop): pin login shell env retry platform * refactor(desktop): use object params in shell env helpers
This commit is contained in:
@@ -10,14 +10,35 @@ import { inheritLoginShellEnv } from "./login-shell-env";
|
||||
const zsh = "/bin/zsh";
|
||||
const describeIfZsh = existsSync(zsh) ? describe : describe.skip;
|
||||
const basePath = "/usr/bin:/bin:/usr/sbin:/sbin";
|
||||
const fakeHome = path.join(os.tmpdir(), "paseo-login-shell-env-fake-home");
|
||||
type LoginShellEnvInput = NonNullable<Parameters<typeof inheritLoginShellEnv>[0]>;
|
||||
type LoginShellSpawnSync = NonNullable<LoginShellEnvInput["spawnSync"]>;
|
||||
|
||||
interface TestClock {
|
||||
advance: (ms: number) => void;
|
||||
now: () => number;
|
||||
}
|
||||
|
||||
interface RecordedLog {
|
||||
message: string;
|
||||
fields: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface RecordedSpawn {
|
||||
argv0: string | undefined;
|
||||
shell: string;
|
||||
args: string[];
|
||||
timeoutMs: number | undefined;
|
||||
}
|
||||
|
||||
interface SpawnResultFields {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
status?: number | null;
|
||||
signal?: NodeJS.Signals | null;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
class RecordingLoginShellLogger {
|
||||
readonly infos: RecordedLog[] = [];
|
||||
readonly warnings: RecordedLog[] = [];
|
||||
@@ -41,6 +62,41 @@ function createEnv(home: string): NodeJS.ProcessEnv {
|
||||
};
|
||||
}
|
||||
|
||||
function createTestClock(): TestClock {
|
||||
let currentMs = 1_000;
|
||||
return {
|
||||
advance: (ms: number) => {
|
||||
currentMs += ms;
|
||||
},
|
||||
now: () => currentMs,
|
||||
};
|
||||
}
|
||||
|
||||
function spawnResult(fields: SpawnResultFields): SpawnSyncReturns<string> {
|
||||
const stdout = fields.stdout ?? "";
|
||||
const stderr = fields.stderr ?? "";
|
||||
return {
|
||||
pid: 0,
|
||||
output: [null, stdout, stderr],
|
||||
stdout,
|
||||
stderr,
|
||||
status: fields.status === undefined ? 0 : fields.status,
|
||||
signal: fields.signal ?? null,
|
||||
error: fields.error,
|
||||
} satisfies SpawnSyncReturns<string>;
|
||||
}
|
||||
|
||||
function successResult(shellCommand: string, env: NodeJS.ProcessEnv): SpawnSyncReturns<string> {
|
||||
const marker = markerFromShellCommand(shellCommand);
|
||||
const stdout = `${marker}${JSON.stringify(env)}${marker}`;
|
||||
return spawnResult({ stdout });
|
||||
}
|
||||
|
||||
function shellArgsFromRecordedCall(call: RecordedSpawn | undefined): string[] {
|
||||
if (!call) return [];
|
||||
return call.args.slice(0, -1);
|
||||
}
|
||||
|
||||
function markerFromShellCommand(shellCommand: string): string {
|
||||
const match = /"([0-9a-f]{12})" \+ JSON\.stringify\(process\.env\) \+ "\1"/.exec(shellCommand);
|
||||
if (!match?.[1]) throw new Error(`missing env marker in shell command: ${shellCommand}`);
|
||||
@@ -55,6 +111,344 @@ async function createShellHome(): Promise<string> {
|
||||
return await mkdtemp(path.join(os.tmpdir(), "paseo-login-shell-env-"));
|
||||
}
|
||||
|
||||
describe("login shell env retry behavior", () => {
|
||||
it("applies the interactive env without retrying", () => {
|
||||
const env = createEnv(fakeHome);
|
||||
const logger = new RecordingLoginShellLogger();
|
||||
const clock = createTestClock();
|
||||
const calls: RecordedSpawn[] = [];
|
||||
const interactivePath = "/interactive/bin:/usr/bin:/bin";
|
||||
const spawnSync: LoginShellSpawnSync = (shell, args, options) => {
|
||||
const recordedArgs = Array.isArray(args) ? args.map(String) : [];
|
||||
calls.push({
|
||||
argv0: options?.argv0,
|
||||
shell: String(shell),
|
||||
args: recordedArgs,
|
||||
timeoutMs: options?.timeout,
|
||||
});
|
||||
clock.advance(5);
|
||||
return successResult(String(recordedArgs.at(-1)), { ...env, PATH: interactivePath });
|
||||
};
|
||||
|
||||
inheritLoginShellEnv({ env, logger, now: clock.now, platform: "darwin", spawnSync });
|
||||
|
||||
expect(env.PATH).toBe(interactivePath);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(shellArgsFromRecordedCall(calls[0])).toEqual(["-i", "-l", "-c"]);
|
||||
expect(calls[0]?.timeoutMs).toBe(15_000);
|
||||
expect(logger.infos.map((entry) => entry.message)).toEqual([
|
||||
"[login-shell-env] start",
|
||||
"[login-shell-env] attempt applied",
|
||||
"[login-shell-env] applied",
|
||||
]);
|
||||
expect(logger.infos[1]?.fields).toMatchObject({
|
||||
attemptKind: "interactive",
|
||||
shellArgs: ["-i", "-l", "-c"],
|
||||
reason: "success",
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
expect(logger.infos[2]?.fields).toMatchObject({
|
||||
attemptKind: "interactive",
|
||||
durationMs: 5,
|
||||
timeoutMs: 30_000,
|
||||
beforePath: basePath,
|
||||
afterPath: interactivePath,
|
||||
pathChanged: true,
|
||||
});
|
||||
expect(logger.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it("retries non-interactively after an interactive timeout", () => {
|
||||
const env = createEnv(fakeHome);
|
||||
const logger = new RecordingLoginShellLogger();
|
||||
const clock = createTestClock();
|
||||
const calls: RecordedSpawn[] = [];
|
||||
const nonInteractivePath = "/login/bin:/usr/bin:/bin";
|
||||
const timeoutError = Object.assign(new Error("spawnSync ETIMEDOUT"), {
|
||||
code: "ETIMEDOUT",
|
||||
});
|
||||
let timedOutStdout = "";
|
||||
const spawnSync: LoginShellSpawnSync = (shell, args, options) => {
|
||||
const recordedArgs = Array.isArray(args) ? args.map(String) : [];
|
||||
calls.push({
|
||||
argv0: options?.argv0,
|
||||
shell: String(shell),
|
||||
args: recordedArgs,
|
||||
timeoutMs: options?.timeout,
|
||||
});
|
||||
|
||||
if (calls.length === 1) {
|
||||
const marker = markerFromShellCommand(String(recordedArgs.at(-1)));
|
||||
timedOutStdout = `${marker}${JSON.stringify({ ...env, PATH: "/timed-out/bin" })}${marker}`;
|
||||
clock.advance(15_000);
|
||||
return spawnResult({
|
||||
stdout: timedOutStdout,
|
||||
status: null,
|
||||
signal: "SIGTERM",
|
||||
error: timeoutError,
|
||||
});
|
||||
}
|
||||
|
||||
clock.advance(3);
|
||||
return successResult(String(recordedArgs.at(-1)), { ...env, PATH: nonInteractivePath });
|
||||
};
|
||||
|
||||
inheritLoginShellEnv({ env, logger, now: clock.now, platform: "darwin", spawnSync });
|
||||
|
||||
expect(env.PATH).toBe(nonInteractivePath);
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(shellArgsFromRecordedCall(calls[0])).toEqual(["-i", "-l", "-c"]);
|
||||
expect(shellArgsFromRecordedCall(calls[1])).toEqual(["-l", "-c"]);
|
||||
expect(calls[0]?.timeoutMs).toBe(15_000);
|
||||
expect(calls[1]?.timeoutMs).toBe(15_000);
|
||||
expect(logger.warnings).toHaveLength(1);
|
||||
expect(logger.warnings[0]?.message).toBe("[login-shell-env] attempt failed; retrying");
|
||||
expect(logger.warnings[0]?.fields).toMatchObject({
|
||||
reason: "timeout",
|
||||
attemptKind: "interactive",
|
||||
shellArgs: ["-i", "-l", "-c"],
|
||||
status: null,
|
||||
signal: "SIGTERM",
|
||||
stdoutLength: timedOutStdout.length,
|
||||
markerFound: true,
|
||||
errorCode: "ETIMEDOUT",
|
||||
durationMs: 15_000,
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
expect(logger.infos[1]?.fields).toMatchObject({
|
||||
attemptKind: "non-interactive",
|
||||
shellArgs: ["-l", "-c"],
|
||||
reason: "success",
|
||||
durationMs: 3,
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
expect(logger.infos[2]?.fields).toMatchObject({
|
||||
attemptKind: "non-interactive",
|
||||
durationMs: 15_003,
|
||||
timeoutMs: 30_000,
|
||||
beforePath: basePath,
|
||||
afterPath: nonInteractivePath,
|
||||
pathChanged: true,
|
||||
});
|
||||
expectNoRawStdout(logger.warnings[0]?.fields ?? {});
|
||||
});
|
||||
|
||||
it("retries non-interactively when the interactive marker is missing", () => {
|
||||
const env = createEnv(fakeHome);
|
||||
const logger = new RecordingLoginShellLogger();
|
||||
const clock = createTestClock();
|
||||
const calls: RecordedSpawn[] = [];
|
||||
const nonInteractivePath = "/profile/bin:/usr/bin:/bin";
|
||||
const missingMarkerStdout = "switched shells before command\n";
|
||||
const spawnSync: LoginShellSpawnSync = (shell, args, options) => {
|
||||
const recordedArgs = Array.isArray(args) ? args.map(String) : [];
|
||||
calls.push({
|
||||
argv0: options?.argv0,
|
||||
shell: String(shell),
|
||||
args: recordedArgs,
|
||||
timeoutMs: options?.timeout,
|
||||
});
|
||||
|
||||
if (calls.length === 1) {
|
||||
clock.advance(25);
|
||||
return spawnResult({ stdout: missingMarkerStdout });
|
||||
}
|
||||
|
||||
clock.advance(2);
|
||||
return successResult(String(recordedArgs.at(-1)), { ...env, PATH: nonInteractivePath });
|
||||
};
|
||||
|
||||
inheritLoginShellEnv({ env, logger, now: clock.now, platform: "darwin", spawnSync });
|
||||
|
||||
expect(env.PATH).toBe(nonInteractivePath);
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(shellArgsFromRecordedCall(calls[0])).toEqual(["-i", "-l", "-c"]);
|
||||
expect(shellArgsFromRecordedCall(calls[1])).toEqual(["-l", "-c"]);
|
||||
expect(calls[0]?.timeoutMs).toBe(15_000);
|
||||
expect(calls[1]?.timeoutMs).toBe(29_975);
|
||||
expect(logger.warnings).toHaveLength(1);
|
||||
expect(logger.warnings[0]?.message).toBe("[login-shell-env] attempt failed; retrying");
|
||||
expect(logger.warnings[0]?.fields).toMatchObject({
|
||||
reason: "marker-missing",
|
||||
attemptKind: "interactive",
|
||||
shellArgs: ["-i", "-l", "-c"],
|
||||
status: 0,
|
||||
signal: null,
|
||||
stdoutLength: missingMarkerStdout.length,
|
||||
markerFound: false,
|
||||
durationMs: 25,
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
expect(logger.infos[1]?.fields).toMatchObject({
|
||||
attemptKind: "non-interactive",
|
||||
reason: "success",
|
||||
durationMs: 2,
|
||||
timeoutMs: 29_975,
|
||||
});
|
||||
expectNoRawStdout(logger.warnings[0]?.fields ?? {});
|
||||
});
|
||||
|
||||
it("keeps the inherited env after both attempts fail", () => {
|
||||
const env = createEnv(fakeHome);
|
||||
const logger = new RecordingLoginShellLogger();
|
||||
const clock = createTestClock();
|
||||
const calls: RecordedSpawn[] = [];
|
||||
const spawnError = Object.assign(new Error("spawnSync ENOENT"), {
|
||||
code: "ENOENT",
|
||||
});
|
||||
const spawnSync: LoginShellSpawnSync = (shell, args, options) => {
|
||||
const recordedArgs = Array.isArray(args) ? args.map(String) : [];
|
||||
calls.push({
|
||||
argv0: options?.argv0,
|
||||
shell: String(shell),
|
||||
args: recordedArgs,
|
||||
timeoutMs: options?.timeout,
|
||||
});
|
||||
|
||||
if (calls.length === 1) {
|
||||
clock.advance(10);
|
||||
return spawnResult({ stdout: "no marker\n" });
|
||||
}
|
||||
|
||||
clock.advance(5);
|
||||
return spawnResult({
|
||||
status: null,
|
||||
signal: null,
|
||||
error: spawnError,
|
||||
});
|
||||
};
|
||||
|
||||
inheritLoginShellEnv({ env, logger, now: clock.now, platform: "darwin", spawnSync });
|
||||
|
||||
expect(env.PATH).toBe(basePath);
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0]?.timeoutMs).toBe(15_000);
|
||||
expect(calls[1]?.timeoutMs).toBe(29_990);
|
||||
expect(logger.infos.map((entry) => entry.message)).toEqual(["[login-shell-env] start"]);
|
||||
expect(logger.warnings.map((entry) => entry.message)).toEqual([
|
||||
"[login-shell-env] attempt failed; retrying",
|
||||
"[login-shell-env] attempt failed",
|
||||
"[login-shell-env] failed; keeping inherited env",
|
||||
]);
|
||||
expect(logger.warnings[0]?.fields).toMatchObject({
|
||||
reason: "marker-missing",
|
||||
attemptKind: "interactive",
|
||||
shellArgs: ["-i", "-l", "-c"],
|
||||
durationMs: 10,
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
expect(logger.warnings[1]?.fields).toMatchObject({
|
||||
reason: "spawn-error",
|
||||
attemptKind: "non-interactive",
|
||||
shellArgs: ["-l", "-c"],
|
||||
durationMs: 5,
|
||||
errorCode: "ENOENT",
|
||||
timeoutMs: 29_990,
|
||||
});
|
||||
expect(logger.warnings[2]?.fields).toMatchObject({
|
||||
reason: "spawn-error",
|
||||
attemptKind: "non-interactive",
|
||||
shellArgs: ["-l", "-c"],
|
||||
errorCode: "ENOENT",
|
||||
durationMs: 15,
|
||||
timeoutMs: 30_000,
|
||||
beforePath: basePath,
|
||||
afterPath: basePath,
|
||||
pathChanged: false,
|
||||
});
|
||||
expectNoRawStdout(logger.warnings[2]?.fields ?? {});
|
||||
});
|
||||
|
||||
it("uses the configured shell env timeout", () => {
|
||||
const env = {
|
||||
...createEnv(fakeHome),
|
||||
PASEO_SHELL_ENV_TIMEOUT_MS: "1234",
|
||||
};
|
||||
const logger = new RecordingLoginShellLogger();
|
||||
const clock = createTestClock();
|
||||
const calls: RecordedSpawn[] = [];
|
||||
const configuredPath = "/configured/bin:/usr/bin:/bin";
|
||||
const spawnSync: LoginShellSpawnSync = (shell, args, options) => {
|
||||
const recordedArgs = Array.isArray(args) ? args.map(String) : [];
|
||||
calls.push({
|
||||
argv0: options?.argv0,
|
||||
shell: String(shell),
|
||||
args: recordedArgs,
|
||||
timeoutMs: options?.timeout,
|
||||
});
|
||||
clock.advance(4);
|
||||
return successResult(String(recordedArgs.at(-1)), { ...env, PATH: configuredPath });
|
||||
};
|
||||
|
||||
inheritLoginShellEnv({ env, logger, now: clock.now, platform: "darwin", spawnSync });
|
||||
|
||||
expect(env.PATH).toBe(configuredPath);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.timeoutMs).toBe(617);
|
||||
expect(logger.infos[0]?.fields).toMatchObject({
|
||||
timeoutMs: 1234,
|
||||
});
|
||||
expect(logger.infos[1]?.fields).toMatchObject({
|
||||
timeoutMs: 617,
|
||||
});
|
||||
expect(logger.infos[2]?.fields).toMatchObject({
|
||||
durationMs: 4,
|
||||
timeoutMs: 1234,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses argv0 for the non-interactive tcsh login retry", () => {
|
||||
const env = {
|
||||
...createEnv(fakeHome),
|
||||
SHELL: "/bin/tcsh",
|
||||
};
|
||||
const logger = new RecordingLoginShellLogger();
|
||||
const clock = createTestClock();
|
||||
const calls: RecordedSpawn[] = [];
|
||||
const nonInteractivePath = "/tcsh/login/bin:/usr/bin:/bin";
|
||||
const spawnSync: LoginShellSpawnSync = (shell, args, options) => {
|
||||
const recordedArgs = Array.isArray(args) ? args.map(String) : [];
|
||||
calls.push({
|
||||
argv0: options?.argv0,
|
||||
shell: String(shell),
|
||||
args: recordedArgs,
|
||||
timeoutMs: options?.timeout,
|
||||
});
|
||||
|
||||
if (calls.length === 1) {
|
||||
clock.advance(8);
|
||||
return spawnResult({ stdout: "no marker\n" });
|
||||
}
|
||||
|
||||
clock.advance(2);
|
||||
return successResult(String(recordedArgs.at(-1)), { ...env, PATH: nonInteractivePath });
|
||||
};
|
||||
|
||||
inheritLoginShellEnv({ env, logger, now: clock.now, platform: "darwin", spawnSync });
|
||||
|
||||
expect(env.PATH).toBe(nonInteractivePath);
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(shellArgsFromRecordedCall(calls[0])).toEqual(["-ic"]);
|
||||
expect(calls[0]?.argv0).toBeUndefined();
|
||||
expect(shellArgsFromRecordedCall(calls[1])).toEqual(["-c"]);
|
||||
expect(calls[1]?.argv0).toBe("-tcsh");
|
||||
expect(calls[1]?.timeoutMs).toBe(29_992);
|
||||
expect(logger.warnings[0]?.fields).toMatchObject({
|
||||
attemptKind: "interactive",
|
||||
shellArgs: ["-ic"],
|
||||
reason: "marker-missing",
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
expect(logger.infos[1]?.fields).toMatchObject({
|
||||
attemptKind: "non-interactive",
|
||||
argv0: "-tcsh",
|
||||
shellArgs: ["-c"],
|
||||
reason: "success",
|
||||
timeoutMs: 29_992,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describeIfZsh("login shell env", () => {
|
||||
const homes = new Set<string>();
|
||||
|
||||
@@ -77,10 +471,17 @@ describeIfZsh("login shell env", () => {
|
||||
expect(env.PATH?.split(path.delimiter)[0]).toBe(binDir);
|
||||
expect(logger.infos.map((entry) => entry.message)).toEqual([
|
||||
"[login-shell-env] start",
|
||||
"[login-shell-env] attempt applied",
|
||||
"[login-shell-env] applied",
|
||||
]);
|
||||
expect(logger.warnings).toEqual([]);
|
||||
expect(logger.infos[1]?.fields).toMatchObject({
|
||||
attemptKind: "interactive",
|
||||
shellArgs: ["-i", "-l", "-c"],
|
||||
reason: "success",
|
||||
});
|
||||
expect(logger.infos[2]?.fields).toMatchObject({
|
||||
attemptKind: "interactive",
|
||||
beforePath: basePath,
|
||||
afterPath: env.PATH,
|
||||
pathChanged: true,
|
||||
@@ -100,6 +501,7 @@ describeIfZsh("login shell env", () => {
|
||||
expect(env.PASEO_TEST_ZSHRC_LOADED).toBe("1");
|
||||
expect(logger.infos.map((entry) => entry.message)).toEqual([
|
||||
"[login-shell-env] start",
|
||||
"[login-shell-env] attempt applied",
|
||||
"[login-shell-env] applied",
|
||||
]);
|
||||
expect(logger.warnings).toEqual([]);
|
||||
@@ -116,20 +518,42 @@ describeIfZsh("login shell env", () => {
|
||||
|
||||
expect(env.PATH).toBe(basePath);
|
||||
expect(logger.infos.map((entry) => entry.message)).toEqual(["[login-shell-env] start"]);
|
||||
expect(logger.warnings).toHaveLength(1);
|
||||
expect(logger.warnings[0]?.message).toBe("[login-shell-env] failed; keeping inherited env");
|
||||
expect(logger.warnings.map((entry) => entry.message)).toEqual([
|
||||
"[login-shell-env] attempt failed; retrying",
|
||||
"[login-shell-env] attempt failed",
|
||||
"[login-shell-env] failed; keeping inherited env",
|
||||
]);
|
||||
expect(logger.warnings[0]?.fields).toMatchObject({
|
||||
reason: "non-zero-exit",
|
||||
attemptKind: "interactive",
|
||||
shell: zsh,
|
||||
shellArgs: ["-i", "-l", "-c"],
|
||||
status: 42,
|
||||
stdoutLength: "premarker\n".length,
|
||||
markerFound: false,
|
||||
});
|
||||
expect(logger.warnings[1]?.fields).toMatchObject({
|
||||
reason: "non-zero-exit",
|
||||
attemptKind: "non-interactive",
|
||||
shell: zsh,
|
||||
shellArgs: ["-l", "-c"],
|
||||
status: 42,
|
||||
stdoutLength: "premarker\n".length,
|
||||
markerFound: false,
|
||||
});
|
||||
expect(logger.warnings[2]?.fields).toMatchObject({
|
||||
reason: "non-zero-exit",
|
||||
attemptKind: "non-interactive",
|
||||
shell: zsh,
|
||||
shellArgs: ["-l", "-c"],
|
||||
status: 42,
|
||||
stdoutLength: "premarker\n".length,
|
||||
markerFound: false,
|
||||
beforePath: basePath,
|
||||
afterPath: basePath,
|
||||
pathChanged: false,
|
||||
});
|
||||
expectNoRawStdout(logger.warnings[0]?.fields ?? {});
|
||||
expectNoRawStdout(logger.warnings[2]?.fields ?? {});
|
||||
});
|
||||
|
||||
it("keeps the inherited env when a timed-out shell printed an env marker", async () => {
|
||||
@@ -162,10 +586,14 @@ describeIfZsh("login shell env", () => {
|
||||
|
||||
expect(env.PATH).toBe(basePath);
|
||||
expect(logger.infos.map((entry) => entry.message)).toEqual(["[login-shell-env] start"]);
|
||||
expect(logger.warnings).toHaveLength(1);
|
||||
expect(logger.warnings[0]?.message).toBe("[login-shell-env] failed; keeping inherited env");
|
||||
expect(logger.warnings.map((entry) => entry.message)).toEqual([
|
||||
"[login-shell-env] attempt failed; retrying",
|
||||
"[login-shell-env] attempt failed",
|
||||
"[login-shell-env] failed; keeping inherited env",
|
||||
]);
|
||||
expect(logger.warnings[0]?.fields).toMatchObject({
|
||||
reason: "spawn-error",
|
||||
reason: "timeout",
|
||||
attemptKind: "interactive",
|
||||
shell: zsh,
|
||||
shellArgs: ["-i", "-l", "-c"],
|
||||
status: null,
|
||||
@@ -173,10 +601,32 @@ describeIfZsh("login shell env", () => {
|
||||
stdoutLength: stdout.length,
|
||||
markerFound: true,
|
||||
errorCode: "ETIMEDOUT",
|
||||
});
|
||||
expect(logger.warnings[1]?.fields).toMatchObject({
|
||||
reason: "timeout",
|
||||
attemptKind: "non-interactive",
|
||||
shell: zsh,
|
||||
shellArgs: ["-l", "-c"],
|
||||
status: null,
|
||||
signal: "SIGTERM",
|
||||
stdoutLength: stdout.length,
|
||||
markerFound: true,
|
||||
errorCode: "ETIMEDOUT",
|
||||
});
|
||||
expect(logger.warnings[2]?.fields).toMatchObject({
|
||||
reason: "timeout",
|
||||
attemptKind: "non-interactive",
|
||||
shell: zsh,
|
||||
shellArgs: ["-l", "-c"],
|
||||
status: null,
|
||||
signal: "SIGTERM",
|
||||
stdoutLength: stdout.length,
|
||||
markerFound: true,
|
||||
errorCode: "ETIMEDOUT",
|
||||
beforePath: basePath,
|
||||
afterPath: basePath,
|
||||
pathChanged: false,
|
||||
});
|
||||
expectNoRawStdout(logger.warnings[0]?.fields ?? {});
|
||||
expectNoRawStdout(logger.warnings[2]?.fields ?? {});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,14 +9,17 @@ import { userInfo as defaultUserInfo } from "node:os";
|
||||
import { basename } from "node:path";
|
||||
import defaultLog from "electron-log/main";
|
||||
|
||||
const RESOLVE_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_RESOLVE_TIMEOUT_MS = 30_000;
|
||||
const TIMEOUT_ENV_KEY = "PASEO_SHELL_ENV_TIMEOUT_MS";
|
||||
const STDERR_LOG_LIMIT = 2000;
|
||||
|
||||
type LoginShellEnvLogger = Pick<typeof defaultLog, "info" | "warn">;
|
||||
type ShellEnvAttemptKind = "interactive" | "non-interactive";
|
||||
|
||||
interface LoginShellEnvDependencies {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
logger?: LoginShellEnvLogger;
|
||||
now?: () => number;
|
||||
platform?: NodeJS.Platform;
|
||||
spawnSync?: typeof defaultSpawnSync;
|
||||
userInfo?: typeof defaultUserInfo;
|
||||
@@ -37,6 +40,8 @@ function pathEnv(env: NodeJS.ProcessEnv | Record<string, string>): string | null
|
||||
|
||||
interface ShellEnvErrorDetails {
|
||||
reason: string;
|
||||
attemptKind?: ShellEnvAttemptKind;
|
||||
argv0?: string;
|
||||
shell?: string;
|
||||
shellArgs?: string[];
|
||||
status?: number | null;
|
||||
@@ -57,19 +62,114 @@ class ShellEnvError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function throwIfShellFailed(
|
||||
result: SpawnSyncReturns<string>,
|
||||
regex: RegExp,
|
||||
shell: string,
|
||||
shellArgs: string[],
|
||||
): void {
|
||||
interface ShellEnvAttempt {
|
||||
kind: ShellEnvAttemptKind;
|
||||
argv0?: string;
|
||||
shellArgs: string[];
|
||||
}
|
||||
|
||||
interface ShellEnvCommand {
|
||||
command: string;
|
||||
attempts: ShellEnvAttempt[];
|
||||
}
|
||||
|
||||
interface ResolvedShellEnv {
|
||||
env: Record<string, string>;
|
||||
attemptKind: ShellEnvAttemptKind;
|
||||
}
|
||||
|
||||
interface AttemptTimeoutInput {
|
||||
totalTimeoutMs: number;
|
||||
attemptsStartedAt: number;
|
||||
now: () => number;
|
||||
attempts: ShellEnvAttempt[];
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface ThrowIfShellFailedInput {
|
||||
result: SpawnSyncReturns<string>;
|
||||
regex: RegExp;
|
||||
shell: string;
|
||||
attempt: ShellEnvAttempt;
|
||||
}
|
||||
|
||||
interface ShellEnvForAttemptInput {
|
||||
deps: Required<LoginShellEnvDependencies>;
|
||||
shellEnv: NodeJS.ProcessEnv;
|
||||
shell: string;
|
||||
command: string;
|
||||
regex: RegExp;
|
||||
attempt: ShellEnvAttempt;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
interface ShellAttemptErrorDetailsInput {
|
||||
error: unknown;
|
||||
shell: string;
|
||||
attempt: ShellEnvAttempt;
|
||||
}
|
||||
|
||||
interface LogShellAttemptFailureInput {
|
||||
deps: Required<LoginShellEnvDependencies>;
|
||||
error: unknown;
|
||||
details: ShellEnvErrorDetails;
|
||||
durationMs: number;
|
||||
timeoutMs: number;
|
||||
willRetry: boolean;
|
||||
}
|
||||
|
||||
interface RestoreElectronEnvInput {
|
||||
env: Record<string, string>;
|
||||
savedRunAsNode: string | undefined;
|
||||
savedNoAttach: string | undefined;
|
||||
}
|
||||
|
||||
interface ResolveShellEnvInput {
|
||||
deps: Required<LoginShellEnvDependencies>;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
function timeoutMsFromEnv(env: NodeJS.ProcessEnv): number {
|
||||
const rawTimeoutMs = env[TIMEOUT_ENV_KEY];
|
||||
if (!rawTimeoutMs) return DEFAULT_RESOLVE_TIMEOUT_MS;
|
||||
|
||||
const timeoutMs = Number.parseInt(rawTimeoutMs, 10);
|
||||
return Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_RESOLVE_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
function timeoutMsForAttempt({
|
||||
totalTimeoutMs,
|
||||
attemptsStartedAt,
|
||||
now,
|
||||
attempts,
|
||||
index,
|
||||
}: AttemptTimeoutInput): number | null {
|
||||
if (attempts.length === 1) return totalTimeoutMs;
|
||||
if (index === 0) return Math.max(1, Math.floor(totalTimeoutMs / 2));
|
||||
|
||||
const remainingMs = totalTimeoutMs - (now() - attemptsStartedAt);
|
||||
return remainingMs > 0 ? remainingMs : null;
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): string | null {
|
||||
return error instanceof Error ? ((error as NodeJS.ErrnoException).code ?? null) : null;
|
||||
}
|
||||
|
||||
function shellFailureReason(result: SpawnSyncReturns<string>): string {
|
||||
if (errorCode(result.error) === "ETIMEDOUT") return "timeout";
|
||||
return result.error ? "spawn-error" : "signal";
|
||||
}
|
||||
|
||||
function throwIfShellFailed({ result, regex, shell, attempt }: ThrowIfShellFailedInput): void {
|
||||
if (result.error || result.signal) {
|
||||
throw new ShellEnvError(
|
||||
"login shell did not complete",
|
||||
{
|
||||
reason: result.error ? "spawn-error" : "signal",
|
||||
reason: shellFailureReason(result),
|
||||
attemptKind: attempt.kind,
|
||||
argv0: attempt.argv0,
|
||||
shell,
|
||||
shellArgs,
|
||||
shellArgs: attempt.shellArgs,
|
||||
status: result.status,
|
||||
signal: result.signal,
|
||||
stdoutLength: result.stdout?.length ?? 0,
|
||||
@@ -82,8 +182,10 @@ function throwIfShellFailed(
|
||||
if (result.status !== 0 && result.status !== null) {
|
||||
throw new ShellEnvError("login shell exited non-zero", {
|
||||
reason: "non-zero-exit",
|
||||
attemptKind: attempt.kind,
|
||||
argv0: attempt.argv0,
|
||||
shell,
|
||||
shellArgs,
|
||||
shellArgs: attempt.shellArgs,
|
||||
status: result.status,
|
||||
signal: result.signal,
|
||||
stdoutLength: result.stdout?.length ?? 0,
|
||||
@@ -96,8 +198,10 @@ function throwIfShellFailed(
|
||||
"login shell produced no stdout",
|
||||
{
|
||||
reason: "no-stdout",
|
||||
attemptKind: attempt.kind,
|
||||
argv0: attempt.argv0,
|
||||
shell,
|
||||
shellArgs,
|
||||
shellArgs: attempt.shellArgs,
|
||||
status: result.status,
|
||||
signal: result.signal,
|
||||
stdoutLength: result.stdout?.length ?? 0,
|
||||
@@ -123,7 +227,171 @@ function getSystemShell(
|
||||
return deps.platform === "darwin" ? "/bin/zsh" : "/bin/bash";
|
||||
}
|
||||
|
||||
function resolveShellEnv(deps: Required<LoginShellEnvDependencies>): Record<string, string> {
|
||||
function shellEnvCommand({ shell, mark }: { shell: string; mark: string }): ShellEnvCommand {
|
||||
const name = basename(shell);
|
||||
|
||||
if (/^(?:pwsh|powershell)(?:-preview)?$/.test(name)) {
|
||||
return {
|
||||
command: `& '${process.execPath}' -p '''${mark}'' + JSON.stringify(process.env) + ''${mark}'''`,
|
||||
attempts: [{ kind: "non-interactive", shellArgs: ["-Login", "-Command"] }],
|
||||
};
|
||||
}
|
||||
|
||||
if (name === "nu") {
|
||||
return {
|
||||
command: `^'${process.execPath}' -p '"${mark}" + JSON.stringify(process.env) + "${mark}"'`,
|
||||
attempts: [
|
||||
{ kind: "interactive", shellArgs: ["-i", "-l", "-c"] },
|
||||
{ kind: "non-interactive", shellArgs: ["-l", "-c"] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (name === "xonsh") {
|
||||
return {
|
||||
command: `import os, json; print("${mark}", json.dumps(dict(os.environ)), "${mark}")`,
|
||||
attempts: [
|
||||
{ kind: "interactive", shellArgs: ["-i", "-l", "-c"] },
|
||||
{ kind: "non-interactive", shellArgs: ["-l", "-c"] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (name === "tcsh" || name === "csh") {
|
||||
return {
|
||||
command: `'${process.execPath}' -p '"${mark}" + JSON.stringify(process.env) + "${mark}"'`,
|
||||
attempts: [
|
||||
{ kind: "interactive", shellArgs: ["-ic"] },
|
||||
{ kind: "non-interactive", argv0: `-${name}`, shellArgs: ["-c"] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: `'${process.execPath}' -p '"${mark}" + JSON.stringify(process.env) + "${mark}"'`,
|
||||
attempts: [
|
||||
{ kind: "interactive", shellArgs: ["-i", "-l", "-c"] },
|
||||
{ kind: "non-interactive", shellArgs: ["-l", "-c"] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function shellEnvForAttempt({
|
||||
deps,
|
||||
shellEnv,
|
||||
shell,
|
||||
command,
|
||||
regex,
|
||||
attempt,
|
||||
timeoutMs,
|
||||
}: ShellEnvForAttemptInput): Record<string, string> {
|
||||
const result = deps.spawnSync(shell, [...attempt.shellArgs, command], {
|
||||
argv0: attempt.argv0,
|
||||
encoding: "utf8",
|
||||
timeout: timeoutMs,
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...shellEnv,
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
ELECTRON_NO_ATTACH_CONSOLE: "1",
|
||||
},
|
||||
});
|
||||
|
||||
throwIfShellFailed({ result, regex, shell, attempt });
|
||||
|
||||
const match = regex.exec(result.stdout);
|
||||
if (!match?.[1]) {
|
||||
throw new ShellEnvError("login shell output did not contain environment marker", {
|
||||
reason: "marker-missing",
|
||||
attemptKind: attempt.kind,
|
||||
argv0: attempt.argv0,
|
||||
shell,
|
||||
shellArgs: attempt.shellArgs,
|
||||
status: result.status,
|
||||
signal: result.signal,
|
||||
stdoutLength: result.stdout.length,
|
||||
markerFound: false,
|
||||
stderr: result.stderr,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(match[1]) as Record<string, string>;
|
||||
} catch (error) {
|
||||
throw new ShellEnvError(
|
||||
"failed to parse login shell environment JSON",
|
||||
{
|
||||
reason: "json-parse",
|
||||
attemptKind: attempt.kind,
|
||||
argv0: attempt.argv0,
|
||||
shell,
|
||||
shellArgs: attempt.shellArgs,
|
||||
status: result.status,
|
||||
signal: result.signal,
|
||||
stdoutLength: result.stdout.length,
|
||||
markerFound: true,
|
||||
stderr: result.stderr,
|
||||
},
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function shellAttemptErrorDetails({
|
||||
error,
|
||||
shell,
|
||||
attempt,
|
||||
}: ShellAttemptErrorDetailsInput): ShellEnvErrorDetails {
|
||||
return error instanceof ShellEnvError
|
||||
? error.details
|
||||
: {
|
||||
reason: "throw",
|
||||
attemptKind: attempt.kind,
|
||||
argv0: attempt.argv0,
|
||||
shell,
|
||||
shellArgs: attempt.shellArgs,
|
||||
};
|
||||
}
|
||||
|
||||
function logShellAttemptFailure({
|
||||
deps,
|
||||
error,
|
||||
details,
|
||||
durationMs,
|
||||
timeoutMs,
|
||||
willRetry,
|
||||
}: LogShellAttemptFailureInput): void {
|
||||
const cause = error instanceof Error ? error.cause : undefined;
|
||||
deps.logger.warn(
|
||||
willRetry ? "[login-shell-env] attempt failed; retrying" : "[login-shell-env] attempt failed",
|
||||
{
|
||||
...details,
|
||||
durationMs,
|
||||
timeoutMs,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
errorCode: error instanceof ShellEnvError ? errorCode(cause) : errorCode(error),
|
||||
stderr: truncateForLog(details.stderr),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function restoreElectronEnv({ env, savedRunAsNode, savedNoAttach }: RestoreElectronEnvInput): void {
|
||||
if (savedRunAsNode) {
|
||||
env.ELECTRON_RUN_AS_NODE = savedRunAsNode;
|
||||
} else {
|
||||
delete env.ELECTRON_RUN_AS_NODE;
|
||||
}
|
||||
|
||||
if (savedNoAttach) {
|
||||
env.ELECTRON_NO_ATTACH_CONSOLE = savedNoAttach;
|
||||
} else {
|
||||
delete env.ELECTRON_NO_ATTACH_CONSOLE;
|
||||
}
|
||||
|
||||
delete env.XDG_RUNTIME_DIR;
|
||||
}
|
||||
|
||||
function resolveShellEnv({ deps, timeoutMs }: ResolveShellEnvInput): ResolvedShellEnv {
|
||||
if (deps.platform === "win32") {
|
||||
throw new ShellEnvError("login shell env is not resolved on Windows", { reason: "win32" });
|
||||
}
|
||||
@@ -135,28 +403,7 @@ function resolveShellEnv(deps: Required<LoginShellEnvDependencies>): Record<stri
|
||||
const regex = new RegExp(mark + "({.*})" + mark);
|
||||
|
||||
const shell = getSystemShell(deps);
|
||||
const name = basename(shell);
|
||||
|
||||
let command: string;
|
||||
let shellArgs: string[];
|
||||
|
||||
if (/^(?:pwsh|powershell)(?:-preview)?$/.test(name)) {
|
||||
command = `& '${process.execPath}' -p '''${mark}'' + JSON.stringify(process.env) + ''${mark}'''`;
|
||||
shellArgs = ["-Login", "-Command"];
|
||||
} else if (name === "nu") {
|
||||
command = `^'${process.execPath}' -p '"${mark}" + JSON.stringify(process.env) + "${mark}"'`;
|
||||
shellArgs = ["-i", "-l", "-c"];
|
||||
} else if (name === "xonsh") {
|
||||
command = `import os, json; print("${mark}", json.dumps(dict(os.environ)), "${mark}")`;
|
||||
shellArgs = ["-i", "-l", "-c"];
|
||||
} else {
|
||||
command = `'${process.execPath}' -p '"${mark}" + JSON.stringify(process.env) + "${mark}"'`;
|
||||
if (name === "tcsh" || name === "csh") {
|
||||
shellArgs = ["-ic"];
|
||||
} else {
|
||||
shellArgs = ["-i", "-l", "-c"];
|
||||
}
|
||||
}
|
||||
const { command, attempts } = shellEnvCommand({ shell, mark });
|
||||
|
||||
const shellEnv = { ...deps.env };
|
||||
delete shellEnv.PASEO_NODE_ENV;
|
||||
@@ -165,72 +412,80 @@ function resolveShellEnv(deps: Required<LoginShellEnvDependencies>): Record<stri
|
||||
|
||||
deps.logger.info("[login-shell-env] start", {
|
||||
shell,
|
||||
shellArgs,
|
||||
timeoutMs: RESOLVE_TIMEOUT_MS,
|
||||
shellArgs: attempts[0]?.shellArgs ?? [],
|
||||
attempts: attempts.map((attempt) => ({
|
||||
attemptKind: attempt.kind,
|
||||
argv0: attempt.argv0,
|
||||
shellArgs: attempt.shellArgs,
|
||||
})),
|
||||
timeoutMs,
|
||||
beforePath: pathEnv(deps.env),
|
||||
});
|
||||
|
||||
const result = deps.spawnSync(shell, [...shellArgs, command], {
|
||||
encoding: "utf8",
|
||||
timeout: RESOLVE_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...shellEnv,
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
ELECTRON_NO_ATTACH_CONSOLE: "1",
|
||||
},
|
||||
});
|
||||
let lastError: unknown;
|
||||
const attemptsStartedAt = deps.now();
|
||||
|
||||
throwIfShellFailed(result, regex, shell, shellArgs);
|
||||
|
||||
const match = regex.exec(result.stdout);
|
||||
if (!match?.[1]) {
|
||||
throw new ShellEnvError("login shell output did not contain environment marker", {
|
||||
reason: "marker-missing",
|
||||
shell,
|
||||
shellArgs,
|
||||
status: result.status,
|
||||
signal: result.signal,
|
||||
stdoutLength: result.stdout.length,
|
||||
markerFound: false,
|
||||
stderr: result.stderr,
|
||||
for (const [index, attempt] of attempts.entries()) {
|
||||
const attemptTimeoutMs = timeoutMsForAttempt({
|
||||
totalTimeoutMs: timeoutMs,
|
||||
attemptsStartedAt,
|
||||
now: deps.now,
|
||||
attempts,
|
||||
index,
|
||||
});
|
||||
}
|
||||
if (attemptTimeoutMs === null) break;
|
||||
|
||||
try {
|
||||
const env = JSON.parse(match[1]) as Record<string, string>;
|
||||
const attemptStartedAt = deps.now();
|
||||
|
||||
if (savedRunAsNode) {
|
||||
env.ELECTRON_RUN_AS_NODE = savedRunAsNode;
|
||||
} else {
|
||||
delete env.ELECTRON_RUN_AS_NODE;
|
||||
}
|
||||
|
||||
if (savedNoAttach) {
|
||||
env.ELECTRON_NO_ATTACH_CONSOLE = savedNoAttach;
|
||||
} else {
|
||||
delete env.ELECTRON_NO_ATTACH_CONSOLE;
|
||||
}
|
||||
|
||||
delete env.XDG_RUNTIME_DIR;
|
||||
|
||||
return env;
|
||||
} catch (error) {
|
||||
throw new ShellEnvError(
|
||||
"failed to parse login shell environment JSON",
|
||||
{
|
||||
reason: "json-parse",
|
||||
try {
|
||||
const env = shellEnvForAttempt({
|
||||
deps,
|
||||
shellEnv,
|
||||
shell,
|
||||
shellArgs,
|
||||
status: result.status,
|
||||
signal: result.signal,
|
||||
stdoutLength: result.stdout.length,
|
||||
markerFound: true,
|
||||
stderr: result.stderr,
|
||||
},
|
||||
{ cause: error },
|
||||
);
|
||||
command,
|
||||
regex,
|
||||
attempt,
|
||||
timeoutMs: attemptTimeoutMs,
|
||||
});
|
||||
const durationMs = deps.now() - attemptStartedAt;
|
||||
restoreElectronEnv({ env, savedRunAsNode, savedNoAttach });
|
||||
|
||||
deps.logger.info("[login-shell-env] attempt applied", {
|
||||
attemptKind: attempt.kind,
|
||||
argv0: attempt.argv0,
|
||||
shell,
|
||||
shellArgs: attempt.shellArgs,
|
||||
reason: "success",
|
||||
durationMs,
|
||||
timeoutMs: attemptTimeoutMs,
|
||||
});
|
||||
|
||||
return { env, attemptKind: attempt.kind };
|
||||
} catch (error) {
|
||||
const details = shellAttemptErrorDetails({ error, shell, attempt });
|
||||
const durationMs = deps.now() - attemptStartedAt;
|
||||
const willRetry =
|
||||
index < attempts.length - 1 &&
|
||||
timeoutMsForAttempt({
|
||||
totalTimeoutMs: timeoutMs,
|
||||
attemptsStartedAt,
|
||||
now: deps.now,
|
||||
attempts,
|
||||
index: index + 1,
|
||||
}) !== null;
|
||||
lastError = error;
|
||||
logShellAttemptFailure({
|
||||
deps,
|
||||
error,
|
||||
details,
|
||||
durationMs,
|
||||
timeoutMs: attemptTimeoutMs,
|
||||
willRetry,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -245,18 +500,22 @@ export function inheritLoginShellEnv(input: LoginShellEnvDependencies = {}): voi
|
||||
const deps: Required<LoginShellEnvDependencies> = {
|
||||
env: input.env ?? process.env,
|
||||
logger: input.logger ?? defaultLog,
|
||||
now: input.now ?? Date.now,
|
||||
platform: input.platform ?? process.platform,
|
||||
spawnSync: input.spawnSync ?? defaultSpawnSync,
|
||||
userInfo: input.userInfo ?? defaultUserInfo,
|
||||
};
|
||||
const beforePath = pathEnv(deps.env);
|
||||
const startedAt = Date.now();
|
||||
const startedAt = deps.now();
|
||||
const timeoutMs = timeoutMsFromEnv(deps.env);
|
||||
|
||||
try {
|
||||
const env = resolveShellEnv(deps);
|
||||
const { env, attemptKind } = resolveShellEnv({ deps, timeoutMs });
|
||||
Object.assign(deps.env, env);
|
||||
deps.logger.info("[login-shell-env] applied", {
|
||||
durationMs: Date.now() - startedAt,
|
||||
attemptKind,
|
||||
durationMs: deps.now() - startedAt,
|
||||
timeoutMs,
|
||||
beforePath,
|
||||
afterPath: pathEnv(deps.env),
|
||||
pathChanged: beforePath !== pathEnv(deps.env),
|
||||
@@ -270,8 +529,8 @@ export function inheritLoginShellEnv(input: LoginShellEnvDependencies = {}): voi
|
||||
const cause = error instanceof Error ? error.cause : undefined;
|
||||
deps.logger.warn("[login-shell-env] failed; keeping inherited env", {
|
||||
...details,
|
||||
durationMs: Date.now() - startedAt,
|
||||
timeoutMs: RESOLVE_TIMEOUT_MS,
|
||||
durationMs: deps.now() - startedAt,
|
||||
timeoutMs,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
errorCode: (cause as NodeJS.ErrnoException | undefined)?.code ?? null,
|
||||
stderr: truncateForLog(details.stderr),
|
||||
|
||||
Reference in New Issue
Block a user