Skip to content

Commit 59651ee

Browse files
committed
fix(daemon): recognize shell-wrapped gateway commands in service audit
When a LaunchAgent ProgramArguments is wrapped in a POSIX shell invocation (`/bin/zsh -lc "... gateway --port ..."`), the service audit incorrectly reported 'Service command does not include the gateway subcommand' because `hasGatewaySubcommand` only checked for a bare 'gateway' token in the array, not inside shell inline-command strings. The fix extends `hasGatewaySubcommand` to also recognise the pattern: ["<shell>", "-c|-lc|-...", "<inline-cmd-containing-gateway>"] where <shell> is any common POSIX shell basename (sh, bash, zsh, dash, fish). A word-boundary regex test on the inline command string confirms that 'gateway' is present as a subcommand, not as part of an unrelated path segment. Fixes #81751.
1 parent c35634c commit 59651ee

2 files changed

Lines changed: 94 additions & 1 deletion

File tree

src/daemon/service-audit.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,54 @@ describe("auditGatewayServiceConfig", () => {
460460

461461
expect(hasIssue(audit, SERVICE_AUDIT_CODES.gatewayProxyEnvEmbedded)).toBe(true);
462462
});
463+
464+
it("does not flag gateway-command-missing for shell-wrapped LaunchAgent using zsh -lc", async () => {
465+
// Reproduces the pattern reported in #81751:
466+
// LaunchAgent ProgramArguments = ["/bin/zsh", "-lc",
467+
// "set -a; [ -f $HOME/.config/api-keys.env ] && . ...; exec .../openclaw/dist/index.js gateway --port 18890"]
468+
const shellCmd =
469+
"set -a; [ -f \"$HOME/.config/api-keys.env\" ] && . \"$HOME/.config/api-keys.env\"; " +
470+
"set +a; unset OPENCLAW_GATEWAY_TOKEN; " +
471+
"exec /opt/homebrew/opt/node/bin/node /Users/antonio/.npm-global/lib/node_modules/openclaw/dist/index.js gateway --port 18890";
472+
const audit = await auditGatewayServiceConfig({
473+
env: { HOME: "/tmp" },
474+
platform: "darwin",
475+
expectedPort: 18890,
476+
command: {
477+
programArguments: ["/bin/zsh", "-lc", shellCmd],
478+
environment: {},
479+
},
480+
});
481+
expect(hasIssue(audit, SERVICE_AUDIT_CODES.gatewayCommandMissing)).toBe(false);
482+
});
483+
484+
it("does not flag gateway-command-missing for shell-wrapped LaunchAgent using /bin/sh -c", async () => {
485+
const shellCmd =
486+
". /tmp/openclaw-env.sh && exec /usr/local/bin/node /opt/openclaw/dist/index.js gateway --port 18889";
487+
const audit = await auditGatewayServiceConfig({
488+
env: { HOME: "/tmp" },
489+
platform: "darwin",
490+
expectedPort: 18889,
491+
command: {
492+
programArguments: ["/bin/sh", "-c", shellCmd],
493+
environment: {},
494+
},
495+
});
496+
expect(hasIssue(audit, SERVICE_AUDIT_CODES.gatewayCommandMissing)).toBe(false);
497+
});
498+
499+
it("still flags gateway-command-missing when the shell inline command does not contain gateway", async () => {
500+
const audit = await auditGatewayServiceConfig({
501+
env: { HOME: "/tmp" },
502+
platform: "darwin",
503+
expectedPort: 18888,
504+
command: {
505+
programArguments: ["/bin/sh", "-c", "exec /usr/local/bin/node /opt/openclaw/dist/index.js start"],
506+
environment: {},
507+
},
508+
});
509+
expect(hasIssue(audit, SERVICE_AUDIT_CODES.gatewayCommandMissing)).toBe(true);
510+
});
463511
});
464512

465513
describe("checkTokenDrift", () => {

src/daemon/service-audit.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,53 @@ export function needsNodeRuntimeMigration(issues: ServiceConfigIssue[]): boolean
7070
);
7171
}
7272

73+
/**
74+
* Returns true if the service command includes the `gateway` subcommand.
75+
*
76+
* Two layouts are recognised:
77+
* 1. Direct invocation: `[..., "gateway", ...]` — any element equals `"gateway"`.
78+
* 2. Shell-wrapped invocation: `["/bin/zsh", "-lc", "... gateway ..."]` — a
79+
* POSIX shell binary (`/bin/sh`, `/bin/bash`, `/bin/zsh`, `sh`, `bash`,
80+
* `zsh`) is invoked with `-c` or `-lc` flags and the inline shell command
81+
* string contains `gateway` as a word boundary match.
82+
*/
7383
function hasGatewaySubcommand(programArguments?: string[]): boolean {
74-
return Boolean(programArguments?.some((arg) => arg === "gateway"));
84+
if (!programArguments || programArguments.length === 0) {
85+
return false;
86+
}
87+
// Fast path: any element is exactly "gateway"
88+
if (programArguments.some((arg) => arg === "gateway")) {
89+
return true;
90+
}
91+
// Shell-wrapped path: detect `/bin/sh -lc "... gateway ..."` style LaunchAgent
92+
// wrappers. Match common POSIX shell binaries (bare names or full paths).
93+
const SHELL_BASENAMES = new Set(["sh", "bash", "zsh", "dash", "fish"]);
94+
const firstArg = programArguments[0];
95+
if (!firstArg) {
96+
return false;
97+
}
98+
const basename = firstArg.split("/").pop() ?? firstArg;
99+
if (!SHELL_BASENAMES.has(basename)) {
100+
return false;
101+
}
102+
// Look for a `-c` or shell option that precedes an inline command string.
103+
// Common patterns: `-c`, `-lc`, `-login -c`, etc.
104+
for (let i = 1; i < programArguments.length; i += 1) {
105+
const arg = programArguments[i];
106+
// If this element looks like a flag, keep scanning; if it looks like the
107+
// inline command string (no leading dash), test it for the gateway word.
108+
if (arg.startsWith("-")) {
109+
// Accept -c, -lc, and other variants that include 'c' (inline command)
110+
if (/c/.test(arg)) {
111+
// The next element is the inline command string
112+
const inlineCmd = programArguments[i + 1];
113+
if (inlineCmd && /\bgateway\b/.test(inlineCmd)) {
114+
return true;
115+
}
116+
}
117+
}
118+
}
119+
return false;
75120
}
76121

77122
function parseSystemdUnit(content: string): {

0 commit comments

Comments
 (0)