synomem 0.7.2 → 0.9.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 (93) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +47 -68
  3. package/dist/backend.d.ts +18 -6
  4. package/dist/backend.d.ts.map +1 -1
  5. package/dist/backend.js +55 -41
  6. package/dist/backend.js.map +1 -1
  7. package/dist/cli.d.ts +20 -25
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +1394 -1281
  10. package/dist/cli.js.map +1 -1
  11. package/dist/configure.d.ts +12 -46
  12. package/dist/configure.d.ts.map +1 -1
  13. package/dist/configure.js +51 -192
  14. package/dist/configure.js.map +1 -1
  15. package/dist/credentials.d.ts +73 -33
  16. package/dist/credentials.d.ts.map +1 -1
  17. package/dist/credentials.js +167 -43
  18. package/dist/credentials.js.map +1 -1
  19. package/dist/discover.d.ts +10 -13
  20. package/dist/discover.d.ts.map +1 -1
  21. package/dist/discover.js +45 -30
  22. package/dist/discover.js.map +1 -1
  23. package/dist/errors.d.ts +1 -1
  24. package/dist/errors.d.ts.map +1 -1
  25. package/dist/errors.js +5 -0
  26. package/dist/errors.js.map +1 -1
  27. package/dist/import.d.ts +3 -0
  28. package/dist/import.d.ts.map +1 -1
  29. package/dist/import.js +3 -0
  30. package/dist/import.js.map +1 -1
  31. package/dist/index.d.ts +9 -7
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +6 -5
  34. package/dist/index.js.map +1 -1
  35. package/dist/mcp/index.d.ts +18 -7
  36. package/dist/mcp/index.d.ts.map +1 -1
  37. package/dist/mcp/index.js +402 -183
  38. package/dist/mcp/index.js.map +1 -1
  39. package/dist/mcp-server.d.ts +5 -1
  40. package/dist/mcp-server.d.ts.map +1 -1
  41. package/dist/mcp-server.js +27 -105
  42. package/dist/mcp-server.js.map +1 -1
  43. package/dist/oauth.d.ts +31 -33
  44. package/dist/oauth.d.ts.map +1 -1
  45. package/dist/oauth.js +178 -125
  46. package/dist/oauth.js.map +1 -1
  47. package/dist/profiles.d.ts +243 -0
  48. package/dist/profiles.d.ts.map +1 -0
  49. package/dist/profiles.js +465 -0
  50. package/dist/profiles.js.map +1 -0
  51. package/dist/project.d.ts +8 -39
  52. package/dist/project.d.ts.map +1 -1
  53. package/dist/project.js +36 -94
  54. package/dist/project.js.map +1 -1
  55. package/dist/remote.d.ts +23 -15
  56. package/dist/remote.d.ts.map +1 -1
  57. package/dist/remote.js +54 -49
  58. package/dist/remote.js.map +1 -1
  59. package/dist/resolvers.d.ts +47 -0
  60. package/dist/resolvers.d.ts.map +1 -0
  61. package/dist/resolvers.js +255 -0
  62. package/dist/resolvers.js.map +1 -0
  63. package/dist/service.d.ts +2 -0
  64. package/dist/service.d.ts.map +1 -1
  65. package/dist/skill-install.d.ts +4 -6
  66. package/dist/skill-install.d.ts.map +1 -1
  67. package/dist/skill-install.js +13 -12
  68. package/dist/skill-install.js.map +1 -1
  69. package/dist/types.d.ts +51 -0
  70. package/dist/types.d.ts.map +1 -1
  71. package/docs/cli.md +173 -196
  72. package/docs/mcp.md +69 -65
  73. package/package.json +1 -1
  74. package/skills/synomem/SKILL.md +30 -4
  75. package/skills/synomem/references/examples.md +14 -0
  76. package/src/backend.ts +66 -64
  77. package/src/cli.ts +2137 -2163
  78. package/src/configure.ts +62 -241
  79. package/src/credentials.ts +208 -84
  80. package/src/discover.ts +60 -36
  81. package/src/errors.ts +5 -0
  82. package/src/import.ts +5 -0
  83. package/src/index.ts +14 -12
  84. package/src/mcp/index.ts +473 -194
  85. package/src/mcp-server.ts +32 -114
  86. package/src/oauth.ts +229 -130
  87. package/src/profiles.ts +644 -0
  88. package/src/project.ts +42 -108
  89. package/src/remote.ts +69 -58
  90. package/src/resolvers.ts +299 -0
  91. package/src/service.ts +2 -0
  92. package/src/skill-install.ts +17 -18
  93. package/src/types.ts +46 -0
