Skip to content

Commit 1755a9d

Browse files
fix(daemon): launch env wrapper through shell (#89967)
1 parent 010b617 commit 1755a9d

3 files changed

Lines changed: 104 additions & 15 deletions

File tree

src/daemon/launchd-plist.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS = 20;
1111
export const LAUNCH_AGENT_UMASK_DECIMAL = 0o077;
1212
export const LAUNCH_AGENT_PROCESS_TYPE = "Interactive";
1313
export const LAUNCH_AGENT_STDIN_PATH = "/dev/null";
14+
export const LAUNCH_AGENT_ENV_WRAPPER_SHELL = "/bin/sh";
1415

1516
const plistEscape = (value: string): string =>
1617
value
@@ -68,12 +69,11 @@ function resolveSiblingGeneratedEnvFilePath(
6869
return `${envFilePath.slice(0, serviceEnvDirEnd)}/${label}.env`;
6970
}
7071

71-
function isGeneratedEnvWrapperArgs(
72-
programArguments: string[],
72+
function isExpectedGeneratedEnvWrapperPair(
73+
wrapperPath: string | undefined,
74+
envFilePath: string | undefined,
7375
options?: ReadLaunchAgentProgramArgumentsOptions,
7476
): boolean {
75-
const wrapperPath = programArguments[0];
76-
const envFilePath = programArguments[1];
7777
if (!wrapperPath || !envFilePath) {
7878
return false;
7979
}
@@ -102,14 +102,34 @@ function isGeneratedEnvWrapperArgs(
102102
);
103103
}
104104

105+
function resolveGeneratedEnvWrapperLayout(
106+
programArguments: string[],
107+
options?: ReadLaunchAgentProgramArgumentsOptions,
108+
): { envFilePath: string; commandStartIndex: number } | null {
109+
if (programArguments[0] === LAUNCH_AGENT_ENV_WRAPPER_SHELL) {
110+
const wrapperPath = programArguments[1];
111+
const envFilePath = programArguments[2];
112+
if (isExpectedGeneratedEnvWrapperPair(wrapperPath, envFilePath, options) && envFilePath) {
113+
return { envFilePath, commandStartIndex: 3 };
114+
}
115+
}
116+
const wrapperPath = programArguments[0];
117+
const envFilePath = programArguments[1];
118+
if (isExpectedGeneratedEnvWrapperPair(wrapperPath, envFilePath, options) && envFilePath) {
119+
return { envFilePath, commandStartIndex: 2 };
120+
}
121+
return null;
122+
}
123+
105124
async function readLaunchAgentEnvironmentFile(
106125
programArguments: string[],
107126
options?: ReadLaunchAgentProgramArgumentsOptions,
108127
): Promise<Record<string, string>> {
109-
const envFilePath = programArguments[1];
110-
if (!isGeneratedEnvWrapperArgs(programArguments, options) || !envFilePath) {
128+
const layout = resolveGeneratedEnvWrapperLayout(programArguments, options);
129+
if (!layout) {
111130
return {};
112131
}
132+
const envFilePath = layout.envFilePath;
113133
let content = "";
114134
const candidateEnvFilePaths = Array.from(
115135
new Set(
@@ -157,10 +177,11 @@ function unwrapGeneratedEnvWrapperArgs(
157177
programArguments: string[],
158178
options?: ReadLaunchAgentProgramArgumentsOptions,
159179
): string[] {
160-
if (!isGeneratedEnvWrapperArgs(programArguments, options)) {
180+
const layout = resolveGeneratedEnvWrapperLayout(programArguments, options);
181+
if (!layout) {
161182
return programArguments;
162183
}
163-
return programArguments.slice(2);
184+
return programArguments.slice(layout.commandStartIndex);
164185
}
165186

166187
const renderEnvDict = (env: Record<string, string | undefined> | undefined): string => {

src/daemon/launchd.test.ts

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
44
import { deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
55
import { GATEWAY_SERVICE_KIND, GATEWAY_SERVICE_MARKER } from "./constants.js";
66
import {
7+
LAUNCH_AGENT_ENV_WRAPPER_SHELL,
78
LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS,
89
LAUNCH_AGENT_PROCESS_TYPE,
910
LAUNCH_AGENT_STDIN_PATH,
@@ -81,6 +82,13 @@ function countMatching<T>(items: readonly T[], predicate: (item: T) => boolean):
8182
return count;
8283
}
8384

85+
function readPlistProgramArgumentStrings(plist: string): string[] {
86+
const match = plist.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/i);
87+
return Array.from((match?.[1] ?? "").matchAll(/<string>([\s\S]*?)<\/string>/gi)).map(
88+
(item) => item[1] ?? "",
89+
);
90+
}
91+
8492
function createDefaultLaunchdEnv(): Record<string, string | undefined> {
8593
return {
8694
HOME: "/Users/test",
@@ -838,8 +846,12 @@ describe("launchd install", () => {
838846
const plist = state.files.get(plistPath) ?? "";
839847
expect(plist).not.toContain("<key>EnvironmentVariables</key>");
840848
expect(plist).not.toContain(apiKey);
841-
expect(plist).toContain(`<string>${wrapperPath}</string>`);
842-
expect(plist).toContain(`<string>${envFilePath}</string>`);
849+
expect(readPlistProgramArgumentStrings(plist)).toEqual([
850+
LAUNCH_AGENT_ENV_WRAPPER_SHELL,
851+
wrapperPath,
852+
envFilePath,
853+
...defaultProgramArguments,
854+
]);
843855
const envFile = state.files.get(envFilePath) ?? "";
844856
expect(envFile).toContain(`export TMPDIR='${tmpDir}'`);
845857
expect(envFile).toContain(`export OPENAI_API_KEY='${apiKey}'`);
@@ -930,6 +942,49 @@ describe("launchd install", () => {
930942
expect(state.files.get(wrapperPath)).toBe(generatedWrapper);
931943
});
932944

945+
it("rewrites legacy LaunchAgent environment wrappers to a system shell executable", async () => {
946+
const env = createDefaultLaunchdEnv();
947+
const envFilePath = "/Users/test/.openclaw/service-env/ai.openclaw.gateway.env";
948+
const wrapperPath = "/Users/test/.openclaw/service-env/ai.openclaw.gateway-env-wrapper.sh";
949+
await installLaunchAgent({
950+
env,
951+
stdout: new PassThrough(),
952+
programArguments: defaultProgramArguments,
953+
environment: { OPENCLAW_GATEWAY_PORT: "19007" },
954+
});
955+
956+
const plistPath = resolveLaunchAgentPlistPath(env);
957+
const legacyPlist = (state.files.get(plistPath) ?? "").replace(
958+
[
959+
`<string>${LAUNCH_AGENT_ENV_WRAPPER_SHELL}</string>`,
960+
`<string>${wrapperPath}</string>`,
961+
`<string>${envFilePath}</string>`,
962+
].join("\n "),
963+
[`<string>${wrapperPath}</string>`, `<string>${envFilePath}</string>`].join("\n "),
964+
);
965+
expect(readPlistProgramArgumentStrings(legacyPlist)).toEqual([
966+
wrapperPath,
967+
envFilePath,
968+
...defaultProgramArguments,
969+
]);
970+
state.files.set(plistPath, legacyPlist);
971+
state.launchctlCalls.length = 0;
972+
973+
await restartLaunchAgent({
974+
env,
975+
stdout: new PassThrough(),
976+
});
977+
978+
const rewritten = state.files.get(plistPath) ?? "";
979+
expect(readPlistProgramArgumentStrings(rewritten)).toEqual([
980+
LAUNCH_AGENT_ENV_WRAPPER_SHELL,
981+
wrapperPath,
982+
envFilePath,
983+
...defaultProgramArguments,
984+
]);
985+
expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(19007);
986+
});
987+
933988
it("repairs a mangled label-derived service-env wrapper path on restart", async () => {
934989
const callerEnv = createDefaultLaunchdEnv();
935990
const serviceEnv = {
@@ -976,8 +1031,12 @@ describe("launchd install", () => {
9761031
});
9771032

9781033
const rewritten = state.files.get(plistPath) ?? "";
979-
expect(rewritten).toContain(`<string>${callerWrapperPath}</string>`);
980-
expect(rewritten).toContain(`<string>${callerEnvFilePath}</string>`);
1034+
expect(readPlistProgramArgumentStrings(rewritten)).toEqual([
1035+
LAUNCH_AGENT_ENV_WRAPPER_SHELL,
1036+
callerWrapperPath,
1037+
callerEnvFilePath,
1038+
...defaultProgramArguments,
1039+
]);
9811040
expect(rewritten).not.toContain(mangledEnvFilePath);
9821041
expect(rewritten).not.toContain(mangledWrapperPath);
9831042
const rewrittenEnv = state.files.get(callerEnvFilePath) ?? "";

src/daemon/launchd.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
import { execFileUtf8 } from "./exec-file.js";
2323
import { isCurrentProcessLaunchdServiceLabel } from "./launchd-current-service.js";
2424
import {
25+
LAUNCH_AGENT_ENV_WRAPPER_SHELL,
2526
buildLaunchAgentPlist as buildLaunchAgentPlistImpl,
2627
LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS,
2728
readLaunchAgentProgramArgumentsFromFile,
@@ -219,8 +220,11 @@ function isLaunchAgentEnvironmentWrapperArgs(params: {
219220
wrapperPath: string;
220221
}): boolean {
221222
return (
222-
params.programArguments[0] === params.wrapperPath &&
223-
params.programArguments[1] === params.envFilePath
223+
(params.programArguments[0] === params.wrapperPath &&
224+
params.programArguments[1] === params.envFilePath) ||
225+
(params.programArguments[0] === LAUNCH_AGENT_ENV_WRAPPER_SHELL &&
226+
params.programArguments[1] === params.wrapperPath &&
227+
params.programArguments[2] === params.envFilePath)
224228
);
225229
}
226230

@@ -274,7 +278,12 @@ async function prepareLaunchAgentProgramArguments(params: {
274278
}
275279

276280
return {
277-
programArguments: [wrapperPath, envFilePath, ...params.programArguments],
281+
programArguments: [
282+
LAUNCH_AGENT_ENV_WRAPPER_SHELL,
283+
wrapperPath,
284+
envFilePath,
285+
...params.programArguments,
286+
],
278287
};
279288
}
280289

0 commit comments

Comments
 (0)