@ccmsg/cli 0.2.7 → 0.2.10
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/README.md +13 -1
- package/package.json +2 -2
- package/src/auth/auth.ts +35 -3
- package/src/auth/http.ts +7 -1
- package/src/auth/webauthn.ts +15 -3
- package/src/plugin/claude.ts +9 -0
- package/src/service/program.ts +140 -0
- package/src/service/service.ts +97 -16
package/README.md
CHANGED
|
@@ -7,13 +7,25 @@ together with its CLI and the plugins it hands to agents.
|
|
|
7
7
|
|
|
8
8
|
- `daemon` — holds sessions, delivery, topics and the mesh, and answers the contract's ops over UDS / WS
|
|
9
9
|
- `cli` — the entry point a session calls its instance through (`ccmsg`)
|
|
10
|
-
- `
|
|
10
|
+
- `plugin` — the plugin an agent receives (`ccmsg plugin install claude`). Claude Code is the only
|
|
11
|
+
agent that has one; a codex plugin is not implemented, and what it would take is tracked in
|
|
12
|
+
[`docs/issue/2026-09-09-codex-plugin-delivery-via-thread-queue.md`](./docs/issue/2026-09-09-codex-plugin-delivery-via-thread-queue.md)
|
|
11
13
|
|
|
12
14
|
The wire contract is [`@ccmsg/protocol`](https://github.com/kawaz/ccmsg-protocol), pinned to a
|
|
13
15
|
version here. Who may call what, and what comes back, is decided by the contract's attribute
|
|
14
16
|
table and schemas; the daemon reads them rather than carrying validation or authorization
|
|
15
17
|
branches of its own.
|
|
16
18
|
|
|
19
|
+
## What it does not do
|
|
20
|
+
|
|
21
|
+
- Serve the webui, or keep a conversation log of its own — the webui is its own static site, and transcript is the source of truth
|
|
22
|
+
- Separate privileges, or mesh with an instance of a different uid / config home — that boundary is the OS's uid and file permissions
|
|
23
|
+
- Re-derive an upstream judgment (the gateway's severity, Claude Code's permission decisions) or observe another config home
|
|
24
|
+
- Carry validation of its own, or serve v1 alongside — the contract's validator is called, and the new lineage stands as a separate instance
|
|
25
|
+
|
|
26
|
+
Authenticating a person is not on that list: the daemon answers "who came" itself, with a
|
|
27
|
+
passkey. [docs/DESIGN.md](./docs/DESIGN.md) §9 carries the reason each of these ties back to.
|
|
28
|
+
|
|
17
29
|
## Documentation
|
|
18
30
|
|
|
19
31
|
- [docs/DESIGN.md](./docs/DESIGN.md) — layers, delivery, the state model, the mesh, and how it is tested
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ccmsg/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
4
4
|
"description": "The ccmsg daemon, CLI and agent plugins for one instance (= one config home)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "kawaz",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"test": "bun test"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@ccmsg/protocol": "1.
|
|
23
|
+
"@ccmsg/protocol": "1.7.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "^1.3.0",
|
package/src/auth/auth.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
AuthChallengeResult,
|
|
6
6
|
AuthRecord,
|
|
7
7
|
AuthRefreshArgs,
|
|
8
|
+
AuthRefreshReason,
|
|
8
9
|
AuthRefreshResult,
|
|
9
10
|
AuthRegisterArgs,
|
|
10
11
|
AuthResolveArgs,
|
|
@@ -145,6 +146,18 @@ export interface MintedSession {
|
|
|
145
146
|
readonly refresh: { readonly value: Base64Url; readonly expires_at: Timestamp };
|
|
146
147
|
}
|
|
147
148
|
|
|
149
|
+
/** What is known about the client that asked for a refresh: its own word for
|
|
150
|
+
* why, and what the carrier observed of it.
|
|
151
|
+
*
|
|
152
|
+
* Kept on the family as `last_refresh` and read by nobody but the person whose
|
|
153
|
+
* sessions they are — a run of `reconnect` at an hour they were asleep is
|
|
154
|
+
* something to recognise. Nothing here is checked, so nothing may turn on it. */
|
|
155
|
+
export interface RefreshFrom {
|
|
156
|
+
readonly reason?: AuthRefreshReason;
|
|
157
|
+
readonly ip?: string;
|
|
158
|
+
readonly userAgent?: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
148
161
|
/** What a registration URL is, as the command that made it prints it. */
|
|
149
162
|
export interface IssuedRegistration {
|
|
150
163
|
readonly sub: Subject;
|
|
@@ -455,6 +468,11 @@ export class Auth {
|
|
|
455
468
|
endpoint: claims.endpoint,
|
|
456
469
|
rp_id: claims.rp_id,
|
|
457
470
|
sign_count: verified.signCount,
|
|
471
|
+
// What the authenticator said about backing this credential up, kept
|
|
472
|
+
// because it decides what removing the line costs the person and nothing
|
|
473
|
+
// else: neither flag is ever read to admit or refuse an exchange.
|
|
474
|
+
backup_eligible: verified.backupEligible,
|
|
475
|
+
backup_state: verified.backupState,
|
|
458
476
|
...(claims.issued_label === undefined ? {} : { issued_label: claims.issued_label }),
|
|
459
477
|
...(args.device_label === undefined ? {} : { device_label: args.device_label }),
|
|
460
478
|
registered_at: at,
|
|
@@ -656,7 +674,7 @@ export class Auth {
|
|
|
656
674
|
* a family minted elsewhere is carried there rather than done here — two
|
|
657
675
|
* instances rotating one family in parallel would merge by last write and
|
|
658
676
|
* read exactly like a stolen token being replayed (§2.4). */
|
|
659
|
-
async refreshToken(value: Base64Url): Promise<MintedSession> {
|
|
677
|
+
async refreshToken(value: Base64Url, from: RefreshFrom = {}): Promise<MintedSession> {
|
|
660
678
|
const held = this.deps.records.byRefresh(value);
|
|
661
679
|
if (held === undefined) {
|
|
662
680
|
// Not the standing generation, nor the one before it. Either it never was
|
|
@@ -666,12 +684,16 @@ export class Auth {
|
|
|
666
684
|
throw new OpError("auth_invalid", "この refresh token は使えません");
|
|
667
685
|
}
|
|
668
686
|
if (held.body.iss !== this.deps.self) {
|
|
687
|
+
// What the client said about this refresh stays here: `auth_rotate`
|
|
688
|
+
// carries the value and nothing else, and the address the issuer would
|
|
689
|
+
// see is this instance's rather than the person's. The issuer records
|
|
690
|
+
// that the family rotated, which is the part it can vouch for.
|
|
669
691
|
const answer = (await this.#atIssuer(held.body.iss, "auth_rotate", {
|
|
670
692
|
refresh_token: value,
|
|
671
693
|
} satisfies AuthRotateArgs)) as AuthRotateResult;
|
|
672
694
|
return { session: { sub: answer.sub, access: answer.access }, refresh: answer.refresh };
|
|
673
695
|
}
|
|
674
|
-
const rotated = this.rotate(value);
|
|
696
|
+
const rotated = this.rotate(value, from);
|
|
675
697
|
return { session: { sub: rotated.sub, access: rotated.access }, refresh: rotated.refresh };
|
|
676
698
|
}
|
|
677
699
|
|
|
@@ -703,7 +725,7 @@ export class Auth {
|
|
|
703
725
|
|
|
704
726
|
/** Rotate a family this instance minted. The one writer's own operation, and
|
|
705
727
|
* what `auth_rotate` runs on its behalf. */
|
|
706
|
-
rotate(value: Base64Url): AuthRotateResult {
|
|
728
|
+
rotate(value: Base64Url, from: RefreshFrom = {}): AuthRotateResult {
|
|
707
729
|
const held = this.deps.records.byRefresh(value);
|
|
708
730
|
if (held === undefined) {
|
|
709
731
|
this.#failReused(value);
|
|
@@ -732,6 +754,16 @@ export class Auth {
|
|
|
732
754
|
iss: this.deps.self,
|
|
733
755
|
access,
|
|
734
756
|
refresh: { value: token(), expires_at: at + REFRESH_TTL_MS },
|
|
757
|
+
// Written by this instance because it is the family's `iss`, and only for
|
|
758
|
+
// the rotation that just happened — the caller's word about why, and
|
|
759
|
+
// where it was asked from, are a hint for the person reading their own
|
|
760
|
+
// sessions back and are never checked (contract, `TokenFamily`).
|
|
761
|
+
last_refresh: {
|
|
762
|
+
at,
|
|
763
|
+
...(from.reason === undefined ? {} : { reason: from.reason }),
|
|
764
|
+
...(from.ip === undefined ? {} : { ip: from.ip }),
|
|
765
|
+
...(from.userAgent === undefined ? {} : { user_agent: from.userAgent }),
|
|
766
|
+
},
|
|
735
767
|
previous_refresh: { value: held.body.refresh.value, expires_at: at + PREVIOUS_GRACE_MS },
|
|
736
768
|
// The value going out of service is remembered as a digest for as long as
|
|
737
769
|
// it would have been accepted, so that presenting it later is recognised
|
package/src/auth/http.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import {
|
|
3
3
|
type AuthAssertArgs,
|
|
4
|
+
type AuthRefreshTokenArgs,
|
|
4
5
|
type AuthRegisterArgs,
|
|
5
6
|
type ErrorCode,
|
|
6
7
|
type InstanceId,
|
|
@@ -196,7 +197,12 @@ export async function handleAuth(
|
|
|
196
197
|
if (held === undefined) {
|
|
197
198
|
return refusal("auth_invalid", "この要求には refresh token がありません", cors);
|
|
198
199
|
}
|
|
199
|
-
const
|
|
200
|
+
const { reason } = args as unknown as AuthRefreshTokenArgs;
|
|
201
|
+
const minted = await deps.auth.refreshToken(held, {
|
|
202
|
+
...(reason === undefined ? {} : { reason }),
|
|
203
|
+
...(seen.ip === undefined ? {} : { ip: seen.ip }),
|
|
204
|
+
...(seen.userAgent === undefined ? {} : { userAgent: seen.userAgent }),
|
|
205
|
+
});
|
|
200
206
|
return answer(minted.session, cors, setCookie(deps, url.pathname, minted));
|
|
201
207
|
}
|
|
202
208
|
}
|
package/src/auth/webauthn.ts
CHANGED
|
@@ -9,11 +9,15 @@ import { type CborValue, decodeCbor, decodeCborWhole, mapEntry } from "./cbor.ts
|
|
|
9
9
|
* log; nothing branches on it. */
|
|
10
10
|
export class WebAuthnError extends Error {}
|
|
11
11
|
|
|
12
|
-
/** The flags of the authenticator data (L2 §6.1).
|
|
13
|
-
* person was present, and that they were verified
|
|
14
|
-
* `userVerification: "required"`, so both have to
|
|
12
|
+
/** The flags of the authenticator data (L2 §6.1). Two of them decide whether an
|
|
13
|
+
* exchange is admitted: that a person was present, and that they were verified
|
|
14
|
+
* — the registration asks for `userVerification: "required"`, so both have to
|
|
15
|
+
* hold on every exchange. The two backup flags decide nothing; they are read at
|
|
16
|
+
* registration and kept as a hint for the person reading their own list. */
|
|
15
17
|
const FLAG_USER_PRESENT = 0x01;
|
|
16
18
|
const FLAG_USER_VERIFIED = 0x04;
|
|
19
|
+
const FLAG_BACKUP_ELIGIBLE = 0x08;
|
|
20
|
+
const FLAG_BACKUP_STATE = 0x10;
|
|
17
21
|
const FLAG_ATTESTED_CREDENTIAL = 0x40;
|
|
18
22
|
|
|
19
23
|
export function base64UrlDecode(value: string): Uint8Array {
|
|
@@ -131,6 +135,12 @@ export interface VerifiedRegistration {
|
|
|
131
135
|
readonly credentialId: Base64Url;
|
|
132
136
|
readonly publicKey: Base64Url;
|
|
133
137
|
readonly signCount: number;
|
|
138
|
+
/** The BE flag: whether the authenticator may back this credential up, which
|
|
139
|
+
* is what separates a synced passkey from one that lives on a single device. */
|
|
140
|
+
readonly backupEligible: boolean;
|
|
141
|
+
/** The BS flag: whether it was backed up at this moment. Eligible and not yet
|
|
142
|
+
* backed up is an ordinary state on a device that has just made the key. */
|
|
143
|
+
readonly backupState: boolean;
|
|
134
144
|
}
|
|
135
145
|
|
|
136
146
|
/** Check a registration (L2 §7.1) and answer what is worth keeping.
|
|
@@ -180,6 +190,8 @@ export function verifyRegistration(
|
|
|
180
190
|
credentialId: base64UrlEncode(data.credentialId),
|
|
181
191
|
publicKey: base64UrlEncode(data.publicKey),
|
|
182
192
|
signCount: data.signCount,
|
|
193
|
+
backupEligible: (data.flags & FLAG_BACKUP_ELIGIBLE) !== 0,
|
|
194
|
+
backupState: (data.flags & FLAG_BACKUP_STATE) !== 0,
|
|
183
195
|
};
|
|
184
196
|
}
|
|
185
197
|
|
package/src/plugin/claude.ts
CHANGED
|
@@ -77,6 +77,15 @@ ccmsg peers --all 他ホストの instance が知っている分も含め
|
|
|
77
77
|
居なくなったセッションで、各行の \`repo\` / \`ws\` / \`branch\` / \`title\` で見分けて
|
|
78
78
|
\`sid\` を取る。\`send_message\` が \`true\` の相手には harness 自身の機能でも届く。
|
|
79
79
|
|
|
80
|
+
## 相手セッションの扱い
|
|
81
|
+
|
|
82
|
+
相手は基本、自分にとってのサブエージェントだと思えばよい。対等な会議を開く場ではないので、
|
|
83
|
+
冒頭の挨拶・賛辞・締めの社交辞令を省き、用件だけを 1〜3 文で送る。
|
|
84
|
+
|
|
85
|
+
やり取りの中身を人へリレーしない。人は全セッションを直接見ているので、相手の完了報告や
|
|
86
|
+
根拠をこちらで要約し直しても情報は増えず、時間とコンテキストだけが減る。人に言うのは
|
|
87
|
+
自セッション目線の事実 (何を頼んだ・何が返り・その結果こちらが何をしたか) だけ。
|
|
88
|
+
|
|
80
89
|
## 見ている人へ知らせる
|
|
81
90
|
|
|
82
91
|
\`\`\`
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/** Which path the init system is told to run, and what became of the one it was
|
|
2
|
+
* told.
|
|
3
|
+
*
|
|
4
|
+
* A unit file outlives the machine's software. The path this process is running
|
|
5
|
+
* as is the path of one version of it — a runtime under a version manager lives
|
|
6
|
+
* in a directory named after the version, and the next upgrade puts an
|
|
7
|
+
* identical program somewhere else and takes that directory away. Registered as
|
|
8
|
+
* it is, the supervisor works until the day it silently does not: the init
|
|
9
|
+
* system goes on asking for a path nothing is at, and the person finds out when
|
|
10
|
+
* nothing answers after a reboot.
|
|
11
|
+
*
|
|
12
|
+
* So a durable path is looked for before anything is written down, and what was
|
|
13
|
+
* written down is read back by `service status`. The judgement here is the
|
|
14
|
+
* coarse half of what `stable-which` does — whether the path names a version
|
|
15
|
+
* rather than a program — and the reading back is what makes the other half
|
|
16
|
+
* unnecessary: a pick that turns out wrong says so as a missing file rather
|
|
17
|
+
* than as silence. */
|
|
18
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
19
|
+
import { basename, delimiter, join } from "node:path";
|
|
20
|
+
import { ENTRY } from "../daemon/registry.ts";
|
|
21
|
+
import type { Env } from "../instance/paths.ts";
|
|
22
|
+
|
|
23
|
+
/** Directory names that belong to one version of something rather than to the
|
|
24
|
+
* thing itself. A path through any of them is gone at the next upgrade. */
|
|
25
|
+
const VERSIONED = ["/nix/store/", "/Cellar/", "/installs/", "/versions/", "/node_modules/"];
|
|
26
|
+
|
|
27
|
+
/** Whether this path is one a unit file may hold. */
|
|
28
|
+
export function durable(path: string): boolean {
|
|
29
|
+
return !VERSIONED.some((mark) => path.includes(mark));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** How large a file may be and still be read as a wrapper script. A wrapper is
|
|
33
|
+
* a few lines; anything else is the program itself. */
|
|
34
|
+
const WRAPPER_MAX_BYTES = 64 * 1024;
|
|
35
|
+
|
|
36
|
+
function isFile(path: string): boolean {
|
|
37
|
+
try {
|
|
38
|
+
return statSync(path).isFile();
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function sameFile(one: string, other: string): boolean {
|
|
45
|
+
try {
|
|
46
|
+
return realpathSync(one) === realpathSync(other);
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Every entry of this name on `PATH`, in the order `PATH` states them. */
|
|
53
|
+
function onPath(name: string, env: Env): string[] {
|
|
54
|
+
return (env["PATH"] ?? "")
|
|
55
|
+
.split(delimiter)
|
|
56
|
+
.filter((dir) => dir !== "")
|
|
57
|
+
.map((dir) => join(dir, name))
|
|
58
|
+
.filter((path) => isFile(path));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether running this path runs what this process is running: the same file,
|
|
62
|
+
* or a wrapper that names the script this process was started with. */
|
|
63
|
+
function leadsHere(candidate: string, self: string): boolean {
|
|
64
|
+
if (sameFile(candidate, self)) return true;
|
|
65
|
+
try {
|
|
66
|
+
if (statSync(candidate).size > WRAPPER_MAX_BYTES) return false;
|
|
67
|
+
return readFileSync(candidate, "utf8").includes(ENTRY);
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface Program {
|
|
74
|
+
/** What the init system is told to run. */
|
|
75
|
+
readonly command: string[];
|
|
76
|
+
/** Whether that first path is one that survives an upgrade. False means the
|
|
77
|
+
* best that could be found still names a version, which is worth saying
|
|
78
|
+
* rather than hiding: the registration works now and is the one to redo
|
|
79
|
+
* after the next upgrade. */
|
|
80
|
+
readonly durable: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The supervisor as a path an init system can keep asking for.
|
|
84
|
+
*
|
|
85
|
+
* A `ccmsg` on `PATH` that leads back here is preferred over the runtime: it is
|
|
86
|
+
* the program by its own name, and it stays put across a runtime upgrade
|
|
87
|
+
* because it is what names the runtime rather than what the runtime is. Failing
|
|
88
|
+
* that, the runtime by name on `PATH`, which at least resolves through whatever
|
|
89
|
+
* the version manager keeps current. Failing both, this process's own path,
|
|
90
|
+
* which is where it started. */
|
|
91
|
+
export function supervisorProgram(env: Env = process.env): Program {
|
|
92
|
+
const self = process.execPath;
|
|
93
|
+
for (const candidate of onPath("ccmsg", env)) {
|
|
94
|
+
if (durable(candidate) && leadsHere(candidate, self)) {
|
|
95
|
+
return { command: [candidate, "daemon", "supervise"], durable: true };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for (const candidate of onPath(basename(self), env)) {
|
|
99
|
+
if (durable(candidate) && sameFile(candidate, self)) {
|
|
100
|
+
return { command: [candidate, ENTRY, "daemon", "supervise"], durable: true };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { command: [self, ENTRY, "daemon", "supervise"], durable: durable(self) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** What a unit file names as its program, and whether anything is there now. */
|
|
107
|
+
export interface RegisteredProgram {
|
|
108
|
+
readonly path: string;
|
|
109
|
+
/** Whether the registered path is one that survives an upgrade. A false here
|
|
110
|
+
* is a registration that works today and will stop working quietly, which is
|
|
111
|
+
* worth seeing before it does. */
|
|
112
|
+
readonly durable: boolean;
|
|
113
|
+
/** False on a registration whose program has moved: the init system is
|
|
114
|
+
* asking for a path nothing is at, and re-registering is the answer. */
|
|
115
|
+
readonly exists: boolean;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const ENTITY: Record<string, string> = { "&": "&", "<": "<", ">": ">" };
|
|
119
|
+
|
|
120
|
+
/** The program the registered unit names — read from the file rather than
|
|
121
|
+
* worked out again, because the question is what the init system was told and
|
|
122
|
+
* not what it would be told today. */
|
|
123
|
+
export function registeredProgram(
|
|
124
|
+
unitFile: string,
|
|
125
|
+
kind: "launchd" | "systemd",
|
|
126
|
+
): RegisteredProgram | null {
|
|
127
|
+
let text: string;
|
|
128
|
+
try {
|
|
129
|
+
text = readFileSync(unitFile, "utf8");
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
const found =
|
|
134
|
+
kind === "launchd"
|
|
135
|
+
? /<key>ProgramArguments<\/key>\s*<array>\s*<string>([^<]*)<\/string>/.exec(text)?.[1]
|
|
136
|
+
: /^ExecStart=(\S+)/m.exec(text)?.[1];
|
|
137
|
+
if (found === undefined) return null;
|
|
138
|
+
const path = found.replace(/&|<|>/g, (entity) => ENTITY[entity] as string);
|
|
139
|
+
return { path, durable: durable(path), exists: existsSync(path) };
|
|
140
|
+
}
|
package/src/service/service.ts
CHANGED
|
@@ -2,8 +2,8 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { CommandError } from "../daemon/link.ts";
|
|
5
|
-
import { ENTRY } from "../daemon/registry.ts";
|
|
6
5
|
import { type Env, resolveStateRoot } from "../instance/paths.ts";
|
|
6
|
+
import { type RegisteredProgram, registeredProgram, supervisorProgram } from "./program.ts";
|
|
7
7
|
|
|
8
8
|
/** What the host's init system was asked, and what it said.
|
|
9
9
|
*
|
|
@@ -34,6 +34,13 @@ export interface ServiceState {
|
|
|
34
34
|
readonly registered: boolean;
|
|
35
35
|
readonly running: boolean;
|
|
36
36
|
readonly pid?: number;
|
|
37
|
+
/** The program the registered unit names, and whether anything is at that
|
|
38
|
+
* path now. `null` when nothing is registered, so there is no unit to read.
|
|
39
|
+
*
|
|
40
|
+
* Here rather than left to be worked out by a reader: a supervisor that
|
|
41
|
+
* cannot start because its program moved with a runtime upgrade looks, from
|
|
42
|
+
* every other field, exactly like one that was never started. */
|
|
43
|
+
readonly program: RegisteredProgram | null;
|
|
37
44
|
/** What the init system itself says, or `null` when it could not be asked.
|
|
38
45
|
*
|
|
39
46
|
* Beside the two fields above rather than folded into them: those are ccmsg's
|
|
@@ -101,7 +108,7 @@ export type LogSource =
|
|
|
101
108
|
* registered from a shell where these were exported and started without them
|
|
102
109
|
* would quietly manage a different set of instances. */
|
|
103
110
|
function supervisorCommand(): string[] {
|
|
104
|
-
return
|
|
111
|
+
return supervisorProgram().command;
|
|
105
112
|
}
|
|
106
113
|
|
|
107
114
|
const CARRIED = [
|
|
@@ -143,16 +150,43 @@ export function serviceFor(env: Env = process.env, platform = process.platform):
|
|
|
143
150
|
throw new CommandError("capability_unavailable", `${platform} には登録先がありません`);
|
|
144
151
|
}
|
|
145
152
|
|
|
146
|
-
|
|
153
|
+
/** Whether launchd's refusal to bootstrap is it saying the unit is already
|
|
154
|
+
* there. Errno 37 is `EBUSY`, which is what a bootstrap racing the teardown of
|
|
155
|
+
* the same label gets. */
|
|
156
|
+
function alreadyLoaded(answer: RunResult): boolean {
|
|
157
|
+
const said = `${answer.stdout} ${answer.stderr}`;
|
|
158
|
+
return /already (loaded|bootstrapped)|Operation already in progress|: 37:/.test(said);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** What the init system said when it refused.
|
|
162
|
+
*
|
|
163
|
+
* The command and its stderr, unabridged: launchd's refusals are numbered
|
|
164
|
+
* rather than worded, and a `Bootstrap failed: 5: Input/output error` handed
|
|
165
|
+
* straight to the person is worth more than anything this could say instead. */
|
|
166
|
+
function refuse(command: readonly string[], answer: RunResult): never {
|
|
167
|
+
const said = answer.stderr.trim() || answer.stdout.trim();
|
|
168
|
+
throw new CommandError(
|
|
169
|
+
"internal_error",
|
|
170
|
+
`${command.join(" ")} が失敗しました (exit ${String(answer.code)})${said === "" ? "" : `: ${said}`}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export class LaunchdService implements Service {
|
|
147
175
|
readonly kind = "launchd" as const;
|
|
148
176
|
readonly unitFile: string;
|
|
177
|
+
readonly #label: string;
|
|
149
178
|
readonly #env: Env;
|
|
150
179
|
readonly #domain: string;
|
|
151
180
|
|
|
152
|
-
|
|
181
|
+
/** The label is a parameter for `Run`'s reason: a test that drives this
|
|
182
|
+
* machine's real launchd has to do it under a name that is not the one the
|
|
183
|
+
* machine's own supervisor is registered under. Nothing in ccmsg passes it —
|
|
184
|
+
* `serviceFor` is where the name is settled. */
|
|
185
|
+
constructor(env: Env, label: string = LAUNCHD_LABEL) {
|
|
153
186
|
this.#env = env;
|
|
187
|
+
this.#label = label;
|
|
154
188
|
const home = env["HOME"] ?? homedir();
|
|
155
|
-
this.unitFile = join(home, "Library", "LaunchAgents", `${
|
|
189
|
+
this.unitFile = join(home, "Library", "LaunchAgents", `${label}.plist`);
|
|
156
190
|
this.#domain = `gui/${String(process.getuid?.() ?? 0)}`;
|
|
157
191
|
}
|
|
158
192
|
|
|
@@ -166,7 +200,7 @@ class LaunchdService implements Service {
|
|
|
166
200
|
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
167
201
|
'<plist version="1.0">',
|
|
168
202
|
"<dict>",
|
|
169
|
-
` <key>Label</key><string>${
|
|
203
|
+
` <key>Label</key><string>${this.#label}</string>`,
|
|
170
204
|
" <key>ProgramArguments</key>",
|
|
171
205
|
" <array>",
|
|
172
206
|
...supervisorCommand().map((arg) => ` <string>${escapeXml(arg)}</string>`),
|
|
@@ -187,32 +221,61 @@ class LaunchdService implements Service {
|
|
|
187
221
|
].join("\n");
|
|
188
222
|
}
|
|
189
223
|
|
|
224
|
+
/** Registering is writing the unit and starting it: a supervisor nothing is
|
|
225
|
+
* supervising is not what the person asked for, and `RunAtLoad` means that a
|
|
226
|
+
* unit put in front of launchd at all is a unit launchd runs. What `start`
|
|
227
|
+
* adds is the case where the file is already there. */
|
|
190
228
|
async register(run: Run): Promise<ServiceState> {
|
|
191
229
|
write(this.unitFile, this.unitText(), serviceLogFile(this.#env));
|
|
192
|
-
await
|
|
193
|
-
return await this.state(run);
|
|
230
|
+
return await this.start(run);
|
|
194
231
|
}
|
|
195
232
|
|
|
196
233
|
async unregister(run: Run): Promise<{ unregistered: boolean }> {
|
|
197
|
-
await run(["launchctl", "bootout", `${this.#domain}/${
|
|
234
|
+
await run(["launchctl", "bootout", `${this.#domain}/${this.#label}`]);
|
|
198
235
|
const existed = existsSync(this.unitFile);
|
|
199
236
|
rmSync(this.unitFile, { force: true });
|
|
200
237
|
return { unregistered: existed };
|
|
201
238
|
}
|
|
202
239
|
|
|
240
|
+
/** Put the unit in front of launchd if it is not already there, then start
|
|
241
|
+
* the program.
|
|
242
|
+
*
|
|
243
|
+
* `kickstart` alone is what a loaded unit needs, and launchd answers a unit
|
|
244
|
+
* it has never heard of with `No such process` — which is exactly the state
|
|
245
|
+
* a login leaves behind, and the state `unregister` leaves behind however
|
|
246
|
+
* quickly a `register` follows it. So what is loaded is read first and the
|
|
247
|
+
* missing half is done here rather than assumed to have been done by
|
|
248
|
+
* whoever wrote the file. */
|
|
203
249
|
async start(run: Run): Promise<ServiceState> {
|
|
204
|
-
|
|
250
|
+
const before = await this.#report(run);
|
|
251
|
+
if (before.service?.loaded !== true) {
|
|
252
|
+
const bootstrap = ["launchctl", "bootstrap", this.#domain, this.unitFile];
|
|
253
|
+
const answer = await run(bootstrap);
|
|
254
|
+
// `Operation already in progress` and `Service is already loaded` are
|
|
255
|
+
// launchd saying the unit is there, which is all this asked for.
|
|
256
|
+
if (answer.code !== 0 && !alreadyLoaded(answer)) refuse(bootstrap, answer);
|
|
257
|
+
}
|
|
258
|
+
const kickstart = ["launchctl", "kickstart", `${this.#domain}/${this.#label}`];
|
|
259
|
+
const answer = await run(kickstart);
|
|
260
|
+
if (answer.code !== 0) refuse(kickstart, answer);
|
|
205
261
|
return await this.state(run);
|
|
206
262
|
}
|
|
207
263
|
|
|
208
264
|
async stop(run: Run): Promise<ServiceState> {
|
|
209
|
-
await run(["launchctl", "kill", "SIGTERM", `${this.#domain}/${
|
|
265
|
+
await run(["launchctl", "kill", "SIGTERM", `${this.#domain}/${this.#label}`]);
|
|
210
266
|
return await this.state(run);
|
|
211
267
|
}
|
|
212
268
|
|
|
213
269
|
async state(run: Run): Promise<ServiceState> {
|
|
270
|
+
return {
|
|
271
|
+
...(await this.#report(run)),
|
|
272
|
+
program: registeredProgram(this.unitFile, this.kind),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async #report(run: Run): Promise<Omit<ServiceState, "program">> {
|
|
214
277
|
const registered = existsSync(this.unitFile);
|
|
215
|
-
const printed = await run(["launchctl", "print", `${this.#domain}/${
|
|
278
|
+
const printed = await run(["launchctl", "print", `${this.#domain}/${this.#label}`]);
|
|
216
279
|
// A non-zero exit is launchd saying it has no such service, which is not
|
|
217
280
|
// the same as having nothing to say: the report stays `null` only when the
|
|
218
281
|
// question could not be put, and here it was and the answer was "no".
|
|
@@ -280,11 +343,10 @@ class SystemdService implements Service {
|
|
|
280
343
|
return { kind: "command", show: [...unit, "--no-pager"], follow: [...unit, "-f"] };
|
|
281
344
|
}
|
|
282
345
|
|
|
346
|
+
/** `LaunchdService.register`'s reasoning, in systemd's vocabulary. */
|
|
283
347
|
async register(run: Run): Promise<ServiceState> {
|
|
284
348
|
write(this.unitFile, this.unitText(), serviceLogFile(this.#env));
|
|
285
|
-
await run
|
|
286
|
-
await run(["systemctl", "--user", "enable", "--now", SYSTEMD_UNIT]);
|
|
287
|
-
return await this.state(run);
|
|
349
|
+
return await this.start(run);
|
|
288
350
|
}
|
|
289
351
|
|
|
290
352
|
async unregister(run: Run): Promise<{ unregistered: boolean }> {
|
|
@@ -295,8 +357,20 @@ class SystemdService implements Service {
|
|
|
295
357
|
return { unregistered: existed };
|
|
296
358
|
}
|
|
297
359
|
|
|
360
|
+
/** `LaunchdService.start`'s reasoning: a unit systemd has not read is a unit
|
|
361
|
+
* `start` answers `not found` for, and a file written since the last reload
|
|
362
|
+
* is exactly that. `enable --now` starts it and puts it in the target, so a
|
|
363
|
+
* supervisor registered today is running after the next login. */
|
|
298
364
|
async start(run: Run): Promise<ServiceState> {
|
|
299
|
-
|
|
365
|
+
const before = await this.#report(run);
|
|
366
|
+
if (before.service?.loaded !== true) {
|
|
367
|
+
const reload = ["systemctl", "--user", "daemon-reload"];
|
|
368
|
+
const reloaded = await run(reload);
|
|
369
|
+
if (reloaded.code !== 0) refuse(reload, reloaded);
|
|
370
|
+
}
|
|
371
|
+
const enable = ["systemctl", "--user", "enable", "--now", SYSTEMD_UNIT];
|
|
372
|
+
const answer = await run(enable);
|
|
373
|
+
if (answer.code !== 0) refuse(enable, answer);
|
|
300
374
|
return await this.state(run);
|
|
301
375
|
}
|
|
302
376
|
|
|
@@ -306,6 +380,13 @@ class SystemdService implements Service {
|
|
|
306
380
|
}
|
|
307
381
|
|
|
308
382
|
async state(run: Run): Promise<ServiceState> {
|
|
383
|
+
return {
|
|
384
|
+
...(await this.#report(run)),
|
|
385
|
+
program: registeredProgram(this.unitFile, this.kind),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async #report(run: Run): Promise<Omit<ServiceState, "program">> {
|
|
309
390
|
const registered = existsSync(this.unitFile);
|
|
310
391
|
const shown = await run([
|
|
311
392
|
"systemctl",
|