@@ -0,0 +1,644 @@
1
+ /**
2
+ * Profiles, connections, and presets — the only runtime identity interface.
3
+ *
4
+ * `<home>/profiles.json` (plan §8):
5
+ *
6
+ * {
7
+ * "version": 1,
8
+ * "credentials": { "codex-mac": { kind, apiUrl, store, secretRef, … } },
9
+ * "profiles": {
10
+ * "codex-gracie": { "credentialRef": "codex-mac", "contextId": "ctx_…" },
11
+ * "gracie-local": { "backend": "local", "actorId": "01…", "contextId": "lctx_…" }
12
+ * },
13
+ * "harnessPresets": { "codex": ["codex-gracie", "codex-astra"] },
14
+ * "defaultProfile": "gracie-local"
15
+ * }
16
+ *
17
+ * A credential entry holds only a REFERENCE to its secret (`secretRef`, an
18
+ * opaque key into the keychain or restricted-file store); the secret itself
19
+ * never appears in this file. A profile routes one stable context through one
20
+ * credential; several profiles may share a credential. Nothing here is
21
+ * authority: the API authorizes every operation against the connection's
22
+ * grant, whatever a profile says.
23
+ *
24
+ * Kept separate from `<home>/config.json`, which is the local store's own
25
+ * policy file and persistent workspace identity.
26
+ */
27
+ import { existsSync } from 'node:fs';
28
+ import { join } from 'node:path';
29
+ import { z } from 'zod';
30
+ import { localStoreWorkspaceId, readSynomemConfig } from './backend.js';
31
+ import { resolveHome } from './config.js';
32
+ import {
33
+ withCredentialLock,
34
+ type CredentialStore,
35
+ type CredentialStores,
36
+ type StoredOAuthCredential,
37
+ } from './credentials.js';
38
+ import { SynomemError } from './errors.js';
39
+ import { atomicWriteFile, readJsonFile } from './fs-utils.js';
40
+ import { refreshOAuthCredential } from './oauth.js';
41
+ import { findProjectSelection } from './project.js';
42
+ import {
43
+ createLocalResolver,
44
+ createRemoteResolver,
45
+ localContextId,
46
+ type ContextResolver,
47
+ type RemoteCredentialSource,
48
+ } from './resolvers.js';
49
+ import type { ActorIdentity, ContextSummary } from './types.js';
50
+
51
+ export const PROFILES_FILE = 'profiles.json';
52
+
53
+ const nameSchema = z
54
+ .string()
55
+ .regex(
56
+ /^[a-z0-9][a-z0-9._-]{0,62}$/i,
57
+ 'names use letters, digits, dot, dash and underscore (max 63)',
58
+ );
59
+
60
+ const credentialEntrySchema = z
61
+ .object({
62
+ kind: z.enum(['oauth', 'access-key']),
63
+ /** The Synomem API origin this credential is for. */
64
+ apiUrl: z.string().url(),
65
+ store: z.enum(['keychain', 'file', 'environment']),
66
+ /** Opaque key into the keychain/file store. Absent for `environment`. */
67
+ secretRef: z
68
+ .string()
69
+ .regex(/^[A-Za-z0-9_-]{1,100}$/)
70
+ .optional(),
71
+ issuer: z.string().optional(),
72
+ resource: z.string().optional(),
73
+ clientId: z.string().optional(),
74
+ /** The server-side connection this credential belongs to, when known. */
75
+ connectionId: z.string().optional(),
76
+ label: z.string().optional(),
77
+ createdAt: z.string(),
78
+ })
79
+ .strict()
80
+ .refine((entry) => (entry.store === 'environment') === (entry.secretRef === undefined), {
81
+ message: 'secretRef is required except for the environment store',
82
+ });
83
+
84
+ const actorSnapshotSchema = z
85
+ .object({
86
+ kind: z.enum(['human', 'agent', 'system']),
87
+ id: z.string().min(1),
88
+ displayName: z.string().optional(),
89
+ })
90
+ .strict();
91
+
92
+ const localProfileSchema = z
93
+ .object({
94
+ backend: z.literal('local'),
95
+ /** The store's home; absent means the root Synomem home. */
96
+ home: z.string().optional(),
97
+ actorId: z.string().min(1),
98
+ actorName: z.string().optional(),
99
+ contextId: z.string().regex(/^lctx_[0-9a-f]{24}$/),
100
+ })
101
+ .strict();
102
+
103
+ const remoteProfileSchema = z
104
+ .object({
105
+ backend: z.literal('remote').optional(),
106
+ credentialRef: nameSchema,
107
+ contextId: z.string().min(1),
108
+ /** Descriptive snapshots taken at creation — for display, never authority. */
109
+ workspaceId: z.string().optional(),
110
+ workspaceName: z.string().optional(),
111
+ actor: actorSnapshotSchema.optional(),
112
+ })
113
+ .strict();
114
+
115
+ export const profileSchema = z.union([localProfileSchema, remoteProfileSchema]);
116
+
117
+ export const profilesConfigSchema = z
118
+ .object({
119
+ version: z.literal(1),
120
+ credentials: z.record(nameSchema, credentialEntrySchema).default({}),
121
+ profiles: z.record(nameSchema, profileSchema).default({}),
122
+ harnessPresets: z.record(nameSchema, z.array(nameSchema).min(1)).default({}),
123
+ defaultProfile: nameSchema.optional(),
124
+ })
125
+ .strict();
126
+
127
+ export type CredentialEntry = z.infer<typeof credentialEntrySchema>;
128
+ export type LocalProfile = z.infer<typeof localProfileSchema>;
129
+ export type RemoteProfile = z.infer<typeof remoteProfileSchema>;
130
+ export type Profile = z.infer<typeof profileSchema>;
131
+ export type ProfilesConfig = z.infer<typeof profilesConfigSchema>;
132
+
133
+ export function isLocalProfile(profile: Profile): profile is LocalProfile {
134
+ return profile.backend === 'local';
135
+ }
136
+
137
+ export function emptyProfilesConfig(): ProfilesConfig {
138
+ return { version: 1, credentials: {}, profiles: {}, harnessPresets: {} };
139
+ }
140
+
141
+ export function assertName(name: string, what: string): string {
142
+ const parsed = nameSchema.safeParse(name);
143
+ if (!parsed.success) {
144
+ throw new SynomemError(
145
+ 'INVALID_INPUT',
146
+ `Invalid ${what} name "${name}": ${parsed.error.issues[0]?.message}.`,
147
+ );
148
+ }
149
+ return parsed.data;
150
+ }
151
+
152
+ /** Reads and writes `<home>/profiles.json`. Injected in tests. */
153
+ export class ProfileStore {
154
+ readonly home: string;
155
+
156
+ constructor(explicitHome?: string) {
157
+ this.home = resolveHome(explicitHome);
158
+ }
159
+
160
+ get path(): string {
161
+ return join(this.home, PROFILES_FILE);
162
+ }
163
+
164
+ read(): ProfilesConfig {
165
+ if (!existsSync(this.path)) {
166
+ // The old remote-backend configuration is refused, not migrated: the
167
+ // store's config.json reader names the replacement commands.
168
+ if (existsSync(join(this.home, 'config.json'))) readSynomemConfig(this.home, {});
169
+ return emptyProfilesConfig();
170
+ }
171
+ let raw: unknown;
172
+ try {
173
+ raw = readJsonFile(this.path);
174
+ } catch {
175
+ throw new SynomemError('CONFIG_INVALID', `${this.path} is not readable JSON.`);
176
+ }
177
+ const parsed = profilesConfigSchema.safeParse(raw);
178
+ if (!parsed.success) {
179
+ const issue = parsed.error.issues[0];
180
+ throw new SynomemError(
181
+ 'CONFIG_INVALID',
182
+ `${this.path} is not a valid version-1 profiles file (${issue?.path.join('.') || 'root'}: ${
183
+ issue?.message ?? 'invalid'
184
+ }). Re-run \`synomem setup\`, or fix the file.`,
185
+ );
186
+ }
187
+ return parsed.data;
188
+ }
189
+
190
+ write(config: ProfilesConfig): void {
191
+ const parsed = profilesConfigSchema.parse(config);
192
+ atomicWriteFile(this.path, `${JSON.stringify(parsed, null, 2)}\n`, 0o600);
193
+ }
194
+ }
195
+
196
+ /* ------------------------------------------------------------ selection */
197
+
198
+ export type Selection =
199
+ | { kind: 'profile'; name: string; source: string }
200
+ | { kind: 'preset'; name: string; source: string };
201
+
202
+ export interface SelectionInput {
203
+ profile?: string;
204
+ preset?: string;
205
+ env?: NodeJS.ProcessEnv;
206
+ cwd?: string;
207
+ /** The Synomem home, so it is never mistaken for a project directory. */
208
+ home?: string;
209
+ }
210
+
211
+ /**
212
+ * Which profile or preset a process acts through. One implementation, used by
213
+ * the CLI and both stdio entrypoints:
214
+ *
215
+ * 1. `--profile` / `--preset` on this invocation
216
+ * 2. `SYNOMEM_PROFILE` / `SYNOMEM_PRESET` for this process
217
+ * 3. the nearest project `.synomem/project.json` (names a profile/preset only)
218
+ * 4. `defaultProfile` in profiles.json
219
+ *
220
+ * Returns undefined when nothing selects anything. Naming something that does
221
+ * not exist is an error, never a fall-through to the next source.
222
+ */
223
+ export function resolveSelection(
224
+ config: ProfilesConfig,
225
+ input: SelectionInput,
226
+ ): Selection | undefined {
227
+ const env = input.env ?? process.env;
228
+ const pick = (
229
+ profile: string | undefined,
230
+ preset: string | undefined,
231
+ source: string,
232
+ ): Selection | undefined => {
233
+ const profileName = profile?.trim() || undefined;
234
+ const presetName = preset?.trim() || undefined;
235
+ if (profileName && presetName) {
236
+ throw new SynomemError(
237
+ 'INVALID_INPUT',
238
+ `${source} names both a profile and a preset; choose one.`,
239
+ );
240
+ }
241
+ if (profileName) {
242
+ if (!config.profiles[profileName]) throw unknownName('profile', profileName, source, config);
243
+ return { kind: 'profile', name: profileName, source };
244
+ }
245
+ if (presetName) {
246
+ if (!config.harnessPresets[presetName])
247
+ throw unknownName('preset', presetName, source, config);
248
+ return { kind: 'preset', name: presetName, source };
249
+ }
250
+ return undefined;
251
+ };
252
+ return (
253
+ pick(input.profile, input.preset, 'the command line') ??
254
+ pick(env.SYNOMEM_PROFILE, env.SYNOMEM_PRESET, 'SYNOMEM_PROFILE/SYNOMEM_PRESET') ??
255
+ (() => {
256
+ const project = findProjectSelection(input.cwd ?? process.cwd(), input.home);
257
+ return project ? pick(project.profile, project.preset, project.path) : undefined;
258
+ })() ??
259
+ (config.defaultProfile
260
+ ? pick(config.defaultProfile, undefined, 'defaultProfile in profiles.json')
261
+ : undefined)
262
+ );
263
+ }
264
+
265
+ function unknownName(
266
+ kind: 'profile' | 'preset',
267
+ name: string,
268
+ source: string,
269
+ config: ProfilesConfig,
270
+ ): SynomemError {
271
+ const known = Object.keys(kind === 'profile' ? config.profiles : config.harnessPresets);
272
+ return new SynomemError(
273
+ 'CONFIG_INVALID',
274
+ `Unknown ${kind} "${name}" (from ${source}). ${
275
+ known.length ? `Known ${kind}s: ${known.join(', ')}.` : `No ${kind}s exist yet.`
276
+ } See \`synomem ${kind} list\`.`,
277
+ );
278
+ }
279
+
280
+ export function noSelectionError(): SynomemError {
281
+ return new SynomemError(
282
+ 'CONFIG_INVALID',
283
+ 'No profile selected. Pass --profile <name> (or set SYNOMEM_PROFILE), or run `synomem setup --backend local` / `synomem connection login` then `synomem profile create`.',
284
+ );
285
+ }
286
+
287
+ /* ------------------------------------------------------------ credentials */
288
+
289
+ export interface CredentialSourceOptions {
290
+ home: string;
291
+ name: string;
292
+ entry: CredentialEntry;
293
+ stores: CredentialStores;
294
+ env?: NodeJS.ProcessEnv;
295
+ fetch?: typeof fetch;
296
+ }
297
+
298
+ export function storeFor(stores: CredentialStores, entry: CredentialEntry): CredentialStore {
299
+ if (entry.store === 'environment') {
300
+ throw new SynomemError('INTERNAL_ERROR', 'The environment store holds no secrets.');
301
+ }
302
+ return stores[entry.store];
303
+ }
304
+
305
+ /**
306
+ * A connection's current bearer: an access key as stored, or an OAuth access
307
+ * token, refreshed under the cross-process lock when it has expired.
308
+ *
309
+ * After taking the lock the stored credential is re-read: if another process
310
+ * already refreshed it (a newer generation), that token is used and the
311
+ * refresh token is not spent again. A refused refresh is never retried — the
312
+ * connection must be logged in again.
313
+ */
314
+ export class ConnectionCredentialSource implements RemoteCredentialSource {
315
+ private cached?: string;
316
+ private cachedExpiry = 0;
317
+ private pending?: Promise<string>;
318
+
319
+ constructor(private readonly options: CredentialSourceOptions) {}
320
+
321
+ async bearer(): Promise<string> {
322
+ if (this.cached && this.cachedExpiry > Date.now()) return this.cached;
323
+ this.pending ??= this.load().finally(() => {
324
+ this.pending = undefined;
325
+ });
326
+ return await this.pending;
327
+ }
328
+
329
+ private reauthorize(detail: string): SynomemError {
330
+ return new SynomemError(
331
+ 'REAUTHORIZATION_REQUIRED',
332
+ `${detail} Run \`synomem connection login --name ${this.options.name}\` to sign in again.`,
333
+ );
334
+ }
335
+
336
+ private async load(): Promise<string> {
337
+ const { entry, name } = this.options;
338
+ const env = this.options.env ?? process.env;
339
+ if (entry.store === 'environment') {
340
+ const token = env.SYNOMEM_ACCESS_TOKEN?.trim();
341
+ if (!token) {
342
+ throw new SynomemError(
343
+ 'AUTH_REQUIRED',
344
+ `Connection ${name} reads its credential from SYNOMEM_ACCESS_TOKEN, which is not set.`,
345
+ );
346
+ }
347
+ this.cached = token;
348
+ this.cachedExpiry = Number.MAX_SAFE_INTEGER;
349
+ return token;
350
+ }
351
+ const store = storeFor(this.options.stores, entry);
352
+ const stored = await store.get(entry.secretRef!);
353
+ if (!stored) {
354
+ throw new SynomemError(
355
+ 'AUTH_REQUIRED',
356
+ `No stored credential for connection ${name}. Run \`synomem connection ${
357
+ entry.kind === 'oauth' ? 'login' : 'add-key'
358
+ } --name ${name}\`.`,
359
+ );
360
+ }
361
+ if (stored.kind === 'access-key') {
362
+ this.cached = stored.secret;
363
+ this.cachedExpiry = Number.MAX_SAFE_INTEGER;
364
+ return stored.secret;
365
+ }
366
+ if (stored.expiresAt > Date.now()) return this.remember(stored);
367
+ return await withCredentialLock(this.options.home, name, async () => {
368
+ const current = await store.get(entry.secretRef!);
369
+ if (!current || current.kind !== 'oauth')
370
+ throw this.reauthorize(`Connection ${name} has no OAuth credential.`);
371
+ // Another process refreshed while we waited for the lock.
372
+ if (current.expiresAt > Date.now()) return this.remember(current);
373
+ let refreshed: StoredOAuthCredential;
374
+ try {
375
+ refreshed = await refreshOAuthCredential(current, this.options.fetch ?? fetch);
376
+ } catch (error) {
377
+ if (error instanceof SynomemError && error.code === 'REMOTE_UNAVAILABLE') throw error;
378
+ throw this.reauthorize(`Connection ${name} could not be refreshed.`);
379
+ }
380
+ // Compare-and-swap: only this process may have spent the refresh token
381
+ // while holding the lock, so a different stored generation means the
382
+ // store was replaced underneath us (a fresh login) — keep that one.
383
+ const latest = await store.get(entry.secretRef!);
384
+ if (latest?.kind === 'oauth' && latest.generation !== current.generation) {
385
+ return this.remember(latest);
386
+ }
387
+ await store.set(entry.secretRef!, refreshed);
388
+ return this.remember(refreshed);
389
+ });
390
+ }
391
+
392
+ private remember(credential: StoredOAuthCredential): string {
393
+ this.cached = credential.accessToken;
394
+ this.cachedExpiry = credential.expiresAt;
395
+ return credential.accessToken;
396
+ }
397
+ }
398
+
399
+ /* ------------------------------------------------------------ resolvers */
400
+
401
+ export interface ResolverDependencies {
402
+ stores: CredentialStores;
403
+ env?: NodeJS.ProcessEnv;
404
+ fetch?: typeof fetch;
405
+ createRemoteResolver?: typeof createRemoteResolver;
406
+ createLocalResolver?: typeof createLocalResolver;
407
+ }
408
+
409
+ export function credentialSourceFor(
410
+ home: string,
411
+ config: ProfilesConfig,
412
+ name: string,
413
+ deps: ResolverDependencies,
414
+ ): ConnectionCredentialSource {
415
+ const entry = config.credentials[name];
416
+ if (!entry) {
417
+ throw new SynomemError(
418
+ 'CONFIG_INVALID',
419
+ `Unknown connection "${name}". See \`synomem connection list\`.`,
420
+ );
421
+ }
422
+ return new ConnectionCredentialSource({
423
+ home,
424
+ name,
425
+ entry,
426
+ stores: deps.stores,
427
+ ...(deps.env ? { env: deps.env } : {}),
428
+ ...(deps.fetch ? { fetch: deps.fetch } : {}),
429
+ });
430
+ }
431
+
432
+ /** The canonical local actor for a local profile, after checking its store. */
433
+ export function localProfileTarget(
434
+ rootHome: string,
435
+ profile: LocalProfile,
436
+ ): { home: string; actor: ActorIdentity } {
437
+ const home = profile.home ?? rootHome;
438
+ const actor: ActorIdentity = {
439
+ kind: 'agent',
440
+ id: profile.actorId,
441
+ ...(profile.actorName ? { displayName: profile.actorName } : {}),
442
+ };
443
+ const expected = localContextId(localStoreWorkspaceId(home), actor);
444
+ if (expected !== profile.contextId) {
445
+ throw new SynomemError(
446
+ 'CONTEXT_FORBIDDEN',
447
+ `Profile's local context ${profile.contextId} does not belong to the store at ${home}. Recreate the profile.`,
448
+ );
449
+ }
450
+ return { home, actor };
451
+ }
452
+
453
+ /** A fixed-mode resolver for one profile. */
454
+ export function profileResolver(
455
+ rootHome: string,
456
+ config: ProfilesConfig,
457
+ name: string,
458
+ deps: ResolverDependencies,
459
+ ): ContextResolver {
460
+ const profile = config.profiles[name];
461
+ if (!profile) throw unknownName('profile', name, 'the selection', config);
462
+ if (isLocalProfile(profile)) {
463
+ const target = localProfileTarget(rootHome, profile);
464
+ return (deps.createLocalResolver ?? createLocalResolver)(target);
465
+ }
466
+ const entry = config.credentials[profile.credentialRef];
467
+ if (!entry) {
468
+ throw new SynomemError(
469
+ 'CONFIG_INVALID',
470
+ `Profile ${name} uses connection "${profile.credentialRef}", which does not exist. See \`synomem connection list\`.`,
471
+ );
472
+ }
473
+ return (deps.createRemoteResolver ?? createRemoteResolver)({
474
+ baseUrl: entry.apiUrl,
475
+ credential: credentialSourceFor(rootHome, config, profile.credentialRef, deps),
476
+ pinnedContextId: profile.contextId,
477
+ ...(deps.fetch ? { fetch: deps.fetch } : {}),
478
+ });
479
+ }
480
+
481
+ interface Route {
482
+ profile: string;
483
+ contextId: string;
484
+ resolver: ContextResolver;
485
+ }
486
+
487
+ /**
488
+ * An explicit-context resolver over a preset's profiles.
489
+ *
490
+ * Remote profiles sharing a connection share one remote resolver, restricted
491
+ * to exactly the contexts the preset names (the server still authorizes each).
492
+ * Every operation must name its context; there is no default and no switch.
493
+ */
494
+ export class PresetResolver implements ContextResolver {
495
+ constructor(
496
+ private readonly name: string,
497
+ private readonly routes: Route[],
498
+ ) {}
499
+
500
+ mode(): 'explicit' {
501
+ return 'explicit';
502
+ }
503
+
504
+ async resolve(contextId?: string) {
505
+ if (!contextId) {
506
+ throw new SynomemError(
507
+ 'CONTEXT_REQUIRED',
508
+ `Preset ${this.name} serves several identities; pass contextId (see synomem_context_list). Profiles: ${this.routes
509
+ .map((route) => `${route.profile}=${route.contextId}`)
510
+ .join(', ')}.`,
511
+ );
512
+ }
513
+ const route = this.routes.find((candidate) => candidate.contextId === contextId);
514
+ if (!route) {
515
+ throw new SynomemError('CONTEXT_FORBIDDEN', 'That context is not available on this server.');
516
+ }
517
+ return await route.resolver.resolve(contextId);
518
+ }
519
+
520
+ async list() {
521
+ const allowed = new Set(this.routes.map((route) => route.contextId));
522
+ const seen = new Set<string>();
523
+ const contexts: Awaited<ReturnType<ContextResolver['list']>>['contexts'] = [];
524
+ for (const resolver of new Set(this.routes.map((route) => route.resolver))) {
525
+ const listing = await resolver.list();
526
+ for (const context of listing.contexts) {
527
+ if (allowed.has(context.contextId) && !seen.has(context.contextId)) {
528
+ seen.add(context.contextId);
529
+ contexts.push(context);
530
+ }
531
+ }
532
+ }
533
+ return { mode: 'explicit' as const, fixedContextId: null, contexts };
534
+ }
535
+
536
+ async close(): Promise<void> {
537
+ await Promise.allSettled(
538
+ [...new Set(this.routes.map((route) => route.resolver))].map(async (resolver) => {
539
+ await resolver.close?.();
540
+ }),
541
+ );
542
+ }
543
+ }
544
+
545
+ export function presetResolver(
546
+ rootHome: string,
547
+ config: ProfilesConfig,
548
+ name: string,
549
+ deps: ResolverDependencies,
550
+ ): PresetResolver {
551
+ const members = config.harnessPresets[name];
552
+ if (!members) throw unknownName('preset', name, 'the selection', config);
553
+ const remoteByCredential = new Map<string, { contexts: string[]; profiles: string[] }>();
554
+ const routes: Route[] = [];
555
+ const pendingRemote: Array<{ profile: string; contextId: string; credential: string }> = [];
556
+ for (const member of members) {
557
+ const profile = config.profiles[member];
558
+ if (!profile) {
559
+ throw new SynomemError(
560
+ 'CONFIG_INVALID',
561
+ `Preset ${name} names profile "${member}", which does not exist.`,
562
+ );
563
+ }
564
+ if (
565
+ routes.some((route) => route.contextId === profile.contextId) ||
566
+ pendingRemote.some((p) => p.contextId === profile.contextId)
567
+ ) {
568
+ throw new SynomemError(
569
+ 'CONFIG_INVALID',
570
+ `Preset ${name} lists context ${profile.contextId} twice.`,
571
+ );
572
+ }
573
+ if (isLocalProfile(profile)) {
574
+ routes.push({
575
+ profile: member,
576
+ contextId: profile.contextId,
577
+ resolver: (deps.createLocalResolver ?? createLocalResolver)(
578
+ localProfileTarget(rootHome, profile),
579
+ ),
580
+ });
581
+ } else {
582
+ const group = remoteByCredential.get(profile.credentialRef) ?? { contexts: [], profiles: [] };
583
+ group.contexts.push(profile.contextId);
584
+ group.profiles.push(member);
585
+ remoteByCredential.set(profile.credentialRef, group);
586
+ pendingRemote.push({
587
+ profile: member,
588
+ contextId: profile.contextId,
589
+ credential: profile.credentialRef,
590
+ });
591
+ }
592
+ }
593
+ for (const [credential, group] of remoteByCredential) {
594
+ const entry = config.credentials[credential];
595
+ if (!entry) {
596
+ throw new SynomemError(
597
+ 'CONFIG_INVALID',
598
+ `Preset ${name} uses connection "${credential}", which does not exist.`,
599
+ );
600
+ }
601
+ const resolver = (deps.createRemoteResolver ?? createRemoteResolver)({
602
+ baseUrl: entry.apiUrl,
603
+ credential: credentialSourceFor(rootHome, config, credential, deps),
604
+ allowedContextIds: group.contexts,
605
+ ...(deps.fetch ? { fetch: deps.fetch } : {}),
606
+ });
607
+ for (const pending of pendingRemote.filter((p) => p.credential === credential)) {
608
+ routes.push({ profile: pending.profile, contextId: pending.contextId, resolver });
609
+ }
610
+ }
611
+ return new PresetResolver(name, routes);
612
+ }
613
+
614
+ /**
615
+ * The single entry point for the CLI and both stdio servers: selection flags
616
+ * and environment in, one resolver out.
617
+ */
618
+ export function resolverForSelection(
619
+ rootHome: string,
620
+ config: ProfilesConfig,
621
+ selection: Selection,
622
+ deps: ResolverDependencies,
623
+ ): ContextResolver {
624
+ return selection.kind === 'profile'
625
+ ? profileResolver(rootHome, config, selection.name, deps)
626
+ : presetResolver(rootHome, config, selection.name, deps);
627
+ }
628
+
629
+ /* ------------------------------------------------------------ display */
630
+
631
+ export function describeContext(context: ContextSummary): string {
632
+ const who = context.actor.displayName ?? context.actor.handle ?? context.actor.id;
633
+ const where = context.workspaceName ?? context.workspaceId;
634
+ return `${who} (${context.actor.kind}) in ${where}`;
635
+ }
636
+
637
+ /** One line describing a profile, for list/show. Never a secret. */
638
+ export function describeProfile(name: string, profile: Profile): string {
639
+ if (isLocalProfile(profile)) {
640
+ return `${name} local ${profile.actorName ?? profile.actorId} ${profile.home ?? '(root home)'} ${profile.contextId}`;
641
+ }
642
+ const who = profile.actor?.displayName ?? profile.actor?.id ?? '?';
643
+ return `${name} remote via ${profile.credentialRef} ${who} in ${profile.workspaceName ?? profile.workspaceId ?? '?'} ${profile.contextId}`;
644
+ }