@monster-orange/den 0.4.20 → 0.4.22

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 (46) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +167 -19
  4. package/package.json +1 -1
  5. package/service/install.sh +213 -63
  6. package/src/shared/agents/den-claude.ts +13 -1
  7. package/src/shared/agents/records.ts +6 -0
  8. package/src/shared/agents/runtime.ts +6 -0
  9. package/src/shared/agents/session-name.ts +21 -1
  10. package/src/shared/attest-code.ts +125 -0
  11. package/src/shared/claim-code.ts +6 -2
  12. package/src/{ui/server/edge-exposure.ts → shared/edge/exposure.ts} +7 -1
  13. package/src/shared/github-app.ts +4 -0
  14. package/src/shared/github-device.ts +38 -1
  15. package/src/shared/launchd-session.ts +92 -0
  16. package/src/shared/owner-github.ts +100 -0
  17. package/src/shared/product.ts +45 -5
  18. package/src/shared/profile.ts +26 -2
  19. package/src/shared/sessions/client.ts +24 -0
  20. package/src/shared/sessions/host.ts +114 -5
  21. package/src/shared/sessions/types.ts +5 -0
  22. package/src/shared/web-session.ts +112 -6
  23. package/src/ui/cli/agent-ops.ts +20 -2
  24. package/src/ui/cli/agents-command.ts +32 -1
  25. package/src/ui/cli/create.ts +92 -6
  26. package/src/ui/cli/doctor-command.ts +126 -15
  27. package/src/ui/cli/doctor-owner.ts +122 -0
  28. package/src/ui/cli/doctor-where.ts +203 -0
  29. package/src/ui/cli/index.ts +1 -1
  30. package/src/ui/cli/service-command.ts +233 -11
  31. package/src/ui/cli/sessions-command.ts +46 -12
  32. package/src/ui/cli/web-command.ts +78 -12
  33. package/src/ui/server/device-flows.ts +45 -10
  34. package/src/ui/server/github-routes.ts +169 -16
  35. package/src/ui/server/index.ts +147 -12
  36. package/src/ui/server/public/agents-page.js +103 -6
  37. package/src/ui/server/public/agents.html +30 -0
  38. package/src/ui/server/public/error-page.js +115 -33
  39. package/src/ui/server/public/error.html +16 -0
  40. package/src/ui/server/public/header.js +5 -7
  41. package/src/ui/server/public/i18n/es.js +24 -1
  42. package/src/ui/server/public/i18n/ja.js +24 -1
  43. package/src/ui/server/public/settings-registry.js +1 -1
  44. package/src/ui/server/public/shell.js +24 -1
  45. package/src/ui/server/route-classes.ts +7 -0
  46. package/src/ui/server/session-routes.ts +4 -0
@@ -56,11 +56,27 @@ export interface GithubIdentity {
56
56
  login: string;
57
57
  }
58
58
 
59
- /** The three calls, swappable in a unit test. */
59
+ /**
60
+ * Whether a login is a member of an org (D17), as far as its own token can
61
+ * tell: `member`, `not_member`, or `unreadable` (the membership is private
62
+ * and this token may not read it: the app lacks the org's Members
63
+ * permission, or isn't installed there).
64
+ */
65
+ export type OrgMembership = "member" | "not_member" | "unreadable";
66
+
67
+ /** The calls, swappable in a unit test. */
60
68
  export interface GithubDeviceClient {
61
69
  start(clientId: string): Promise<DeviceCode>;
62
70
  poll(clientId: string, deviceCode: string): Promise<PollResult>;
63
71
  user(token: string): Promise<GithubIdentity>;
72
+ /**
73
+ * D17: is `login` (the token's own account) a member of `org`? First its
74
+ * own membership (`GET /user/memberships/orgs/<org>`, which a GitHub App
75
+ * reads with the org's Members permission), then the public list (`GET
76
+ * /orgs/<org>/members/<login>`: 204 for a public member). A network
77
+ * failure throws.
78
+ */
79
+ orgMember(token: string, org: string, login: string): Promise<OrgMembership>;
64
80
  }
65
81
 
