@rynx-ai/cli 0.1.11-beta.49 → 0.1.11-beta.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ import type { AutostartRenderInput } from "./autostart.js";
2
+ export declare function renderAutostartEnvironmentFile(input: AutostartRenderInput): string;
3
+ export declare function serviceEnvironmentPath(input: AutostartRenderInput): string;
4
+ export declare function currentShell(): string;
5
+ export declare function autostartLauncherArguments(input: AutostartRenderInput, serviceFlag: string): string[];
@@ -0,0 +1,64 @@
1
+ import { readlinkSync } from "node:fs";
2
+ import { userInfo } from "node:os";
3
+ import path from "node:path";
4
+ // These describe a particular service invocation or IPC connection. The new
5
+ // service/Node process supplies its own values; all other variables are captured.
6
+ const BLOCKED_SERVICE_ENVIRONMENT = new Set([
7
+ "INVOCATION_ID",
8
+ "JOURNAL_STREAM",
9
+ "LISTEN_FDS",
10
+ "LISTEN_FDNAMES",
11
+ "LISTEN_PID",
12
+ "MAINPID",
13
+ "MANAGERPID",
14
+ "NOTIFY_SOCKET",
15
+ "ELECTRON_RUN_AS_NODE",
16
+ "NODE_CHANNEL_FD",
17
+ "NODE_UNIQUE_ID",
18
+ "PM2_HOME",
19
+ "RYNX_DAEMON_LIFECYCLE",
20
+ "RYNX_HOME",
21
+ "RYNX_REFRESH_LOGIN_SHELL_PATH",
22
+ "RYNX_SYSTEMD_SERVICE",
23
+ "RYNX_LAUNCHD_SERVICE",
24
+ "XPC_SERVICE_NAME",
25
+ "SYSTEMD_EXEC_PID",
26
+ "SYSTEMD_INVOCATION_ID",
27
+ "WATCHDOG_PID",
28
+ "WATCHDOG_USEC",
29
+ ]);
30
+ export function renderAutostartEnvironmentFile(input) {
31
+ const env = { ...input.environment, PATH: input.pathEnv };
32
+ return Object.entries(env)
33
+ .filter(([name, value]) => value !== undefined &&
34
+ /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) &&
35
+ !BLOCKED_SERVICE_ENVIRONMENT.has(name))
36
+ .sort(([left], [right]) => left.localeCompare(right))
37
+ // Preserve literal newlines and quote metacharacters. The launcher parses
38
+ // this as data; it must not execute the assignments in a shell.
39
+ .map(([name, value]) => `${name}="${value.replace(/[\\"$`]/g, "\\$&")}"`)
40
+ .join("\n") + "\n";
41
+ }
42
+ export function serviceEnvironmentPath(input) {
43
+ return path.join(input.dataDir, "autostart.env");
44
+ }
45
+ export function currentShell() {
46
+ try {
47
+ const parent = readlinkSync(`/proc/${process.ppid}/exe`);
48
+ if (/^(?:ba|z|fi|da|k|mk|c|tc)?sh$/.test(path.basename(parent)))
49
+ return parent;
50
+ }
51
+ catch {
52
+ // The caller may be a launcher, or /proc may be unavailable.
53
+ }
54
+ return process.env.SHELL || userInfo().shell || "/bin/sh";
55
+ }
56
+ export function autostartLauncherArguments(input, serviceFlag) {
57
+ return [
58
+ path.join(path.dirname(input.cliPath), "autostart-launcher.js"),
59
+ input.shellPath ?? "/bin/sh",
60
+ input.cliPath,
61
+ input.dataDir,
62
+ serviceFlag,
63
+ ];
64
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,48 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ // Keep this entry independent of CLI initialization: restore the environment
5
+ // and initialize the user's shell before loading any Rynx runtime code.
6
+ const [shellPath, cliPath, dataDir, serviceFlag] = process.argv.slice(2);
7
+ if (!shellPath || !cliPath || !dataDir ||
8
+ (serviceFlag !== "--systemd-service" && serviceFlag !== "--launchd-service")) {
9
+ throw new Error("invalid autostart launcher arguments");
10
+ }
11
+ const contents = readFileSync(path.join(dataDir, "autostart.env"), "utf8");
12
+ // Parse only the quoted format emitted by renderAutostartEnvironmentFile.
13
+ // Never source assignments: a saved SHELLOPTS/UID may be readonly in a shell.
14
+ const assignments = [...contents.matchAll(/([A-Za-z_][A-Za-z0-9_]*)="((?:\\[\\"$`]|[^"\\])*)"\n/g)];
15
+ if (assignments.map(([assignment]) => assignment).join("") !== contents) {
16
+ throw new Error("invalid autostart environment snapshot");
17
+ }
18
+ const snapshot = Object.fromEntries(assignments.map(([, name = "", value = ""]) => [
19
+ name,
20
+ value.replace(/\\([\\"$`])/g, "$1"),
21
+ ]));
22
+ const isCShell = /^(?:t?csh)$/.test(path.basename(shellPath));
23
+ const command = "exec " + [
24
+ "/usr/bin/env",
25
+ `RYNX_HOME=${dataDir}`,
26
+ serviceFlag === "--systemd-service"
27
+ ? "RYNX_SYSTEMD_SERVICE=rynx.service"
28
+ : "RYNX_LAUNCHD_SERVICE=ai.rynx.daemon",
29
+ process.execPath,
30
+ cliPath,
31
+ "start",
32
+ serviceFlag,
33
+ ].map((value) => {
34
+ const quoted = `'${value.replaceAll("'", "'\\''")}'`;
35
+ return isCShell ? quoted.replaceAll("!", "\\!").replaceAll("\n", "\\\n") : quoted;
36
+ }).join(" ");
37
+ // csh/tcsh require -l on its own. Reading the command from stdin preserves
38
+ // their login AND interactive startup, including configuration guarded by prompt.
39
+ const result = spawnSync(shellPath, isCShell ? ["-l"] : ["-lic", command], {
40
+ env: { ...process.env, ...snapshot },
41
+ stdio: [isCShell ? "pipe" : "inherit", "inherit", "inherit"],
42
+ ...(isCShell ? { input: `${command}\n` } : {}),
43
+ });
44
+ // The shell passes its final environment directly to Rynx. Do not merge the
45
+ // snapshot again: that would undo configuration overrides and unset operations.
46
+ if (result.error)
47
+ throw result.error;
48
+ process.exitCode = result.status ?? 1;
@@ -17,11 +17,13 @@ export interface AutostartRenderInput {
17
17
  logDir: string;
18
18
  nodePath: string;
19
19
  pathEnv: string;
20
+ shellPath?: string;
20
21
  }
