File size: 13,064 Bytes
d197cf3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
/** Detects system-domain launchd ownership before mutating a user LaunchAgent. */
import fs from "node:fs/promises";
import path from "node:path";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { sanitizeForLog } from "../../packages/terminal-core/src/ansi.js";
import { isMissingPathError } from "../infra/errors.js";
import { execFileUtf8 } from "./exec-file.js";
import {
  execLaunchctl,
  formatLaunchctlResultDetail,
  isLaunchctlNotLoaded,
  launchctlInspectionReason,
  type LaunchctlResult,
} from "./launchd-exec.js";
import type { ServiceInspectionReason } from "./service-inspection-error.js";

const SYSTEM_LAUNCH_DAEMON_DIR = "/Library/LaunchDaemons";
const PLUTIL_PATH = "/usr/bin/plutil";

type SystemLaunchDaemonOwnership =
  | { status: "absent"; serviceTarget: string }
  | { status: "loaded"; serviceTarget: string }
  | { status: "installed"; serviceTarget: string; plistPath: string }
  | {
      status: "unverifiable";
      serviceTarget: string;
      operation: "launchctl" | "filesystem";
      detail: string;
      reason?: ServiceInspectionReason;
    };

type SystemLaunchDaemonConflict = Exclude<SystemLaunchDaemonOwnership, { status: "absent" }>;

function formatUnknownError(error: unknown): string {
  const raw = error instanceof Error ? error.message : String(error);
  return truncateUtf16Safe(sanitizeForLog(raw), 500);
}

function quotePosixArgument(value: string): string {
  return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
}

/**
 * Renders the package-independent ownership probe used by detached restart helpers.
 * The caller must refuse activation when `openclaw_system_launchd_conflict` is non-empty.
 */
export function renderSystemLaunchDaemonOwnershipShellProbe(label: string): string {
  const serviceTarget = `system/${label}`;
  return `openclaw_system_launchd_conflict=""
openclaw_system_launchd_detail=""
openclaw_system_launchd_target=${quotePosixArgument(serviceTarget)}
openclaw_system_launchd_dir=${quotePosixArgument(SYSTEM_LAUNCH_DAEMON_DIR)}
openclaw_system_launchd_label=${quotePosixArgument(label)}
openclaw_query_system_launchd() {
  openclaw_system_launchd_probe=$(launchctl print "$openclaw_system_launchd_target" 2>&1)
  openclaw_system_launchd_probe_status=$?
  # POSIX shell status 126/127 means execution failed; >128 can represent a signal.
  # Partial absence output cannot establish that the ownership query completed.
  if [ "$openclaw_system_launchd_probe_status" -eq 0 ]; then
    openclaw_system_launchd_conflict="$openclaw_system_launchd_target"
    openclaw_system_launchd_detail="loaded system LaunchDaemon $openclaw_system_launchd_target"
  elif [ "$openclaw_system_launchd_probe_status" -eq 126 ] || [ "$openclaw_system_launchd_probe_status" -eq 127 ] || [ "$openclaw_system_launchd_probe_status" -gt 128 ] ||
       ! printf '%s' "$openclaw_system_launchd_probe" | /usr/bin/grep -Eiq 'could not find service|no such process|not found'; then
    openclaw_system_launchd_conflict="$openclaw_system_launchd_target"
    openclaw_system_launchd_detail="could not verify $openclaw_system_launchd_target (exit $openclaw_system_launchd_probe_status): $openclaw_system_launchd_probe"
  fi
}
openclaw_query_system_launchd
if [ -z "$openclaw_system_launchd_conflict" ]; then
  if [ ! -e "$openclaw_system_launchd_dir" ]; then
    :
  elif [ ! -r "$openclaw_system_launchd_dir" ] || [ ! -x "$openclaw_system_launchd_dir" ]; then
    openclaw_system_launchd_conflict="$openclaw_system_launchd_dir"
    openclaw_system_launchd_detail="could not inspect $openclaw_system_launchd_dir"
  else
    openclaw_system_launchd_entries=""
    if openclaw_system_launchd_entries=$(/usr/bin/mktemp "\${TMPDIR:-/tmp}/openclaw-launchd-scan.XXXXXX" 2>&1); then
      if /usr/bin/find "$openclaw_system_launchd_dir" -mindepth 1 -maxdepth 1 -name '*.plist' -print0 >"$openclaw_system_launchd_entries"; then
        while IFS= read -r -d '' openclaw_system_launchd_plist; do
          # Unreadable plists are treated as foreign: loaded same-label daemons are caught by the
          # bracketing launchctl probes; an unloaded unreadable same-label plist is an accepted operator-created edge (#120481).
          if [ ! -r "$openclaw_system_launchd_plist" ]; then
            continue
          fi
          if openclaw_system_launchd_plist_label=$(/usr/bin/plutil -extract Label raw -o - -- "$openclaw_system_launchd_plist" 2>&1); then
            if [ "$openclaw_system_launchd_plist_label" != "$openclaw_system_launchd_label" ]; then
              continue
            fi
            openclaw_system_launchd_conflict="$openclaw_system_launchd_plist"
            openclaw_system_launchd_detail="installed same-label system LaunchDaemon plist $openclaw_system_launchd_plist"
            break
          elif /usr/bin/plutil -lint -- "$openclaw_system_launchd_plist" >/dev/null 2>&1; then
            continue
          else
            openclaw_system_launchd_conflict="$openclaw_system_launchd_plist"
            openclaw_system_launchd_detail="could not inspect system LaunchDaemon plist $openclaw_system_launchd_plist: $openclaw_system_launchd_plist_label"
            break
          fi
        done <"$openclaw_system_launchd_entries"
      else
        openclaw_system_launchd_conflict="$openclaw_system_launchd_dir"
        openclaw_system_launchd_detail="could not enumerate $openclaw_system_launchd_dir"
      fi
      /bin/rm -f "$openclaw_system_launchd_entries"
    else
      openclaw_system_launchd_conflict="$openclaw_system_launchd_dir"
      openclaw_system_launchd_detail="could not create a secure system LaunchDaemon scan snapshot: $openclaw_system_launchd_entries"
    fi
  fi
fi
if [ -z "$openclaw_system_launchd_conflict" ]; then
  openclaw_query_system_launchd
fi
`;
}