66
82
  const TIMEOUT_MS = 15_000;
@@ -218,6 +234,27 @@ export function githubDeviceClient(
218
234
  throw new Error(`GitHub's /user answered ${r.status}`);
219
235
  return { id: j.id, login: j.login };
220
236
  },
237
+ async orgMember(token, org, login) {
238
+ const headers = {
239
+ accept: "application/vnd.github+json",
240
+ authorization: `Bearer ${token}`,
241
+ "x-github-api-version": "2022-11-28",
242
+ };
243
+ const own = await request(
244
+ `${ep.api}/user/memberships/orgs/${encodeURIComponent(org)}`,
245
+ { method: "GET", headers },
246
+ );
247
+ if (own.status === 200)
248
+ return parse(own.body).state === "active" ? "member" : "not_member";
249
+ const pub = await request(
250
+ `${ep.api}/orgs/${encodeURIComponent(org)}/members/${encodeURIComponent(login)}`,
251
+ { method: "GET", headers },
252
+ );
253
+ if (pub.status === 204) return "member";
254
+ // A 404 for its own membership means none; a 403 means this token
255
+ // may not read it, so a private membership can't be told apart.
256
+ return own.status === 404 ? "not_member" : "unreadable";
257
+ },
221
258
  };
222
259
  }
223
260
 
