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 zsh = "/bin/zsh";
|
||||||
const describeIfZsh = existsSync(zsh) ? describe : describe.skip;
|
const describeIfZsh = existsSync(zsh) ? describe : describe.skip;
|
||||||
const basePath = "/usr/bin:/bin:/usr/sbin:/sbin";
|
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 LoginShellEnvInput = NonNullable<Parameters<typeof inheritLoginShellEnv>[0]>;
|
||||||
type LoginShellSpawnSync = NonNullable<LoginShellEnvInput["spawnSync"]>;
|
type LoginShellSpawnSync = NonNullable<LoginShellEnvInput["spawnSync"]>;
|
||||||
|
|
||||||
|
interface TestClock {
|
||||||
|
advance: (ms: number) => void;
|
||||||
|
now: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
interface RecordedLog {
|
interface RecordedLog {
|
||||||
message: string;
|
message: string;
|
||||||
fields: Record<string, unknown>;
|
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 {
|
class RecordingLoginShellLogger {
|
||||||
readonly infos: RecordedLog[] = [];
|
readonly infos: RecordedLog[] = [];
|
||||||
readonly warnings: 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 {
|
function markerFromShellCommand(shellCommand: string): string {
|
||||||
const match = /"([0-9a-f]{12})" \+ JSON\.stringify\(process\.env\) \+ "\1"/.exec(shellCommand);
|
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}`);
|
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-"));
|
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", () => {
|
describeIfZsh("login shell env", () => {
|
||||||
const homes = new Set<string>();
|
const homes = new Set<string>();
|
||||||
|
|
||||||
@@ -77,10 +471,17 @@ describeIfZsh("login shell env", () => {
|
|||||||
expect(env.PATH?.split(path.delimiter)[0]).toBe(binDir);
|
expect(env.PATH?.split(path.delimiter)[0]).toBe(binDir);
|
||||||
expect(logger.infos.map((entry) => entry.message)).toEqual([
|
expect(logger.infos.map((entry) => entry.message)).toEqual([
|
||||||
"[login-shell-env] start",
|
"[login-shell-env] start",
|
||||||
|
"[login-shell-env] attempt applied",
|
||||||
"[login-shell-env] applied",
|
"[login-shell-env] applied",
|
||||||
]);
|
]);
|
||||||
expect(logger.warnings).toEqual([]);
|
expect(logger.warnings).toEqual([]);
|
||||||
expect(logger.infos[1]?.fields).toMatchObject({
|
expect(logger.infos[1]?.fields).toMatchObject({
|
||||||
|
attemptKind: "interactive",
|
||||||
|
shellArgs: ["-i", "-l", "-c"],
|
||||||
|
reason: "success",
|
||||||
|
});
|
||||||
|
expect(logger.infos[2]?.fields).toMatchObject({
|
||||||
|
attemptKind: "interactive",
|
||||||
beforePath: basePath,
|
beforePath: basePath,
|
||||||
afterPath: env.PATH,
|
afterPath: env.PATH,
|
||||||
pathChanged: true,
|
pathChanged: true,
|
||||||
@@ -100,6 +501,7 @@ describeIfZsh("login shell env", () => {
|
|||||||
expect(env.PASEO_TEST_ZSHRC_LOADED).toBe("1");
|
expect(env.PASEO_TEST_ZSHRC_LOADED).toBe("1");
|
||||||
expect(logger.infos.map((entry) => entry.message)).toEqual([
|
expect(logger.infos.map((entry) => entry.message)).toEqual([
|
||||||
"[login-shell-env] start",
|
"[login-shell-env] start",
|
||||||
|
"[login-shell-env] attempt applied",
|
||||||
"[login-shell-env] applied",
|
"[login-shell-env] applied",
|
||||||
]);
|
]);
|
||||||
expect(logger.warnings).toEqual([]);
|
expect(logger.warnings).toEqual([]);
|
||||||
@@ -116,20 +518,42 @@ describeIfZsh("login shell env", () => {
|
|||||||
|
|
||||||
expect(env.PATH).toBe(basePath);
|
expect(env.PATH).toBe(basePath);
|
||||||
expect(logger.infos.map((entry) => entry.message)).toEqual(["[login-shell-env] start"]);
|
expect(logger.infos.map((entry) => entry.message)).toEqual(["[login-shell-env] start"]);
|
||||||
expect(logger.warnings).toHaveLength(1);
|
expect(logger.warnings.map((entry) => entry.message)).toEqual([
|
||||||
expect(logger.warnings[0]?.message).toBe("[login-shell-env] failed; keeping inherited env");
|
"[login-shell-env] attempt failed; retrying",
|
||||||
|
"[login-shell-env] attempt failed",
|
||||||
|
"[login-shell-env] failed; keeping inherited env",
|
||||||
|
]);
|
||||||
expect(logger.warnings[0]?.fields).toMatchObject({
|
expect(logger.warnings[0]?.fields).toMatchObject({
|
||||||
reason: "non-zero-exit",
|
reason: "non-zero-exit",
|
||||||
|
attemptKind: "interactive",
|
||||||
shell: zsh,
|
shell: zsh,
|
||||||
shellArgs: ["-i", "-l", "-c"],
|
shellArgs: ["-i", "-l", "-c"],
|
||||||
status: 42,
|
status: 42,
|
||||||
stdoutLength: "premarker\n".length,
|
stdoutLength: "premarker\n".length,
|
||||||
markerFound: false,
|
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,
|
beforePath: basePath,
|
||||||
afterPath: basePath,
|
afterPath: basePath,
|
||||||
pathChanged: false,
|
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 () => {
|
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(env.PATH).toBe(basePath);
|
||||||
expect(logger.infos.map((entry) => entry.message)).toEqual(["[login-shell-env] start"]);
|
expect(logger.infos.map((entry) => entry.message)).toEqual(["[login-shell-env] start"]);
|
||||||
expect(logger.warnings).toHaveLength(1);
|
expect(logger.warnings.map((entry) => entry.message)).toEqual([
|
||||||
expect(logger.warnings[0]?.message).toBe("[login-shell-env] failed; keeping inherited env");
|
"[login-shell-env] attempt failed; retrying",
|
||||||
|
"[login-shell-env] attempt failed",
|
||||||
|
"[login-shell-env] failed; keeping inherited env",
|
||||||
|
]);
|
||||||
expect(logger.warnings[0]?.fields).toMatchObject({
|
expect(logger.warnings[0]?.fields).toMatchObject({
|
||||||
reason: "spawn-error",
|
reason: "timeout",
|
||||||
|
attemptKind: "interactive",
|
||||||
shell: zsh,
|
shell: zsh,
|
||||||
shellArgs: ["-i", "-l", "-c"],
|
shellArgs: ["-i", "-l", "-c"],
|
||||||
status: null,
|
status: null,
|
||||||
@@ -173,10 +601,32 @@ describeIfZsh("login shell env", () => {
|
|||||||
stdoutLength: stdout.length,
|
stdoutLength: stdout.length,
|
||||||
markerFound: true,
|
markerFound: true,
|
||||||
errorCode: "ETIMEDOUT",
|
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,
|
beforePath: basePath,
|
||||||
afterPath: basePath,
|
afterPath: basePath,
|
||||||
pathChanged: false,
|
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 { basename } from "node:path";
|
||||||
import defaultLog from "electron-log/main";
|
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;
|
const STDERR_LOG_LIMIT = 2000;
|
||||||
|
|
||||||
type LoginShellEnvLogger = Pick<typeof defaultLog, "info" | "warn">;
|
type LoginShellEnvLogger = Pick<typeof defaultLog, "info" | "warn">;
|
||||||
|
type ShellEnvAttemptKind = "interactive" | "non-interactive";
|
||||||
|
|
||||||
interface LoginShellEnvDependencies {
|
interface LoginShellEnvDependencies {
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
logger?: LoginShellEnvLogger;
|
logger?: LoginShellEnvLogger;
|
||||||
|
now?: () => number;
|
||||||
platform?: NodeJS.Platform;
|
platform?: NodeJS.Platform;
|
||||||
spawnSync?: typeof defaultSpawnSync;
|
spawnSync?: typeof defaultSpawnSync;
|
||||||
userInfo?: typeof defaultUserInfo;
|
userInfo?: typeof defaultUserInfo;
|
||||||
@@ -37,6 +40,8 @@ function pathEnv(env: NodeJS.ProcessEnv | Record<string, string>): string | null
|
|||||||
|
|
||||||
interface ShellEnvErrorDetails {
|
interface ShellEnvErrorDetails {
|
||||||
reason: string;
|
reason: string;
|
||||||
|
attemptKind?: ShellEnvAttemptKind;
|
||||||
|
argv0?: string;
|
||||||
shell?: string;
|
shell?: string;
|
||||||
shellArgs?: string[];
|
shellArgs?: string[];
|
||||||
status?: number | null;
|
status?: number | null;
|
||||||
@@ -57,19 +62,114 @@ class ShellEnvError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function throwIfShellFailed(
|
interface ShellEnvAttempt {
|
||||||
result: SpawnSyncReturns<string>,
|
kind: ShellEnvAttemptKind;
|
||||||
regex: RegExp,
|
argv0?: string;
|
||||||
shell: string,
|
shellArgs: string[];
|
||||||
shellArgs: string[],
|
}
|
||||||
): void {
|
|
||||||
|
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) {
|
if (result.error || result.signal) {
|
||||||
throw new ShellEnvError(
|
throw new ShellEnvError(
|
||||||
"login shell did not complete",
|
"login shell did not complete",
|
||||||
{
|
{
|
||||||
reason: result.error ? "spawn-error" : "signal",
|
reason: shellFailureReason(result),
|
||||||
|
attemptKind: attempt.kind,
|
||||||
|
argv0: attempt.argv0,
|
||||||
shell,
|
shell,
|
||||||
shellArgs,
|
shellArgs: attempt.shellArgs,
|
||||||
status: result.status,
|
status: result.status,
|
||||||
signal: result.signal,
|
signal: result.signal,
|
||||||
stdoutLength: result.stdout?.length ?? 0,
|
stdoutLength: result.stdout?.length ?? 0,
|
||||||
@@ -82,8 +182,10 @@ function throwIfShellFailed(
|
|||||||
if (result.status !== 0 && result.status !== null) {
|
if (result.status !== 0 && result.status !== null) {
|
||||||
throw new ShellEnvError("login shell exited non-zero", {
|
throw new ShellEnvError("login shell exited non-zero", {
|
||||||
reason: "non-zero-exit",
|
reason: "non-zero-exit",
|
||||||
|
attemptKind: attempt.kind,
|
||||||
|
argv0: attempt.argv0,
|
||||||
shell,
|
shell,
|
||||||
shellArgs,
|
shellArgs: attempt.shellArgs,
|
||||||
status: result.status,
|
status: result.status,
|
||||||
signal: result.signal,
|
signal: result.signal,
|
||||||
stdoutLength: result.stdout?.length ?? 0,
|
stdoutLength: result.stdout?.length ?? 0,
|
||||||
@@ -96,8 +198,10 @@ function throwIfShellFailed(
|
|||||||
"login shell produced no stdout",
|
"login shell produced no stdout",
|
||||||
{
|
{
|
||||||
reason: "no-stdout",
|
reason: "no-stdout",
|
||||||
|
attemptKind: attempt.kind,
|
||||||
|
argv0: attempt.argv0,
|
||||||
shell,
|
shell,
|
||||||
shellArgs,
|
shellArgs: attempt.shellArgs,
|
||||||
status: result.status,
|
status: result.status,
|
||||||
signal: result.signal,
|
signal: result.signal,
|
||||||
stdoutLength: result.stdout?.length ?? 0,
|
stdoutLength: result.stdout?.length ?? 0,
|
||||||
@@ -123,7 +227,171 @@ function getSystemShell(
|
|||||||
return deps.platform === "darwin" ? "/bin/zsh" : "/bin/bash";
|
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") {
|
if (deps.platform === "win32") {
|
||||||
throw new ShellEnvError("login shell env is not resolved on Windows", { reason: "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 regex = new RegExp(mark + "({.*})" + mark);
|
||||||
|
|
||||||
const shell = getSystemShell(deps);
|
const shell = getSystemShell(deps);
|
||||||
const name = basename(shell);
|
const { command, attempts } = shellEnvCommand({ shell, mark });
|
||||||
|
|
||||||
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 shellEnv = { ...deps.env };
|
const shellEnv = { ...deps.env };
|
||||||
delete shellEnv.PASEO_NODE_ENV;
|
delete shellEnv.PASEO_NODE_ENV;
|
||||||
@@ -165,72 +412,80 @@ function resolveShellEnv(deps: Required<LoginShellEnvDependencies>): Record<stri
|
|||||||
|
|
||||||
deps.logger.info("[login-shell-env] start", {
|
deps.logger.info("[login-shell-env] start", {
|
||||||
shell,
|
shell,
|
||||||
shellArgs,
|
shellArgs: attempts[0]?.shellArgs ?? [],
|
||||||
timeoutMs: RESOLVE_TIMEOUT_MS,
|
attempts: attempts.map((attempt) => ({
|
||||||
|
attemptKind: attempt.kind,
|
||||||
|
argv0: attempt.argv0,
|
||||||
|
shellArgs: attempt.shellArgs,
|
||||||
|
})),
|
||||||
|
timeoutMs,
|
||||||
beforePath: pathEnv(deps.env),
|
beforePath: pathEnv(deps.env),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = deps.spawnSync(shell, [...shellArgs, command], {
|
let lastError: unknown;
|
||||||
encoding: "utf8",
|
const attemptsStartedAt = deps.now();
|
||||||
timeout: RESOLVE_TIMEOUT_MS,
|
|
||||||
windowsHide: true,
|
|
||||||
env: {
|
|
||||||
...shellEnv,
|
|
||||||
ELECTRON_RUN_AS_NODE: "1",
|
|
||||||
ELECTRON_NO_ATTACH_CONSOLE: "1",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
throwIfShellFailed(result, regex, shell, shellArgs);
|
for (const [index, attempt] of attempts.entries()) {
|
||||||
|
const attemptTimeoutMs = timeoutMsForAttempt({
|
||||||
const match = regex.exec(result.stdout);
|
totalTimeoutMs: timeoutMs,
|
||||||
if (!match?.[1]) {
|
attemptsStartedAt,
|
||||||
throw new ShellEnvError("login shell output did not contain environment marker", {
|
now: deps.now,
|
||||||
reason: "marker-missing",
|
attempts,
|
||||||
shell,
|
index,
|
||||||
shellArgs,
|
|
||||||
status: result.status,
|
|
||||||
signal: result.signal,
|
|
||||||
stdoutLength: result.stdout.length,
|
|
||||||
markerFound: false,
|
|
||||||
stderr: result.stderr,
|
|
||||||
});
|
});
|
||||||
}
|
if (attemptTimeoutMs === null) break;
|
||||||
|
|
||||||
try {
|
const attemptStartedAt = deps.now();
|
||||||
const env = JSON.parse(match[1]) as Record<string, string>;
|
|
||||||
|
|
||||||
if (savedRunAsNode) {
|
try {
|
||||||
env.ELECTRON_RUN_AS_NODE = savedRunAsNode;
|
const env = shellEnvForAttempt({
|
||||||
} else {
|
deps,
|
||||||
delete env.ELECTRON_RUN_AS_NODE;
|
shellEnv,
|
||||||
}
|
|
||||||
|
|
||||||
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",
|
|
||||||
shell,
|
shell,
|
||||||
shellArgs,
|
command,
|
||||||
status: result.status,
|
regex,
|
||||||
signal: result.signal,
|
attempt,
|
||||||
stdoutLength: result.stdout.length,
|
timeoutMs: attemptTimeoutMs,
|
||||||
markerFound: true,
|
});
|
||||||
stderr: result.stderr,
|
const durationMs = deps.now() - attemptStartedAt;
|
||||||
},
|
restoreElectronEnv({ env, savedRunAsNode, savedNoAttach });
|
||||||
{ cause: error },
|
|
||||||
);
|
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> = {
|
const deps: Required<LoginShellEnvDependencies> = {
|
||||||
env: input.env ?? process.env,
|
env: input.env ?? process.env,
|
||||||
logger: input.logger ?? defaultLog,
|
logger: input.logger ?? defaultLog,
|
||||||
|
now: input.now ?? Date.now,
|
||||||
platform: input.platform ?? process.platform,
|
platform: input.platform ?? process.platform,
|
||||||
spawnSync: input.spawnSync ?? defaultSpawnSync,
|
spawnSync: input.spawnSync ?? defaultSpawnSync,
|
||||||
userInfo: input.userInfo ?? defaultUserInfo,
|
userInfo: input.userInfo ?? defaultUserInfo,
|
||||||
};
|
};
|
||||||
const beforePath = pathEnv(deps.env);
|
const beforePath = pathEnv(deps.env);
|
||||||
const startedAt = Date.now();
|
const startedAt = deps.now();
|
||||||
|
const timeoutMs = timeoutMsFromEnv(deps.env);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const env = resolveShellEnv(deps);
|
const { env, attemptKind } = resolveShellEnv({ deps, timeoutMs });
|
||||||
Object.assign(deps.env, env);
|
Object.assign(deps.env, env);
|
||||||
deps.logger.info("[login-shell-env] applied", {
|
deps.logger.info("[login-shell-env] applied", {
|
||||||
durationMs: Date.now() - startedAt,
|
attemptKind,
|
||||||
|
durationMs: deps.now() - startedAt,
|
||||||
|
timeoutMs,
|
||||||
beforePath,
|
beforePath,
|
||||||
afterPath: pathEnv(deps.env),
|
afterPath: pathEnv(deps.env),
|
||||||
pathChanged: beforePath !== 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;
|
const cause = error instanceof Error ? error.cause : undefined;
|
||||||
deps.logger.warn("[login-shell-env] failed; keeping inherited env", {
|
deps.logger.warn("[login-shell-env] failed; keeping inherited env", {
|
||||||
...details,
|
...details,
|
||||||
durationMs: Date.now() - startedAt,
|
durationMs: deps.now() - startedAt,
|
||||||
timeoutMs: RESOLVE_TIMEOUT_MS,
|
timeoutMs,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
errorCode: (cause as NodeJS.ErrnoException | undefined)?.code ?? null,
|
errorCode: (cause as NodeJS.ErrnoException | undefined)?.code ?? null,
|
||||||
stderr: truncateForLog(details.stderr),
|
stderr: truncateForLog(details.stderr),
|
||||||
|
|||||||
Reference in New Issue
Block a user