@ccmsg/cli 0.13.0 → 0.14.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccmsg/cli",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "description": "The ccmsg daemon, CLI and agent plugins for one instance (= one config home)",
5
5
  "license": "MIT",
6
6
  "author": "kawaz",
package/src/auth/admin.ts CHANGED
@@ -51,7 +51,10 @@ export interface Administered {
51
51
  }
52
52
 
53
53
  /** Run one administrative request. */
54
- export function handleAdmin(at: Administered, request: AdminRequest): DispatchResult {
54
+ export async function handleAdmin(
55
+ at: Administered,
56
+ request: AdminRequest,
57
+ ): Promise<DispatchResult> {
55
58
  const auth = at.auth;
56
59
  try {
57
60
  switch (request.admin) {
@@ -77,7 +80,7 @@ export function handleAdmin(at: Administered, request: AdminRequest): DispatchRe
77
80
  case "passkey_list":
78
81
  return reply(request.request_id, { credentials: auth.list() });
79
82
  case "passkey_remove": {
80
- const { closed } = auth.remove(request.sub);
83
+ const { closed } = await auth.remove(request.sub);
81
84
  return reply(request.request_id, { sub: request.sub, closed });
82
85
  }
83
86
  }
package/src/auth/auth.ts CHANGED
@@ -316,8 +316,8 @@ export class Auth {
316
316
  }
317
317
 
318
318
  /** Remove one person: the tombstones, and every connection they hold. */
319
- remove(sub: Subject): { records: AuthRecord[]; closed: number } {
320
- const records = this.deps.records.remove(sub);
319
+ async remove(sub: Subject): Promise<{ records: AuthRecord[]; closed: number }> {
320
+ const records = await this.deps.records.remove(sub);
321
321
  return { records, closed: this.disconnect(sub) };
322
322
  }
323
323
 
@@ -340,8 +340,8 @@ export class Auth {
340
340
  }
341
341
 
342
342
  /** Take what a peer wrote on `auth.records`, and act on the removals in it. */
343
- merge(records: readonly AuthRecord[]): void {
344
- const { removed } = this.deps.records.merge(records);
343
+ async merge(records: readonly AuthRecord[]): Promise<void> {
344
+ const { removed } = await this.deps.records.merge(records);
345
345
  for (const sub of removed) this.disconnect(sub);
346
346
  }
347
347
 
@@ -479,7 +479,7 @@ export class Auth {
479
479
  ...(from.ip === undefined ? {} : { registered_ip: from.ip }),
480
480
  ...(from.userAgent === undefined ? {} : { registered_user_agent: from.userAgent }),
481
481
  };
482
- this.deps.records.write(credentialKey(claims.sub, verified.credentialId), record, at);
482
+ await this.deps.records.write(credentialKey(claims.sub, verified.credentialId), record, at);
483
483
  return this.mint(claims.sub);
484
484
  }
485
485
 
@@ -589,7 +589,7 @@ export class Auth {
589
589
  );
590
590
  await this.#spendAnywhere(args.challenge);
591
591
  const at = this.#now();
592
- this.deps.records.write(
592
+ await this.deps.records.write(
593
593
  credentialKey(record.sub, record.credential_id),
594
594
  {
595
595
  ...record,
@@ -654,7 +654,7 @@ export class Auth {
654
654
  // --- tokens (DR-0001 §2.4) ---
655
655
 
656
656
  /** Make a family for this person, minted by this instance. */
657
- mint(sub: Subject): MintedSession {
657
+ async mint(sub: Subject): Promise<MintedSession> {
658
658
  const at = this.#now();
659
659
  const family: TokenFamily = {
660
660
  kind: "token_family",
@@ -664,7 +664,7 @@ export class Auth {
664
664
  refresh: { value: token(), expires_at: at + REFRESH_TTL_MS },
665
665
  };
666
666
  const id = randomBytes(8).toString("hex");
667
- this.deps.records.write(familyKey(sub, id), family, at);
667
+ await this.deps.records.write(familyKey(sub, id), family, at);
668
668
  return { session: { sub, access: family.access }, refresh: family.refresh };
669
669
  }
670
670
 
@@ -698,7 +698,7 @@ export class Auth {
698
698
  } satisfies AuthRotateArgs)) as AuthRotateResult;
699
699
  return { session: { sub: answer.sub, access: answer.access }, refresh: answer.refresh };
700
700
  }
701
- const rotated = this.rotate(value, from);
701
+ const rotated = await this.rotate(value, from);
702
702
  return { session: { sub: rotated.sub, access: rotated.access }, refresh: rotated.refresh };
703
703
  }
704
704
 
@@ -714,7 +714,7 @@ export class Auth {
714
714
  const owner = this.deps.records.owning(value, digestOf(value));
715
715
  if (owner === undefined) return;
716
716
  if (owner.body.iss === this.deps.self) {
717
- this.#failReused(value);
717
+ await this.#failReused(value);
718
718
  return;
719
719
  }
720
720
  try {
@@ -730,10 +730,10 @@ export class Auth {
730
730
 
731
731
  /** Rotate a family this instance minted. The one writer's own operation, and
732
732
  * what `auth.rotate` runs on its behalf. */
733
- rotate(value: Base64Url, from: RefreshFrom = {}): AuthRotateResult {
733
+ async rotate(value: Base64Url, from: RefreshFrom = {}): Promise<AuthRotateResult> {
734
734
  const held = this.deps.records.byRefresh(value);
735
735
  if (held === undefined) {
736
- this.#failReused(value);
736
+ await this.#failReused(value);
737
737
  throw new OpError("auth_invalid", "この refresh token は使えません");
738
738
  }
739
739
  if (held.body.iss !== this.deps.self) {
@@ -777,7 +777,7 @@ export class Auth {
777
777
  // holds wherever the reused value is presented (contract, `TokenFamily`).
778
778
  retired: retire(held.body, at),
779
779
  };
780
- this.deps.records.write(held.key, rotated, at);
780
+ await this.deps.records.write(held.key, rotated, at);
781
781
  return { sub: rotated.sub, access: rotated.access, refresh: rotated.refresh };
782
782
  }
783
783
 
@@ -789,7 +789,7 @@ export class Auth {
789
789
  * before it past its grace, and any generation this instance rotated away
790
790
  * while it has been running. A value older than what any of those covers
791
791
  * matches nothing and is refused as a stranger. */
792
- #failReused(value: Base64Url): void {
792
+ async #failReused(value: Base64Url): Promise<void> {
793
793
  const digest = digestOf(value);
794
794
  const now = this.#now();
795
795
  for (const held of this.deps.records.families()) {
@@ -805,7 +805,7 @@ export class Auth {
805
805
  this.deps.log?.("a refresh token was reused after it was rotated away", {
806
806
  sub: held.body.sub,
807
807
  });
808
- this.deps.records.fail(held.key);
808
+ await this.deps.records.fail(held.key);
809
809
  // The tokens are gone, and so is what they were holding open: a
810
810
  // connection that outlived the family it was admitted on would be the
811
811
  // stolen token still working.
@@ -932,7 +932,7 @@ export function authHandlers(auth: Auth) {
932
932
  // the URL and the count of tries against it (DR-0001 §2.2).
933
933
  return { kind: "register", claims: auth.resolveRegistration(args.token, args.code) };
934
934
  },
935
- "auth.rotate": (input: HandlerInput): AuthRotateResult => {
935
+ "auth.rotate": (input: HandlerInput): Promise<AuthRotateResult> => {
936
936
  const args = input.args as unknown as AuthRotateArgs;
937
937
  // The receiving instance's account of the person, taken as stated: it is
938
938
  // the only one that saw them, and `last_refresh` is a hint nothing is
@@ -1,5 +1,6 @@
1
- import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
1
+ import { readFileSync } from "node:fs";
2
+ import { mkdir, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
3
4
  import type {
4
5
  AuthRecord,
5
6
  AuthTombstone,
@@ -81,9 +82,13 @@ export interface RecordsDeps {
81
82
  * and in the authenticator, and losing a family logs its person out. */
82
83
  export class AuthRecords {
83
84
  readonly #records = new Map<string, AuthRecord>();
84
- #loaded = false;
85
85
 
86
- constructor(private readonly deps: RecordsDeps) {}
86
+ /** The writes already asked for, as one chain. */
87
+ #writing: Promise<void> = Promise.resolve();
88
+
89
+ constructor(private readonly deps: RecordsDeps) {
90
+ this.#read();
91
+ }
87
92
 
88
93
  #now(): Timestamp {
89
94
  return (this.deps.now ?? Date.now)();
@@ -94,7 +99,6 @@ export class AuthRecords {
94
99
  * Answers whether the set moved, which is what decides whether the change is
95
100
  * worth writing down and passing on. */
96
101
  accept(record: AuthRecord): boolean {
97
- this.#load();
98
102
  const held = this.#records.get(record.key);
99
103
  if (held !== undefined && held.updated_at >= record.updated_at) return false;
100
104
  if (this.#refused(record)) return false;
@@ -135,13 +139,18 @@ export class AuthRecords {
135
139
  * millisecond, which a rotation and the mint before it easily do, must not
136
140
  * silently drop the second. So the instant is moved past what is held rather
137
141
  * than compared against it. */
138
- write(key: string, body: AuthRecord["body"], now: Timestamp = this.#now()): boolean {
139
- this.#load();
142
+ async write(
143
+ key: string,
144
+ body: AuthRecord["body"],
145
+ now: Timestamp = this.#now(),
146
+ ): Promise<boolean> {
140
147
  const held = this.#records.get(key);
141
148
  const at = held === undefined ? now : Math.max(now, held.updated_at + 1);
142
149
  const record: AuthRecord = { key, updated_at: at, body };
143
150
  if (!this.accept(record)) return false;
144
- this.#persist();
151
+ // Handed to the peers once it is written down, so no peer holds a record
152
+ // this instance would not have after a restart.
153
+ await this.#persist();
145
154
  this.deps.publish([record]);
146
155
  return true;
147
156
  }
@@ -156,7 +165,7 @@ export class AuthRecords {
156
165
  * This instance is its only writer, so a copy coming back is a copy of an
157
166
  * older state — which is exactly what a failed family looks like from a peer
158
167
  * that has not heard yet, and taking it would undo the failure. */
159
- merge(records: readonly AuthRecord[]): { changed: number; removed: Subject[] } {
168
+ async merge(records: readonly AuthRecord[]): Promise<{ changed: number; removed: Subject[] }> {
160
169
  let changed = 0;
161
170
  const removed: Subject[] = [];
162
171
  for (const record of records) {
@@ -165,7 +174,7 @@ export class AuthRecords {
165
174
  changed += 1;
166
175
  if (record.body.kind === "tombstone") removed.push(record.body.sub);
167
176
  }
168
- if (changed > 0) this.#persist();
177
+ if (changed > 0) await this.#persist();
169
178
  return { changed, removed };
170
179
  }
171
180
 
@@ -175,7 +184,7 @@ export class AuthRecords {
175
184
  * lengths of time. A family expires with its refresh token, so the mark over
176
185
  * it only has to outlive the longest one; a credential has no expiry of its
177
186
  * own, so the mark over it has none either (DR-0001 §2.6). */
178
- remove(sub: Subject): AuthRecord[] {
187
+ async remove(sub: Subject): Promise<AuthRecord[]> {
179
188
  const at = this.#now();
180
189
  const credential: AuthTombstone = { kind: "tombstone", sub, deleted_at: at };
181
190
  const family: AuthTombstone = {
@@ -189,7 +198,7 @@ export class AuthRecords {
189
198
  { key: familyPrefix(sub), updated_at: at, body: family },
190
199
  ];
191
200
  for (const mark of marks) this.accept(mark);
192
- this.#persist();
201
+ await this.#persist();
193
202
  this.deps.publish(marks);
194
203
  return marks;
195
204
  }
@@ -197,13 +206,11 @@ export class AuthRecords {
197
206
  /** Whether this subject has been removed, which is what a registration for
198
207
  * one has to be refused by. */
199
208
  removed(sub: Subject): boolean {
200
- this.#load();
201
209
  const held = this.#records.get(credentialPrefix(sub));
202
210
  return held?.body.kind === "tombstone";
203
211
  }
204
212
 
205
213
  credentials(): CredentialRecord[] {
206
- this.#load();
207
214
  const found: CredentialRecord[] = [];
208
215
  for (const record of this.#records.values()) {
209
216
  if (record.body.kind === "credential") found.push(record.body);
@@ -228,7 +235,6 @@ export class AuthRecords {
228
235
  }
229
236
 
230
237
  families(): { key: string; body: TokenFamily }[] {
231
- this.#load();
232
238
  const found: { key: string; body: TokenFamily }[] = [];
233
239
  for (const record of this.#records.values()) {
234
240
  if (record.body.kind === "token_family") found.push({ key: record.key, body: record.body });
@@ -275,12 +281,11 @@ export class AuthRecords {
275
281
  * the key, which is exactly what a revoked family needs. It is kept for the
276
282
  * same seven days a removal's is: past the longest refresh token, there is
277
283
  * nothing left for a returning peer to revive. */
278
- fail(key: string): void {
279
- this.#load();
284
+ async fail(key: string): Promise<void> {
280
285
  const held = this.#records.get(key);
281
286
  if (held === undefined || held.body.kind !== "token_family") return;
282
287
  const at = this.#now();
283
- this.write(key, {
288
+ await this.write(key, {
284
289
  kind: "tombstone",
285
290
  sub: held.body.sub,
286
291
  deleted_at: at,
@@ -306,7 +311,6 @@ export class AuthRecords {
306
311
 
307
312
  /** Every record, for the snapshot a peer's subscription is answered with. */
308
313
  all(): AuthRecord[] {
309
- this.#load();
310
314
  this.#expire();
311
315
  return [...this.#records.values()];
312
316
  }
@@ -330,9 +334,19 @@ export class AuthRecords {
330
334
  }
331
335
  }
332
336
 
333
- #load(): void {
334
- if (this.#loaded) return;
335
- this.#loaded = true;
337
+ /** Settle once every write asked for so far has landed. What a stop waits on
338
+ * before it lets go of the config home (DESIGN §8.5 step 4). */
339
+ async flush(): Promise<void> {
340
+ await this.#writing;
341
+ }
342
+
343
+ /** The file, read as this is built — before the instance is accepting
344
+ * anything, so nobody is waiting on it (DR-0015). Reading it when the first
345
+ * authentication asked would put the read inside the turn that answers it,
346
+ * and the `auth.records` snapshot is answered from what is held rather than
347
+ * from a promise. A file that is not there is an instance nobody has
348
+ * registered against, which is what an empty set says. */
349
+ #read(): void {
336
350
  let text: string;
337
351
  try {
338
352
  text = readFileSync(this.#file(), "utf8");
@@ -356,21 +370,34 @@ export class AuthRecords {
356
370
  this.#expire();
357
371
  }
358
372
 
359
- #persist(): void {
373
+ /** The set as it stands, written whole.
374
+ *
375
+ * The body is taken here, before anything is awaited, so what is written is
376
+ * the set as it was when the change was answered; the writes are chained so
377
+ * that two of them cannot be racing for one file and the older land last
378
+ * (DR-0015). */
379
+ #persist(): Promise<void> {
360
380
  this.#expire();
361
- mkdirSync(this.deps.dir, { recursive: true, mode: 0o700 });
362
- const file = this.#file();
363
- const temporary = `${file}.ccmsg-${String(process.pid)}-${String(Date.now())}`;
364
- // The set holds tokens, so the file is the instance's own to read: it is
365
- // created with the mode rather than fixed afterwards, so there is no
366
- // instant at which it stands readable by anyone else.
367
- writeFileSync(temporary, JSON.stringify([...this.#records.values()]), { mode: 0o600 });
368
- try {
369
- renameSync(temporary, file);
370
- } catch (cause) {
371
- unlinkSync(temporary);
372
- throw cause;
373
- }
381
+ const body = JSON.stringify([...this.#records.values()]);
382
+ const written = this.#writing.then(async () => {
383
+ await mkdir(this.deps.dir, { recursive: true, mode: 0o700 });
384
+ const file = this.#file();
385
+ const temporary = `${file}.ccmsg-${String(process.pid)}-${String(Date.now())}`;
386
+ // The set holds tokens, so the file is the instance's own to read: it is
387
+ // created with the mode rather than fixed afterwards, so there is no
388
+ // instant at which it stands readable by anyone else.
389
+ await writeFile(temporary, body, { mode: 0o600 });
390
+ try {
391
+ await rename(temporary, file);
392
+ } catch (cause) {
393
+ await unlink(temporary);
394
+ throw cause;
395
+ }
396
+ });
397
+ // The chain carries the order, not the outcome: a write that failed is
398
+ // answered to its own caller, and the ones behind it still go.
399
+ this.#writing = written.catch(() => {});
400
+ return written;
374
401
  }
375
402
 
376
403
  #file(): string {
@@ -383,11 +410,6 @@ export function recordsDir(stateDir: string): string {
383
410
  return join(stateDir, AUTH_DIR);
384
411
  }
385
412
 
386
- /** The parent of a path, made when a caller wants to write into it. */
387
- export function ensureDir(file: string): void {
388
- mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
389
- }
390
-
391
413
  /** Whether an instance is the one allowed to write this family (DR-0001 §2.4). */
392
414
  export function writes(family: TokenFamily, self: InstanceId): boolean {
393
415
  return family.iss === self;
@@ -275,18 +275,26 @@ export class Supervisor {
275
275
  }
276
276
  return await op(unit);
277
277
  }
278
- const answers: (T | { dir: string; error: { code: string; msg: string } })[] = [];
279
278
  // Taken as a list first: each step below waits, and a request arriving in
280
279
  // between may add or remove one — what `--all` answers about is the set as
281
280
  // it stood when it was asked.
282
281
  const units = Array.from(this.#units.values());
283
- for (const unit of units) {
284
- try {
285
- answers.push(await op(unit));
286
- } catch (cause) {
287
- if (!(cause instanceof CommandError)) throw cause;
288
- answers.push({ dir: unit.target.dir, error: { code: cause.code, msg: cause.message } });
282
+ // Every config home at once. Each is a child process of its own, and a
283
+ // start that waits on one of them serving is not a reason the next one has
284
+ // not been asked to start yet (DR-0015). The answers are still the units in
285
+ // the order they were taken, because that is the list the caller asked
286
+ // about.
287
+ const settled = await Promise.allSettled(units.map((unit) => op(unit)));
288
+ const answers: (T | { dir: string; error: { code: string; msg: string } })[] = [];
289
+ for (const [index, outcome] of settled.entries()) {
290
+ if (outcome.status === "fulfilled") {
291
+ answers.push(outcome.value);
292
+ continue;
289
293
  }
294
+ const cause: unknown = outcome.reason;
295
+ if (!(cause instanceof CommandError)) throw cause;
296
+ const unit = units[index] as Supervised;
297
+ answers.push({ dir: unit.target.dir, error: { code: cause.code, msg: cause.message } });
290
298
  }
291
299
  return answers;
292
300
  }
@@ -1,4 +1,5 @@
1
1
  import { realpathSync } from "node:fs";
2
+ import { realpath } from "node:fs/promises";
2
3
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
3
4
  import type { FileKind, Role, Sid } from "@ccmsg/protocol";
4
5
  import { OpError } from "../dispatch/index.ts";
@@ -69,11 +70,11 @@ export class Containment {
69
70
  constructor(private readonly source: RootsSource) {}
70
71
 
71
72
  /** A path named by kind, as an op's arguments give it. */
72
- locate(args: PathArgs, viewer: Viewer = {}): Located {
73
+ async locate(args: PathArgs, viewer: Viewer = {}): Promise<Located> {
73
74
  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 };
75
+ const named = await this.absolute(args, roots);
76
+ const real = await canonical(named);
77
+ return { ...(await this.admit(args.kind, real, roots)), named };
77
78
  }
78
79
 
79
80
  /** An absolute path with no kind: which surface admits it, if any.
@@ -81,7 +82,7 @@ export class Containment {
81
82
  * The surfaces are tried in the order the contract states, and the answer is
82
83
  * one value for every refusal — outside the allowlists, or simply not there —
83
84
  * 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
+ async identify(sid: Sid, path: string, viewer: Viewer = {}): Promise<Located | undefined> {
85
86
  let roots: SessionRoots;
86
87
  try {
87
88
  roots = this.rootsFor(sid, viewer);
@@ -90,10 +91,10 @@ export class Containment {
90
91
  }
91
92
  if (!isAbsolute(path)) return undefined;
92
93
  const named = resolve(path);
93
- const real = canonical(named);
94
+ const real = await canonical(named);
94
95
  for (const kind of KINDS) {
95
96
  try {
96
- return { ...this.admit(kind, real, roots), named };
97
+ return { ...(await this.admit(kind, real, roots)), named };
97
98
  } catch {
98
99
  // The next surface may admit it; running out of surfaces is the miss.
99
100
  }
@@ -106,15 +107,15 @@ export class Containment {
106
107
  * fixed to (DR-0019). A name that leaves the inbox is refused as unwritable
107
108
  * rather than as forbidden — the path is reachable, and only writing there
108
109
  * is not. */
109
- inbox(sid: Sid, path: string, viewer: Viewer = {}): Located {
110
+ async inbox(sid: Sid, path: string, viewer: Viewer = {}): Promise<Located> {
110
111
  const roots = this.rootsFor(sid, viewer);
111
112
  const cwd = roots.cwd;
112
113
  if (cwd === undefined || !isAbsolute(cwd)) {
113
114
  throw new OpError("path_forbidden", `${sid} states no working directory to write into`);
114
115
  }
115
- const base = canonical(cwd);
116
+ const base = await canonical(cwd);
116
117
  const named = resolve(base, path);
117
- const real = canonical(named);
118
+ const real = await canonical(named);
118
119
  const inbox = join(base, INBOX);
119
120
  if (!within(real, inbox)) {
120
121
  throw new OpError("path_not_writable", `only ${INBOX}/ takes a written file`);
@@ -123,7 +124,7 @@ export class Containment {
123
124
  }
124
125
 
125
126
  /** The directory a listing or a walk starts from. */
126
- root(args: DirArgs, viewer: Viewer = {}): Located {
127
+ root(args: DirArgs, viewer: Viewer = {}): Promise<Located> {
127
128
  return this.locate({ sid: args.sid, kind: args.kind, path: args.path ?? "" }, viewer);
128
129
  }
129
130
 
@@ -142,13 +143,13 @@ export class Containment {
142
143
  }
143
144
 
144
145
  /** Turn an op's `path` into an absolute one, in the shape its kind states. */
145
- private absolute(args: PathArgs, roots: SessionRoots): string {
146
+ private async absolute(args: PathArgs, roots: SessionRoots): Promise<string> {
146
147
  if (args.kind === "contained") {
147
148
  const root = roots.root;
148
149
  if (root === undefined || !isAbsolute(root)) {
149
150
  throw new OpError("path_forbidden", "this session states no root to be contained by");
150
151
  }
151
- return resolve(canonical(root), `.${sep}${args.path}`);
152
+ return resolve(await canonical(root), `.${sep}${args.path}`);
152
153
  }
153
154
  if (!isAbsolute(args.path)) {
154
155
  throw new OpError("path_forbidden", `a ${args.kind} path is absolute`);
@@ -159,17 +160,21 @@ export class Containment {
159
160
  /** Whether a resolved path is inside the surface it claims. The check runs on
160
161
  * what the filesystem resolved, so a symlink pointing out of a root is
161
162
  * refused however it was spelled (DR-0008 §3). */
162
- private admit(kind: FileKind, real: string, roots: SessionRoots): Omit<Located, "named"> {
163
+ private async admit(
164
+ kind: FileKind,
165
+ real: string,
166
+ roots: SessionRoots,
167
+ ): Promise<Omit<Located, "named">> {
163
168
  if (kind === "contained") {
164
- const root = roots.root === undefined ? undefined : canonical(roots.root);
169
+ const root = roots.root === undefined ? undefined : await canonical(roots.root);
165
170
  if (root === undefined || !within(real, root)) {
166
171
  throw new OpError("path_forbidden", "the path is outside the session's root");
167
172
  }
168
173
  return { kind, real, path: relativeTo(root, real) };
169
174
  }
170
175
  if (kind === "workspace") {
171
- const folder = roots.workspace_folders.find((each) => within(real, canonical(each)));
172
- if (folder === undefined) {
176
+ const folders = await Promise.all(roots.workspace_folders.map(canonical));
177
+ if (!folders.some((folder) => within(real, folder))) {
173
178
  throw new OpError("path_forbidden", "the path is in no workspace folder of this session");
174
179
  }
175
180
  return { kind, real, path: real };
@@ -179,7 +184,9 @@ export class Containment {
179
184
  // path spelled through a symlink and the same file spelled directly are
180
185
  // one entry, and a path since replaced by a symlink resolves elsewhere and
181
186
  // is no longer in the list.
182
- const named = roots.external_files.some((each) => canonical(each) === real);
187
+ const named = (await Promise.all(roots.external_files.map(canonical))).some(
188
+ (each) => each === real,
189
+ );
183
190
  if (!named) {
184
191
  throw new OpError("path_forbidden", "the path is not one this session's transcript named");
185
192
  }
@@ -234,7 +241,23 @@ const KINDS = ["contained", "workspace", "external"] as const;
234
241
  * last segment back, so a file about to be created is decided by where it would
235
242
  * land rather than being refused for not being there. A parent that does not
236
243
  * resolve either leaves the path as written, which no surface admits. */
237
- export function canonical(path: string): string {
244
+ export async function canonical(path: string): Promise<string> {
245
+ const absolute = resolve(path);
246
+ try {
247
+ return await realpath(absolute);
248
+ } catch {
249
+ const parent = dirname(absolute);
250
+ if (parent === absolute) return absolute;
251
+ try {
252
+ return join(await realpath(parent), basename(absolute));
253
+ } catch {
254
+ return absolute;
255
+ }
256
+ }
257
+ }
258
+
259
+ /** The synchronous form required while `session.status` states its value synchronously (DESIGN §6); an asynchronous topic value uses `canonical` as the single path. */
260
+ export function canonicalSync(path: string): string {
238
261
  const absolute = resolve(path);
239
262
  try {
240
263
  return realpathSync(absolute);