21
22
  export declare function autostartPlatformLabel(platform?: NodeJS.Platform): string | undefined;
22
23
  export declare function inspectAutostart(): AutostartState;
23
24
  export declare function enableAutostart(): AutostartState;
24
25
  export declare function disableAutostart(): AutostartState;
25
- /** Refresh paths in an existing registration without enabling a new one. */
26
+ /** Refresh an existing registration and its environment without enabling a new one. */
26
27
  export declare function refreshAutostart(): boolean;
27
28
  export declare function renderLaunchAgent(input: AutostartRenderInput): string;
29
+ export declare function assertMacLaunchdInvocation(): void;
package/dist/autostart.js CHANGED
@@ -4,9 +4,11 @@ import { homedir, userInfo } from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { rynxHome } from "@rynx-ai/core";
7
+ import { autostartLauncherArguments, currentShell, renderAutostartEnvironmentFile, serviceEnvironmentPath, } from "./autostart-environment.js";
7
8
  import { disableLinuxAutostart, enableLinuxAutostart, inspectLinuxAutostart, refreshLinuxServiceIfPresent, } from "./systemd-service.js";
8
9
  export { renderSystemdUnit } from "./systemd-service.js";
9
10
  const LAUNCHD_LABEL = "ai.rynx.daemon";
