@ccmsg/cli 0.1.0
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/LICENSE +21 -0
- package/README.md +23 -0
- package/package.json +32 -0
- package/src/cli.ts +1074 -0
- package/src/daemon/control.ts +88 -0
- package/src/daemon/index.ts +6 -0
- package/src/daemon/link.ts +93 -0
- package/src/daemon/log.ts +116 -0
- package/src/daemon/registry.ts +285 -0
- package/src/daemon/snapshot.ts +115 -0
- package/src/daemon/supervise.ts +446 -0
- package/src/dispatch/caller.ts +47 -0
- package/src/dispatch/dispatch.ts +128 -0
- package/src/dispatch/handler.ts +55 -0
- package/src/dispatch/identity.ts +22 -0
- package/src/dispatch/index.ts +5 -0
- package/src/dispatch/result.ts +58 -0
- package/src/files/containment.ts +263 -0
- package/src/files/files.ts +421 -0
- package/src/files/index.ts +14 -0
- package/src/files/sandbox.ts +0 -0
- package/src/greeting/hook.ts +48 -0
- package/src/greeting/index.ts +2 -0
- package/src/greeting/meta.ts +66 -0
- package/src/instance/config.ts +424 -0
- package/src/instance/handlers.ts +28 -0
- package/src/instance/identity.ts +44 -0
- package/src/instance/index.ts +8 -0
- package/src/instance/instance.ts +911 -0
- package/src/instance/lock.ts +108 -0
- package/src/instance/log.ts +30 -0
- package/src/instance/paths.ts +200 -0
- package/src/instance/socket.ts +62 -0
- package/src/kv/index.ts +2 -0
- package/src/kv/merge.ts +66 -0
- package/src/kv/store.ts +195 -0
- package/src/launcher/index.ts +4 -0
- package/src/launcher/launcher.ts +190 -0
- package/src/launcher/roots.ts +32 -0
- package/src/launcher/spawn.ts +81 -0
- package/src/launcher/tree.ts +80 -0
- package/src/mesh/index.ts +5 -0
- package/src/mesh/keys.ts +158 -0
- package/src/mesh/mesh.ts +1169 -0
- package/src/mesh/probe.ts +100 -0
- package/src/mesh/relay.ts +147 -0
- package/src/mesh/wire.ts +96 -0
- package/src/messaging/delivery.ts +375 -0
- package/src/messaging/direct.ts +433 -0
- package/src/messaging/handlers.ts +14 -0
- package/src/messaging/inbox.ts +191 -0
- package/src/messaging/index.ts +5 -0
- package/src/messaging/notify.ts +117 -0
- package/src/plugin/claude.ts +148 -0
- package/src/plugin/index.ts +13 -0
- package/src/plugin/install.ts +416 -0
- package/src/service/index.ts +1 -0
- package/src/service/service.ts +359 -0
- package/src/sessions/classify.ts +66 -0
- package/src/sessions/dump.ts +105 -0
- package/src/sessions/fork.ts +127 -0
- package/src/sessions/handlers.ts +158 -0
- package/src/sessions/harness.ts +167 -0
- package/src/sessions/index.ts +26 -0
- package/src/sessions/last-live.ts +111 -0
- package/src/sessions/processes.ts +413 -0
- package/src/sessions/registry.ts +785 -0
- package/src/sessions/search.ts +278 -0
- package/src/sessions/status.ts +209 -0
- package/src/sessions/terminals.ts +72 -0
- package/src/sessions/workspace.ts +140 -0
- package/src/topics/handlers.ts +42 -0
- package/src/topics/index.ts +2 -0
- package/src/topics/topics.ts +290 -0
- package/src/transcript/files.ts +201 -0
- package/src/transcript/fold.ts +833 -0
- package/src/transcript/index.ts +16 -0
- package/src/transcript/read.ts +82 -0
- package/src/transcript/tail.ts +195 -0
- package/src/transcript/transcripts.ts +162 -0
- package/src/translate/helper.ts +87 -0
- package/src/translate/index.ts +2 -0
- package/src/translate/translate.ts +127 -0
- package/src/transport/conn.ts +129 -0
- package/src/transport/dial.ts +65 -0
- package/src/transport/driver.ts +102 -0
- package/src/transport/entry.ts +39 -0
- package/src/transport/framing.ts +131 -0
- package/src/transport/index.ts +8 -0
- package/src/transport/listener.ts +39 -0
- package/src/transport/uds.ts +88 -0
- package/src/transport/ws.ts +170 -0
- package/src/upstream/events.ts +125 -0
- package/src/upstream/gateway.ts +275 -0
- package/src/upstream/index.ts +8 -0
- package/src/upstream/json.ts +81 -0
- package/src/upstream/requests.ts +234 -0
- package/src/upstream/stats.ts +99 -0
- package/src/upstream/status.ts +281 -0
- package/src/upstream/usage.ts +208 -0
- package/src/upstream/webhook.ts +141 -0
- package/src/version.ts +8 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Capability,
|
|
3
|
+
DirTreeArgs,
|
|
4
|
+
DirTreeResult,
|
|
5
|
+
LauncherConfigReadResult,
|
|
6
|
+
LauncherRunArgs,
|
|
7
|
+
LauncherRunResult,
|
|
8
|
+
} from "@ccmsg/protocol";
|
|
9
|
+
import { type HandlerInput, OpError } from "../dispatch/index.ts";
|
|
10
|
+
import type { LauncherConfig, LauncherTemplateConfig } from "../instance/config.ts";
|
|
11
|
+
import { spawnLaunch } from "./spawn.ts";
|
|
12
|
+
import { insideRoots } from "./roots.ts";
|
|
13
|
+
import { dirTree } from "./tree.ts";
|
|
14
|
+
|
|
15
|
+
/** The capability the three launcher ops need. A launcher with no roots and no
|
|
16
|
+
* recipes has no form to answer with, so a client is told there is none rather
|
|
17
|
+
* than being handed an empty one. */
|
|
18
|
+
export function launcherCapabilities(config?: LauncherConfig): Capability[] {
|
|
19
|
+
return config === undefined ? [] : ["launcher"];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** One launch, as everything but the spawning sees it. */
|
|
23
|
+
export interface Launch {
|
|
24
|
+
/** The shell and its program, ready to run. */
|
|
25
|
+
readonly argv: readonly string[];
|
|
26
|
+
/** The checked directory, which is both where the command runs and what it
|
|
27
|
+
* sees as its working directory. */
|
|
28
|
+
readonly cwd: string;
|
|
29
|
+
readonly env: Record<string, string | undefined>;
|
|
30
|
+
readonly timeoutMs: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Whoever starts the command. The one part of a launch that touches the host,
|
|
34
|
+
* kept behind a name so a test can watch a launch being assembled without a
|
|
35
|
+
* process being started. */
|
|
36
|
+
export type LaunchRunner = (launch: Launch) => Promise<LauncherRunResult>;
|
|
37
|
+
|
|
38
|
+
export interface LauncherDeps {
|
|
39
|
+
/** The environment a launched command starts from, before `clean_env`. */
|
|
40
|
+
readonly env?: Record<string, string | undefined>;
|
|
41
|
+
readonly run?: LaunchRunner;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What the launcher may start, and the two ops that read and use it.
|
|
45
|
+
*
|
|
46
|
+
* No value a caller sends is ever spliced into shell text: the parameters reach
|
|
47
|
+
* the command as shell variables, and the directory is the one the containment
|
|
48
|
+
* check resolved rather than the one the caller wrote. */
|
|
49
|
+
export class Launcher {
|
|
50
|
+
readonly #env: Record<string, string | undefined>;
|
|
51
|
+
readonly #run: LaunchRunner;
|
|
52
|
+
|
|
53
|
+
constructor(
|
|
54
|
+
private readonly config: LauncherConfig,
|
|
55
|
+
deps: LauncherDeps = {},
|
|
56
|
+
) {
|
|
57
|
+
this.#env = deps.env ?? process.env;
|
|
58
|
+
this.#run = deps.run ?? spawnLaunch;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The form: where a session may run, and the recipes it may run under. How
|
|
62
|
+
* the shell runs one is not reported, because nothing a client does with the
|
|
63
|
+
* answer depends on it. */
|
|
64
|
+
configRead(): LauncherConfigReadResult {
|
|
65
|
+
return {
|
|
66
|
+
root_dirs: [...this.config.root_dirs],
|
|
67
|
+
templates: this.config.templates.map((template) => ({
|
|
68
|
+
name: template.name,
|
|
69
|
+
command: template.command,
|
|
70
|
+
params: template.params.map((param) => ({ name: param.name, default: param.default })),
|
|
71
|
+
})),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
tree(args: DirTreeArgs): DirTreeResult {
|
|
76
|
+
return dirTree(this.config, args);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
run(args: LauncherRunArgs): Promise<LauncherRunResult> {
|
|
80
|
+
const cwd = insideRoots(this.config, args.cwd);
|
|
81
|
+
if (cwd === undefined) {
|
|
82
|
+
// The op states no refusal for a path, so a directory outside the roots
|
|
83
|
+
// is answered as what it is from here: an argument this launcher cannot
|
|
84
|
+
// act on.
|
|
85
|
+
throw new OpError("invalid_args", `${args.cwd} is not a directory the launcher may start in`);
|
|
86
|
+
}
|
|
87
|
+
const template = this.template(args.template);
|
|
88
|
+
const declared = new Map(template.params.map((param) => [param.name, param.default]));
|
|
89
|
+
for (const name of Object.keys(args.params)) {
|
|
90
|
+
if (!declared.has(name)) {
|
|
91
|
+
throw new OpError(
|
|
92
|
+
"invalid_args",
|
|
93
|
+
`${template.name} declares no parameter called ${name}, so nothing would read it`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const values: Record<string, string> = {};
|
|
98
|
+
for (const [name, fallback] of declared) {
|
|
99
|
+
values[carrier(name)] = args.params[name] ?? fallback;
|
|
100
|
+
}
|
|
101
|
+
return this.#run({
|
|
102
|
+
argv: shellArgv(
|
|
103
|
+
template.shell,
|
|
104
|
+
program([...declared.keys()], args.command ?? template.command),
|
|
105
|
+
),
|
|
106
|
+
cwd,
|
|
107
|
+
env: { ...cleaned(this.#env, this.config), ...values },
|
|
108
|
+
timeoutMs: this.config.timeout_secs * 1000,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The recipe a launch names, or the default one. An unknown name is refused
|
|
113
|
+
* rather than replaced with another, because running a different recipe than
|
|
114
|
+
* the one asked for is worse than running none. */
|
|
115
|
+
private template(name?: string): LauncherTemplateConfig {
|
|
116
|
+
if (name === undefined) {
|
|
117
|
+
const first = this.config.templates[0];
|
|
118
|
+
if (first === undefined) throw new OpError("invalid_args", "this launcher has no recipe");
|
|
119
|
+
return first;
|
|
120
|
+
}
|
|
121
|
+
const found = this.config.templates.find((template) => template.name === name);
|
|
122
|
+
if (found === undefined) throw new OpError("invalid_args", `no recipe is called ${name}`);
|
|
123
|
+
return found;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function launcherHandlers(launcher: Launcher) {
|
|
128
|
+
return {
|
|
129
|
+
launcher_config_read: (): LauncherConfigReadResult => launcher.configRead(),
|
|
130
|
+
launcher_run: (input: HandlerInput): Promise<LauncherRunResult> =>
|
|
131
|
+
launcher.run(input.args as unknown as LauncherRunArgs),
|
|
132
|
+
dir_tree: (input: HandlerInput): DirTreeResult =>
|
|
133
|
+
launcher.tree(input.args as unknown as DirTreeArgs),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The environment variable one parameter's value travels in.
|
|
138
|
+
*
|
|
139
|
+
* A carrier of its own, in a namespace the shell erases before the command
|
|
140
|
+
* runs, so the value reaches the command as a plain shell variable and nothing
|
|
141
|
+
* the command starts inherits it. */
|
|
142
|
+
export function carrier(name: string): string {
|
|
143
|
+
return `ccmsg_launch_param_${name}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The shell program: the prologue that moves every parameter out of the
|
|
147
|
+
* environment, then the recipe's command.
|
|
148
|
+
*
|
|
149
|
+
* Both run in one shell. A nested `sh -c` would defeat the whole arrangement,
|
|
150
|
+
* since a variable that is not exported does not cross into a child. Every
|
|
151
|
+
* declared parameter is defined, empty rather than absent when nothing supplied
|
|
152
|
+
* one, so a command run under `set -u` does not abort on a field left blank. */
|
|
153
|
+
export function program(names: readonly string[], command: string): string {
|
|
154
|
+
const prologue = names
|
|
155
|
+
.map((name) => `unset -v ${name}; ${name}="$${carrier(name)}"; unset -v ${carrier(name)}`)
|
|
156
|
+
.join("\n");
|
|
157
|
+
// A newline rather than `;` so a command opening with a comment or a shell
|
|
158
|
+
// keyword parses exactly as it was written.
|
|
159
|
+
return `${prologue}\n${command}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function shellArgv(shell: "bash" | "zsh", command: string): string[] {
|
|
163
|
+
return shell === "bash"
|
|
164
|
+
? ["bash", "-eu", "-o", "pipefail", "-c", command]
|
|
165
|
+
: ["zsh", "-e", "-u", "-o", "pipefail", "-c", command];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The environment a launched command starts from: this instance's own, minus
|
|
169
|
+
* what `clean_env` names and back again for what `keep_env` does. Keep wins, so
|
|
170
|
+
* one broad pattern can be written beside its exceptions. */
|
|
171
|
+
function cleaned(
|
|
172
|
+
env: Record<string, string | undefined>,
|
|
173
|
+
config: LauncherConfig,
|
|
174
|
+
): Record<string, string | undefined> {
|
|
175
|
+
const clean = config.clean_env.map(pattern);
|
|
176
|
+
const keep = config.keep_env.map(pattern);
|
|
177
|
+
const kept: Record<string, string | undefined> = {};
|
|
178
|
+
for (const [name, value] of Object.entries(env)) {
|
|
179
|
+
if (clean.some((each) => each.test(name)) && !keep.some((each) => each.test(name))) continue;
|
|
180
|
+
kept[name] = value;
|
|
181
|
+
}
|
|
182
|
+
return kept;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** One `clean_env` pattern, where `*` stands for any run of characters and
|
|
186
|
+
* everything else is literal. */
|
|
187
|
+
function pattern(glob: string): RegExp {
|
|
188
|
+
const escaped = glob.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
189
|
+
return new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`);
|
|
190
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { isAbsolute } from "node:path";
|
|
3
|
+
import type { LauncherConfig } from "../instance/config.ts";
|
|
4
|
+
import { canonical, within } from "../files/index.ts";
|
|
5
|
+
|
|
6
|
+
/** Whether a directory is one the launcher may act on, and what the filesystem
|
|
7
|
+
* calls it.
|
|
8
|
+
*
|
|
9
|
+
* The comparison runs on what both sides resolve to, so a path spelled through
|
|
10
|
+
* a symlink out of a root is refused however it was written — the same rule the
|
|
11
|
+
* file ops go through, asked of the roots config states rather than of the ones
|
|
12
|
+
* a session states.
|
|
13
|
+
*
|
|
14
|
+
* A root that no longer resolves grants nothing and stops nothing: another
|
|
15
|
+
* configured root may still hold the candidate. */
|
|
16
|
+
export function insideRoots(config: LauncherConfig, path: string): string | undefined {
|
|
17
|
+
if (!isAbsolute(path)) return undefined;
|
|
18
|
+
const real = canonical(path);
|
|
19
|
+
if (!isDirectory(real)) return undefined;
|
|
20
|
+
for (const root of config.root_dirs) {
|
|
21
|
+
if (within(real, canonical(root))) return real;
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isDirectory(path: string): boolean {
|
|
27
|
+
try {
|
|
28
|
+
return statSync(path).isDirectory();
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { LauncherRunResult } from "@ccmsg/protocol";
|
|
2
|
+
import type { Launch } from "./launcher.ts";
|
|
3
|
+
|
|
4
|
+
/** How long a command stopped for outliving its allowance is given to leave
|
|
5
|
+
* before it is killed outright. */
|
|
6
|
+
const FORCE_KILL_MS = 500;
|
|
7
|
+
|
|
8
|
+
/** How long the pipes are read after the command itself has exited.
|
|
9
|
+
*
|
|
10
|
+
* A launch that detaches something — which is what starting a session in a
|
|
11
|
+
* terminal is — leaves that grandchild holding the write end, so end-of-file
|
|
12
|
+
* may never arrive. Without a bound the reply would wait for the session to
|
|
13
|
+
* finish. On an ordinary exit every descriptor closes at once and this costs
|
|
14
|
+
* nothing. */
|
|
15
|
+
const DRAIN_MS = 500;
|
|
16
|
+
|
|
17
|
+
/** Start one assembled launch and answer with what it did.
|
|
18
|
+
*
|
|
19
|
+
* Both pipes are read from the moment the command starts, so output larger than
|
|
20
|
+
* a pipe buffer cannot stop the command from finishing. */
|
|
21
|
+
export async function spawnLaunch(launch: Launch): Promise<LauncherRunResult> {
|
|
22
|
+
const child = Bun.spawn([...launch.argv], {
|
|
23
|
+
cwd: launch.cwd,
|
|
24
|
+
env: launch.env,
|
|
25
|
+
stdout: "pipe",
|
|
26
|
+
stderr: "pipe",
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
let timedOut = false;
|
|
30
|
+
let force: ReturnType<typeof setTimeout> | undefined;
|
|
31
|
+
const deadline = setTimeout(() => {
|
|
32
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
33
|
+
timedOut = true;
|
|
34
|
+
child.kill("SIGTERM");
|
|
35
|
+
force = setTimeout(() => {
|
|
36
|
+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
37
|
+
}, FORCE_KILL_MS);
|
|
38
|
+
}, launch.timeoutMs);
|
|
39
|
+
|
|
40
|
+
const stdout = collect(child.stdout);
|
|
41
|
+
const stderr = collect(child.stderr);
|
|
42
|
+
try {
|
|
43
|
+
const code = await child.exited;
|
|
44
|
+
const [out, err] = await Promise.all([stdout(DRAIN_MS), stderr(DRAIN_MS)]);
|
|
45
|
+
return {
|
|
46
|
+
stdout: out,
|
|
47
|
+
stderr: err,
|
|
48
|
+
// A signal leaves no code to state, so the field is absent rather than
|
|
49
|
+
// carrying a number that means something else.
|
|
50
|
+
...(child.signalCode === null ? { exit_code: code } : {}),
|
|
51
|
+
timed_out: timedOut,
|
|
52
|
+
};
|
|
53
|
+
} finally {
|
|
54
|
+
clearTimeout(deadline);
|
|
55
|
+
if (force !== undefined) clearTimeout(force);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Read a pipe as it arrives, and answer with what got there within the grace.
|
|
60
|
+
*
|
|
61
|
+
* Not `Response.text()`: that resolves only at end-of-file, which is the one
|
|
62
|
+
* thing a detached grandchild can postpone forever. */
|
|
63
|
+
function collect(stream: ReadableStream<Uint8Array>): (graceMs: number) => Promise<string> {
|
|
64
|
+
const chunks: Uint8Array[] = [];
|
|
65
|
+
const reader = stream.getReader();
|
|
66
|
+
const drained = (async () => {
|
|
67
|
+
for (;;) {
|
|
68
|
+
const { done, value } = await reader.read();
|
|
69
|
+
if (done) break;
|
|
70
|
+
if (value !== undefined) chunks.push(value);
|
|
71
|
+
}
|
|
72
|
+
})();
|
|
73
|
+
return async (graceMs) => {
|
|
74
|
+
await Promise.race([
|
|
75
|
+
drained.catch(() => {}),
|
|
76
|
+
new Promise((resolve) => setTimeout(resolve, graceMs)),
|
|
77
|
+
]);
|
|
78
|
+
await reader.cancel().catch(() => {});
|
|
79
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { readdirSync } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import type { DirTreeArgs, DirTreeEntry, DirTreeResult } from "@ccmsg/protocol";
|
|
4
|
+
import type { LauncherConfig } from "../instance/config.ts";
|
|
5
|
+
import { insideRoots } from "./roots.ts";
|
|
6
|
+
|
|
7
|
+
/** How far one request may walk, whatever it asks for.
|
|
8
|
+
*
|
|
9
|
+
* Design rationale: the depth is the client's to choose, and a walk whose cost
|
|
10
|
+
* the client sets has no bound at all. Five levels covers the configured depth
|
|
11
|
+
* plus several lazy expansions, and going deeper stays a matter of asking for a
|
|
12
|
+
* descendant — which is the same walk, paid for one request at a time. */
|
|
13
|
+
const MAX_DEPTH = 5;
|
|
14
|
+
|
|
15
|
+
/** The directories a session could be started in.
|
|
16
|
+
*
|
|
17
|
+
* Directories only, and only below the configured roots. A root the config does
|
|
18
|
+
* not hold contributes nothing rather than failing the request: the op states no
|
|
19
|
+
* refusal for a path, and a tree assembled from several roots would otherwise be
|
|
20
|
+
* lost whole because one of them went away. */
|
|
21
|
+
export function dirTree(config: LauncherConfig, args: DirTreeArgs): DirTreeResult {
|
|
22
|
+
const depth = Math.min(MAX_DEPTH, args.depth ?? config.depth);
|
|
23
|
+
// A filter of nothing is not a filter: an emptied search box shows the tree
|
|
24
|
+
// rather than hiding all of it.
|
|
25
|
+
const filter = args.filter === undefined || args.filter === "" ? undefined : args.filter;
|
|
26
|
+
const entries: DirTreeEntry[] = [];
|
|
27
|
+
for (const root of args.roots) {
|
|
28
|
+
const real = insideRoots(config, root);
|
|
29
|
+
if (real === undefined) continue;
|
|
30
|
+
entries.push(...walk(config, real, real, depth, filter));
|
|
31
|
+
}
|
|
32
|
+
return { entries: sorted(entries) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function walk(
|
|
36
|
+
config: LauncherConfig,
|
|
37
|
+
root: string,
|
|
38
|
+
at: string,
|
|
39
|
+
depth: number,
|
|
40
|
+
filter: string | undefined,
|
|
41
|
+
): DirTreeEntry[] {
|
|
42
|
+
const entries: DirTreeEntry[] = [];
|
|
43
|
+
for (const dirent of read(at)) {
|
|
44
|
+
// Design rationale: dot-directories are left out. This answers "where could
|
|
45
|
+
// a session run", and a repository's `.git` is not one of those places —
|
|
46
|
+
// browsing a session's own files is a different op with different rules.
|
|
47
|
+
if (dirent.name.startsWith(".")) continue;
|
|
48
|
+
const path = join(at, dirent.name);
|
|
49
|
+
if (dirent.isSymbolicLink()) {
|
|
50
|
+
// A link is a place to run only if what it points at is one, so it goes
|
|
51
|
+
// through the same containment its target would.
|
|
52
|
+
if (insideRoots(config, path) === undefined) continue;
|
|
53
|
+
} else if (!dirent.isDirectory()) continue;
|
|
54
|
+
|
|
55
|
+
const children = depth > 1 ? walk(config, root, path, depth - 1, filter) : undefined;
|
|
56
|
+
if (filter !== undefined) {
|
|
57
|
+
const matches = relative(root, path).includes(filter);
|
|
58
|
+
// An ancestor of a match survives the filter: without it a match several
|
|
59
|
+
// levels down would have nothing to hang from.
|
|
60
|
+
if (!matches && (children === undefined || children.length === 0)) continue;
|
|
61
|
+
}
|
|
62
|
+
entries.push({ path, ...(children === undefined ? {} : { children: sorted(children) }) });
|
|
63
|
+
}
|
|
64
|
+
return entries;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function read(dir: string) {
|
|
68
|
+
try {
|
|
69
|
+
return readdirSync(dir, { withFileTypes: true });
|
|
70
|
+
} catch {
|
|
71
|
+
// A directory that cannot be read is still a place to run; what it holds is
|
|
72
|
+
// simply not known, which is the same answer as holding nothing.
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Codepoint order, which is the same wherever the daemon runs. */
|
|
78
|
+
function sorted(entries: DirTreeEntry[]): DirTreeEntry[] {
|
|
79
|
+
return entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
80
|
+
}
|
package/src/mesh/keys.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createPublicKey,
|
|
3
|
+
generateKeyPairSync,
|
|
4
|
+
type JsonWebKeyInput,
|
|
5
|
+
type KeyObject,
|
|
6
|
+
randomBytes,
|
|
7
|
+
sign,
|
|
8
|
+
verify,
|
|
9
|
+
} from "node:crypto";
|
|
10
|
+
import type { Endpoint } from "@ccmsg/protocol";
|
|
11
|
+
|
|
12
|
+
/** The handshake format this instance speaks (mesh-peer-auth §5.3). */
|
|
13
|
+
export const MESH_VER = 1;
|
|
14
|
+
|
|
15
|
+
/** The signature algorithms a proof may be made with.
|
|
16
|
+
*
|
|
17
|
+
* A set rather than one name, because the JWS header is not trusted: the header
|
|
18
|
+
* names an algorithm and this is what decides whether that name may be used at
|
|
19
|
+
* all, which is what stops `alg: none` and algorithm confusion (§5.7-7). Two
|
|
20
|
+
* entries have to be expressible for a migration, so it is a set even while it
|
|
21
|
+
* holds one. */
|
|
22
|
+
export const ALLOWED_ALGS: ReadonlySet<string> = new Set(["EdDSA"]);
|
|
23
|
+
|
|
24
|
+
/** How long a proof stays valid (§5.9). A handshake within one region completes
|
|
25
|
+
* in tens to hundreds of milliseconds, and a peer that cannot manage this could
|
|
26
|
+
* not hold a mesh link anyway. */
|
|
27
|
+
export const PROOF_LIFETIME_MS = 10_000;
|
|
28
|
+
|
|
29
|
+
/** 128 bits, the floor §5.9 sets for both the challenge and the key id: the only
|
|
30
|
+
* requirement on either is that it cannot be predicted. */
|
|
31
|
+
const RANDOM_BYTES = 16;
|
|
32
|
+
|
|
33
|
+
export function randomId(): string {
|
|
34
|
+
return randomBytes(RANDOM_BYTES).toString("hex");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A public key as it travels: a JWK carrying the id it answers to.
|
|
38
|
+
*
|
|
39
|
+
* The id is on the key because the receiver compares three of them — the
|
|
40
|
+
* greeting's, the proof header's, and this one — and a key that arrived without
|
|
41
|
+
* its own id could not be part of that comparison (§5.7-8). */
|
|
42
|
+
export interface MeshJwk {
|
|
43
|
+
readonly kty: string;
|
|
44
|
+
readonly crv?: string;
|
|
45
|
+
readonly x?: string;
|
|
46
|
+
readonly kid: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One connection's signing key.
|
|
50
|
+
*
|
|
51
|
+
* It lives as long as the handshake it was made for: created when the dial
|
|
52
|
+
* starts, fetched once by the peer, and destroyed when the acknowledgement
|
|
53
|
+
* arrives or the connection goes, whichever comes first (§7). Nothing rotates
|
|
54
|
+
* it because nothing outlives one connection. */
|
|
55
|
+
export class EphemeralKey {
|
|
56
|
+
readonly kid = randomId();
|
|
57
|
+
readonly #private: KeyObject;
|
|
58
|
+
readonly #public: KeyObject;
|
|
59
|
+
|
|
60
|
+
constructor() {
|
|
61
|
+
const pair = generateKeyPairSync("ed25519");
|
|
62
|
+
this.#private = pair.privateKey;
|
|
63
|
+
this.#public = pair.publicKey;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
jwk(): MeshJwk {
|
|
67
|
+
return { ...(this.#public.export({ format: "jwk" }) as object), kid: this.kid } as MeshJwk;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The proof of §5.4: the challenge, and the two endpoint URLs, signed.
|
|
71
|
+
*
|
|
72
|
+
* The URLs are signed even though the greeting already carried them, because
|
|
73
|
+
* the greeting is not signed and therefore states nothing. */
|
|
74
|
+
proof(claim: ProofClaim): string {
|
|
75
|
+
const header = { alg: "EdDSA", kid: this.kid };
|
|
76
|
+
const input = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(claim))}`;
|
|
77
|
+
return `${input}.${sign(null, Buffer.from(input), this.#private).toString("base64url")}`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** What a proof asserts. */
|
|
82
|
+
export interface ProofClaim {
|
|
83
|
+
readonly ver: number;
|
|
84
|
+
readonly iss: Endpoint;
|
|
85
|
+
readonly aud: Endpoint;
|
|
86
|
+
readonly challenge: string;
|
|
87
|
+
/** Unix seconds, as a JWS `exp` is. */
|
|
88
|
+
readonly exp: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A proof that has been read but not yet judged: the header's `kid`, and the
|
|
92
|
+
* claim, both of which the caller compares before any signature is checked. */
|
|
93
|
+
export interface ParsedProof {
|
|
94
|
+
readonly kid: string;
|
|
95
|
+
readonly alg: string;
|
|
96
|
+
readonly claim: ProofClaim;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export class ProofError extends Error {}
|
|
100
|
+
|
|
101
|
+
/** Read a compact JWS into its parts, refusing anything malformed or signed
|
|
102
|
+
* with an algorithm outside the allowed set.
|
|
103
|
+
*
|
|
104
|
+
* The algorithm is checked here, before the key is even looked at, because the
|
|
105
|
+
* check exists to decide whether the header may be acted on at all (§5.7-7). */
|
|
106
|
+
export function parseProof(jws: string): ParsedProof {
|
|
107
|
+
const parts = jws.split(".");
|
|
108
|
+
if (parts.length !== 3) throw new ProofError("a proof is a compact JWS of three parts");
|
|
109
|
+
const [rawHeader, rawClaim] = parts as [string, string, string];
|
|
110
|
+
const header = decode(rawHeader, "header");
|
|
111
|
+
const alg = header["alg"];
|
|
112
|
+
const kid = header["kid"];
|
|
113
|
+
if (typeof alg !== "string" || !ALLOWED_ALGS.has(alg)) {
|
|
114
|
+
throw new ProofError(`a proof signed with ${String(alg)} is not one this instance accepts`);
|
|
115
|
+
}
|
|
116
|
+
if (typeof kid !== "string" || kid === "") throw new ProofError("a proof names its key");
|
|
117
|
+
const claim = decode(rawClaim, "claim");
|
|
118
|
+
for (const field of ["ver", "iss", "aud", "challenge", "exp"] as const) {
|
|
119
|
+
if (claim[field] === undefined) throw new ProofError(`a proof states its ${field}`);
|
|
120
|
+
}
|
|
121
|
+
return { kid, alg, claim: claim as unknown as ProofClaim };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Whether the signature was made by the key the JWK carries. */
|
|
125
|
+
export function verifyProof(jws: string, jwk: MeshJwk): boolean {
|
|
126
|
+
const parts = jws.split(".");
|
|
127
|
+
if (parts.length !== 3) return false;
|
|
128
|
+
const [rawHeader, rawClaim, rawSignature] = parts as [string, string, string];
|
|
129
|
+
let key: KeyObject;
|
|
130
|
+
try {
|
|
131
|
+
key = createPublicKey({ key: { ...jwk }, format: "jwk" } as JsonWebKeyInput);
|
|
132
|
+
} catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
return verify(
|
|
136
|
+
null,
|
|
137
|
+
Buffer.from(`${rawHeader}.${rawClaim}`),
|
|
138
|
+
key,
|
|
139
|
+
Buffer.from(rawSignature, "base64url"),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function decode(part: string, what: string): Record<string, unknown> {
|
|
144
|
+
let parsed: unknown;
|
|
145
|
+
try {
|
|
146
|
+
parsed = JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
|
|
147
|
+
} catch {
|
|
148
|
+
throw new ProofError(`a proof's ${what} is not JSON`);
|
|
149
|
+
}
|
|
150
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
151
|
+
throw new ProofError(`a proof's ${what} is not an object`);
|
|
152
|
+
}
|
|
153
|
+
return parsed as Record<string, unknown>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function b64url(text: string): string {
|
|
157
|
+
return Buffer.from(text, "utf8").toString("base64url");
|
|
158
|
+
}
|