@monster-orange/den 0.4.21 → 0.4.23

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/docs/distributions.md +186 -42
  3. package/package.json +1 -1
  4. package/service/install.sh +213 -63
  5. package/src/shared/agents/den-claude.ts +13 -1
  6. package/src/shared/agents/guardrails/rotation-hook.ts +5 -0
  7. package/src/shared/agents/records.ts +6 -0
  8. package/src/shared/agents/rotate-settings.ts +309 -0
  9. package/src/shared/agents/rotation.ts +110 -5
  10. package/src/shared/agents/runtime.ts +6 -0
  11. package/src/shared/agents/session-name.ts +21 -1
  12. package/src/shared/den-config.ts +27 -12
  13. package/src/shared/launchd-session.ts +92 -0
  14. package/src/shared/sessions/client.ts +24 -0
  15. package/src/shared/sessions/host.ts +114 -5
  16. package/src/shared/sessions/types.ts +5 -0
  17. package/src/ui/cli/agent-ops.ts +469 -17
  18. package/src/ui/cli/agents-command.ts +200 -3
  19. package/src/ui/cli/doctor-command.ts +72 -0
  20. package/src/ui/cli/doctor-where.ts +203 -0
  21. package/src/ui/cli/index.ts +1 -1
  22. package/src/ui/cli/server-agent-ops.ts +2 -2
  23. package/src/ui/cli/service-command.ts +217 -11
  24. package/src/ui/cli/sessions-command.ts +46 -12
  25. package/src/ui/cli/web-command.ts +36 -5
  26. package/src/ui/cli/web-service.ts +160 -0
  27. package/src/ui/server/agent-routes.ts +149 -5
  28. package/src/ui/server/index.ts +17 -5
  29. package/src/ui/server/public/agents-page.js +316 -27
  30. package/src/ui/server/public/agents.html +62 -0
  31. package/src/ui/server/public/i18n/es.js +58 -2
  32. package/src/ui/server/public/i18n/ja.js +54 -2
  33. package/src/ui/server/public/settings-registry.js +204 -0
  34. package/src/ui/server/public/settings.html +43 -4
  35. package/src/ui/server/public/shell.js +56 -2
  36. package/src/ui/server/route-classes.ts +21 -0
  37. package/src/ui/server/session-routes.ts +27 -5