type LaunchDaemonPlistLabelResult =
  | { status: "ok"; label: string }
  | { status: "unlabeled" }
  | { status: "missing" }
  | { status: "unreadable" }
  | { status: "unverifiable"; detail: string };

/** Reads the top-level Label through the native parser for XML and binary plists. */
export async function readLaunchDaemonPlistLabel(
  plistPath: string,
): Promise<LaunchDaemonPlistLabelResult> {
  const converted = await execFileUtf8(PLUTIL_PATH, [
    "-convert",
    "json",
    "-o",
    "-",
    "--",
    plistPath,
  ]);
  if (converted.code === 0) {
    try {
      const plist = JSON.parse(converted.stdout) as { Label?: unknown } | null;
      const label = plist?.Label;
      return typeof label === "string" && label.length > 0
        ? { status: "ok", label }
        : { status: "unlabeled" };
    } catch (error) {
      return { status: "unverifiable", detail: formatUnknownError(error) };
    }
  }
  try {
    await fs.access(plistPath, fs.constants.R_OK);
  } catch (error) {
    if (isMissingPathError(error)) {
      return { status: "missing" };
    }
    const code = (error as NodeJS.ErrnoException | undefined)?.code;
    if (code === "EACCES" || code === "EPERM") {
      return { status: "unreadable" };
    }
    return { status: "unverifiable", detail: formatUnknownError(error) };
  }
  return {
    status: "unverifiable",
    detail: formatLaunchctlResultDetail(converted) || "plutil could not decode the plist",
  };
}

type InstalledSystemLaunchDaemonScan =
  | { status: "absent" }
  | { status: "installed"; plistPath: string }
  | { status: "unverifiable"; detail: string };

async function findInstalledSystemLaunchDaemon(
  label: string,
): Promise<InstalledSystemLaunchDaemonScan> {
  let entries: string[];
  try {
    entries = await fs.readdir(SYSTEM_LAUNCH_DAEMON_DIR);
  } catch (error) {
    if (isMissingPathError(error)) {
      return { status: "absent" };
    }
    return { status: "unverifiable", detail: formatUnknownError(error) };
  }

  for (const entry of entries.filter((candidate) => candidate.endsWith(".plist")).toSorted()) {
    const plistPath = path.posix.join(SYSTEM_LAUNCH_DAEMON_DIR, entry);
    const result = await readLaunchDaemonPlistLabel(plistPath);
    if (result.status === "ok" && result.label === label) {
      return { status: "installed", plistPath };
    }
    // Unreadable plists are treated as foreign: loaded same-label daemons are caught by the
    // bracketing launchctl probes; an unloaded unreadable same-label plist is an accepted operator-created edge (#120481).
    if (result.status === "unreadable") {
      continue;
    }
    if (result.status === "unverifiable") {
      return { status: "unverifiable", detail: `${plistPath}: ${result.detail}` };
    }
  }
  return { status: "absent" };
}

