@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.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -0
  3. package/package.json +32 -0
  4. package/src/cli.ts +1074 -0
  5. package/src/daemon/control.ts +88 -0
  6. package/src/daemon/index.ts +6 -0
  7. package/src/daemon/link.ts +93 -0
  8. package/src/daemon/log.ts +116 -0
  9. package/src/daemon/registry.ts +285 -0
  10. package/src/daemon/snapshot.ts +115 -0
  11. package/src/daemon/supervise.ts +446 -0
  12. package/src/dispatch/caller.ts +47 -0
  13. package/src/dispatch/dispatch.ts +128 -0
  14. package/src/dispatch/handler.ts +55 -0
  15. package/src/dispatch/identity.ts +22 -0
  16. package/src/dispatch/index.ts +5 -0
  17. package/src/dispatch/result.ts +58 -0
  18. package/src/files/containment.ts +263 -0
  19. package/src/files/files.ts +421 -0
  20. package/src/files/index.ts +14 -0
  21. package/src/files/sandbox.ts +0 -0
  22. package/src/greeting/hook.ts +48 -0
  23. package/src/greeting/index.ts +2 -0
  24. package/src/greeting/meta.ts +66 -0
  25. package/src/instance/config.ts +424 -0
  26. package/src/instance/handlers.ts +28 -0
  27. package/src/instance/identity.ts +44 -0
  28. package/src/instance/index.ts +8 -0
  29. package/src/instance/instance.ts +911 -0
  30. package/src/instance/lock.ts +108 -0
  31. package/src/instance/log.ts +30 -0
  32. package/src/instance/paths.ts +200 -0
  33. package/src/instance/socket.ts +62 -0
  34. package/src/kv/index.ts +2 -0
  35. package/src/kv/merge.ts +66 -0
  36. package/src/kv/store.ts +195 -0
  37. package/src/launcher/index.ts +4 -0
  38. package/src/launcher/launcher.ts +190 -0
  39. package/src/launcher/roots.ts +32 -0
  40. package/src/launcher/spawn.ts +81 -0
  41. package/src/launcher/tree.ts +80 -0
  42. package/src/mesh/index.ts +5 -0
  43. package/src/mesh/keys.ts +158 -0
  44. package/src/mesh/mesh.ts +1169 -0
  45. package/src/mesh/probe.ts +100 -0
  46. package/src/mesh/relay.ts +147 -0
  47. package/src/mesh/wire.ts +96 -0
  48. package/src/messaging/delivery.ts +375 -0
  49. package/src/messaging/direct.ts +433 -0
  50. package/src/messaging/handlers.ts +14 -0
  51. package/src/messaging/inbox.ts +191 -0
  52. package/src/messaging/index.ts +5 -0
  53. package/src/messaging/notify.ts +117 -0
  54. package/src/plugin/claude.ts +148 -0
  55. package/src/plugin/index.ts +13 -0
  56. package/src/plugin/install.ts +416 -0
  57. package/src/service/index.ts +1 -0
  58. package/src/service/service.ts +359 -0
  59. package/src/sessions/classify.ts +66 -0
  60. package/src/sessions/dump.ts +105 -0
  61. package/src/sessions/fork.ts +127 -0
  62. package/src/sessions/handlers.ts +158 -0
  63. package/src/sessions/harness.ts +167 -0
  64. package/src/sessions/index.ts +26 -0
  65. package/src/sessions/last-live.ts +111 -0
  66. package/src/sessions/processes.ts +413 -0
  67. package/src/sessions/registry.ts +785 -0
  68. package/src/sessions/search.ts +278 -0
  69. package/src/sessions/status.ts +209 -0
  70. package/src/sessions/terminals.ts +72 -0
  71. package/src/sessions/workspace.ts +140 -0
  72. package/src/topics/handlers.ts +42 -0
  73. package/src/topics/index.ts +2 -0
  74. package/src/topics/topics.ts +290 -0
  75. package/src/transcript/files.ts +201 -0
  76. package/src/transcript/fold.ts +833 -0
  77. package/src/transcript/index.ts +16 -0
  78. package/src/transcript/read.ts +82 -0
  79. package/src/transcript/tail.ts +195 -0
  80. package/src/transcript/transcripts.ts +162 -0
  81. package/src/translate/helper.ts +87 -0
  82. package/src/translate/index.ts +2 -0
  83. package/src/translate/translate.ts +127 -0
  84. package/src/transport/conn.ts +129 -0
  85. package/src/transport/dial.ts +65 -0
  86. package/src/transport/driver.ts +102 -0
  87. package/src/transport/entry.ts +39 -0
  88. package/src/transport/framing.ts +131 -0
  89. package/src/transport/index.ts +8 -0
  90. package/src/transport/listener.ts +39 -0
  91. package/src/transport/uds.ts +88 -0
  92. package/src/transport/ws.ts +170 -0
  93. package/src/upstream/events.ts +125 -0
  94. package/src/upstream/gateway.ts +275 -0
  95. package/src/upstream/index.ts +8 -0
  96. package/src/upstream/json.ts +81 -0
  97. package/src/upstream/requests.ts +234 -0
  98. package/src/upstream/stats.ts +99 -0
  99. package/src/upstream/status.ts +281 -0
  100. package/src/upstream/usage.ts +208 -0
  101. package/src/upstream/webhook.ts +141 -0
  102. 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,5 @@
1
+ export * from "./caller.ts";
2
+ export * from "./dispatch.ts";
3
+ export * from "./handler.ts";
4
+ export * from "./identity.ts";
5
+ export * from "./result.ts";
@@ -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
+ }