11
+ const LAUNCHD_SERVICE_ENV = "RYNX_LAUNCHD_SERVICE";
10
12
  const COMMAND_TIMEOUT_MS = 5_000;
11
13
  export function autostartPlatformLabel(platform = process.platform) {
12
14
  if (platform === "darwin")
@@ -44,7 +46,7 @@ export function disableAutostart() {
44
46
  disableLinuxAutostart();
45
47
  return inspectAutostart();
46
48
  }
47
- /** Refresh paths in an existing registration without enabling a new one. */
49
+ /** Refresh an existing registration and its environment without enabling a new one. */
48
50
  export function refreshAutostart() {
49
51
  if (process.platform === "darwin") {
50
52
  const context = currentContext();
@@ -68,6 +70,7 @@ function unsupportedState() {
68
70
  export function renderLaunchAgent(input) {
69
71
  const outLog = path.join(input.logDir, "autostart-out.log");
70
72
  const errLog = path.join(input.logDir, "autostart-err.log");
73
+ const argv = [input.nodePath, ...autostartLauncherArguments(input, "--launchd-service")];
71
74
  return `<?xml version="1.0" encoding="UTF-8"?>
72
75
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
73
76
  <plist version="1.0">
@@ -76,9 +79,7 @@ export function renderLaunchAgent(input) {
76
79
  <string>${escapeXml(LAUNCHD_LABEL)}</string>
77
80
  <key>ProgramArguments</key>
78
81
  <array>
79
- <string>${escapeXml(input.nodePath)}</string>
80
- <string>${escapeXml(input.cliPath)}</string>
81
- <string>start</string>
82
+ ${argv.map((value) => ` <string>${escapeXml(value)}</string>`).join("\n")}
82
83
  </array>
83
84
  <key>RunAtLoad</key>
84
85
  <true/>
@@ -92,6 +93,8 @@ export function renderLaunchAgent(input) {
92
93
  <string>${escapeXml(input.pathEnv)}</string>
93
94
  <key>RYNX_HOME</key>
94
95
  <string>${escapeXml(input.dataDir)}</string>
96
+ <key>${LAUNCHD_SERVICE_ENV}</key>
97
+ <string>${LAUNCHD_LABEL}</string>
95
98
  </dict>
96
99
  <key>StandardOutPath</key>
97
100
  <string>${escapeXml(outLog)}</string>
@@ -101,6 +104,14 @@ export function renderLaunchAgent(input) {
101
104
  </plist>
102
105
  `;
103
106
  }
107
+ export function assertMacLaunchdInvocation() {
108
+ if (process.platform !== "darwin") {
109
+ throw new Error("--launchd-service is only valid on macOS");
110
+ }
111
+ if (process.env[LAUNCHD_SERVICE_ENV] !== LAUNCHD_LABEL) {
112
+ throw new Error("--launchd-service requires the Rynx launchd environment");
113
+ }
114
+ }
104
115
  function currentContext() {
105
116
  const dataDir = rynxHome();
106
117
  const homeDir = homedir();
@@ -109,8 +120,10 @@ function currentContext() {
109
120
  dataDir,
110
121
  logDir: path.join(dataDir, "logs"),
111
122
  cliPath: fileURLToPath(new URL("./cli.js", import.meta.url)),
123
+ environment: process.env,
112
124
  nodePath: process.execPath,
113
125
  pathEnv: process.env.PATH || defaultPath(),
126
+ shellPath: currentShell(),
114
127
  };
115
128
  }
116
129
  function defaultPath() {
@@ -142,19 +155,42 @@ function syncMacRegistration(context) {
142
155
  ? readFileSync(registrationPath, "utf8")
143
156
  : undefined;
144
157
  const content = renderLaunchAgent(context);
145
- if (previous === content)
158
+ const changed = previous !== content;
159
+ const environmentPath = serviceEnvironmentPath(context);
160
+ const environment = renderAutostartEnvironmentFile(context);
161
+ const previousEnvironment = existsSync(environmentPath)
162
+ ? readFileSync(environmentPath, "utf8")
163
+ : undefined;
164
+ const environmentChanged = previousEnvironment !== environment;
165
+ if (!changed && !environmentChanged)
146
166
  return false;
147
- const wasLoaded = launchctlLoaded();
148
- replaceFileAtomically(registrationPath, content);
167
+ const wasLoaded = changed && launchctlLoaded();
168
+ const restore = () => {
169
+ if (environmentChanged)
170
+ restoreRegistrationFile(environmentPath, previousEnvironment, 0o600);
171
+ if (changed)
172
+ restoreRegistrationFile(registrationPath, previous);
173
+ };
174
+ try {
175
+ if (environmentChanged)
176
+ replaceFileAtomically(environmentPath, environment, 0o600);
177
+ if (changed)
178
+ replaceFileAtomically(registrationPath, content);
179
+ }
180
+ catch (error) {
181
+ restore();
182
+ throw error;
183
+ }
184
+ // An environment-only refresh needs no reload: the job reads it on every run.
149
185
  if (!wasLoaded)
150
186
  return true;
151
187
  if (!launchctlBootout(registrationPath)) {
152
- restoreRegistrationFile(registrationPath, previous);
188
+ restore();
153
189
  throw new Error(`launchctl could not unload ${LAUNCHD_LABEL}; the previous file was restored`);
154
190
  }
155
191
  if (launchctlBootstrap(registrationPath))
156
192
  return true;
157
- restoreRegistrationFile(registrationPath, previous);
193
+ restore();
158
194
  if (previous !== undefined && launchctlBootstrap(registrationPath)) {
159
195
  throw new Error(`launchctl could not load the updated registration; the previous job was restored`);
160
196
  }
@@ -166,6 +202,7 @@ function disableMac(context) {
166
202
  throw new Error(`launchctl could not unload ${LAUNCHD_LABEL}`);
167
203
  }
168
204
  rmSync(registrationPath, { force: true });
205
+ rmSync(serviceEnvironmentPath(context), { force: true });
169
206
  }
170
207
  function launchctlLoaded() {
171
208
  const uid = userInfo().uid;
@@ -206,14 +243,14 @@ function escapeXml(value) {
206
243
  .replaceAll("<", "&lt;")
207
244
  .replaceAll(">", "&gt;");
208
245
  }
209
- function replaceFileAtomically(file, content) {
246
+ function replaceFileAtomically(file, content, mode = 0o644) {
210
247
  mkdirSync(path.dirname(file), { recursive: true });
211
248
  const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
212
249
  try {
213
250
  writeFileSync(temporary, content, {
214
251
  encoding: "utf8",
215
252
  flag: "wx",
216
- mode: 0o644,
253
+ mode,
217
254
  });
218
255
  renameSync(temporary, file);
219
256
  }
@@ -221,9 +258,9 @@ function replaceFileAtomically(file, content) {
221
258
  rmSync(temporary, { force: true });
222
259
  }
223
260
  }
224
- function restoreRegistrationFile(file, previous) {
261
+ function restoreRegistrationFile(file, previous, mode = 0o644) {
225
262
  if (previous === undefined)
226
263
  rmSync(file, { force: true });
227
264
  else
228
- replaceFileAtomically(file, previous);
265
+ replaceFileAtomically(file, previous, mode);
229
266
  }
@@ -1,11 +1,12 @@
1
1
  import { startStandaloneDaemon, statusStandaloneDaemon, stopStandaloneDaemon, streamStandaloneDaemonLogs, } from "../standalone.js";
2
2
  import { shutdownResidentDaemonIfIdle, waitForResidentDaemonExit, } from "../control-client.js";
3
3
  import { resolveDaemonControlEndpoint } from "../control-endpoint.js";
4
- import { refreshAutostart } from "../autostart.js";
4
+ import { assertMacLaunchdInvocation, refreshAutostart } from "../autostart.js";
5
5
  import { assertLinuxSystemdInvocation } from "../systemd-service.js";
6
6
  import { clearLegacyMaintenanceState } from "../legacy-maintenance.js";
7
7
  import { fail } from "./errors.js";
8
8
  const SYSTEMD_SERVICE_FLAG = "--systemd-service";
9
+ const LAUNCHD_SERVICE_FLAG = "--launchd-service";
9
10
  const IF_IDLE_FLAG = "--if-idle";
10
11
  export async function runLifecycleCommand(command, args, options = {}) {
11
12
  if (args.length === 1 && args[0] === SYSTEMD_SERVICE_FLAG) {
@@ -16,6 +17,12 @@ export async function runLifecycleCommand(command, args, options = {}) {
16
17
  return stopStandaloneDaemon();
17
18
  fail(`${SYSTEMD_SERVICE_FLAG} is only valid for start and stop`);
18
19
  }
20
+ if (args.length === 1 && args[0] === LAUNCHD_SERVICE_FLAG) {
21
+ assertMacLaunchdInvocation();
22
+ if (command === "start")
23
+ return startStandaloneDaemon();
24
+ fail(`${LAUNCHD_SERVICE_FLAG} is only valid for start`);
25
+ }
19
26
  const stopIfIdle = command === "stop" && args.length === 1 && args[0] === IF_IDLE_FLAG;
20
27
  const statusJson = command === "status" && args.length === 1 && args[0] === "--json";
21
28
  if (args.length > 0 && !stopIfIdle && !statusJson) {
@@ -1,7 +1,8 @@
1
1
  import type { AutostartRenderInput, AutostartState } from "./autostart.js";
2
+ import { renderAutostartEnvironmentFile as renderSystemdEnvironmentFile } from "./autostart-environment.js";
3
+ export { renderSystemdEnvironmentFile };
2
4
  export declare const RYNX_SYSTEMD_SERVICE = "rynx.service";
3
5
  export declare const RYNX_SYSTEMD_SERVICE_ENV = "RYNX_SYSTEMD_SERVICE";
4
- export declare const RYNX_SERVICE_ENV_KEYS = "RYNX_SERVICE_ENV_KEYS";
5
6
  export interface LinuxSystemdServiceState {
6
7
  loadState: string;
7
8
  activeState: string;
@@ -4,79 +4,13 @@ import { homedir, userInfo } from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { rynxHome } from "@rynx-ai/core";
7
+ import { autostartLauncherArguments, currentShell, renderAutostartEnvironmentFile as renderSystemdEnvironmentFile, serviceEnvironmentPath, } from "./autostart-environment.js";
8
+ export { renderSystemdEnvironmentFile };
7
9
  export const RYNX_SYSTEMD_SERVICE = "rynx.service";
8
10
  export const RYNX_SYSTEMD_SERVICE_ENV = "RYNX_SYSTEMD_SERVICE";
9
- export const RYNX_SERVICE_ENV_KEYS = "RYNX_SERVICE_ENV_KEYS";
10
11
  const COMMAND_TIMEOUT_MS = 5_000;
11
- const AUTOMATIC_SERVICE_ENVIRONMENT = new Set([
12
- "ALL_PROXY",
13
- "AWS_CA_BUNDLE",
14
- "CURL_CA_BUNDLE",
15
- "GIT_SSL_CAINFO",
16
- "HTTPS_PROXY",
17
- "HTTP_PROXY",
18
- "NODE_EXTRA_CA_CERTS",
19
- "NO_PROXY",
20
- "REQUESTS_CA_BUNDLE",
21
- "SSL_CERT_DIR",
22
- "SSL_CERT_FILE",
23
- "all_proxy",
24
- "https_proxy",
25
- "http_proxy",
26
- "no_proxy",
27
- ]);
28
- const BLOCKED_SERVICE_ENVIRONMENT = new Set([
29
- "PATH",
30
- "INVOCATION_ID",
31
- "JOURNAL_STREAM",
32
- "LISTEN_FDS",
33
- "LISTEN_FDNAMES",
34
- "LISTEN_PID",
35
- "MAINPID",
36
- "MANAGERPID",
37
- "NOTIFY_SOCKET",
38
- "ELECTRON_RUN_AS_NODE",
39
- "NODE_CHANNEL_FD",
40
- "NODE_UNIQUE_ID",
41
- "PM2_HOME",
42
- "RYNX_DAEMON_LIFECYCLE",
43
- "RYNX_HOME",
44
- "RYNX_REFRESH_LOGIN_SHELL_PATH",
45
- RYNX_SERVICE_ENV_KEYS,
46
- RYNX_SYSTEMD_SERVICE_ENV,
47
- "SYSTEMD_EXEC_PID",
48
- "SYSTEMD_INVOCATION_ID",
49
- "WATCHDOG_PID",
50
- "WATCHDOG_USEC",
51
- ]);
52
- const TRANSIENT_SERVICE_ENVIRONMENT = new Set([
53
- "_",
54
- "CI",
55
- "CLICOLOR",
56
- "CLICOLOR_FORCE",
57
- "CODEX_CI",
58
- "COLORTERM",
59
- "FORCE_COLOR",
60
- "GH_PAGER",
61
- "GIT_PAGER",
62
- "NO_COLOR",
63
- "OLDPWD",
64
- "PAGER",
65
- "PWD",
66
- "SHLVL",
67
- "SSH_CLIENT",
68
- "SSH_CONNECTION",
69
- "SSH_TTY",
70
- "TERM",
71
- "TERM_PROGRAM",
72
- "TERM_PROGRAM_VERSION",
73
- "TERM_SESSION_ID",
74
- "TERMINFO",
75
- "TMUX",
76
- "TMUX_PANE",
77
- ]);
78
12
  export function renderSystemdUnit(input) {
79
- const inheritedEnvironment = renderInheritedEnvironment(input.environment);
13
+ const argv = [input.nodePath, ...autostartLauncherArguments(input, "--systemd-service")];
80
14
  return `[Unit]
81
15
  Description=Rynx local control plane
82
16
  After=network-online.target
@@ -93,12 +27,11 @@ SendSIGKILL=no
93
27
  TimeoutStartSec=60
94
28
  TimeoutStopSec=45
95
29
  WorkingDirectory=${systemdPath(input.dataDir)}
96
- ${inheritedEnvironment}
97
30
  Environment=${systemdQuote(`PATH=${input.pathEnv}`)}
98
31
  Environment=${systemdQuote(`RYNX_HOME=${input.dataDir}`)}
99
32
  Environment=${systemdQuote(`${RYNX_SYSTEMD_SERVICE_ENV}=${RYNX_SYSTEMD_SERVICE}`)}
100
- ExecStart=${systemdQuote(input.nodePath)} ${systemdQuote(input.cliPath)} start --systemd-service
101
- ExecStop=${systemdQuote(input.nodePath)} ${systemdQuote(input.cliPath)} stop --systemd-service
33
+ ExecStart=:${argv.map(systemdQuote).join(" ")}
34
+ ExecStop=:${systemdQuote(input.nodePath)} ${systemdQuote(input.cliPath)} stop --systemd-service
102
35
 
103
36
  [Install]
104
37
  WantedBy=default.target
@@ -161,16 +94,17 @@ export function disableLinuxAutostart() {
161
94
  requireSuccess(disabled, "systemctl --user disable");
162
95
  }
163
96
  rmSync(context.unitPath, { force: true });
97
+ rmSync(serviceEnvironmentPath(context), { force: true });
164
98
  requireSuccess(run("systemctl", ["--user", "daemon-reload"]), "systemctl --user daemon-reload");
165
99
  }
166
100
  /** Refresh an existing autostart unit without changing enablement. */
167
101
  export function refreshLinuxServiceIfPresent() {
168
- if (process.platform !== "linux" || !linuxUserSystemdAvailable())
102
+ if (process.platform !== "linux")
169
103
  return false;
170
104
  const context = currentContext();
171
105
  if (!existsSync(context.unitPath))
172
106
  return false;
173
- return syncLinuxService(context);
107
+ return syncLinuxService(context, linuxUserSystemdAvailable());
174
108
  }
175
109
  export function assertLinuxSystemdInvocation() {
176
110
  if (process.platform !== "linux") {
@@ -296,11 +230,12 @@ function currentContext() {
296
230
  environment: process.env,
297
231
  nodePath: process.execPath,
298
232
  pathEnv: process.env.PATH || defaultLinuxPath(),
233
+ shellPath: currentShell(),
299
234
  pm2Home,
300
235
  unitPath: path.join(homeDir, ".config", "systemd", "user", RYNX_SYSTEMD_SERVICE),
301
236
  };
302
237
  }
303
- function syncLinuxService(context) {
238
+ function syncLinuxService(context, reload = true) {
304
239
  mkdirSync(context.dataDir, { recursive: true, mode: 0o700 });
305
240
  mkdirSync(context.logDir, { recursive: true, mode: 0o700 });
306
241
  const content = renderSystemdUnit(context);
@@ -308,14 +243,30 @@ function syncLinuxService(context) {
308
243
  ? readFileSync(context.unitPath, "utf8")
309
244
  : undefined;
310
245
  const changed = previous !== content;
311
- if (changed)
312
- replaceFileAtomically(context.unitPath, content);
246
+ const environmentPath = serviceEnvironmentPath(context);
247
+ const environment = renderSystemdEnvironmentFile(context);
248
+ const previousEnvironment = existsSync(environmentPath)
249
+ ? readFileSync(environmentPath, "utf8")
250
+ : undefined;
251
+ const environmentChanged = previousEnvironment !== environment;
313
252
  try {
253
+ if (environmentChanged)
254
+ replaceFileAtomically(environmentPath, environment);
255
+ if (changed)
256
+ replaceFileAtomically(context.unitPath, content);
257
+ if (!reload)
258
+ return changed || environmentChanged;
314
259
  requireSuccess(run("systemctl", ["--user", "daemon-reload"]), "systemctl --user daemon-reload");
315
260
  assertStaticServiceDefinition(context, inspectLinuxSystemdService());
316
- return changed;
261
+ return changed || environmentChanged;
317
262
  }
318
263
  catch (error) {
264
+ if (environmentChanged) {
265
+ if (previousEnvironment === undefined)
266
+ rmSync(environmentPath, { force: true });
267
+ else
268
+ replaceFileAtomically(environmentPath, previousEnvironment);
269
+ }
319
270
  if (changed) {
320
271
  if (previous === undefined)
321
272
  rmSync(context.unitPath, { force: true });
@@ -389,6 +340,7 @@ function assertStaticServiceDefinition(context, state) {
389
340
  ]) {
390
341
  if (!value.includes(context.nodePath) ||
391
342
  !value.includes(context.cliPath) ||
343
+ (action === "start" && !value.includes(autostartLauncherArguments(context, "--systemd-service")[0])) ||
392
344
  !value.includes(action) ||
393
345
  !value.includes("--systemd-service")) {
394
346
  errors.push(`${name}=${value || "(empty)"}`);
@@ -501,8 +453,7 @@ function replaceFileAtomically(file, content) {
501
453
  writeFileSync(temporary, content, {
502
454
  encoding: "utf8",
503
455
  flag: "wx",
504
- // The generated unit contains the caller's environment and may include
505
- // runtime credentials. Keep the snapshot private to the owning user.
456
+ // Environment snapshots may include runtime credentials.
506
457
  mode: 0o600,
507
458
  });
508
459
  renameSync(temporary, file);
@@ -539,24 +490,6 @@ function signalMatches(value, number, name) {
539
490
  function defaultLinuxPath() {
540
491
  return "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
541
492
  }
542
- function renderInheritedEnvironment(env) {
543
- if (!env)
544
- return "";
545
- const requested = new Set((env[RYNX_SERVICE_ENV_KEYS] ?? "")
546
- .split(/[\s,]+/)
547
- .filter((name) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name)));
548
- return Object.entries(env)
549
- .filter(([name, value]) => value !== undefined &&
550
- /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) &&
551
- (AUTOMATIC_SERVICE_ENVIRONMENT.has(name) || requested.has(name)) &&
552
- !name.startsWith("npm_") &&
553
- !name.startsWith("RYNX_RUNNER_") &&
554
- !BLOCKED_SERVICE_ENVIRONMENT.has(name) &&
555
- !TRANSIENT_SERVICE_ENVIRONMENT.has(name))
556
- .sort(([left], [right]) => left.localeCompare(right))
557
- .map(([name, value]) => `Environment=${systemdQuote(`${name}=${value}`)}`)
558
- .join("\n");
559
- }
560
493
  function systemdQuote(value) {
561
494
  return `"${value
562
495
  .replaceAll("\\", "\\\\")
@@ -567,13 +500,10 @@ function systemdQuote(value) {
567
500
  .replaceAll("\r", "\\r")}"`;
568
501
  }
569
502
  function systemdPath(value) {
570
- return value
571
- .replaceAll("%", "%%")
572
- .replaceAll("\\", "\\x5c")
573
- .replaceAll(" ", "\\x20")
574
- .replaceAll("\t", "\\t")
575
- .replaceAll("\n", "\\n")
576
- .replaceAll("\r", "\\r")
577
- .replaceAll('"', "\\x22")
578
- .replaceAll("'", "\\x27");
503
+ // These scalar path directives expand specifiers, but do not unquote words
504
+ // or decode the C-style escapes accepted by ExecStart/Environment.
505
+ if (/[\r\n]/.test(value)) {
506
+ throw new Error("systemd autostart paths cannot contain line breaks");
507
+ }
508
+ return value.replaceAll("%", "%%");
579
509
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rynx-ai/cli",
3
- "version": "0.1.11-beta.49",
3
+ "version": "0.1.11-beta.50",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/rynx-ai/rynx.git",
@@ -51,11 +51,11 @@
51
51
  "dependencies": {
52
52
  "@clack/prompts": "^1.6.0",
53
53
  "ws": "^8.21.0",
54
- "@rynx-ai/core": "0.1.11-beta.49",
55
- "@rynx-ai/daemon": "0.1.11-beta.49",
56
- "@rynx-ai/emulator": "0.1.11-beta.49",
57
- "@rynx-ai/protocol": "0.1.11-beta.49",
58
- "@rynx-ai/tmux": "0.1.11-beta.49"
54
+ "@rynx-ai/emulator": "0.1.11-beta.50",
55
+ "@rynx-ai/protocol": "0.1.11-beta.50",
56
+ "@rynx-ai/core": "0.1.11-beta.50",
57
+ "@rynx-ai/daemon": "0.1.11-beta.50",
58
+ "@rynx-ai/tmux": "0.1.11-beta.50"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/ws": "^8.18.1"