mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix: harden gateway launchd and configure sections
This commit is contained in:
@@ -42,6 +42,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/auth: include the checked credential source in missing API key errors, so users can see which env var, profile, or config path to fix. Fixes #82785. Thanks @loeclos.
|
||||
- Providers/GitHub Copilot: hash Responses replay item ids with sha256 instead of a weak 32-bit hash and build same-provider Copilot tool-call ids distinctly, so concurrent tool-call replays no longer collide and reject follow-up turns.
|
||||
- Agents/replay: normalize malformed assistant replay content before transport conversion while preserving empty-stop replay repair, so bad provider history no longer crashes with non-iterable content. Fixes #43795. (#82748) Thanks @IWhatsskill.
|
||||
- Gateway/macOS: write LaunchAgent stdout under `~/Library/Logs/openclaw`, suppress stderr, and attach stdin to `/dev/null` so launchd startup avoids symlinked state-dir log failures and silent module-evaluation hangs. Fixes #40207 and #46153. Thanks @dhruvkelawala and @frankr.
|
||||
- CLI/configure: let model-only section setup enter provider auth directly instead of first asking where the Gateway runs, unblocking OAuth/token setup in terminals where that unrelated prompt is unresponsive. Fixes #39223. Thanks @LevityLeads.
|
||||
- Providers/Anthropic-messages: extract `reasoning_content` from `thinking` blocks during assistant replay so proxy providers that route through the Anthropic-messages transport preserve reasoning context across tool-call follow-up turns. Thanks @Sunnyone2three.
|
||||
- Agents/GitHub Copilot: normalize replayed Responses tool-call IDs before dispatch so resumed sessions with historical overlong tool IDs continue instead of failing Copilot schema validation. (#82750) Thanks @galiniliev.
|
||||
- CLI/web: resolve provider-scoped web search/fetch SecretRefs for `infer web ... --provider ...` while leaving unrelated plugin secrets untouched. Fixes #82621. Thanks @leno23.
|
||||
|
||||
@@ -55,7 +55,7 @@ Available sections:
|
||||
|
||||
Notes:
|
||||
|
||||
- Choosing where the Gateway runs always updates `gateway.mode`. You can select "Continue" without other sections if that is all you need.
|
||||
- The full wizard and gateway-related sections ask where the Gateway runs and update `gateway.mode`. Section filters that do not include `gateway`, `daemon`, or `health` go directly to the requested setup.
|
||||
- After local config writes, configure installs selected downloadable plugins when the chosen setup path requires them. Remote gateway config does not install local plugin packages.
|
||||
- Channel-oriented services (Slack/Discord/Matrix/Microsoft Teams) prompt for channel/room allowlists during setup. You can enter names or IDs; the wizard resolves names to IDs when possible.
|
||||
- If you run the daemon install step, token auth requires a token, and `gateway.auth.token` is SecretRef-managed, configure validates the SecretRef but does not persist resolved plaintext token values into supervisor service environment metadata.
|
||||
|
||||
+1
-1
@@ -1560,7 +1560,7 @@ lives on the [Models FAQ](/help/faq-models).
|
||||
|
||||
Service/supervisor logs (when the gateway runs via launchd/systemd):
|
||||
|
||||
- macOS: `$OPENCLAW_STATE_DIR/logs/gateway.log` and `gateway.err.log` (default: `~/.openclaw/logs/...`; profiles use `~/.openclaw-<profile>/logs/...`)
|
||||
- macOS launchd stdout: `~/Library/Logs/openclaw/gateway.log` (profiles use `gateway-<profile>.log`; stderr is suppressed)
|
||||
- Linux: `journalctl --user -u openclaw-gateway[-<profile>].service -n 200 --no-pager`
|
||||
- Windows: `schtasks /Query /TN "OpenClaw Gateway (<profile>)" /V /FO LIST`
|
||||
|
||||
|
||||
@@ -49,7 +49,8 @@ Behavior:
|
||||
|
||||
Logging:
|
||||
|
||||
- launchd stdout/err: `/tmp/openclaw/openclaw-gateway.log`
|
||||
- launchd stdout: `~/Library/Logs/openclaw/gateway.log` (profiles use `gateway-<profile>.log`)
|
||||
- launchd stderr: suppressed
|
||||
|
||||
## Version compatibility
|
||||
|
||||
|
||||
@@ -37,6 +37,11 @@ vi.mock("../../daemon/restart-logs.js", () => ({
|
||||
stdoutPath: "/tmp/gateway.out.log",
|
||||
stderrPath: "/tmp/gateway.err.log",
|
||||
}),
|
||||
resolveGatewaySupervisorLogPaths: () => ({
|
||||
logDir: "/Users/test/Library/Logs/openclaw",
|
||||
stdoutPath: "/Users/test/Library/Logs/openclaw/gateway.log",
|
||||
stderrPath: "/Users/test/Library/Logs/openclaw/gateway.err.log",
|
||||
}),
|
||||
resolveGatewayRestartLogPath: () => "/tmp/gateway-restart.log",
|
||||
}));
|
||||
|
||||
@@ -160,6 +165,46 @@ describe("printDaemonStatus", () => {
|
||||
expectMockLineContains(runtime.error, formatCliCommand("openclaw gateway restart"));
|
||||
});
|
||||
|
||||
it("prints macOS launchd stdout and suppressed stderr when gateway is not listening", () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, "platform", { value: "darwin" });
|
||||
try {
|
||||
printDaemonStatus(
|
||||
{
|
||||
service: {
|
||||
label: "LaunchAgent",
|
||||
loaded: true,
|
||||
loadedText: "loaded",
|
||||
notLoadedText: "not loaded",
|
||||
runtime: { status: "running", pid: 8000 },
|
||||
command: { programArguments: [], environment: { HOME: "/Users/test" } },
|
||||
},
|
||||
gateway: {
|
||||
bindMode: "loopback",
|
||||
bindHost: "127.0.0.1",
|
||||
port: 18789,
|
||||
portSource: "env/config",
|
||||
probeUrl: "ws://127.0.0.1:18789",
|
||||
},
|
||||
port: {
|
||||
port: 18789,
|
||||
status: "free",
|
||||
listeners: [],
|
||||
hints: [],
|
||||
},
|
||||
extraServices: [],
|
||||
},
|
||||
{ json: false },
|
||||
);
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform });
|
||||
}
|
||||
|
||||
expectMockLineContains(runtime.error, "Gateway port 18789 is not listening");
|
||||
expectMockLineContains(runtime.error, "/Users/test/Library/Logs/openclaw/gateway.log");
|
||||
expectMockLineContains(runtime.error, "Errors: suppressed");
|
||||
});
|
||||
|
||||
it("prints probe kind and capability separately", () => {
|
||||
printDaemonStatus(
|
||||
{
|
||||
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
resolveGatewaySystemdServiceName,
|
||||
} from "../../daemon/constants.js";
|
||||
import { renderGatewayServiceCleanupHints } from "../../daemon/inspect.js";
|
||||
import { resolveGatewayLogPaths, resolveGatewayRestartLogPath } from "../../daemon/restart-logs.js";
|
||||
import {
|
||||
resolveGatewayRestartLogPath,
|
||||
resolveGatewaySupervisorLogPaths,
|
||||
} from "../../daemon/restart-logs.js";
|
||||
import {
|
||||
isSystemdUnavailableDetail,
|
||||
renderSystemdUnavailableHints,
|
||||
@@ -393,9 +396,9 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
|
||||
errorText(`Logs: journalctl --user -u ${unit}.service -n 200 --no-pager`),
|
||||
);
|
||||
} else if (process.platform === "darwin") {
|
||||
const logs = resolveGatewayLogPaths(serviceEnv);
|
||||
const logs = resolveGatewaySupervisorLogPaths(serviceEnv, { platform: "darwin" });
|
||||
defaultRuntime.error(`${errorText("Logs:")} ${shortenHomePath(logs.stdoutPath)}`);
|
||||
defaultRuntime.error(`${errorText("Errors:")} ${shortenHomePath(logs.stderrPath)}`);
|
||||
defaultRuntime.error(`${errorText("Errors:")} suppressed`);
|
||||
}
|
||||
defaultRuntime.error(
|
||||
`${errorText("Restart log:")} ${shortenHomePath(resolveGatewayRestartLogPath(serviceEnv))}`,
|
||||
|
||||
@@ -26,6 +26,8 @@ const mocks = vi.hoisted(() => {
|
||||
waitForGatewayReachable: vi.fn(),
|
||||
resolveControlUiLinks: vi.fn(),
|
||||
summarizeExistingConfig: vi.fn(),
|
||||
promptAuthConfig: vi.fn(),
|
||||
promptGatewayConfig: vi.fn(),
|
||||
promptRemoteGatewayConfig: vi.fn(async (cfg: OpenClawConfig) => ({
|
||||
...cfg,
|
||||
gateway: { mode: "remote", remote: { url: "wss://gateway.example.test" } },
|
||||
@@ -86,11 +88,11 @@ vi.mock("./health-format.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./configure.gateway.js", () => ({
|
||||
promptGatewayConfig: vi.fn(),
|
||||
promptGatewayConfig: mocks.promptGatewayConfig,
|
||||
}));
|
||||
|
||||
vi.mock("./configure.gateway-auth.js", () => ({
|
||||
promptAuthConfig: vi.fn(),
|
||||
promptAuthConfig: mocks.promptAuthConfig,
|
||||
}));
|
||||
|
||||
vi.mock("./configure.channels.js", () => ({
|
||||
@@ -276,6 +278,13 @@ describe("runConfigureWizard", () => {
|
||||
]);
|
||||
mocks.setupSearch.mockReset();
|
||||
mocks.setupSearch.mockImplementation(async (cfg: OpenClawConfig) => cfg);
|
||||
mocks.promptAuthConfig.mockReset();
|
||||
mocks.promptAuthConfig.mockImplementation(async (cfg: OpenClawConfig) => cfg);
|
||||
mocks.promptGatewayConfig.mockReset();
|
||||
mocks.promptGatewayConfig.mockImplementation(async (cfg: OpenClawConfig) => ({
|
||||
config: cfg,
|
||||
port: 18789,
|
||||
}));
|
||||
});
|
||||
|
||||
it("persists gateway.mode=local when only the run mode is selected", async () => {
|
||||
@@ -328,6 +337,46 @@ describe("runConfigureWizard", () => {
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("does not gate model-only configure behind Gateway run-mode selection", async () => {
|
||||
setupBaseWizardState();
|
||||
|
||||
await runConfigureWizard({ command: "configure", sections: ["model"] }, createRuntime());
|
||||
|
||||
expect(mocks.promptAuthConfig).toHaveBeenCalledOnce();
|
||||
expect(mocks.clackSelect).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "Where will the Gateway run?" }),
|
||||
);
|
||||
expect(mocks.probeGatewayReachable).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeoutMs: 300 }),
|
||||
);
|
||||
expect(mocks.ensureControlUiAssetsBuilt).not.toHaveBeenCalled();
|
||||
expect(mocks.resolveControlUiLinks).not.toHaveBeenCalled();
|
||||
expect(requireWriteConfig().gateway).toBeUndefined();
|
||||
});
|
||||
|
||||
it("runs model-only configure for existing remote Gateway configs", async () => {
|
||||
setupBaseWizardState({
|
||||
gateway: { mode: "remote", remote: { url: "wss://gateway.example.test" } },
|
||||
});
|
||||
|
||||
await runConfigureWizard({ command: "configure", sections: ["model"] }, createRuntime());
|
||||
|
||||
expect(mocks.promptAuthConfig).toHaveBeenCalledOnce();
|
||||
expect(mocks.promptRemoteGatewayConfig).not.toHaveBeenCalled();
|
||||
expect(getGateway(requireWriteConfig()).mode).toBe("remote");
|
||||
expect(mocks.ensureControlUiAssetsBuilt).not.toHaveBeenCalled();
|
||||
expect(mocks.resolveControlUiLinks).not.toHaveBeenCalled();
|
||||
expect(mocks.probeGatewayReachable).not.toHaveBeenCalled();
|
||||
expect(mocks.note).toHaveBeenCalledWith(
|
||||
[
|
||||
"Remote Gateway:",
|
||||
"wss://gateway.example.test",
|
||||
"Docs: https://docs.openclaw.ai/gateway/remote",
|
||||
].join("\n"),
|
||||
"Gateway",
|
||||
);
|
||||
});
|
||||
|
||||
it("persists provider-owned web search config changes returned by setupSearch", async () => {
|
||||
setupBaseWizardState();
|
||||
mocks.setupSearch.mockImplementation(async (cfg: OpenClawConfig) =>
|
||||
@@ -337,7 +386,7 @@ describe("runConfigureWizard", () => {
|
||||
})(cfg),
|
||||
);
|
||||
queueWizardPrompts({
|
||||
select: ["local"],
|
||||
select: [],
|
||||
confirm: [true, false],
|
||||
});
|
||||
|
||||
@@ -347,7 +396,7 @@ describe("runConfigureWizard", () => {
|
||||
mockCallArg(mocks.setupSearch, "setupSearch"),
|
||||
"setupSearch config",
|
||||
);
|
||||
expect(getGateway(setupConfig).mode).toBe("local");
|
||||
expect(setupConfig.gateway).toBeUndefined();
|
||||
const written = requireWriteConfig();
|
||||
const search = getWebSearch(written);
|
||||
expect(search.provider).toBe("firecrawl");
|
||||
@@ -365,7 +414,7 @@ describe("runConfigureWizard", () => {
|
||||
setupBaseWizardState();
|
||||
mocks.resolveSearchProviderOptions.mockReturnValue([]);
|
||||
queueWizardPrompts({
|
||||
select: ["local"],
|
||||
select: [],
|
||||
confirm: [true, false],
|
||||
});
|
||||
|
||||
@@ -385,7 +434,7 @@ describe("runConfigureWizard", () => {
|
||||
it("does not load managed search provider options when web search is disabled", async () => {
|
||||
setupBaseWizardState();
|
||||
queueWizardPrompts({
|
||||
select: ["local"],
|
||||
select: [],
|
||||
confirm: [false, true],
|
||||
});
|
||||
|
||||
@@ -404,7 +453,7 @@ describe("runConfigureWizard", () => {
|
||||
it("defers channel status checks until a channel is selected", async () => {
|
||||
setupBaseWizardState();
|
||||
queueWizardPrompts({
|
||||
select: ["local", "configure"],
|
||||
select: ["configure"],
|
||||
confirm: [],
|
||||
});
|
||||
|
||||
@@ -412,7 +461,7 @@ describe("runConfigureWizard", () => {
|
||||
|
||||
const setupChannelsCall = mocks.setupChannels.mock.calls[0] as Array<unknown> | undefined;
|
||||
const setupChannelsConfig = requireRecord(setupChannelsCall?.[0], "setupChannels config");
|
||||
expect(getGateway(setupChannelsConfig).mode).toBe("local");
|
||||
expect(setupChannelsConfig.gateway).toBeUndefined();
|
||||
const setupChannelsOptions = requireRecord(setupChannelsCall?.[3], "setupChannels options");
|
||||
expect(setupChannelsOptions.deferStatusUntilSelection).toBe(true);
|
||||
expect(setupChannelsOptions.skipStatusNote).toBe(true);
|
||||
@@ -439,7 +488,7 @@ describe("runConfigureWizard", () => {
|
||||
})(cfg),
|
||||
);
|
||||
queueWizardPrompts({
|
||||
select: ["local"],
|
||||
select: [],
|
||||
confirm: [true, false],
|
||||
});
|
||||
|
||||
@@ -461,7 +510,7 @@ describe("runConfigureWizard", () => {
|
||||
},
|
||||
});
|
||||
queueWizardPrompts({
|
||||
select: ["local", "cached"],
|
||||
select: ["cached"],
|
||||
confirm: [true, true, false, true],
|
||||
});
|
||||
|
||||
@@ -527,7 +576,7 @@ describe("runConfigureWizard", () => {
|
||||
};
|
||||
setupBaseWizardState(baseConfig);
|
||||
queueWizardPrompts({
|
||||
select: ["local"],
|
||||
select: [],
|
||||
confirm: [],
|
||||
});
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
} from "./onboard-helpers.js";
|
||||
import { promptRemoteGatewayConfig } from "./onboard-remote.js";
|
||||
import { setupSkills } from "./onboard-skills.js";
|
||||
import type { OnboardMode } from "./onboard-types.js";
|
||||
|
||||
type ConfigureSectionChoice = WizardSection | "__continue";
|
||||
type SetupPluginConfigModule = typeof import("../wizard/setup.plugin-config.js");
|
||||
@@ -406,74 +407,86 @@ export async function runConfigureWizard(
|
||||
}
|
||||
}
|
||||
|
||||
const localUrl = "ws://127.0.0.1:18789";
|
||||
const remoteUrl = normalizeOptionalString(baseConfig.gateway?.remote?.url) ?? "";
|
||||
const localProbePromise = (async () => {
|
||||
const [baseLocalProbeToken, baseLocalProbePassword] = await Promise.all([
|
||||
resolveGatewaySecretInputForWizard({
|
||||
cfg: baseConfig,
|
||||
value: baseConfig.gateway?.auth?.token,
|
||||
path: "gateway.auth.token",
|
||||
}),
|
||||
resolveGatewaySecretInputForWizard({
|
||||
cfg: baseConfig,
|
||||
value: baseConfig.gateway?.auth?.password,
|
||||
path: "gateway.auth.password",
|
||||
}),
|
||||
]);
|
||||
return probeGatewayReachable({
|
||||
url: localUrl,
|
||||
token: process.env.OPENCLAW_GATEWAY_TOKEN ?? baseLocalProbeToken,
|
||||
password: process.env.OPENCLAW_GATEWAY_PASSWORD ?? baseLocalProbePassword,
|
||||
timeoutMs: GATEWAY_HINT_PROBE_TIMEOUT_MS,
|
||||
});
|
||||
})();
|
||||
const remoteProbePromise = remoteUrl
|
||||
? (async () => {
|
||||
const baseRemoteProbeToken = await resolveGatewaySecretInputForWizard({
|
||||
const selectedSections = opts.sections;
|
||||
const shouldPromptGatewayRunMode =
|
||||
!selectedSections ||
|
||||
selectedSections.includes("gateway") ||
|
||||
selectedSections.includes("daemon") ||
|
||||
selectedSections.includes("health");
|
||||
const promptGatewayRunMode = async (): Promise<OnboardMode> => {
|
||||
const localUrl = "ws://127.0.0.1:18789";
|
||||
const remoteUrl = normalizeOptionalString(baseConfig.gateway?.remote?.url) ?? "";
|
||||
const localProbePromise = (async () => {
|
||||
const [baseLocalProbeToken, baseLocalProbePassword] = await Promise.all([
|
||||
resolveGatewaySecretInputForWizard({
|
||||
cfg: baseConfig,
|
||||
value: baseConfig.gateway?.remote?.token,
|
||||
path: "gateway.remote.token",
|
||||
});
|
||||
return probeGatewayReachable({
|
||||
url: remoteUrl,
|
||||
token: baseRemoteProbeToken,
|
||||
timeoutMs: GATEWAY_HINT_PROBE_TIMEOUT_MS,
|
||||
});
|
||||
})()
|
||||
: Promise.resolve(null);
|
||||
const [localProbe, remoteProbe] = await Promise.all([localProbePromise, remoteProbePromise]);
|
||||
value: baseConfig.gateway?.auth?.token,
|
||||
path: "gateway.auth.token",
|
||||
}),
|
||||
resolveGatewaySecretInputForWizard({
|
||||
cfg: baseConfig,
|
||||
value: baseConfig.gateway?.auth?.password,
|
||||
path: "gateway.auth.password",
|
||||
}),
|
||||
]);
|
||||
return probeGatewayReachable({
|
||||
url: localUrl,
|
||||
token: process.env.OPENCLAW_GATEWAY_TOKEN ?? baseLocalProbeToken,
|
||||
password: process.env.OPENCLAW_GATEWAY_PASSWORD ?? baseLocalProbePassword,
|
||||
timeoutMs: GATEWAY_HINT_PROBE_TIMEOUT_MS,
|
||||
});
|
||||
})();
|
||||
const remoteProbePromise = remoteUrl
|
||||
? (async () => {
|
||||
const baseRemoteProbeToken = await resolveGatewaySecretInputForWizard({
|
||||
cfg: baseConfig,
|
||||
value: baseConfig.gateway?.remote?.token,
|
||||
path: "gateway.remote.token",
|
||||
});
|
||||
return probeGatewayReachable({
|
||||
url: remoteUrl,
|
||||
token: baseRemoteProbeToken,
|
||||
timeoutMs: GATEWAY_HINT_PROBE_TIMEOUT_MS,
|
||||
});
|
||||
})()
|
||||
: Promise.resolve(null);
|
||||
const [localProbe, remoteProbe] = await Promise.all([localProbePromise, remoteProbePromise]);
|
||||
return guardCancel(
|
||||
await select({
|
||||
message: "Where will the Gateway run?",
|
||||
options: [
|
||||
{
|
||||
value: "local",
|
||||
label: "Local (this machine)",
|
||||
hint: localProbe.ok
|
||||
? `Gateway reachable (${localUrl})`
|
||||
: `No gateway detected (${localUrl})`,
|
||||
},
|
||||
{
|
||||
value: "remote",
|
||||
label: "Remote (info-only)",
|
||||
hint: !remoteUrl
|
||||
? "No remote URL configured yet"
|
||||
: remoteProbe?.ok
|
||||
? `Gateway reachable (${remoteUrl})`
|
||||
: `Configured but unreachable (${remoteUrl})`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
};
|
||||
|
||||
const mode = guardCancel(
|
||||
await select({
|
||||
message: "Where will the Gateway run?",
|
||||
options: [
|
||||
{
|
||||
value: "local",
|
||||
label: "Local (this machine)",
|
||||
hint: localProbe.ok
|
||||
? `Gateway reachable (${localUrl})`
|
||||
: `No gateway detected (${localUrl})`,
|
||||
},
|
||||
{
|
||||
value: "remote",
|
||||
label: "Remote (info-only)",
|
||||
hint: !remoteUrl
|
||||
? "No remote URL configured yet"
|
||||
: remoteProbe?.ok
|
||||
? `Gateway reachable (${remoteUrl})`
|
||||
: `Configured but unreachable (${remoteUrl})`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
const mode = shouldPromptGatewayRunMode ? await promptGatewayRunMode() : "local";
|
||||
const metadataMode: OnboardMode =
|
||||
shouldPromptGatewayRunMode || baseConfig.gateway?.mode !== "remote" ? mode : "remote";
|
||||
const shouldSkipGatewaySummary = !shouldPromptGatewayRunMode;
|
||||
|
||||
if (mode === "remote") {
|
||||
if (shouldPromptGatewayRunMode && mode === "remote") {
|
||||
let remoteConfig = await promptRemoteGatewayConfig(baseConfig, prompter);
|
||||
remoteConfig = applyWizardMetadata(remoteConfig, {
|
||||
command: opts.command,
|
||||
mode,
|
||||
mode: metadataMode,
|
||||
});
|
||||
const committed = await commitConfigWithPendingPluginInstalls({
|
||||
nextConfig: remoteConfig,
|
||||
@@ -489,7 +502,7 @@ export async function runConfigureWizard(
|
||||
let nextConfig = { ...baseConfig };
|
||||
let mergeBaseConfig = structuredClone(baseConfig);
|
||||
let didSetGatewayMode = false;
|
||||
if (nextConfig.gateway?.mode !== "local") {
|
||||
if (shouldPromptGatewayRunMode && nextConfig.gateway?.mode !== "local") {
|
||||
nextConfig = {
|
||||
...nextConfig,
|
||||
gateway: {
|
||||
@@ -508,7 +521,7 @@ export async function runConfigureWizard(
|
||||
const persistConfig = async () => {
|
||||
nextConfig = applyWizardMetadata(nextConfig, {
|
||||
command: opts.command,
|
||||
mode,
|
||||
mode: metadataMode,
|
||||
});
|
||||
|
||||
// Retry loop: if config was mutated by a plugin, re-read and merge before retry
|
||||
@@ -630,8 +643,8 @@ export async function runConfigureWizard(
|
||||
gatewayPort = Number.parseInt(portInput, 10);
|
||||
};
|
||||
|
||||
if (opts.sections) {
|
||||
const selected = opts.sections;
|
||||
if (selectedSections) {
|
||||
const selected = selectedSections;
|
||||
if (!selected || selected.length === 0) {
|
||||
outro("No configuration changes selected.");
|
||||
return;
|
||||
@@ -767,6 +780,20 @@ export async function runConfigureWizard(
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldSkipGatewaySummary) {
|
||||
const remoteUrl = normalizeOptionalString(nextConfig.gateway?.remote?.url);
|
||||
if (remoteUrl) {
|
||||
note(
|
||||
["Remote Gateway:", remoteUrl, "Docs: https://docs.openclaw.ai/gateway/remote"].join(
|
||||
"\n",
|
||||
),
|
||||
"Gateway",
|
||||
);
|
||||
}
|
||||
outro("Configuration updated.");
|
||||
return;
|
||||
}
|
||||
|
||||
const controlUiAssets = await ensureControlUiAssetsBuilt(runtime);
|
||||
if (!controlUiAssets.ok && controlUiAssets.message) {
|
||||
runtime.error(controlUiAssets.message);
|
||||
|
||||
@@ -11,6 +11,9 @@ const restartLogMocks = vi.hoisted(() => ({
|
||||
resolveGatewayLogPaths: vi.fn<() => GatewayLogPaths>(() => {
|
||||
throw new Error("skip log tail");
|
||||
}),
|
||||
resolveGatewaySupervisorLogPaths: vi.fn<() => GatewayLogPaths>(() => {
|
||||
throw new Error("skip log tail");
|
||||
}),
|
||||
resolveGatewayRestartLogPath: vi.fn<() => string>(() => "/tmp/gateway-restart.log"),
|
||||
}));
|
||||
|
||||
@@ -25,6 +28,7 @@ const gatewayMocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("../../daemon/restart-logs.js", () => ({
|
||||
resolveGatewayLogPaths: restartLogMocks.resolveGatewayLogPaths,
|
||||
resolveGatewaySupervisorLogPaths: restartLogMocks.resolveGatewaySupervisorLogPaths,
|
||||
resolveGatewayRestartLogPath: restartLogMocks.resolveGatewayRestartLogPath,
|
||||
}));
|
||||
|
||||
@@ -87,6 +91,9 @@ describe("status-all diagnosis port checks", () => {
|
||||
restartLogMocks.resolveGatewayLogPaths.mockImplementation(() => {
|
||||
throw new Error("skip log tail");
|
||||
});
|
||||
restartLogMocks.resolveGatewaySupervisorLogPaths.mockImplementation(() => {
|
||||
throw new Error("skip log tail");
|
||||
});
|
||||
restartLogMocks.resolveGatewayRestartLogPath.mockReturnValue("/tmp/gateway-restart.log");
|
||||
gatewayMocks.readFileTailLines.mockResolvedValue([]);
|
||||
gatewayMocks.summarizeLogTail.mockImplementation((lines: string[]) => lines);
|
||||
@@ -232,10 +239,10 @@ describe("status-all diagnosis port checks", () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, "platform", { value: "darwin" });
|
||||
try {
|
||||
restartLogMocks.resolveGatewayLogPaths.mockReturnValue({
|
||||
logDir: "/tmp/openclaw/logs",
|
||||
stdoutPath: "/tmp/openclaw/logs/gateway.log",
|
||||
stderrPath: "/tmp/openclaw/logs/gateway.err.log",
|
||||
restartLogMocks.resolveGatewaySupervisorLogPaths.mockReturnValue({
|
||||
logDir: "/Users/test/Library/Logs/openclaw",
|
||||
stdoutPath: "/Users/test/Library/Logs/openclaw/gateway.log",
|
||||
stderrPath: "/Users/test/Library/Logs/openclaw/gateway.err.log",
|
||||
});
|
||||
restartLogMocks.resolveGatewayRestartLogPath.mockReturnValue(
|
||||
"/tmp/openclaw/logs/gateway-restart.log",
|
||||
@@ -255,10 +262,10 @@ describe("status-all diagnosis port checks", () => {
|
||||
|
||||
const output = params.lines.join("\n");
|
||||
expect(gatewayMocks.readFileTailLines).not.toHaveBeenCalledWith(
|
||||
"/tmp/openclaw/logs/gateway.err.log",
|
||||
"/Users/test/Library/Logs/openclaw/gateway.err.log",
|
||||
40,
|
||||
);
|
||||
expect(output).toContain("# stdout: /tmp/openclaw/logs/gateway.log");
|
||||
expect(output).toContain("# stdout: /Users/test/Library/Logs/openclaw/gateway.log");
|
||||
expect(output).toContain("gateway stdout current");
|
||||
expect(output).not.toContain("# stderr:");
|
||||
expect(output).not.toContain("failed to bind gateway socket stale");
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { ProgressReporter } from "../../cli/progress.js";
|
||||
import { formatConfigIssueLine } from "../../config/issue-format.js";
|
||||
import { resolveGatewayLogPaths, resolveGatewayRestartLogPath } from "../../daemon/restart-logs.js";
|
||||
import {
|
||||
resolveGatewayLogPaths,
|
||||
resolveGatewayRestartLogPath,
|
||||
resolveGatewaySupervisorLogPaths,
|
||||
} from "../../daemon/restart-logs.js";
|
||||
import {
|
||||
formatPortDiagnostics,
|
||||
isDualStackLoopbackGatewayListeners,
|
||||
@@ -231,7 +235,9 @@ export async function appendStatusAllDiagnosis(params: {
|
||||
params.progress.setLabel("Reading logs…");
|
||||
const logPaths = (() => {
|
||||
try {
|
||||
return resolveGatewayLogPaths(process.env);
|
||||
return process.platform === "darwin"
|
||||
? resolveGatewaySupervisorLogPaths(process.env, { platform: "darwin" })
|
||||
: resolveGatewayLogPaths(process.env);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { readLastGatewayErrorLine } from "./diagnostics.js";
|
||||
import { resolveGatewayLogPaths } from "./restart-logs.js";
|
||||
import { resolveGatewayLogPaths, resolveGatewaySupervisorLogPaths } from "./restart-logs.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
@@ -22,11 +22,14 @@ function makeTempStateDir(): string {
|
||||
describe("readLastGatewayErrorLine", () => {
|
||||
it("ignores stale launchd stderr when stderr is suppressed", async () => {
|
||||
const stateDir = makeTempStateDir();
|
||||
const env = { OPENCLAW_STATE_DIR: stateDir };
|
||||
const { logDir, stdoutPath, stderrPath } = resolveGatewayLogPaths(env);
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
fs.writeFileSync(stderrPath, "failed to bind gateway socket stale\n", "utf8");
|
||||
fs.writeFileSync(stdoutPath, "gateway stdout current\n", "utf8");
|
||||
const homeDir = makeTempStateDir();
|
||||
const env = { HOME: homeDir, OPENCLAW_STATE_DIR: stateDir };
|
||||
const stateLogs = resolveGatewayLogPaths(env);
|
||||
const launchdLogs = resolveGatewaySupervisorLogPaths(env, { platform: "darwin" });
|
||||
fs.mkdirSync(stateLogs.logDir, { recursive: true });
|
||||
fs.mkdirSync(launchdLogs.logDir, { recursive: true });
|
||||
fs.writeFileSync(stateLogs.stderrPath, "failed to bind gateway socket stale\n", "utf8");
|
||||
fs.writeFileSync(launchdLogs.stdoutPath, "gateway stdout current\n", "utf8");
|
||||
|
||||
await expect(readLastGatewayErrorLine(env, { platform: "darwin" })).resolves.toBe(
|
||||
"gateway stdout current",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { resolveGatewayLogPaths } from "./restart-logs.js";
|
||||
import { resolveGatewayLogPaths, resolveGatewaySupervisorLogPaths } from "./restart-logs.js";
|
||||
|
||||
const GATEWAY_LOG_ERROR_PATTERNS = [
|
||||
/refusing to bind gateway/i,
|
||||
@@ -28,8 +28,12 @@ export async function readLastGatewayErrorLine(
|
||||
env: NodeJS.ProcessEnv,
|
||||
options?: { platform?: NodeJS.Platform },
|
||||
): Promise<string | null> {
|
||||
const readStderr = (options?.platform ?? process.platform) !== "darwin";
|
||||
const { stdoutPath, stderrPath } = resolveGatewayLogPaths(env);
|
||||
const platform = options?.platform ?? process.platform;
|
||||
const readStderr = platform !== "darwin";
|
||||
const { stdoutPath, stderrPath } =
|
||||
platform === "darwin"
|
||||
? resolveGatewaySupervisorLogPaths(env, { platform })
|
||||
: resolveGatewayLogPaths(env);
|
||||
const stderrRaw = readStderr ? await fs.readFile(stderrPath, "utf8").catch(() => "") : "";
|
||||
const stdoutRaw = await fs.readFile(stdoutPath, "utf8").catch(() => "");
|
||||
const lines = [...stderrRaw.split(/\r?\n/), ...stdoutRaw.split(/\r?\n/)].map((line) =>
|
||||
|
||||
@@ -9,6 +9,7 @@ export const LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS = 20;
|
||||
// launchd stores plist integer values in decimal; 0o077 renders as 63 (owner-only files).
|
||||
export const LAUNCH_AGENT_UMASK_DECIMAL = 0o077;
|
||||
export const LAUNCH_AGENT_PROCESS_TYPE = "Interactive";
|
||||
export const LAUNCH_AGENT_STDIN_PATH = "/dev/null";
|
||||
|
||||
const plistEscape = (value: string): string =>
|
||||
value
|
||||
@@ -180,5 +181,5 @@ export function buildLaunchAgentPlist({
|
||||
? `\n <key>Comment</key>\n <string>${plistEscape(comment.trim())}</string>`
|
||||
: "";
|
||||
const envXml = renderEnvDict(environment);
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n <dict>\n <key>Label</key>\n <string>${plistEscape(label)}</string>\n ${commentXml}\n <key>RunAtLoad</key>\n <true/>\n <key>KeepAlive</key>\n <true/>\n <key>ExitTimeOut</key>\n <integer>${LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS}</integer>\n <key>ProcessType</key>\n <string>${LAUNCH_AGENT_PROCESS_TYPE}</string>\n <key>ThrottleInterval</key>\n <integer>${LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS}</integer>\n <key>Umask</key>\n <integer>${LAUNCH_AGENT_UMASK_DECIMAL}</integer>\n <key>ProgramArguments</key>\n <array>${argsXml}\n </array>\n ${workingDirXml}\n <key>StandardOutPath</key>\n <string>${plistEscape(stdoutPath)}</string>\n <key>StandardErrorPath</key>\n <string>${plistEscape(stderrPath)}</string>${envXml}\n </dict>\n</plist>\n`;
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n <dict>\n <key>Label</key>\n <string>${plistEscape(label)}</string>\n ${commentXml}\n <key>RunAtLoad</key>\n <true/>\n <key>KeepAlive</key>\n <true/>\n <key>ExitTimeOut</key>\n <integer>${LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS}</integer>\n <key>ProcessType</key>\n <string>${LAUNCH_AGENT_PROCESS_TYPE}</string>\n <key>ThrottleInterval</key>\n <integer>${LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS}</integer>\n <key>Umask</key>\n <integer>${LAUNCH_AGENT_UMASK_DECIMAL}</integer>\n <key>ProgramArguments</key>\n <array>${argsXml}\n </array>\n ${workingDirXml}\n <key>StandardInPath</key>\n <string>${plistEscape(LAUNCH_AGENT_STDIN_PATH)}</string>\n <key>StandardOutPath</key>\n <string>${plistEscape(stdoutPath)}</string>\n <key>StandardErrorPath</key>\n <string>${plistEscape(stderrPath)}</string>${envXml}\n </dict>\n</plist>\n`;
|
||||
}
|
||||
|
||||
@@ -94,6 +94,26 @@ describe("scheduleDetachedLaunchdRestartHandoff", () => {
|
||||
expect(args[1]).not.toContain('basename "$service_target"');
|
||||
});
|
||||
|
||||
it("bootouts and bootstraps for reload mode", () => {
|
||||
spawnMock.mockReturnValue({ pid: 4242, unref: unrefMock });
|
||||
|
||||
scheduleDetachedLaunchdRestartHandoff({
|
||||
env: {
|
||||
HOME: "/Users/test",
|
||||
OPENCLAW_PROFILE: "default",
|
||||
},
|
||||
mode: "reload",
|
||||
waitForPid: 9876,
|
||||
});
|
||||
|
||||
const [, args] = requireSpawnCall();
|
||||
expect(args[1]).toContain("openclaw restart attempt source=launchd-handoff mode=reload");
|
||||
expect(args[1]).toContain('launchctl enable "$service_target"');
|
||||
expect(args[1]).toContain('launchctl bootout "$service_target"');
|
||||
expect(args[1]).toContain('if launchctl bootstrap "$domain" "$plist_path"; then');
|
||||
expect(args[1]).not.toContain('launchctl kickstart -k "$service_target"');
|
||||
});
|
||||
|
||||
it("sanitizes restart helper environment overrides before spawning", () => {
|
||||
spawnMock.mockReturnValue({ pid: 4242, unref: unrefMock });
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { sanitizeForLog } from "../terminal/ansi.js";
|
||||
import { resolveGatewayLaunchAgentLabel } from "./constants.js";
|
||||
import { renderPosixRestartLogSetup } from "./restart-logs.js";
|
||||
|
||||
type LaunchdRestartHandoffMode = "kickstart" | "start-after-exit";
|
||||
type LaunchdRestartHandoffMode = "kickstart" | "reload" | "start-after-exit";
|
||||
|
||||
type LaunchdRestartHandoffResult = {
|
||||
ok: boolean;
|
||||
@@ -150,6 +150,30 @@ exit "$status"
|
||||
`;
|
||||
}
|
||||
|
||||
if (mode === "reload") {
|
||||
// Reloading is required after plist content changes; kickstart alone keeps
|
||||
// launchd's already-loaded stdout/stderr/stdin paths.
|
||||
return `service_target="$1"
|
||||
domain="$2"
|
||||
plist_path="$3"
|
||||
${waitForCallerPid}
|
||||
status=0
|
||||
launchctl enable "$service_target"
|
||||
launchctl bootout "$service_target" >/dev/null 2>&1 || true
|
||||
if launchctl bootstrap "$domain" "$plist_path"; then
|
||||
status=0
|
||||
else
|
||||
status=$?
|
||||
fi
|
||||
if [ "$status" -eq 0 ]; then
|
||||
printf '[%s] openclaw restart done source=launchd-handoff mode=${mode}\\n' "$(date -u +%FT%TZ)" >&2
|
||||
else
|
||||
printf '[%s] openclaw restart failed source=launchd-handoff mode=${mode} status=%s\\n' "$(date -u +%FT%TZ)" "$status" >&2
|
||||
fi
|
||||
exit "$status"
|
||||
`;
|
||||
}
|
||||
|
||||
const verifyLaunchdReload = `print_retry_count="${START_AFTER_EXIT_PRINT_RETRY_COUNT}"
|
||||
while [ "$print_retry_count" -gt 0 ]; do
|
||||
if launchctl print "$service_target" >/dev/null 2>&1; then
|
||||
|
||||
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS,
|
||||
LAUNCH_AGENT_PROCESS_TYPE,
|
||||
LAUNCH_AGENT_STDIN_PATH,
|
||||
LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS,
|
||||
LAUNCH_AGENT_UMASK_DECIMAL,
|
||||
} from "./launchd-plist.js";
|
||||
@@ -751,6 +752,10 @@ describe("launchd install", () => {
|
||||
const plist = state.files.get(plistPath) ?? "";
|
||||
expect(plist).toContain("<key>KeepAlive</key>");
|
||||
expect(plist).toContain("<true/>");
|
||||
expect(plist).toContain("<key>StandardInPath</key>");
|
||||
expect(plist).toContain(`<string>${LAUNCH_AGENT_STDIN_PATH}</string>`);
|
||||
expect(plist).toContain("<key>StandardOutPath</key>");
|
||||
expect(plist).toContain("<string>/Users/test/Library/Logs/openclaw/gateway.log</string>");
|
||||
expect(plist).not.toContain("<key>SuccessfulExit</key>");
|
||||
expect(plist).toContain("<key>ExitTimeOut</key>");
|
||||
expect(plist).toContain(`<integer>${LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS}</integer>`);
|
||||
@@ -792,7 +797,9 @@ describe("launchd install", () => {
|
||||
});
|
||||
|
||||
const plist = state.files.get(plistPath) ?? "";
|
||||
expect(plist).toContain("<key>StandardInPath</key>");
|
||||
expect(plist).toContain("<key>StandardOutPath</key>");
|
||||
expect(plist).toContain("<string>/Users/test/Library/Logs/openclaw/gateway.log</string>");
|
||||
expect(plist).toContain("<key>StandardErrorPath</key>");
|
||||
expect(plist).toContain("<string>/dev/null</string>");
|
||||
expect(plist).toContain("<key>KeepAlive</key>");
|
||||
@@ -1158,6 +1165,45 @@ describe("launchd install", () => {
|
||||
expect(launchctlCommandNames()).not.toContain("bootstrap");
|
||||
});
|
||||
|
||||
it("reloads launchd after rewriting an existing plist", async () => {
|
||||
const env = {
|
||||
...createDefaultLaunchdEnv(),
|
||||
OPENCLAW_GATEWAY_PORT: "18789",
|
||||
};
|
||||
const plistPath = resolveLaunchAgentPlistPath(env);
|
||||
state.files.set(
|
||||
plistPath,
|
||||
[
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<plist version="1.0">',
|
||||
" <dict>",
|
||||
" <key>Label</key>",
|
||||
" <string>ai.openclaw.gateway</string>",
|
||||
" <key>ProgramArguments</key>",
|
||||
" <array>",
|
||||
" <string>node</string>",
|
||||
" <string>gateway.js</string>",
|
||||
" </array>",
|
||||
" <key>StandardOutPath</key>",
|
||||
" <string>/Users/test/.openclaw-default/logs/gateway.log</string>",
|
||||
" </dict>",
|
||||
"</plist>",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
await restartLaunchAgent({
|
||||
env,
|
||||
stdout: new PassThrough(),
|
||||
});
|
||||
|
||||
const plist = state.files.get(plistPath) ?? "";
|
||||
expect(plist).toContain("<key>StandardInPath</key>");
|
||||
expect(plist).toContain("<string>/dev/null</string>");
|
||||
expect(plist).toContain("<string>/Users/test/Library/Logs/openclaw/gateway.log</string>");
|
||||
expect(launchctlCommandNames()).toEqual(["enable", "bootout", "enable", "bootstrap"]);
|
||||
expect(launchctlCommandNames()).not.toContain("kickstart");
|
||||
});
|
||||
|
||||
it("uses the configured gateway port for stale cleanup", async () => {
|
||||
const env = {
|
||||
...createDefaultLaunchdEnv(),
|
||||
@@ -1196,6 +1242,24 @@ describe("launchd install", () => {
|
||||
...createDefaultLaunchdEnv(),
|
||||
OPENCLAW_GATEWAY_PORT: "19002",
|
||||
};
|
||||
const plistPath = resolveLaunchAgentPlistPath(env);
|
||||
const originalPlist = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<plist version="1.0">',
|
||||
" <dict>",
|
||||
" <key>Label</key>",
|
||||
" <string>ai.openclaw.gateway</string>",
|
||||
" <key>ProgramArguments</key>",
|
||||
" <array>",
|
||||
" <string>node</string>",
|
||||
" <string>gateway.js</string>",
|
||||
" </array>",
|
||||
" <key>StandardOutPath</key>",
|
||||
" <string>/Users/test/.openclaw-default/logs/gateway.log</string>",
|
||||
" </dict>",
|
||||
"</plist>",
|
||||
].join("\n");
|
||||
state.files.set(plistPath, originalPlist);
|
||||
inspectPortUsage.mockResolvedValue({
|
||||
port: 19002,
|
||||
status: "busy",
|
||||
@@ -1215,6 +1279,8 @@ describe("launchd install", () => {
|
||||
|
||||
expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(19002);
|
||||
expect(inspectPortUsage).toHaveBeenCalledWith(19002);
|
||||
expect(state.files.get(plistPath)).toBe(originalPlist);
|
||||
expect(state.fileWrites).toHaveLength(0);
|
||||
expect(launchctlCommandNames()).not.toContain("kickstart");
|
||||
});
|
||||
|
||||
@@ -1305,6 +1371,45 @@ describe("launchd install", () => {
|
||||
expect(state.launchctlCalls).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("hands plist reload off when current LaunchAgent needs rewritten paths", async () => {
|
||||
const env = createDefaultLaunchdEnv();
|
||||
const plistPath = resolveLaunchAgentPlistPath(env);
|
||||
launchdRestartHandoffState.isCurrentProcessLaunchdServiceLabel.mockReturnValue(true);
|
||||
state.files.set(
|
||||
plistPath,
|
||||
[
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<plist version="1.0">',
|
||||
" <dict>",
|
||||
" <key>Label</key>",
|
||||
" <string>ai.openclaw.gateway</string>",
|
||||
" <key>ProgramArguments</key>",
|
||||
" <array>",
|
||||
" <string>node</string>",
|
||||
" <string>gateway.js</string>",
|
||||
" </array>",
|
||||
" <key>StandardOutPath</key>",
|
||||
" <string>/Users/test/.openclaw-default/logs/gateway.log</string>",
|
||||
" </dict>",
|
||||
"</plist>",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const result = await restartLaunchAgent({
|
||||
env,
|
||||
stdout: new PassThrough(),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ outcome: "scheduled" });
|
||||
expect(launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff).toHaveBeenCalledWith({
|
||||
env,
|
||||
mode: "reload",
|
||||
waitForPid: process.pid,
|
||||
});
|
||||
expect(state.files.get(plistPath)).toContain("/Users/test/Library/Logs/openclaw/gateway.log");
|
||||
expect(state.launchctlCalls).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("surfaces detached handoff failures", async () => {
|
||||
const env = createDefaultLaunchdEnv();
|
||||
launchdRestartHandoffState.isCurrentProcessLaunchdServiceLabel.mockReturnValue(true);
|
||||
|
||||
+37
-8
@@ -25,7 +25,7 @@ import {
|
||||
} from "./launchd-restart-handoff.js";
|
||||
import { formatLine, toPosixPath, writeFormattedLines } from "./output.js";
|
||||
import { resolveGatewayStateDir, resolveHomeDir } from "./paths.js";
|
||||
import { resolveGatewayLogPaths } from "./restart-logs.js";
|
||||
import { resolveGatewaySupervisorLogPaths } from "./restart-logs.js";
|
||||
import { parseKeyValueOutput } from "./runtime-parse.js";
|
||||
import type { GatewayServiceRuntime } from "./service-runtime.js";
|
||||
import type {
|
||||
@@ -822,7 +822,7 @@ async function writeLaunchAgentPlist({
|
||||
environment,
|
||||
description,
|
||||
}: Omit<GatewayServiceInstallArgs, "stdout">): Promise<{ plistPath: string; stdoutPath: string }> {
|
||||
const { logDir, stdoutPath } = resolveGatewayLogPaths(env);
|
||||
const { logDir, stdoutPath } = resolveGatewaySupervisorLogPaths(env, { platform: "darwin" });
|
||||
await ensureSecureDirectory(logDir);
|
||||
|
||||
const domain = resolveGuiDomain();
|
||||
@@ -925,13 +925,13 @@ async function rewriteLaunchAgentPlistForRestart({
|
||||
env: GatewayServiceEnv;
|
||||
label: string;
|
||||
plistPath: string;
|
||||
}): Promise<void> {
|
||||
}): Promise<boolean> {
|
||||
const existing = await readLaunchAgentProgramArgumentsFromFile(plistPath);
|
||||
if (!existing?.programArguments.length) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const { logDir, stdoutPath } = resolveGatewayLogPaths(env);
|
||||
const { logDir, stdoutPath } = resolveGatewaySupervisorLogPaths(env, { platform: "darwin" });
|
||||
await ensureSecureDirectory(logDir);
|
||||
|
||||
const serviceDescription = resolveGatewayServiceDescription({
|
||||
@@ -953,8 +953,13 @@ async function rewriteLaunchAgentPlistForRestart({
|
||||
stderrPath: LAUNCH_AGENT_STDERR_PATH,
|
||||
environment: prepared.inlineEnvironment,
|
||||
});
|
||||
const previousPlist = await fs.readFile(plistPath, "utf8").catch(() => "");
|
||||
if (previousPlist === plist) {
|
||||
return false;
|
||||
}
|
||||
await fs.writeFile(plistPath, plist, { encoding: "utf8", mode: LAUNCH_AGENT_PLIST_MODE });
|
||||
await fs.chmod(plistPath, LAUNCH_AGENT_PLIST_MODE).catch(() => undefined);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function ensureLaunchAgentLoadedAfterFailure(params: {
|
||||
@@ -992,9 +997,14 @@ export async function restartLaunchAgent({
|
||||
// detached handoff. A direct `kickstart -k` would terminate the caller before
|
||||
// it can finish the restart command.
|
||||
if (isCurrentProcessLaunchdServiceLabel(label)) {
|
||||
const plistReloadNeeded = await rewriteLaunchAgentPlistForRestart({
|
||||
env: serviceEnv,
|
||||
label,
|
||||
plistPath,
|
||||
});
|
||||
const handoff = scheduleDetachedLaunchdRestartHandoff({
|
||||
env: serviceEnv,
|
||||
mode: "kickstart",
|
||||
mode: plistReloadNeeded ? "reload" : "kickstart",
|
||||
waitForPid: process.pid,
|
||||
});
|
||||
if (!handoff.ok) {
|
||||
@@ -1017,11 +1027,31 @@ export async function restartLaunchAgent({
|
||||
);
|
||||
}
|
||||
}
|
||||
const plistReloadNeeded = await rewriteLaunchAgentPlistForRestart({
|
||||
env: serviceEnv,
|
||||
label,
|
||||
plistPath,
|
||||
});
|
||||
|
||||
// `openclaw gateway restart` is an explicit operator request to bring the
|
||||
// LaunchAgent back, so clear any persisted disabled state before restart.
|
||||
await execLaunchctl(["enable", serviceTarget]);
|
||||
|
||||
if (plistReloadNeeded) {
|
||||
const bootout = await execLaunchctl(["bootout", serviceTarget]);
|
||||
if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) {
|
||||
throw new Error(`launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`);
|
||||
}
|
||||
await bootstrapLaunchAgentOrThrow({
|
||||
domain,
|
||||
serviceTarget,
|
||||
plistPath,
|
||||
actionHint: "openclaw gateway restart",
|
||||
});
|
||||
writeLaunchAgentActionLine(stdout, "Restarted LaunchAgent", serviceTarget);
|
||||
return { outcome: "completed" };
|
||||
}
|
||||
|
||||
const start = await execLaunchctl(["kickstart", "-k", serviceTarget]);
|
||||
if (start.code === 0) {
|
||||
writeLaunchAgentActionLine(stdout, "Restarted LaunchAgent", serviceTarget);
|
||||
@@ -1033,8 +1063,7 @@ export async function restartLaunchAgent({
|
||||
throw new Error(`launchctl kickstart failed: ${start.stderr || start.stdout}`.trim());
|
||||
}
|
||||
|
||||
// If the service was previously booted out, re-register the plist and retry.
|
||||
await rewriteLaunchAgentPlistForRestart({ env: serviceEnv, label, plistPath });
|
||||
// If the service was previously booted out, re-register the rewritten plist and retry.
|
||||
await bootstrapLaunchAgentOrThrow({
|
||||
domain,
|
||||
serviceTarget,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
renderPosixRestartLogSetup,
|
||||
resolveGatewayLogPaths,
|
||||
resolveGatewayRestartLogPath,
|
||||
resolveGatewaySupervisorLogPaths,
|
||||
} from "./restart-logs.js";
|
||||
|
||||
describe("restart log conventions", () => {
|
||||
@@ -35,6 +36,35 @@ describe("restart log conventions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps macOS LaunchAgent stdout outside the state directory", () => {
|
||||
const env = {
|
||||
HOME: "/Users/test",
|
||||
OPENCLAW_STATE_DIR: "/Volumes/External/openclaw",
|
||||
};
|
||||
|
||||
expect(resolveGatewaySupervisorLogPaths(env, { platform: "darwin" })).toEqual({
|
||||
logDir: "/Users/test/Library/Logs/openclaw",
|
||||
stdoutPath: "/Users/test/Library/Logs/openclaw/gateway.log",
|
||||
stderrPath: "/Users/test/Library/Logs/openclaw/gateway.err.log",
|
||||
});
|
||||
expect(resolveGatewayRestartLogPath(env)).toBe(
|
||||
`/Volumes/External/openclaw/logs/${GATEWAY_RESTART_LOG_FILENAME}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps macOS LaunchAgent logs profile-aware in the shared user log directory", () => {
|
||||
const env = {
|
||||
HOME: "/Users/test",
|
||||
OPENCLAW_PROFILE: "work",
|
||||
};
|
||||
|
||||
expect(resolveGatewaySupervisorLogPaths(env, { platform: "darwin" })).toEqual({
|
||||
logDir: "/Users/test/Library/Logs/openclaw",
|
||||
stdoutPath: "/Users/test/Library/Logs/openclaw/gateway-work.log",
|
||||
stderrPath: "/Users/test/Library/Logs/openclaw/gateway-work.err.log",
|
||||
});
|
||||
});
|
||||
|
||||
it("renders best-effort POSIX log setup with escaped paths", () => {
|
||||
const setup = renderPosixRestartLogSetup({
|
||||
HOME: "/Users/test's",
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
import path from "node:path";
|
||||
import { quoteCmdScriptArg } from "./cmd-argv.js";
|
||||
import { resolveGatewayStateDir } from "./paths.js";
|
||||
import { resolveGatewayProfileSuffix } from "./constants.js";
|
||||
import { resolveGatewayStateDir, resolveHomeDir } from "./paths.js";
|
||||
import type { GatewayServiceEnv } from "./service-types.js";
|
||||
|
||||
export const GATEWAY_RESTART_LOG_FILENAME = "gateway-restart.log";
|
||||
|
||||
export function resolveGatewayLogPaths(env: GatewayServiceEnv): {
|
||||
export type GatewayLogPaths = {
|
||||
logDir: string;
|
||||
stdoutPath: string;
|
||||
stderrPath: string;
|
||||
} {
|
||||
};
|
||||
|
||||
function resolveGatewayLogPrefix(env: GatewayServiceEnv): string {
|
||||
return env.OPENCLAW_LOG_PREFIX?.trim() || "gateway";
|
||||
}
|
||||
|
||||
function resolveMacLaunchAgentLogPrefix(env: GatewayServiceEnv): string {
|
||||
return (
|
||||
env.OPENCLAW_LOG_PREFIX?.trim() || `gateway${resolveGatewayProfileSuffix(env.OPENCLAW_PROFILE)}`
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveGatewayLogPaths(env: GatewayServiceEnv): GatewayLogPaths {
|
||||
const stateDir = resolveGatewayStateDir(env);
|
||||
const logDir = path.join(stateDir, "logs");
|
||||
const prefix = env.OPENCLAW_LOG_PREFIX?.trim() || "gateway";
|
||||
const prefix = resolveGatewayLogPrefix(env);
|
||||
return {
|
||||
logDir,
|
||||
stdoutPath: path.join(logDir, `${prefix}.log`),
|
||||
@@ -20,6 +33,26 @@ export function resolveGatewayLogPaths(env: GatewayServiceEnv): {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMacLaunchAgentLogPaths(env: GatewayServiceEnv): GatewayLogPaths {
|
||||
const home = resolveHomeDir(env).replaceAll("\\", "/");
|
||||
const logDir = path.posix.join(home, "Library", "Logs", "openclaw");
|
||||
const prefix = resolveMacLaunchAgentLogPrefix(env);
|
||||
return {
|
||||
logDir,
|
||||
stdoutPath: path.posix.join(logDir, `${prefix}.log`),
|
||||
stderrPath: path.posix.join(logDir, `${prefix}.err.log`),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveGatewaySupervisorLogPaths(
|
||||
env: GatewayServiceEnv,
|
||||
options?: { platform?: NodeJS.Platform },
|
||||
): GatewayLogPaths {
|
||||
return (options?.platform ?? process.platform) === "darwin"
|
||||
? resolveMacLaunchAgentLogPaths(env)
|
||||
: resolveGatewayLogPaths(env);
|
||||
}
|
||||
|
||||
export function resolveGatewayRestartLogPath(env: GatewayServiceEnv): string {
|
||||
return path.join(resolveGatewayLogPaths(env).logDir, GATEWAY_RESTART_LOG_FILENAME);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ describe("buildPlatformRuntimeLogHints", () => {
|
||||
buildPlatformRuntimeLogHints({
|
||||
platform: "darwin",
|
||||
env: {
|
||||
HOME: "/Users/test",
|
||||
OPENCLAW_STATE_DIR: "/tmp/openclaw-state",
|
||||
OPENCLAW_LOG_PREFIX: "gateway",
|
||||
},
|
||||
@@ -14,7 +15,7 @@ describe("buildPlatformRuntimeLogHints", () => {
|
||||
windowsTaskName: "OpenClaw Gateway",
|
||||
}),
|
||||
).toEqual([
|
||||
"Launchd stdout (if installed): /tmp/openclaw-state/logs/gateway.log",
|
||||
"Launchd stdout (if installed): /Users/test/Library/Logs/openclaw/gateway.log",
|
||||
"Launchd stderr (if installed): suppressed",
|
||||
"Restart attempts: /tmp/openclaw-state/logs/gateway-restart.log",
|
||||
]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { toPosixPath } from "./output.js";
|
||||
import { resolveGatewayLogPaths, resolveGatewayRestartLogPath } from "./restart-logs.js";
|
||||
import { resolveGatewayRestartLogPath, resolveGatewaySupervisorLogPaths } from "./restart-logs.js";
|
||||
|
||||
function toDarwinDisplayPath(value: string): string {
|
||||
return toPosixPath(value).replace(/^[A-Za-z]:/, "");
|
||||
@@ -14,7 +14,7 @@ export function buildPlatformRuntimeLogHints(params: {
|
||||
const platform = params.platform ?? process.platform;
|
||||
const env = { ...process.env, ...params.env };
|
||||
if (platform === "darwin") {
|
||||
const logs = resolveGatewayLogPaths(env);
|
||||
const logs = resolveGatewaySupervisorLogPaths(env, { platform });
|
||||
return [
|
||||
`Launchd stdout (if installed): ${toDarwinDisplayPath(logs.stdoutPath)}`,
|
||||
"Launchd stderr (if installed): suppressed",
|
||||
|
||||
@@ -5,12 +5,18 @@ const resolveGatewayLogPathsMock = vi.fn(() => ({
|
||||
stdoutPath: "C:\\tmp\\openclaw-state\\logs\\gateway.log",
|
||||
stderrPath: "C:\\tmp\\openclaw-state\\logs\\gateway.err.log",
|
||||
}));
|
||||
const resolveGatewaySupervisorLogPathsMock = vi.fn(() => ({
|
||||
logDir: "C:\\Users\\test\\Library\\Logs\\openclaw",
|
||||
stdoutPath: "C:\\Users\\test\\Library\\Logs\\openclaw\\gateway.log",
|
||||
stderrPath: "C:\\Users\\test\\Library\\Logs\\openclaw\\gateway.err.log",
|
||||
}));
|
||||
const resolveGatewayRestartLogPathMock = vi.fn(
|
||||
() => "C:\\tmp\\openclaw-state\\logs\\gateway-restart.log",
|
||||
);
|
||||
|
||||
vi.mock("./restart-logs.js", () => ({
|
||||
resolveGatewayLogPaths: resolveGatewayLogPathsMock,
|
||||
resolveGatewaySupervisorLogPaths: resolveGatewaySupervisorLogPathsMock,
|
||||
resolveGatewayRestartLogPath: resolveGatewayRestartLogPathMock,
|
||||
}));
|
||||
|
||||
@@ -29,7 +35,7 @@ describe("buildPlatformRuntimeLogHints", () => {
|
||||
windowsTaskName: "OpenClaw Gateway",
|
||||
}),
|
||||
).toEqual([
|
||||
"Launchd stdout (if installed): /tmp/openclaw-state/logs/gateway.log",
|
||||
"Launchd stdout (if installed): /Users/test/Library/Logs/openclaw/gateway.log",
|
||||
"Launchd stderr (if installed): suppressed",
|
||||
"Restart attempts: /tmp/openclaw-state/logs/gateway-restart.log",
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user