@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,128 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Capability,
|
|
3
|
+
type InstanceId,
|
|
4
|
+
isRoleAllowed,
|
|
5
|
+
OP_ATTRIBUTES,
|
|
6
|
+
OP_SCHEMAS,
|
|
7
|
+
opAttributes,
|
|
8
|
+
type OpName,
|
|
9
|
+
validationErrors,
|
|
10
|
+
} from "@ccmsg/protocol";
|
|
11
|
+
import type { Handlers, Requester } from "./handler.ts";
|
|
12
|
+
import { type DispatchResult, failure, OpError, reply } from "./result.ts";
|
|
13
|
+
|
|
14
|
+
/** What dispatch needs from the instance around it. */
|
|
15
|
+
export interface DispatchDeps {
|
|
16
|
+
/** This instance's own id, compared with the destination of an
|
|
17
|
+
* `instance-local` op to decide whether the op is ours to run. */
|
|
18
|
+
readonly self: InstanceId;
|
|
19
|
+
/** The capabilities this instance has, as `hello` reports them. */
|
|
20
|
+
readonly capabilities: ReadonlySet<Capability>;
|
|
21
|
+
/** The instance that owns the subject of an `instance-local` op, or
|
|
22
|
+
* `undefined` when no other instance owns it and we answer ourselves.
|
|
23
|
+
* The routing table behind this is the `peers` topic (daemon-v2 §7.3). */
|
|
24
|
+
readonly resolveInstance: (op: OpName, frame: Record<string, unknown>) => InstanceId | undefined;
|
|
25
|
+
readonly handlers: Handlers;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isOpName(op: string): op is OpName {
|
|
29
|
+
return Object.hasOwn(OP_ATTRIBUTES, op);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Decide one frame.
|
|
33
|
+
*
|
|
34
|
+
* The six steps of daemon-v2 §3.2 are written once, here, and read the op
|
|
35
|
+
* attribute table for every op. Adding an op is a row in the table plus a
|
|
36
|
+
* schema and an implementation — never a check in this function (M1). */
|
|
37
|
+
export async function dispatch(
|
|
38
|
+
frame: unknown,
|
|
39
|
+
conn: Requester,
|
|
40
|
+
deps: DispatchDeps,
|
|
41
|
+
): Promise<DispatchResult> {
|
|
42
|
+
const identity = conn.identity;
|
|
43
|
+
if (typeof frame !== "object" || frame === null || Array.isArray(frame)) {
|
|
44
|
+
return failure(undefined, "bad_request", "a request must be a JSON object");
|
|
45
|
+
}
|
|
46
|
+
const fields = frame as Record<string, unknown>;
|
|
47
|
+
|
|
48
|
+
const rawId = fields["request_id"];
|
|
49
|
+
const requestId = typeof rawId === "string" && rawId.length > 0 ? rawId : undefined;
|
|
50
|
+
const op = fields["op"];
|
|
51
|
+
if (typeof op !== "string") {
|
|
52
|
+
return failure(requestId, "bad_request", "a request must carry an op name");
|
|
53
|
+
}
|
|
54
|
+
if (requestId === undefined) {
|
|
55
|
+
return failure(undefined, "bad_request", "a request must carry a request_id");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 1. the op is in the contract
|
|
59
|
+
if (!isOpName(op)) {
|
|
60
|
+
return failure(requestId, "unknown_op", `no such op: ${op}`);
|
|
61
|
+
}
|
|
62
|
+
const attrs = opAttributes(op);
|
|
63
|
+
|
|
64
|
+
// 2. the arguments pass the op's schema
|
|
65
|
+
const problems = validationErrors(OP_SCHEMAS[op].request, fields);
|
|
66
|
+
if (problems.length > 0) {
|
|
67
|
+
return failure(requestId, "invalid_args", problems.join("; "));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 3. the identity `hello` settles, when the op needs one
|
|
71
|
+
if (attrs.needs_hello && identity.state !== "settled") {
|
|
72
|
+
return failure(requestId, "hello_required", `${op} needs an identity settled by hello`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 4. the connection's role is one the op allows. An anonymous connection has
|
|
76
|
+
// no role to compare, and the ops it may reach (`needs_hello: false`) are
|
|
77
|
+
// open to every role, so there is nothing to refuse here.
|
|
78
|
+
if (identity.state === "settled" && !isRoleAllowed(op, identity.role)) {
|
|
79
|
+
return failure(requestId, "forbidden", `${op} is not open to ${identity.role}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 5. the capability the op declares, when it declares one
|
|
83
|
+
if (attrs.capability !== undefined && !deps.capabilities.has(attrs.capability)) {
|
|
84
|
+
return failure(
|
|
85
|
+
requestId,
|
|
86
|
+
"capability_unavailable",
|
|
87
|
+
`${op} needs the ${attrs.capability} capability, which this instance does not have`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 6. an instance-local op whose subject belongs elsewhere goes to mesh.
|
|
92
|
+
//
|
|
93
|
+
// A request that has already been here is dropped before that: a cycle in
|
|
94
|
+
// the routing would otherwise send it round the same instances until every
|
|
95
|
+
// deadline expired (§7.3). It is answered rather than left unanswered,
|
|
96
|
+
// because the caller learns the same thing sooner and the code is the one
|
|
97
|
+
// the contract gives a destination that could not be reached.
|
|
98
|
+
const hops = fields["hops"];
|
|
99
|
+
if (Array.isArray(hops) && hops.includes(deps.self)) {
|
|
100
|
+
return failure(requestId, "instance_unreachable", `${op} came back to ${deps.self}`);
|
|
101
|
+
}
|
|
102
|
+
if (attrs.locality === "instance-local") {
|
|
103
|
+
const asked = fields["to_instance"];
|
|
104
|
+
const target = typeof asked === "string" ? asked : deps.resolveInstance(op, fields);
|
|
105
|
+
if (target !== undefined && target !== deps.self) {
|
|
106
|
+
return { kind: "forward", to: target, frame: fields };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 7. the implementation, which starts from "validated and allowed"
|
|
111
|
+
try {
|
|
112
|
+
const body = await deps.handlers[op]({
|
|
113
|
+
op,
|
|
114
|
+
args: fields,
|
|
115
|
+
conn,
|
|
116
|
+
identity: identity.state === "settled" ? identity : undefined,
|
|
117
|
+
// The one route by which a role reaches an implementation (§3.2).
|
|
118
|
+
role: attrs.scope === "role" && identity.state === "settled" ? identity.role : undefined,
|
|
119
|
+
});
|
|
120
|
+
return reply(requestId, body);
|
|
121
|
+
} catch (cause) {
|
|
122
|
+
if (cause instanceof OpError) return failure(requestId, cause.code, cause.message);
|
|
123
|
+
// Anything else is the implementation failing for a reason that is not the
|
|
124
|
+
// caller's: the arguments passed the op's schema and the call was allowed,
|
|
125
|
+
// so re-reading the arguments would tell the caller nothing.
|
|
126
|
+
return failure(requestId, "internal_error", `the op failed: ${String(cause)}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { OpName, Role } from "@ccmsg/protocol";
|
|
2
|
+
import type { ConnIdentity, SettledIdentity } from "./identity.ts";
|
|
3
|
+
|
|
4
|
+
/** The connection a request arrived on, as an implementation sees it.
|
|
5
|
+
*
|
|
6
|
+
* It is the `Conn` transport accepted, narrowed to what an op may do with it:
|
|
7
|
+
* read the identity, push frames, and learn that the connection is gone. A
|
|
8
|
+
* subscription is held by a connection and ends with it (daemon-v2 §6.3), so
|
|
9
|
+
* this is what the topic mechanism keys its subscribers on. Declared here
|
|
10
|
+
* rather than imported from transport because dispatch sits below it. */
|
|
11
|
+
export interface Requester {
|
|
12
|
+
readonly identity: ConnIdentity;
|
|
13
|
+
/** Push one frame that is not a reply — a topic frame or a connection event. */
|
|
14
|
+
send(frame: object): void;
|
|
15
|
+
/** Push one frame after the reply to the request being handled goes out, so
|
|
16
|
+
* a subscribe's snapshot follows its acknowledgement rather than preceding it. */
|
|
17
|
+
deferSend(frame: object): void;
|
|
18
|
+
onClose(listener: () => void): void;
|
|
19
|
+
/** End the connection. One op needs it: the mesh handshake decides whether a
|
|
20
|
+
* connection is a peer at all, and a connection that fails it is closed
|
|
21
|
+
* rather than left open and anonymous (mesh-peer-auth §5.8). */
|
|
22
|
+
close(code?: number, reason?: string): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What an op implementation receives.
|
|
26
|
+
*
|
|
27
|
+
* The arguments are already validated and the caller is already allowed
|
|
28
|
+
* (daemon-v2 §3.2): a handler starts from "this may be run", so it holds no
|
|
29
|
+
* check of its own. */
|
|
30
|
+
export interface HandlerInput {
|
|
31
|
+
readonly op: OpName;
|
|
32
|
+
/** The connection the request arrived on. Ops that hold something for the
|
|
33
|
+
* length of a connection — the subscriptions of daemon-v2 §6.3 — need it;
|
|
34
|
+
* ops that only answer ignore it. */
|
|
35
|
+
readonly conn: Requester;
|
|
36
|
+
/** The whole request frame, validated against the op's request schema. */
|
|
37
|
+
readonly args: Record<string, unknown>;
|
|
38
|
+
/** The connection's identity, absent for the two ops that run before `hello`. */
|
|
39
|
+
readonly identity?: SettledIdentity;
|
|
40
|
+
/** Set only for ops the attribute table marks `scope: "role"`, where the role
|
|
41
|
+
* changes what the reply may contain rather than whether the call is allowed.
|
|
42
|
+
* This is the only route by which a role reaches an implementation
|
|
43
|
+
* (daemon-v2 §3.2). */
|
|
44
|
+
readonly role?: Role;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** An op implementation. It answers with the op's response body (dispatch adds
|
|
48
|
+
* `ok` and `request_id`, so the body never carries the reply's envelope), and
|
|
49
|
+
* may answer with a promise of one — dispatch awaits what it returns. */
|
|
50
|
+
export type OpHandler = (input: HandlerInput) => unknown;
|
|
51
|
+
|
|
52
|
+
/** One handler per op in the contract. The record is total on purpose: an op
|
|
53
|
+
* added to the attribute table does not compile until it has an implementation
|
|
54
|
+
* reachable through dispatch (M1). */
|
|
55
|
+
export type Handlers = Readonly<Record<OpName, OpHandler>>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Role, Sid } from "@ccmsg/protocol";
|
|
2
|
+
|
|
3
|
+
/** What dispatch knows about the connection a frame arrived on.
|
|
4
|
+
*
|
|
5
|
+
* transport settles this (daemon-v2 §3.1): a connection starts anonymous and
|
|
6
|
+
* becomes `settled` when `hello` binds a role, and a session's `sid`, to it.
|
|
7
|
+
* Nothing else about the connection reaches dispatch — the authorization steps
|
|
8
|
+
* read the op attribute table, not the connection. */
|
|
9
|
+
export type ConnIdentity = AnonymousIdentity | SettledIdentity;
|
|
10
|
+
|
|
11
|
+
export interface AnonymousIdentity {
|
|
12
|
+
readonly state: "anonymous";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SettledIdentity {
|
|
16
|
+
readonly state: "settled";
|
|
17
|
+
readonly role: Role;
|
|
18
|
+
/** Present when the role is a session, which is the only role that names one. */
|
|
19
|
+
readonly sid?: Sid;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const ANONYMOUS: AnonymousIdentity = { state: "anonymous" };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ErrorCode, ErrorResponse, InstanceId } from "@ccmsg/protocol";
|
|
2
|
+
|
|
3
|
+
/** What dispatch decided about one frame.
|
|
4
|
+
*
|
|
5
|
+
* `forward` is the only outcome that is not an answer: the op belongs to
|
|
6
|
+
* another instance and mesh has to carry it there (daemon-v2 §3.2 step 6).
|
|
7
|
+
* There is no mesh yet, so dispatch names the destination and stops. */
|
|
8
|
+
export type DispatchResult =
|
|
9
|
+
/** The frame was not a request and has no answer. The mesh handshake's own
|
|
10
|
+
* traffic is the only thing that arrives this way: it travels on the
|
|
11
|
+
* connection being authenticated because that is the connection it is about
|
|
12
|
+
* (mesh-peer-auth §5), and the contract's op vocabulary has no name for it. */
|
|
13
|
+
| { readonly kind: "none" }
|
|
14
|
+
| { readonly kind: "reply"; readonly response: Record<string, unknown> }
|
|
15
|
+
| { readonly kind: "error"; readonly response: ErrorResponse }
|
|
16
|
+
| { readonly kind: "forward"; readonly to: InstanceId; readonly frame: Record<string, unknown> };
|
|
17
|
+
|
|
18
|
+
/** An implementation's refusal, in the contract's own vocabulary.
|
|
19
|
+
*
|
|
20
|
+
* The six steps before a handler answer with codes dispatch derives from the
|
|
21
|
+
* attribute table. The codes an op lists for itself (`topic_unknown` and the
|
|
22
|
+
* like) are known only to the implementation, so it throws this and dispatch
|
|
23
|
+
* turns it into the same error envelope every other refusal uses. */
|
|
24
|
+
export class OpError extends Error {
|
|
25
|
+
constructor(
|
|
26
|
+
readonly code: ErrorCode,
|
|
27
|
+
msg: string,
|
|
28
|
+
) {
|
|
29
|
+
super(msg);
|
|
30
|
+
this.name = "OpError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The reply envelope, built here and nowhere else so the wire shape stays in
|
|
35
|
+
* one place (daemon-v2 §11.1). */
|
|
36
|
+
export function reply(
|
|
37
|
+
requestId: string,
|
|
38
|
+
body: unknown,
|
|
39
|
+
): Extract<DispatchResult, { kind: "reply" }> {
|
|
40
|
+
const fields = typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
|
41
|
+
return { kind: "reply", response: { ok: true, request_id: requestId, ...fields } };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function failure(
|
|
45
|
+
requestId: string | undefined,
|
|
46
|
+
code: ErrorCode,
|
|
47
|
+
msg: string,
|
|
48
|
+
): Extract<DispatchResult, { kind: "error" }> {
|
|
49
|
+
const response: ErrorResponse =
|
|
50
|
+
requestId === undefined
|
|
51
|
+
? { ok: false, error: { code, msg } }
|
|
52
|
+
: {
|
|
53
|
+
ok: false,
|
|
54
|
+
request_id: requestId,
|
|
55
|
+
error: { code, msg },
|
|
56
|
+
};
|
|
57
|
+
return { kind: "error", response };
|
|
58
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
3
|
+
import type { FileKind, Role, Sid } from "@ccmsg/protocol";
|
|
4
|
+
import { OpError } from "../dispatch/index.ts";
|
|
5
|
+
|
|
6
|
+
/** The three allowlists one session reaches files through.
|
|
7
|
+
*
|
|
8
|
+
* They are the session's own facts, not the caller's: the browsable root is
|
|
9
|
+
* where the session works, the workspace folders are what its editor names, and
|
|
10
|
+
* the external files are the paths its transcript named outside both. Whoever
|
|
11
|
+
* holds those facts states them here, and a session that stated none is a
|
|
12
|
+
* session no path is admitted for — nothing is guessed from a neighbouring
|
|
13
|
+
* value. */
|
|
14
|
+
export interface SessionRoots {
|
|
15
|
+
/** Where `contained` paths are relative to: the repository container the
|
|
16
|
+
* session greeted with, or its working directory. Absent when the session
|
|
17
|
+
* greeted with neither, which admits no contained path at all. */
|
|
18
|
+
readonly root?: string;
|
|
19
|
+
/** Where `file_write` puts a file. Kept apart from `root`, which may be the
|
|
20
|
+
* container above the working copy. */
|
|
21
|
+
readonly cwd?: string;
|
|
22
|
+
/** Absolute folder paths, each admitting its whole subtree. */
|
|
23
|
+
readonly workspace_folders: readonly string[];
|
|
24
|
+
/** Absolute file paths, each admitting exactly itself. */
|
|
25
|
+
readonly external_files: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Who states a session's allowlists. */
|
|
29
|
+
export interface RootsSource {
|
|
30
|
+
roots(sid: Sid): SessionRoots | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** One path, decided.
|
|
34
|
+
*
|
|
35
|
+
* `real` is what the filesystem calls it and what every later operation uses;
|
|
36
|
+
* `path` is the shape the kind implies, which is what the reply states back. */
|
|
37
|
+
export interface Located {
|
|
38
|
+
readonly kind: FileKind;
|
|
39
|
+
readonly real: string;
|
|
40
|
+
readonly path: string;
|
|
41
|
+
/** The same path with its own last segment unresolved: what was named, not
|
|
42
|
+
* what it points at. An op that acts on the name rather than on the file —
|
|
43
|
+
* `file_delete`, which unlinks a name — asks what kind of thing was named,
|
|
44
|
+
* and only this distinguishes a file from a symlink to one. */
|
|
45
|
+
readonly named: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The whole file-access decision, for every op that names a path.
|
|
49
|
+
*
|
|
50
|
+
* One function rather than a check per handler: a handler starts from "this
|
|
51
|
+
* path may be reached" the way it already starts from "this call is allowed",
|
|
52
|
+
* so the three surfaces, the symlink resolution behind them and the visible
|
|
53
|
+
* range a caller has are decided in one place and cannot come apart.
|
|
54
|
+
*
|
|
55
|
+
* `Viewer` is that visible range, and it is where the role of a `scope: "role"`
|
|
56
|
+
* op arrives (§3.2): a session reads its own session's files, a person reads
|
|
57
|
+
* any session's, and a role the rule does not name reaches nothing rather than
|
|
58
|
+
* being guessed at. An op the attribute table gives no `scope` states no role
|
|
59
|
+
* here, and needs none — dispatch has already settled who may call it. */
|
|
60
|
+
export interface Viewer {
|
|
61
|
+
/** Present only for an op the attribute table marks `scope: "role"`, which
|
|
62
|
+
* is the one route by which a role reaches an implementation (§3.2). */
|
|
63
|
+
readonly role?: Role;
|
|
64
|
+
/** The session the connection speaks for, when it speaks for one. */
|
|
65
|
+
readonly sid?: Sid;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class Containment {
|
|
69
|
+
constructor(private readonly source: RootsSource) {}
|
|
70
|
+
|
|
71
|
+
/** A path named by kind, as an op's arguments give it. */
|
|
72
|
+
locate(args: PathArgs, viewer: Viewer = {}): Located {
|
|
73
|
+
const roots = this.rootsFor(args.sid, viewer);
|
|
74
|
+
const named = this.absolute(args, roots);
|
|
75
|
+
const real = canonical(named);
|
|
76
|
+
return { ...this.admit(args.kind, real, roots), named };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** An absolute path with no kind: which surface admits it, if any.
|
|
80
|
+
*
|
|
81
|
+
* The surfaces are tried in the order the contract states, and the answer is
|
|
82
|
+
* one value for every refusal — outside the allowlists, or simply not there —
|
|
83
|
+
* so a caller cannot learn from it whether a path it may not read exists. */
|
|
84
|
+
identify(sid: Sid, path: string, viewer: Viewer = {}): Located | undefined {
|
|
85
|
+
let roots: SessionRoots;
|
|
86
|
+
try {
|
|
87
|
+
roots = this.rootsFor(sid, viewer);
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
if (!isAbsolute(path)) return undefined;
|
|
92
|
+
const named = resolve(path);
|
|
93
|
+
const real = canonical(named);
|
|
94
|
+
for (const kind of KINDS) {
|
|
95
|
+
try {
|
|
96
|
+
return { ...this.admit(kind, real, roots), named };
|
|
97
|
+
} catch {
|
|
98
|
+
// The next surface may admit it; running out of surfaces is the miss.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Where `file_write` writes, which is the one destination no kind names: the
|
|
105
|
+
* session's working directory, and within it the inbox the destination is
|
|
106
|
+
* fixed to (DR-0019). A name that leaves the inbox is refused as unwritable
|
|
107
|
+
* rather than as forbidden — the path is reachable, and only writing there
|
|
108
|
+
* is not. */
|
|
109
|
+
inbox(sid: Sid, path: string, viewer: Viewer = {}): Located {
|
|
110
|
+
const roots = this.rootsFor(sid, viewer);
|
|
111
|
+
const cwd = roots.cwd;
|
|
112
|
+
if (cwd === undefined || !isAbsolute(cwd)) {
|
|
113
|
+
throw new OpError("path_forbidden", `${sid} states no working directory to write into`);
|
|
114
|
+
}
|
|
115
|
+
const base = canonical(cwd);
|
|
116
|
+
const named = resolve(base, path);
|
|
117
|
+
const real = canonical(named);
|
|
118
|
+
const inbox = join(base, INBOX);
|
|
119
|
+
if (!within(real, inbox)) {
|
|
120
|
+
throw new OpError("path_not_writable", `only ${INBOX}/ takes a written file`);
|
|
121
|
+
}
|
|
122
|
+
return { kind: "contained", real, named, path: relativeTo(base, real) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The directory a listing or a walk starts from. */
|
|
126
|
+
root(args: DirArgs, viewer: Viewer = {}): Located {
|
|
127
|
+
return this.locate({ sid: args.sid, kind: args.kind, path: args.path ?? "" }, viewer);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private rootsFor(sid: Sid, viewer: Viewer): SessionRoots {
|
|
131
|
+
if (!sees(sid, viewer)) {
|
|
132
|
+
throw new OpError(
|
|
133
|
+
"path_forbidden",
|
|
134
|
+
`the files of ${sid} are outside this connection's range`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
const roots = this.source.roots(sid);
|
|
138
|
+
if (roots === undefined) {
|
|
139
|
+
throw new OpError("path_forbidden", `nothing is known about the files of ${sid}`);
|
|
140
|
+
}
|
|
141
|
+
return roots;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Turn an op's `path` into an absolute one, in the shape its kind states. */
|
|
145
|
+
private absolute(args: PathArgs, roots: SessionRoots): string {
|
|
146
|
+
if (args.kind === "contained") {
|
|
147
|
+
const root = roots.root;
|
|
148
|
+
if (root === undefined || !isAbsolute(root)) {
|
|
149
|
+
throw new OpError("path_forbidden", "this session states no root to be contained by");
|
|
150
|
+
}
|
|
151
|
+
return resolve(canonical(root), `.${sep}${args.path}`);
|
|
152
|
+
}
|
|
153
|
+
if (!isAbsolute(args.path)) {
|
|
154
|
+
throw new OpError("path_forbidden", `a ${args.kind} path is absolute`);
|
|
155
|
+
}
|
|
156
|
+
return args.path;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Whether a resolved path is inside the surface it claims. The check runs on
|
|
160
|
+
* what the filesystem resolved, so a symlink pointing out of a root is
|
|
161
|
+
* refused however it was spelled (DR-0008 §3). */
|
|
162
|
+
private admit(kind: FileKind, real: string, roots: SessionRoots): Omit<Located, "named"> {
|
|
163
|
+
if (kind === "contained") {
|
|
164
|
+
const root = roots.root === undefined ? undefined : canonical(roots.root);
|
|
165
|
+
if (root === undefined || !within(real, root)) {
|
|
166
|
+
throw new OpError("path_forbidden", "the path is outside the session's root");
|
|
167
|
+
}
|
|
168
|
+
return { kind, real, path: relativeTo(root, real) };
|
|
169
|
+
}
|
|
170
|
+
if (kind === "workspace") {
|
|
171
|
+
const folder = roots.workspace_folders.find((each) => within(real, canonical(each)));
|
|
172
|
+
if (folder === undefined) {
|
|
173
|
+
throw new OpError("path_forbidden", "the path is in no workspace folder of this session");
|
|
174
|
+
}
|
|
175
|
+
return { kind, real, path: real };
|
|
176
|
+
}
|
|
177
|
+
// `external` admits exactly the files the transcript named, so both sides
|
|
178
|
+
// of the comparison go through this same resolution (DR-0024 §3.2) — a
|
|
179
|
+
// path spelled through a symlink and the same file spelled directly are
|
|
180
|
+
// one entry, and a path since replaced by a symlink resolves elsewhere and
|
|
181
|
+
// is no longer in the list.
|
|
182
|
+
const named = roots.external_files.some((each) => canonical(each) === real);
|
|
183
|
+
if (!named) {
|
|
184
|
+
throw new OpError("path_forbidden", "the path is not one this session's transcript named");
|
|
185
|
+
}
|
|
186
|
+
return { kind, real, path: real };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Which sessions a caller may name, for every `scope: "role"` op.
|
|
191
|
+
*
|
|
192
|
+
* A session sees the session it speaks for, a person sees every session. A
|
|
193
|
+
* role the rule does not name sees nothing — the attribute table decides who
|
|
194
|
+
* may call an op, and a role it later admits is one this rule has to be told
|
|
195
|
+
* about rather than one it guesses a range for. An op with no `scope` states
|
|
196
|
+
* no role, and has none to narrow by: dispatch already settled who may call it.
|
|
197
|
+
*
|
|
198
|
+
* The visible range is one function rather than one per op: `transcript_read`
|
|
199
|
+
* and the file ops narrow by the same rule, and two spellings of it could come
|
|
200
|
+
* apart while both still passing their own tests. */
|
|
201
|
+
export function sees(sid: Sid, viewer: Viewer): boolean {
|
|
202
|
+
switch (viewer.role) {
|
|
203
|
+
case undefined:
|
|
204
|
+
return true;
|
|
205
|
+
case "user":
|
|
206
|
+
return true;
|
|
207
|
+
case "session":
|
|
208
|
+
return viewer.sid === sid;
|
|
209
|
+
default:
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface PathArgs {
|
|
215
|
+
readonly sid: Sid;
|
|
216
|
+
readonly kind: FileKind;
|
|
217
|
+
readonly path: string;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface DirArgs {
|
|
221
|
+
readonly sid: Sid;
|
|
222
|
+
readonly kind: FileKind;
|
|
223
|
+
readonly path?: string;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The directory `file_write` writes into, relative to the working directory. */
|
|
227
|
+
const INBOX = join("docs", "inbox");
|
|
228
|
+
|
|
229
|
+
const KINDS = ["contained", "workspace", "external"] as const;
|
|
230
|
+
|
|
231
|
+
/** What the filesystem calls a path, whether or not it is there yet.
|
|
232
|
+
*
|
|
233
|
+
* A path that does not exist is resolved as far as its parent and given its own
|
|
234
|
+
* last segment back, so a file about to be created is decided by where it would
|
|
235
|
+
* land rather than being refused for not being there. A parent that does not
|
|
236
|
+
* resolve either leaves the path as written, which no surface admits. */
|
|
237
|
+
export function canonical(path: string): string {
|
|
238
|
+
const absolute = resolve(path);
|
|
239
|
+
try {
|
|
240
|
+
return realpathSync(absolute);
|
|
241
|
+
} catch {
|
|
242
|
+
const parent = dirname(absolute);
|
|
243
|
+
if (parent === absolute) return absolute;
|
|
244
|
+
try {
|
|
245
|
+
return join(realpathSync(parent), basename(absolute));
|
|
246
|
+
} catch {
|
|
247
|
+
return absolute;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Whether a resolved path is the root or below it. Shared with the launcher,
|
|
253
|
+
* whose roots come from config rather than from a session: what "inside" means
|
|
254
|
+
* is the same question, and two spellings of it could come apart. */
|
|
255
|
+
export function within(path: string, root: string): boolean {
|
|
256
|
+
return path === root || path.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** A contained path as the contract states it: relative to the root, with the
|
|
260
|
+
* root itself the empty string. */
|
|
261
|
+
function relativeTo(root: string, real: string): string {
|
|
262
|
+
return real === root ? "" : real.slice(root.length + 1);
|
|
263
|
+
}
|