diff --git a/CHANGELOG.md b/CHANGELOG.md index 12d3e26c1bc..80b6d78c1cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ Docs: https://docs.openclaw.ai - Export/session: keep inline export HTML scripts and vendor libraries injected after template formatting so generated session exports open with the app code, markdown renderer, and syntax highlighter present. Fixes #41862 and #49957; carries forward #41861 and #68947. Thanks @briannewman, @martenzi, and @armanddp. - Agents/ACPX: stage the patched Claude ACP adapter as an ACPX runtime dependency and route known Codex/Claude ACP commands through local wrappers, so Gateway runtime no longer depends on live `npx` adapter resolution. Fixes #73202. Thanks @joerod26. - Memory/compaction: let pre-compaction memory flush use an exact `agents.defaults.compaction.memoryFlush.model` override such as `ollama/qwen3:8b` without inheriting the active session fallback chain, so local housekeeping can avoid paid conversation models. Fixes #53772. Thanks @limen96. +- macOS/update: stop managed Gateway services before package replacement and keep LaunchAgent service secrets out of world-readable plist metadata by loading them from owner-only env files. Fixes #72996. Thanks @Mathewb7. - Gateway/hooks: route non-delivered hook completion and error summaries to the target agent's main session instead of the default agent session, preserving multi-agent hook isolation. Fixes #24693; carries forward #68667. Thanks @abersonFAC and @bluesky6868. - Control UI/models: request the configured Gateway model-list view so dashboards with only `models.providers.*.models` show those configured models first instead of flooding the picker with the full built-in catalog. Fixes #65405. Thanks @wbyanclaw. - CLI/models: keep default-model and allowlist pickers on explicit `models.providers.*.models` entries when `models.mode` is `replace` instead of loading the full built-in catalog. Fixes #64950. Thanks @mrozentsvayg. diff --git a/docs/cli/daemon.md b/docs/cli/daemon.md index 4a1f648892c..ecad50e0780 100644 --- a/docs/cli/daemon.md +++ b/docs/cli/daemon.md @@ -50,6 +50,7 @@ Notes: - When token auth requires a token and `gateway.auth.token` is SecretRef-managed, `install` validates that the SecretRef is resolvable but does not persist the resolved token into service environment metadata. - If token auth requires a token and the configured token SecretRef is unresolved, install fails closed. - If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, install is blocked until mode is set explicitly. +- On macOS, `install` keeps LaunchAgent plists owner-only and loads managed service environment values through an owner-only file and wrapper instead of serializing API keys or auth-profile env refs into `EnvironmentVariables`. - If you intentionally run multiple gateways on one host, isolate ports, config/state, and workspaces; see [/gateway#multiple-gateways-same-host](/gateway#multiple-gateways-same-host). ## Prefer diff --git a/docs/cli/update.md b/docs/cli/update.md index 2ea75e736cf..0e29c06aac0 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -96,6 +96,14 @@ keeps packaged sidecars and channel-owned plugin records aligned with the installed OpenClaw build while leaving full plugin-command completion rebuilds to explicit `openclaw completion --write-state` runs. +When a local managed Gateway service is installed and restart is enabled, +package-manager updates stop the running service before replacing the package +tree, then refresh the service metadata from the updated install, restart the +service, and verify the restarted Gateway reports the expected version. With +`--no-restart`, package replacement still runs but the managed service is not +stopped or restarted, so the running Gateway may keep old code until you restart +it manually. + ## Git checkout flow ### Channel selection diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index b664a303095..21ead0de744 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -22,6 +22,8 @@ const readPackageName = vi.fn(); const readPackageVersion = vi.fn(); const resolveGlobalManager = vi.fn(); const serviceLoaded = vi.fn(); +const serviceStop = vi.fn(); +const serviceRestart = vi.fn(); const prepareRestartScript = vi.fn(); const runRestartScript = vi.fn(); const mockedRunDaemonInstall = vi.fn(); @@ -187,6 +189,8 @@ vi.mock("../daemon/service.js", () => ({ isLoaded: (...args: unknown[]) => serviceLoaded(...args), readCommand: (...args: unknown[]) => serviceReadCommand(...args), readRuntime: (...args: unknown[]) => serviceReadRuntime(...args), + stop: (...args: unknown[]) => serviceStop(...args), + restart: (...args: unknown[]) => serviceRestart(...args), })), })); @@ -470,6 +474,8 @@ describe("update-cli", () => { readPackageName.mockResolvedValue("openclaw"); readPackageVersion.mockResolvedValue("1.0.0"); resolveGlobalManager.mockResolvedValue("npm"); + serviceStop.mockResolvedValue(undefined); + serviceRestart.mockResolvedValue({ outcome: "completed" }); serviceLoaded.mockResolvedValue(false); serviceReadCommand.mockImplementation(async () => (await serviceLoaded()) ? { programArguments: ["openclaw", "gateway", "run"] } : null, @@ -1389,6 +1395,85 @@ describe("update-cli", () => { ); }); + it("stops a running managed gateway before package replacement", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-stop-service-")); + const nodeModules = path.join(tempDir, "node_modules"); + const pkgRoot = path.join(nodeModules, "openclaw"); + const entryPath = path.join(pkgRoot, "dist", "index.js"); + mockPackageInstallStatus(pkgRoot); + await fs.mkdir(path.dirname(entryPath), { recursive: true }); + await fs.writeFile( + path.join(pkgRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.4.21" }), + "utf-8", + ); + await fs.writeFile(entryPath, "export {};\n", "utf-8"); + await writePackageDistInventory(pkgRoot); + serviceReadCommand.mockResolvedValue({ + programArguments: ["openclaw", "gateway", "run"], + environment: { + OPENCLAW_SERVICE_MARKER: "openclaw", + OPENCLAW_SERVICE_KIND: "gateway", + }, + }); + serviceLoaded.mockResolvedValue(true); + serviceReadRuntime.mockResolvedValue({ + status: "running", + pid: 4242, + state: "running", + }); + pathExists.mockImplementation(async (candidate: string) => { + try { + await fs.access(candidate); + return true; + } catch { + return false; + } + }); + vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => { + if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") { + return { + stdout: `${nodeModules}\n`, + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }; + } + return { + stdout: "", + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }; + }); + + await updateCommand({ yes: true }); + + const npmInstallCallIndex = vi + .mocked(runCommandWithTimeout) + .mock.calls.findIndex( + (call) => Array.isArray(call[0]) && call[0][0] === "npm" && call[0][1] === "i", + ); + const npmInstallCallOrder = + vi.mocked(runCommandWithTimeout).mock.invocationCallOrder[npmInstallCallIndex]; + expect(serviceStop).toHaveBeenCalledWith( + expect.objectContaining({ + env: expect.objectContaining({ + OPENCLAW_SERVICE_MARKER: "openclaw", + OPENCLAW_SERVICE_KIND: "gateway", + }), + }), + ); + const serviceStopCallOrder = serviceStop.mock.invocationCallOrder[0]; + expect(serviceStopCallOrder).toBeDefined(); + expect(npmInstallCallOrder).toBeDefined(); + expect(serviceStopCallOrder).toBeLessThan(npmInstallCallOrder); + }); + it("refreshes package installs even when the current version already matches the target", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-current-")); const nodeModules = path.join(tempDir, "node_modules"); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 0d1c10d0b0c..57a2342f066 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -152,6 +152,75 @@ export function shouldUseLegacyProcessRestartAfterUpdate(params: { return !isPackageManagerUpdateMode(params.updateMode); } +type PrePackageServiceStop = { + stopped: boolean; + serviceEnv?: NodeJS.ProcessEnv; +}; + +async function maybeStopManagedServiceBeforePackageUpdate(params: { + shouldRestart: boolean; + jsonMode: boolean; +}): Promise { + let service: ReturnType; + let serviceState: Awaited>; + try { + service = resolveGatewayService(); + serviceState = await readGatewayServiceState(service, { env: process.env }); + } catch { + return { stopped: false }; + } + + if (!serviceState.installed) { + return { stopped: false }; + } + + if (!params.shouldRestart) { + if (!params.jsonMode && serviceState.running) { + defaultRuntime.log( + theme.warn( + "--no-restart is set while the managed gateway service is running; the package update will not stop or restart that process.", + ), + ); + } + return { stopped: false, serviceEnv: serviceState.env }; + } + + if (!serviceState.running) { + return { stopped: false, serviceEnv: serviceState.env }; + } + + if (!params.jsonMode) { + defaultRuntime.log(theme.muted("Stopping managed gateway service before package update...")); + } + await service.stop({ env: serviceState.env, stdout: process.stdout }); + return { stopped: true, serviceEnv: serviceState.env }; +} + +async function maybeRestartServiceAfterFailedPackageUpdate(params: { + prePackageServiceStop: PrePackageServiceStop | undefined; + jsonMode: boolean; +}): Promise { + if (!params.prePackageServiceStop?.stopped || !params.prePackageServiceStop.serviceEnv) { + return; + } + try { + await resolveGatewayService().restart({ + env: params.prePackageServiceStop.serviceEnv, + stdout: process.stdout, + }); + if (!params.jsonMode) { + defaultRuntime.log(theme.muted("Restarted managed gateway service after failed update.")); + } + } catch (err) { + const message = `Failed to restart managed gateway service after failed update: ${String(err)}`; + if (params.jsonMode) { + defaultRuntime.error(message); + } else { + defaultRuntime.log(theme.warn(message)); + } + } +} + function isRunningInsideGatewayService( env: Record = process.env, ): boolean { @@ -1386,31 +1455,56 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { const { progress, stop } = createUpdateProgress(showProgress); const startedAt = Date.now(); - const result = - updateInstallKind === "package" - ? await runPackageInstallUpdate({ - root, - installKind, - tag, - timeoutMs: updateStepTimeoutMs, - startedAt, - progress, - jsonMode: Boolean(opts.json), - }) - : await runGitUpdate({ - root, - switchToGit, - installKind, - timeoutMs, - startedAt, - progress, - channel, - tag, - showProgress, - opts, - stop, - devTargetRef, - }); + let prePackageServiceStop: PrePackageServiceStop | undefined; + if (updateInstallKind === "package") { + try { + prePackageServiceStop = await maybeStopManagedServiceBeforePackageUpdate({ + shouldRestart, + jsonMode: Boolean(opts.json), + }); + } catch (err) { + stop(); + defaultRuntime.error(`Failed to stop managed gateway service before update: ${String(err)}`); + defaultRuntime.exit(1); + return; + } + } + + let result: UpdateRunResult; + try { + result = + updateInstallKind === "package" + ? await runPackageInstallUpdate({ + root, + installKind, + tag, + timeoutMs: updateStepTimeoutMs, + startedAt, + progress, + jsonMode: Boolean(opts.json), + }) + : await runGitUpdate({ + root, + switchToGit, + installKind, + timeoutMs, + startedAt, + progress, + channel, + tag, + showProgress, + opts, + stop, + devTargetRef, + }); + } catch (err) { + stop(); + await maybeRestartServiceAfterFailedPackageUpdate({ + prePackageServiceStop, + jsonMode: Boolean(opts.json), + }); + throw err; + } stop(); if (!opts.json || result.status !== "ok") { @@ -1418,11 +1512,19 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { } if (result.status === "error") { + await maybeRestartServiceAfterFailedPackageUpdate({ + prePackageServiceStop, + jsonMode: Boolean(opts.json), + }); defaultRuntime.exit(1); return; } if (result.status === "skipped") { + await maybeRestartServiceAfterFailedPackageUpdate({ + prePackageServiceStop, + jsonMode: Boolean(opts.json), + }); if (result.reason === "dirty") { defaultRuntime.error(theme.error("Update blocked: local files are edited in this checkout.")); defaultRuntime.log( @@ -1524,6 +1626,10 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { } else { defaultRuntime.error(theme.error("Update failed during plugin post-update sync.")); } + await maybeRestartServiceAfterFailedPackageUpdate({ + prePackageServiceStop, + jsonMode: Boolean(opts.json), + }); defaultRuntime.exit(1); return; } diff --git a/src/config/state-dir-dotenv.ts b/src/config/state-dir-dotenv.ts index 6fce928aa48..76d360fe92b 100644 --- a/src/config/state-dir-dotenv.ts +++ b/src/config/state-dir-dotenv.ts @@ -44,8 +44,8 @@ export function readStateDirDotEnvVarsFromStateDir(stateDir: string): Record, @@ -56,7 +56,7 @@ export function readStateDirDotEnvVars( /** * Durable service env sources survive beyond the invoking shell and are safe to - * persist into gateway install metadata. + * persist into owner-only gateway service environment sources. * * Precedence: * 1. state-dir `.env` file vars diff --git a/src/daemon/launchd-plist.ts b/src/daemon/launchd-plist.ts index fa2a780a5c8..b0f70a83eac 100644 --- a/src/daemon/launchd-plist.ts +++ b/src/daemon/launchd-plist.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import type { GatewayServiceEnvironmentValueSource } from "./service-types.js"; // launchd applies ThrottleInterval to any rapid relaunch, including // intentional gateway restarts. Keep it low so CLI restarts and forced @@ -23,6 +24,54 @@ const plistUnescape = (value: string): string => .replaceAll("<", "<") .replaceAll("&", "&"); +function parseGeneratedEnvValue(value: string): string { + const trimmed = value.trim(); + if (!trimmed.startsWith("'") || !trimmed.endsWith("'")) { + return trimmed; + } + return trimmed.slice(1, -1).replaceAll("'\\''", "'"); +} + +async function readLaunchAgentEnvironmentFile( + programArguments: string[], +): Promise> { + const envFilePath = programArguments[1]; + if (!programArguments[0]?.endsWith("-env-wrapper.sh") || !envFilePath) { + return {}; + } + let content = ""; + try { + content = await fs.readFile(envFilePath, "utf8"); + } catch { + return {}; + } + const environment: Record = {}; + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) { + continue; + } + const match = line.match(/^export\s+([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + if (!match) { + continue; + } + const key = match[1]; + const value = match[2]; + if (!key || value === undefined) { + continue; + } + environment[key] = parseGeneratedEnvValue(value); + } + return environment; +} + +function unwrapGeneratedEnvWrapperArgs(programArguments: string[]): string[] { + if (!programArguments[0]?.endsWith("-env-wrapper.sh") || !programArguments[1]) { + return programArguments; + } + return programArguments.slice(2); +} + const renderEnvDict = (env: Record | undefined): string => { if (!env) { return ""; @@ -46,6 +95,7 @@ export async function readLaunchAgentProgramArgumentsFromFile(plistPath: string) programArguments: string[]; workingDirectory?: string; environment?: Record; + environmentValueSources?: Record; sourcePath?: string; } | null> { try { @@ -62,7 +112,7 @@ export async function readLaunchAgentProgramArgumentsFromFile(plistPath: string) ); const workingDirectory = workingDirMatch ? plistUnescape(workingDirMatch[1] ?? "").trim() : ""; const envMatch = plist.match(/EnvironmentVariables<\/key>\s*([\s\S]*?)<\/dict>/i); - const environment: Record = {}; + const inlineEnvironment: Record = {}; if (envMatch) { for (const pair of envMatch[1].matchAll( /([\s\S]*?)<\/key>\s*([\s\S]*?)<\/string>/gi, @@ -72,13 +122,28 @@ export async function readLaunchAgentProgramArgumentsFromFile(plistPath: string) continue; } const value = plistUnescape(pair[2] ?? "").trim(); - environment[key] = value; + inlineEnvironment[key] = value; } } + const fileEnvironment = await readLaunchAgentEnvironmentFile(args); + const effectiveProgramArguments = unwrapGeneratedEnvWrapperArgs(args); + const environment = { ...inlineEnvironment, ...fileEnvironment }; + const environmentValueSources: Record = {}; + for (const key of Object.keys(inlineEnvironment)) { + environmentValueSources[key] = Object.hasOwn(fileEnvironment, key) + ? "inline-and-file" + : "inline"; + } + for (const key of Object.keys(fileEnvironment)) { + environmentValueSources[key] = Object.hasOwn(inlineEnvironment, key) + ? "inline-and-file" + : "file"; + } return { - programArguments: args.filter(Boolean), + programArguments: effectiveProgramArguments.filter(Boolean), ...(workingDirectory ? { workingDirectory } : {}), ...(Object.keys(environment).length > 0 ? { environment } : {}), + ...(Object.keys(environmentValueSources).length > 0 ? { environmentValueSources } : {}), sourcePath: plistPath, }; } catch { diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 5c2f9d0bd7c..c426624c334 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -8,6 +8,7 @@ import { installLaunchAgent, isLaunchAgentListed, parseLaunchctlPrint, + readLaunchAgentProgramArguments, readLaunchAgentRuntime, repairLaunchAgentBootstrap, restartLaunchAgent, @@ -474,21 +475,42 @@ describe("launchd install", () => { expect(installKickstartIndex).toBe(-1); }); - it("writes TMPDIR to LaunchAgent environment when provided", async () => { + it("writes LaunchAgent environment to an owner-only env file when provided", async () => { const env = createDefaultLaunchdEnv(); const tmpDir = "/Users/test/.openclaw/tmp"; + const apiKey = "secret-api-key"; await installLaunchAgent({ env, stdout: new PassThrough(), programArguments: defaultProgramArguments, - environment: { TMPDIR: tmpDir }, + environment: { TMPDIR: tmpDir, OPENAI_API_KEY: apiKey }, }); const plistPath = resolveLaunchAgentPlistPath(env); + const envFilePath = "/Users/test/.openclaw/service-env/ai.openclaw.gateway.env"; + const wrapperPath = "/Users/test/.openclaw/service-env/ai.openclaw.gateway-env-wrapper.sh"; const plist = state.files.get(plistPath) ?? ""; - expect(plist).toContain("EnvironmentVariables"); - expect(plist).toContain("TMPDIR"); - expect(plist).toContain(`${tmpDir}`); + expect(plist).not.toContain("EnvironmentVariables"); + expect(plist).not.toContain(apiKey); + expect(plist).toContain(`${wrapperPath}`); + expect(plist).toContain(`${envFilePath}`); + const envFile = state.files.get(envFilePath) ?? ""; + expect(envFile).toContain(`export TMPDIR='${tmpDir}'`); + expect(envFile).toContain(`export OPENAI_API_KEY='${apiKey}'`); + expect(state.fileModes.get(envFilePath)).toBe(0o600); + expect(state.fileModes.get(wrapperPath)).toBe(0o700); + expect(state.dirModes.get("/Users/test/.openclaw/service-env")).toBe(0o700); + + const command = await readLaunchAgentProgramArguments(env); + expect(command?.programArguments).toEqual(defaultProgramArguments); + expect(command?.environment).toMatchObject({ + TMPDIR: tmpDir, + OPENAI_API_KEY: apiKey, + }); + expect(command?.environmentValueSources).toMatchObject({ + TMPDIR: "file", + OPENAI_API_KEY: "file", + }); }); it("creates the LaunchAgent TMPDIR before bootstrap", async () => { @@ -582,7 +604,7 @@ describe("launchd install", () => { expect(state.dirModes.get(env.HOME!)).toBe(0o755); expect(state.dirModes.get("/Users/test/Library")).toBe(0o755); expect(state.dirModes.get("/Users/test/Library/LaunchAgents")).toBe(0o755); - expect(state.fileModes.get(plistPath)).toBe(0o644); + expect(state.fileModes.get(plistPath)).toBe(0o600); }); it("stops LaunchAgent by disabling relaunch before stopping the process", async () => { diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index a1eeb603bd7..b33ded905dc 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { normalizeEnvVarKey } from "../infra/host-env-security.js"; import { parseStrictInteger, parseStrictPositiveInteger } from "../infra/parse-finite-number.js"; import { cleanStaleGatewayProcessesSync } from "../infra/restart-stale-pids.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; @@ -20,7 +21,7 @@ import { scheduleDetachedLaunchdRestartHandoff, } from "./launchd-restart-handoff.js"; import { formatLine, toPosixPath, writeFormattedLines } from "./output.js"; -import { resolveHomeDir } from "./paths.js"; +import { resolveGatewayStateDir, resolveHomeDir } from "./paths.js"; import { resolveGatewayLogPaths } from "./restart-logs.js"; import { parseKeyValueOutput } from "./runtime-parse.js"; import type { GatewayServiceRuntime } from "./service-runtime.js"; @@ -35,8 +36,11 @@ import type { } from "./service-types.js"; const LAUNCH_AGENT_DIR_MODE = 0o755; -const LAUNCH_AGENT_PLIST_MODE = 0o644; +const LAUNCH_AGENT_PLIST_MODE = 0o600; const LAUNCH_AGENT_PRIVATE_DIR_MODE = 0o700; +const LAUNCH_AGENT_ENV_FILE_MODE = 0o600; +const LAUNCH_AGENT_ENV_WRAPPER_MODE = 0o700; +const LAUNCH_AGENT_ENV_DIR_NAME = "service-env"; function assertValidLaunchAgentLabel(label: string): string { const trimmed = label.trim(); @@ -62,6 +66,109 @@ function resolveLaunchAgentPlistPathForLabel( return path.posix.join(home, "Library", "LaunchAgents", `${label}.plist`); } +function resolveLaunchAgentEnvDir(env: GatewayServiceEnv): string { + return path.join(resolveGatewayStateDir(env), LAUNCH_AGENT_ENV_DIR_NAME); +} + +function resolveLaunchAgentEnvFilePath(env: GatewayServiceEnv, label: string): string { + return path.join(resolveLaunchAgentEnvDir(env), `${label}.env`); +} + +function resolveLaunchAgentEnvWrapperPath(env: GatewayServiceEnv, label: string): string { + return path.join(resolveLaunchAgentEnvDir(env), `${label}-env-wrapper.sh`); +} + +function shellSingleQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function collectLaunchAgentEnvironmentEntries( + environment: GatewayServiceEnv | undefined, +): Array<[string, string]> { + const entries: Array<[string, string]> = []; + for (const [rawKey, rawValue] of Object.entries(environment ?? {})) { + const key = normalizeEnvVarKey(rawKey, { portable: true }); + const value = rawValue?.trim(); + if (!key || !value) { + continue; + } + entries.push([key, value]); + } + return entries.toSorted(([left], [right]) => left.localeCompare(right)); +} + +function buildLaunchAgentEnvironmentFile(entries: Array<[string, string]>): string { + return [ + "# Generated by OpenClaw. Do not edit while the gateway service is installed.", + ...entries.map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`), + "", + ].join("\n"); +} + +function buildLaunchAgentEnvironmentWrapper(): string { + return `#!/bin/sh +set -eu +env_file="$1" +shift +if [ -f "$env_file" ]; then + . "$env_file" +fi +exec "$@" +`; +} + +function isLaunchAgentEnvironmentWrapperArgs(params: { + programArguments: string[]; + envFilePath: string; + wrapperPath: string; +}): boolean { + return ( + params.programArguments[0] === params.wrapperPath && + params.programArguments[1] === params.envFilePath + ); +} + +async function prepareLaunchAgentProgramArguments(params: { + env: GatewayServiceEnv; + label: string; + programArguments: string[]; + environment: GatewayServiceEnv | undefined; +}): Promise<{ programArguments: string[]; inlineEnvironment?: GatewayServiceEnv }> { + const entries = collectLaunchAgentEnvironmentEntries(params.environment); + if (entries.length === 0) { + return { programArguments: params.programArguments }; + } + + const envDir = resolveLaunchAgentEnvDir(params.env); + const envFilePath = resolveLaunchAgentEnvFilePath(params.env, params.label); + const wrapperPath = resolveLaunchAgentEnvWrapperPath(params.env, params.label); + await ensureSecureDirectory(envDir, LAUNCH_AGENT_PRIVATE_DIR_MODE); + await fs.writeFile(envFilePath, buildLaunchAgentEnvironmentFile(entries), { + encoding: "utf8", + mode: LAUNCH_AGENT_ENV_FILE_MODE, + }); + await fs.chmod(envFilePath, LAUNCH_AGENT_ENV_FILE_MODE).catch(() => undefined); + await fs.writeFile(wrapperPath, buildLaunchAgentEnvironmentWrapper(), { + encoding: "utf8", + mode: LAUNCH_AGENT_ENV_WRAPPER_MODE, + }); + await fs.chmod(wrapperPath, LAUNCH_AGENT_ENV_WRAPPER_MODE).catch(() => undefined); + + if ( + isLaunchAgentEnvironmentWrapperArgs({ + programArguments: params.programArguments, + envFilePath, + wrapperPath, + }) + ) { + return { programArguments: params.programArguments }; + } + + return { + programArguments: [wrapperPath, envFilePath, ...params.programArguments], + }; +} + export function resolveLaunchAgentPlistPath(env: GatewayServiceEnv): string { const label = resolveLaunchAgentLabel({ env }); return resolveLaunchAgentPlistPathForLabel(env, label); @@ -551,16 +658,22 @@ async function writeLaunchAgentPlist({ await ensureSecureDirectory(libraryDir); await ensureSecureDirectory(path.dirname(plistPath)); await ensureLaunchAgentEnvironmentDirectories(environment); + const prepared = await prepareLaunchAgentProgramArguments({ + env, + label, + programArguments, + environment, + }); const serviceDescription = resolveGatewayServiceDescription({ env, environment, description }); const plist = buildLaunchAgentPlist({ label, comment: serviceDescription, - programArguments, + programArguments: prepared.programArguments, workingDirectory, stdoutPath, stderrPath, - environment, + environment: prepared.inlineEnvironment, }); await fs.writeFile(plistPath, plist, { encoding: "utf8", mode: LAUNCH_AGENT_PLIST_MODE }); await fs.chmod(plistPath, LAUNCH_AGENT_PLIST_MODE).catch(() => undefined); @@ -638,14 +751,20 @@ async function rewriteLaunchAgentPlistForRestart({ env, environment: existing.environment, }); + const prepared = await prepareLaunchAgentProgramArguments({ + env, + label, + programArguments: existing.programArguments, + environment: existing.environment, + }); const plist = buildLaunchAgentPlist({ label, comment: serviceDescription, - programArguments: existing.programArguments, + programArguments: prepared.programArguments, workingDirectory: existing.workingDirectory, stdoutPath, stderrPath, - environment: existing.environment, + environment: prepared.inlineEnvironment, }); await fs.writeFile(plistPath, plist, { encoding: "utf8", mode: LAUNCH_AGENT_PLIST_MODE }); await fs.chmod(plistPath, LAUNCH_AGENT_PLIST_MODE).catch(() => undefined);