function classifySystemLaunchDaemonQuery(
  serviceTarget: string,
  result: LaunchctlResult,
): SystemLaunchDaemonOwnership {
  if (result.code === 0) {
    return { status: "loaded", serviceTarget };
  }
  return isLaunchctlNotLoaded(result)
    ? { status: "absent", serviceTarget }
    : {
        status: "unverifiable",
        serviceTarget,
        operation: "launchctl",
        detail: formatLaunchctlResultDetail(result) || `exit code ${result.code}`,
        reason: launchctlInspectionReason(result, serviceTarget),
      };
}

export async function inspectSystemLaunchDaemonOwnership(
  label: string,
  options: { scanInstalledPlists?: boolean; timeoutMs?: number } = {},
): Promise<SystemLaunchDaemonOwnership> {
  const serviceTarget = `system/${label}`;
  if (process.platform !== "darwin") {
    return { status: "absent", serviceTarget };
  }

  const initialQuery = classifySystemLaunchDaemonQuery(
    serviceTarget,
    await execLaunchctl(["print", serviceTarget], options.timeoutMs),
  );
  if (initialQuery.status !== "absent") {
    return initialQuery;
  }
  if (options.scanInstalledPlists === false) {
    return { status: "absent", serviceTarget };
  }

  const installed = await findInstalledSystemLaunchDaemon(label);
  if (installed.status === "installed") {
    return { status: "installed", serviceTarget, plistPath: installed.plistPath };
  }
  if (installed.status === "unverifiable") {
    return {
      status: "unverifiable",
      serviceTarget,
      operation: "filesystem",
      detail: installed.detail,
    };
  }
  // Close the query-to-directory-snapshot race at the last responsible moment.
  // Arbitrary root installers cannot share a lock with this unprivileged process;
  // activation paths therefore repeat this complete probe immediately before use.
  return classifySystemLaunchDaemonQuery(
    serviceTarget,
    await execLaunchctl(["print", serviceTarget], options.timeoutMs),
  );
}

export function formatSystemLaunchDaemonOwnershipSummary(
  ownership: SystemLaunchDaemonConflict,
): string {
  switch (ownership.status) {
    case "loaded":
      return `System LaunchDaemon ${ownership.serviceTarget} already owns this gateway label.`;
    case "installed":
      return `System LaunchDaemon plist ${ownership.plistPath} already owns this gateway label.`;
    case "unverifiable":
      return `System LaunchDaemon ownership for ${ownership.serviceTarget} could not be verified: ${ownership.detail}`;
    default: {
      const exhaustive: never = ownership;
      throw new Error(`Unexpected system LaunchDaemon ownership: ${String(exhaustive)}`);
    }
  }
}

function formatSystemLaunchDaemonOwnershipError(ownership: SystemLaunchDaemonConflict): string {
  const recovery =
    ownership.status === "loaded"
      ? `Keep it as the sole gateway manager, or unload it with \`sudo launchctl bootout ${ownership.serviceTarget}\` and remove its plist before retrying.`
      : ownership.status === "installed"
        ? `Keep it as the sole gateway manager, or remove or relocate ${quotePosixArgument(ownership.plistPath)} before retrying.`
        : "Fix the reported launchctl or filesystem access error, then retry.";
  return [
    formatSystemLaunchDaemonOwnershipSummary(ownership),
    "Refusing to create or activate a user LaunchAgent for the same label because duplicate KeepAlive managers can restart-loop the gateway.",
    "OpenClaw does not manage system LaunchDaemons, and --force does not override system ownership.",
    recovery,
  ].join("\n");
}

class SystemLaunchDaemonOwnershipError extends Error {
  readonly code = "SYSTEM_LAUNCH_DAEMON_OWNERSHIP";

  constructor(readonly ownership: SystemLaunchDaemonConflict) {
    super(formatSystemLaunchDaemonOwnershipError(ownership));
    this.name = "SystemLaunchDaemonOwnershipError";
  }
}

export function isSystemLaunchDaemonOwnershipError(
  error: unknown,
): error is SystemLaunchDaemonOwnershipError {
  return error instanceof SystemLaunchDaemonOwnershipError;
}

export async function assertNoSystemLaunchDaemonOwnership(label: string): Promise<void> {
  const ownership = await inspectSystemLaunchDaemonOwnership(label);
  if (ownership.status !== "absent") {
    // System-domain ownership is host-wide. A gui-domain manager with the same
    // label can create two independent KeepAlive loops for one gateway port.
    throw new SystemLaunchDaemonOwnershipError(ownership);
  }
}