@@ -0,0 +1,92 @@
1
+ /**
2
+ * D20: the launchd session a process runs in, as `launchctl managername`
3
+ * names it. It decides what survives a logout:
4
+ *
5
+ * Aqua, LoginWindow the desktop login session (gui/<uid>): ends when the
6
+ * person logs out, with every process in it.
7
+ * Background outside it (an ssh shell, user/<uid>): keeps running
8
+ * after logout, but has no login keychain and no GUI
9
+ * (headed apps, WebKit on macOS).
10
+ * System a LaunchDaemon: as Background.
11
+ *
12
+ * A child inherits its parent's session, so a session host started from
13
+ * the desktop dies at logout with every agent in it. Off macOS there is no
14
+ * such thing (null).
15
+ */
16
+ import { spawnSync } from "child_process";
17
+
18
+ /**
19
+ * `service install --mode always` is opt-in: it is meant to keep the
20
+ * server, the host and every agent running across a logout, but no logout
21
+ * has proven it yet. Every place that offers it says so.
22
+ */
23
+ export const ALWAYS_UNPROVEN = "not yet proven across a logout";
24
+
25
+ export type LaunchctlRunner = () => { status: number | null; stdout: string };
26
+
27
+ const defaultRunner: LaunchctlRunner = () => {
28
+ const r = spawnSync("launchctl", ["managername"], {
29
+ encoding: "utf-8",
30
+ timeout: 2000,
31
+ });
32
+ return { status: r.status, stdout: r.stdout ?? "" };
33
+ };
34
+
35
+ /** `launchctl managername`, or "unknown"; null off macOS. */
36
+ export function readLaunchdSession(
37
+ run: LaunchctlRunner = defaultRunner,
38
+ platform: NodeJS.Platform = process.platform,
39
+ ): string | null {
40
+ if (platform !== "darwin") return null;
41
+ try {
42
+ const r = run();
43
+ const name = r.status === 0 ? r.stdout.trim() : "";
44
+ // Only a plain word: it is shown and stored as-is.
45
+ return /^[A-Za-z]{1,32}$/.test(name) ? name : "unknown";
46
+ } catch {
47
+ return "unknown";
48
+ }
49
+ }
50
+
51
+ let cached: string | null | undefined;
52
+
53
+ /** This process's launchd session (it never changes, so asked once). */
54
+ export function launchdSession(): string | null {
55
+ if (cached === undefined) cached = readLaunchdSession();
56
+ return cached;
57
+ }
58
+
59
+ /**
60
+ * D20: `web up` from a shell outside the desktop session (an ssh shell:
61
+ * Background) starts a server there; say what that means and the right
62
+ * way. null in the desktop session or off macOS.
63
+ */
64
+ export function webUpWarning(
65
+ session: string | null | undefined,
66
+ cli: string,
67
+ ): string | null {
68
+ if (session !== "Background" && session !== "System") return null;
69
+ return `this shell runs outside the desktop session (${session}, e.g. ssh): a server started here keeps running after logout but has no login keychain and no GUI, and its agents run here too. To run it as a service: ${cli} service install --mode always (opt-in, ${ALWAYS_UNPROVEN}) or --mode login`;
70
+ }
71
+
72
+ /** Whether a process in this session ends when the person logs out. */
73
+ export function endsAtLogout(session: string | null | undefined): boolean {
74
+ return session === "Aqua" || session === "LoginWindow";
75
+ }
76
+
77
+ /**
78
+ * The line doctor and the agent page show for `what` (the server, the
79
+ * session host, an agent) running in `session`: the fix when it ends at
80
+ * logout, what it lacks when it doesn't. null when there is nothing to say.
81
+ */
82
+ export function sessionWarning(
83
+ session: string | null | undefined,
84
+ cli: string,
85
+ what: string,
86
+ ): string | null {
87
+ if (endsAtLogout(session))
88
+ return `the ${what} runs in the desktop login session (${session}): it stops when you log out. To keep it running: ${cli} service install --mode always (opt-in, ${ALWAYS_UNPROVEN})`;
89
+ if (session === "Background" || session === "System")
90
+ return `the ${what} keeps running after logout (${session}); it has no login keychain and no GUI, so it uses the 1Password service-account token, headless browsers and Full Disk Access instead`;
91
+ return null;
92
+ }
@@ -45,6 +45,8 @@ export class HostClient extends EventEmitter {
45
45
  private readonly attachments = new Map<string, AttachHandlers>();
46
46
  hostPid = 0;
47
47
  hostStartedAt = "";
48
+ /** D20: the host's launchd session (null off macOS or an older host). */
49
+ hostLaunchd: string | null = null;
48
50
 
49
51
  constructor(private readonly socket: net.Socket) {
50
52
  super();
@@ -117,10 +119,32 @@ export class HostClient extends EventEmitter {
117
119
  this.request(op, body).catch(() => undefined);
118
120
  }
119
121
 
122
+ /**
123
+ * D18: the host's executable and whether it has Full Disk Access (null:
124
+ * cannot tell). null from a host older than 0.4.22.
125
+ */
126
+ async access(): Promise<{
127
+ exe: string;
128
+ fullDiskAccess: boolean | null;
129
+ } | null> {
130
+ try {
131
+ const r = await this.request("access");
132
+ return {
133
+ exe: String(r.exe ?? ""),
134
+ fullDiskAccess:
135
+ typeof r.fullDiskAccess === "boolean" ? r.fullDiskAccess : null,
136
+ };
137
+ } catch (e) {
138
+ if (e instanceof HostError && e.code === "bad-request") return null;
139
+ throw e;
140
+ }
141
+ }
142
+
120
143
  async hello(): Promise<void> {
121
144
  const r = await this.request("hello");
122
145
  this.hostPid = Number(r.pid);
123
146
  this.hostStartedAt = String(r.startedAt ?? "");
147
+ this.hostLaunchd = typeof r.launchd === "string" ? r.launchd : null;
124
148
  }
125
149
 
126
150
  async start(
@@ -29,6 +29,7 @@ import {
29
29
  type Request,
30
30
  } from "./protocol.js";
31
31
  import type { PtyLoad, PtyProcess } from "./pty.js";
32
+ import { launchdSession } from "../launchd-session.js";
32
33
  import {
33
34
  SESSION_POOLS,
34
35
  SESSION_SIGNALS,
@@ -54,6 +55,14 @@ export interface SessionHostOptions {
54
55
  log?: (line: string) => void;
55
56
  /** Called after a `shutdown` request has closed the host. */
56
57
  onShutdown?: () => void;
58
+ /** D20: the launchd session it runs in (default: asked; null off macOS). */
59
+ launchdSession?: string | null;
60
+ /**
61
+ * D18: whether this process has Full Disk Access (default: it can list
62
+ * macOS's TCC folder, which only such a process can; null off macOS).
63
+ * What it starts inherits it, so the agents have it too.
64
+ */
65
+ fullDiskAccess?: () => boolean | null;
57
66
  }
58
67
 
59
68
  interface Session {
@@ -169,6 +178,27 @@ function printableActor(v: unknown): string {
169
178
 
170
179
  const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
171
180
 
181
+ /**
182
+ * D18: Full Disk Access, as macOS grants it to this executable: only such
183
+ * a process may list ~/Library/Application Support/com.apple.TCC. null off
184
+ * macOS, or when the folder is not there to ask.
185
+ */
186
+ export function hasFullDiskAccess(
187
+ home: string = process.env.HOME ?? "",
188
+ platform: NodeJS.Platform = process.platform,
189
+ ): boolean | null {
190
+ if (platform !== "darwin" || !home) return null;
191
+ try {
192
+ fs.readdirSync(
193
+ path.join(home, "Library", "Application Support", "com.apple.TCC"),
194
+ );
195
+ return true;
196
+ } catch (e) {
197
+ const code = (e as NodeJS.ErrnoException).code;
198
+ return code === "EPERM" || code === "EACCES" ? false : null;
199
+ }
200
+ }
201
+
172
202
  export class SessionHost {
173
203
  readonly paths: SessionPaths;
174
204
  private readonly sessions = new Map<string, Session>();
@@ -176,9 +206,17 @@ export class SessionHost {
176
206
  private server: net.Server | null = null;
177
207
  private readonly startedAt = new Date().toISOString();
178
208
  private closed = false;
209
+ /** D20: where it runs; stamped on every session it starts. */
210
+ private readonly launchd: string | null;
179
211
 
180
212
  constructor(private readonly opts: SessionHostOptions) {
181
213
  this.paths = sessionPaths(opts.stateDir);
214
+ const where =
215
+ opts.launchdSession !== undefined
216
+ ? opts.launchdSession
217
+ : launchdSession();
218
+ // "unknown" (launchctl said nothing) is no stamp at all.
219
+ this.launchd = where === "unknown" ? null : where;
182
220
  }
183
221
 
184
222
  private log(line: string): void {
@@ -189,6 +227,34 @@ export class SessionHost {
189
227
  return this.opts.scrollbackBytes ?? DEFAULT_SCROLLBACK_BYTES;
190
228
  }
191
229
 
230
+ /**
231
+ * Calls `onGone` once its state dir no longer exists (checked every
232
+ * `everyMs`): nothing it owns can be kept any more, so the host should
233
+ * end. A test that deletes its temp state dir no longer leaves a host
234
+ * running; a real install never deletes its state dir under a running
235
+ * host. Returns a stop function.
236
+ */
237
+ watchStateDir(everyMs: number, onGone: () => void): () => void {
238
+ const t = setInterval(() => {
239
+ if (fs.existsSync(this.opts.stateDir)) return;
240
+ clearInterval(t);
241
+ onGone();
242
+ }, everyMs);
243
+ t.unref();
244
+ return () => clearInterval(t);
245
+ }
246
+
247
+ /**
248
+ * D18: serve once no other host answers on the socket, checking every
249
+ * `everyMs`; resolves once it listens.
250
+ */
251
+ async listenWhenFree(everyMs: number): Promise<"listening"> {
252
+ for (;;) {
253
+ if ((await this.listen()) === "listening") return "listening";
254
+ await sleep(everyMs);
255
+ }
256
+ }
257
+
192
258
  /** Serve on the socket, or stand down when a host already answers there. */
193
259
  async listen(): Promise<"listening" | "already-running"> {
194
260
  const { socket } = this.paths;
@@ -293,6 +359,7 @@ export class SessionHost {
293
359
  exited?: boolean;
294
360
  exitCode?: number;
295
361
  prompted?: boolean;
362
+ launchd?: unknown;
296
363
  };
297
364
  if (typeof rec.id !== "string" || typeof rec.name !== "string")
298
365
  continue;
@@ -308,6 +375,10 @@ export class SessionHost {
308
375
  ? { exitCode: rec.exitCode }
309
376
  : {}),
310
377
  ...(rec.prompted === true ? { prompted: true } : {}),
378
+ ...(typeof rec.launchd === "string" &&
379
+ /^[A-Za-z]{1,32}$/.test(rec.launchd)
380
+ ? { launchd: rec.launchd }
381
+ : {}),
311
382
  },
312
383
  cwd: rec.cwd,
313
384
  pty: null,
@@ -338,6 +409,7 @@ export class SessionHost {
338
409
  ...(s.info.exited ? { exited: true } : {}),
339
410
  ...(s.info.exitCode !== undefined ? { exitCode: s.info.exitCode } : {}),
340
411
  ...(s.info.prompted ? { prompted: true } : {}),
412
+ ...(s.info.launchd ? { launchd: s.info.launchd } : {}),
341
413
  };
342
414
  try {
343
415
  fs.writeFileSync(
@@ -419,6 +491,7 @@ export class SessionHost {
419
491
  v: PROTOCOL_VERSION,
420
492
  pid: process.pid,
421
493
  startedAt: this.startedAt,
494
+ launchd: this.launchd,
422
495
  };
423
496
  case "list":
424
497
  return {
@@ -498,6 +571,12 @@ export class SessionHost {
498
571
  this.forget(s);
499
572
  return {};
500
573
  }
574
+ case "access":
575
+ // D18: what this host (and every agent in it) may reach.
576
+ return {
577
+ exe: process.execPath,
578
+ fullDiskAccess: (this.opts.fullDiskAccess ?? hasFullDiskAccess)(),
579
+ };
501
580
  default:
502
581
  throw new Refusal("bad-request", `unknown op "${String(req.op)}"`);
503
582
  }
@@ -607,6 +686,7 @@ export class SessionHost {
607
686
  name,
608
687
  pid: pty.pid,
609
688
  startedAt: new Date().toISOString(),
689
+ ...(this.launchd ? { launchd: this.launchd } : {}),
610
690
  },
611
691
  cwd,
612
692
  pty,
@@ -712,7 +792,19 @@ export class SessionHost {
712
792
  * already running; 1 when it cannot listen.
713
793
  */
714
794
  export async function runSessionHost(
715
- opts: Omit<SessionHostOptions, "onShutdown">,
795
+ opts: Omit<SessionHostOptions, "onShutdown"> & {
796
+ /**
797
+ * D18: the launchd job's host. While another host answers on the
798
+ * socket (one started before the job, from a terminal or the web
799
+ * server), wait for it to end, then serve, instead of exiting at once
800
+ * (launchd would restart it every few seconds). Checks every
801
+ * standByMs (default 5 s).
802
+ */
803
+ standBy?: boolean;
804
+ standByMs?: number;
805
+ /** How often it checks its state dir still exists (default 10 s). */
806
+ stateDirCheckMs?: number;
807
+ },
716
808
  ): Promise<number> {
717
809
  const log =
718
810
  opts.log ??
@@ -729,15 +821,32 @@ export async function runSessionHost(
729
821
  log(`session host could not start: ${(e as Error).message}`);
730
822
  return 1;
731
823
  }
732
- if (state === "already-running") {
824
+ const stop = () => void host.close().then(() => process.exit(0));
825
+ if (state === "already-running" && opts.standBy) {
826
+ process.on("SIGTERM", stop);
827
+ process.on("SIGINT", stop);
828
+ log(
829
+ `a session host is already running on ${host.paths.socket}: standing by until it ends (its sessions keep running there)`,
830
+ );
831
+ try {
832
+ state = await host.listenWhenFree(opts.standByMs ?? 5000);
833
+ } catch (e) {
834
+ log(`session host could not start: ${(e as Error).message}`);
835
+ return 1;
836
+ }
837
+ } else if (state === "already-running") {
733
838
  log(`a session host is already running on ${host.paths.socket}`);
734
839
  return 0;
840
+ } else {
841
+ process.on("SIGTERM", stop);
842
+ process.on("SIGINT", stop);
735
843
  }
736
- const stop = () => void host.close().then(() => process.exit(0));
737
- process.on("SIGTERM", stop);
738
- process.on("SIGINT", stop);
739
844
  // No terminal to hang up on: it was started detached.
740
845
  process.on("SIGHUP", () => undefined);
741
846
  log(`session host ${process.pid} listening on ${host.paths.socket}`);
847
+ host.watchStateDir(opts.stateDirCheckMs ?? 10_000, () => {
848
+ log(`its state dir ${opts.stateDir} is gone: stopping`);
849
+ stop();
850
+ });
742
851
  return 0;
743
852
  }
@@ -30,6 +30,11 @@ export interface SessionInfo {
30
30
  * starts afresh instead. Never what was typed.
31
31
  */
32
32
  prompted?: boolean;
33
+ /**
34
+ * D20: the launchd session its host ran in (`launchctl managername`):
35
+ * Aqua ends at logout, Background survives it. Absent off macOS.
36
+ */
37
+ launchd?: string;
33
38
  }
34
39
 
35
40
  /**