@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.
- package/CHANGELOG.md +164 -0
- package/README.md +7 -7
- package/docs/distributions.md +167 -19
- package/package.json +1 -1
- package/service/install.sh +213 -63
- package/src/shared/agents/den-claude.ts +13 -1
- package/src/shared/agents/records.ts +6 -0
- package/src/shared/agents/runtime.ts +6 -0
- package/src/shared/agents/session-name.ts +21 -1
- package/src/shared/attest-code.ts +125 -0
- package/src/shared/claim-code.ts +6 -2
- package/src/{ui/server/edge-exposure.ts → shared/edge/exposure.ts} +7 -1
- package/src/shared/github-app.ts +4 -0
- package/src/shared/github-device.ts +38 -1
- package/src/shared/launchd-session.ts +92 -0
- package/src/shared/owner-github.ts +100 -0
- package/src/shared/product.ts +45 -5
- package/src/shared/profile.ts +26 -2
- package/src/shared/sessions/client.ts +24 -0
- package/src/shared/sessions/host.ts +114 -5
- package/src/shared/sessions/types.ts +5 -0
- package/src/shared/web-session.ts +112 -6
- package/src/ui/cli/agent-ops.ts +20 -2
- package/src/ui/cli/agents-command.ts +32 -1
- package/src/ui/cli/create.ts +92 -6
- package/src/ui/cli/doctor-command.ts +126 -15
- package/src/ui/cli/doctor-owner.ts +122 -0
- package/src/ui/cli/doctor-where.ts +203 -0
- package/src/ui/cli/index.ts +1 -1
- package/src/ui/cli/service-command.ts +233 -11
- package/src/ui/cli/sessions-command.ts +46 -12
- package/src/ui/cli/web-command.ts +78 -12
- package/src/ui/server/device-flows.ts +45 -10
- package/src/ui/server/github-routes.ts +169 -16
- package/src/ui/server/index.ts +147 -12
- package/src/ui/server/public/agents-page.js +103 -6
- package/src/ui/server/public/agents.html +30 -0
- package/src/ui/server/public/error-page.js +115 -33
- package/src/ui/server/public/error.html +16 -0
- package/src/ui/server/public/header.js +5 -7
- package/src/ui/server/public/i18n/es.js +24 -1
- package/src/ui/server/public/i18n/ja.js +24 -1
- package/src/ui/server/public/settings-registry.js +1 -1
- package/src/ui/server/public/shell.js +24 -1
- package/src/ui/server/route-classes.ts +7 -0
- 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
|
-
/**
|
|
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
|
+
}
|
package/src/shared/product.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
|
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();
|
package/src/shared/profile.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
/**
|