@@ -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
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * `web.owner_github` (D17): the GitHub login of the person who runs this
3
+ * instance, recorded in config/local/user.yml at `web up` or `service
4
+ * install` from `gh` when it is signed in here and no owner is linked yet.
5
+ * With it, that person connects their GitHub account from any browser, on
6
+ * any machine (a devbox they reach from a laptop), and nobody else can
7
+ * claim the instance from another machine (github-routes.ts decideFlow).
8
+ *
9
+ * Only the login is kept: never a token. `gh api user` runs with a short
10
+ * timeout; gh missing or signed out records nothing.
11
+ */
12
+ import { execFileSync } from "child_process";
13
+ import { readUserConfig, writeUserConfig } from "./den-config.js";
14
+ import { parseGithubName } from "./web-session.js";
15
+ import { readWebOwner } from "./web-owner.js";
16
+
17
+ export interface OwnerGithubDeps {
18
+ /** The login `gh` is signed in as here, or null. */
19
+ ghLogin?: () => string | null;
20
+ /** Whether an owner is linked already. */
21
+ ownerLinked?: () => boolean;
22
+ read?: typeof readUserConfig;
23
+ write?: typeof writeUserConfig;
24
+ }
25
+
26
+ /** `gh api user`'s login, or null (gh missing, signed out, slow, odd output). */
27
+ export function ghLogin(env: NodeJS.ProcessEnv = process.env): string | null {
28
+ try {
29
+ const out = execFileSync("gh", ["api", "user", "--jq", ".login"], {
30
+ env,
31
+ timeout: 8_000,
32
+ stdio: ["ignore", "pipe", "ignore"],
33
+ encoding: "utf-8",
34
+ }).trim();
35
+ return parseGithubName(out, "gh api user", "gh") ?? null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ export type OwnerGithubResult =
42
+ | { state: "linked" }
43
+ | { state: "set"; login: string }
44
+ | { state: "recorded"; login: string }
45
+ | { state: "none" };
46
+
47
+ /**
48
+ * Record `web.owner_github` from gh when no owner is linked and it isn't
49
+ * set: `recorded`. Already set: `set`. An owner linked: `linked` (nothing
50
+ * to do). gh can't say: `none`. Never throws.
51
+ */
52
+ export function recordOwnerGithub(
53
+ deps: OwnerGithubDeps = {},
54
+ ): OwnerGithubResult {
55
+ try {
56
+ if ((deps.ownerLinked ?? (() => readWebOwner() !== null))())
57
+ return { state: "linked" };
58
+ const read = deps.read ?? readUserConfig;
59
+ const cfg = read();
60
+ const web = { ...((cfg as { web?: Record<string, unknown> }).web ?? {}) };
61
+ let set: string | undefined;
62
+ try {
63
+ set = parseGithubName(
64
+ web.owner_github,
65
+ "web.owner_github",
66
+ "config/local/user.yml",
67
+ );
68
+ } catch {
69
+ return { state: "none" }; // a value someone typed: leave it alone
70
+ }
71
+ if (set) return { state: "set", login: set };
72
+ const login = (deps.ghLogin ?? ghLogin)();
73
+ if (!login) return { state: "none" };
74
+ web.owner_github = login;
75
+ (deps.write ?? writeUserConfig)({ ...cfg, web } as typeof cfg);
76
+ return { state: "recorded", login };
77
+ } catch {
78
+ return { state: "none" };
79
+ }
80
+ }
81
+
82
+ /** The line `web up` and `service install` print, or null. */
83
+ export function ownerGithubLine(
84
+ r: OwnerGithubResult,
85
+ cli = "den",
86
+ ): string | null {
87
+ if (r.state === "recorded")
88
+ return `GitHub owner: @${r.login} (from gh; web.owner_github in config/local/user.yml). Connect your GitHub account from any browser to sign in.`;
89
+ if (r.state === "none") return ghNotSignedInHint(cli);
90
+ return null;
91
+ }
92
+
93
+ /** No owner, no web.owner_github, and gh can't say who you are: the fix. */
94
+ export function ghNotSignedInHint(cli = "den"): string {
95
+ return (
96
+ "GitHub owner: not known, because gh isn't signed in here. " +
97
+ `Run \`gh auth login\`, then \`${cli} web restart\`; or set web.owner_github: <your GitHub login> in config/local/user.yml. ` +
98
+ "Then you can connect your GitHub account from any browser."
99
+ );
100
+ }
@@ -8,6 +8,9 @@
8
8
  * distribution root can be found (e.g. help text printed outside a checkout).
9
9
  */
10
10
 
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import { parse as parseYaml } from "yaml";
11
14
  import { getDistRoot } from "./paths.js";
12
15
  import {
13
16
  loadProfile,
@@ -17,7 +20,11 @@ import {
17
20
  } from "./profile.js";
18
21
 
19
22
  export interface Product extends ProductProfile {
20
- /** Display name: the name with its first letter upper-cased ("Den"). */
23
+ /**
24
+ * Display name (D11): the org config's brand.name ("Acme Den") when it is
25
+ * safe to show anywhere (TITLE_SAFE), else the name with its first letter
26
+ * upper-cased ("Acme-den").
27
+ */
21
28
  title: string;
22
29
  /** The YAML manifest file name (".den.yml"). */
23
30
  manifestYml: string;
@@ -25,11 +32,43 @@ export interface Product extends ProductProfile {
25
32
  manifestJson: string;
26
33
  }
27
34
 
28
- function withDerived(p: ProductProfile): Product {
35
+ /**
36
+ * What a brand.name may hold to be the display title: letters, digits,
37
+ * spaces, ".", "_" and "-", up to 40. The title is filled into page scripts
38
+ * (inside "…" and '…' strings) and HTML attributes, so no quote, backslash,
39
+ * "<" or "&" may reach it (the 0.4.20 hyphen bug's kin).
40
+ */
41
+ export const TITLE_SAFE = /^[\p{L}\p{N}][\p{L}\p{N} ._-]{0,39}$/u;
42
+
43
+ /** The name, first letter upper-cased: "acme-den" → "Acme-den". */
44
+ export function nameTitle(name: string): string {
45
+ return name.charAt(0).toUpperCase() + name.slice(1);
46
+ }
47
+
48
+ /** The org config's brand.name, trimmed, or undefined. Never throws. */
49
+ export function brandNameAt(root: string | null, configFile: string) {
50
+ if (!root) return undefined;
51
+ try {
52
+ const raw = parseYaml(
53
+ fs.readFileSync(path.join(root, configFile), "utf-8"),
54
+ ) as { brand?: { name?: unknown } } | null;
55
+ const n = raw?.brand?.name;
56
+ return typeof n === "string" && n.trim() ? n.trim() : undefined;
57
+ } catch {
58
+ return undefined;
59
+ }
60
+ }
61
+
62
+ /** The display title: a safe brand.name, else nameTitle. */
63
+ export function titleOf(name: string, brandName?: string): string {
64
+ return brandName && TITLE_SAFE.test(brandName) ? brandName : nameTitle(name);
65
+ }
66
+
67
+ function withDerived(p: ProductProfile, root: string | null = null): Product {
29
68
  const yml = p.manifestFiles[0];
30
69
  return {
31
70
  ...p,
32
- title: p.name.charAt(0).toUpperCase() + p.name.slice(1),
71
+ title: titleOf(p.name, brandNameAt(root, p.configFile)),
33
72
  manifestYml: yml,
34
73
  manifestJson: p.manifestFiles[1] ?? yml.replace(/\.ya?ml$/, "") + ".json",
35
74
  };
@@ -40,10 +79,11 @@ let derived: { profile: ProductProfile; product: Product } | null = null;
40
79
  /** The running product. Never throws. */
41
80
  export function product(): Product {
42
81
  try {
43
- const profile = loadProfile(getDistRoot());
82
+ const root = getDistRoot();
83
+ const profile = loadProfile(root);
44
84
  // Same profile object (loadProfile caches it): same derived names.
45
85
  if (derived?.profile !== profile)
46
- derived = { profile, product: withDerived(profile) };
86
+ derived = { profile, product: withDerived(profile, root) };
47
87
  return derived.product;
48
88
  } catch {
49
89
  const pinned = process.env.DEN_PROFILE?.trim();
@@ -58,6 +58,13 @@ interface RawProduct {
58
58
  }
59
59
 
60
60
  const SLUG = /^[a-z][a-z0-9-]*$/;
61
+ /**
62
+ * A path the pages show (config, manifest, services and workspaces dirs):
63
+ * letters, digits, ".", "_", "-" and "/". The web pages carry these
64
+ * names as placeholders filled when served (product.ts productTokens), so
65
+ * a quote, backslash, "$" or "<" in one would break a page's script.
66
+ */
67
+ const SAFE_PATH = /^[A-Za-z0-9._/-]+$/;
61
68
  const PREFIX = /^[A-Z][A-Z0-9_]*$/;
62
69
 
63
70
  const str = (v: unknown): string | undefined =>
@@ -83,7 +90,7 @@ export function profileFrom(
83
90
  const name = str(raw.name) ?? "den";
84
91
  if (!SLUG.test(name))
85
92
  throw new Error(
86
- `den.yml: product.name "${name}" must be lower-case letters, digits and "-"`,
93
+ `den.yml: product.name "${name}" must be lower-case letters, digits and "-", starting with a letter`,
87
94
  );
88
95
  const envPrefix =
89
96
  str(raw.env_prefix) ?? name.toUpperCase().replace(/-/g, "_");
@@ -91,11 +98,28 @@ export function profileFrom(
91
98
  throw new Error(
92
99
  `den.yml: product.env_prefix "${envPrefix}" must be upper-case letters, digits and "_"`,
93
100
  );
101
+ const cli = str(raw.cli) ?? name;
102
+ if (!SLUG.test(cli))
103
+ throw new Error(
104
+ `den.yml: product.cli "${cli}" must be lower-case letters, digits and "-", starting with a letter`,
105
+ );
106
+ for (const key of [
107
+ "config",
108
+ "manifest",
109
+ "services_dir",
110
+ "workspaces_dir",
111
+ ] as const) {
112
+ const v = str(raw[key]);
113
+ if (v !== undefined && !SAFE_PATH.test(v))
114
+ throw new Error(
115
+ `den.yml: product.${key} "${v}" may use only letters, digits, ".", "_", "-" and "/"`,
116
+ );
117
+ }
94
118
  const manifest = str(raw.manifest) ?? `.${name}.yml`;
95
119
  const json = manifest.replace(/\.ya?ml$/, ".json");
96
120
  return {
97
121
  name,
98
- cli: str(raw.cli) ?? name,
122
+ cli,
99
123
  envPrefix,
100
124
  configFile: str(raw.config) ?? `config/${name}.yml`,
101
125
  manifestFiles: json === manifest ? [manifest] : [manifest, json],
@@ -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
  /**