synomem 0.8.0 → 0.9.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/CHANGELOG.md +50 -0
- package/README.md +47 -68
- package/dist/backend.d.ts +18 -6
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js +55 -41
- package/dist/backend.js.map +1 -1
- package/dist/cli.d.ts +20 -33
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1391 -1321
- package/dist/cli.js.map +1 -1
- package/dist/configure.d.ts +12 -46
- package/dist/configure.d.ts.map +1 -1
- package/dist/configure.js +51 -192
- package/dist/configure.js.map +1 -1
- package/dist/credentials.d.ts +82 -33
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +270 -44
- package/dist/credentials.js.map +1 -1
- package/dist/discover.d.ts +8 -35
- package/dist/discover.d.ts.map +1 -1
- package/dist/discover.js +42 -38
- package/dist/discover.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +4 -0
- package/dist/errors.js.map +1 -1
- package/dist/import.d.ts +3 -0
- package/dist/import.d.ts.map +1 -1
- package/dist/import.js +3 -0
- package/dist/import.js.map +1 -1
- package/dist/index.d.ts +10 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -5
- package/dist/index.js.map +1 -1
- package/dist/mcp/index.d.ts +18 -18
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +402 -231
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp-server.d.ts +5 -1
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +27 -105
- package/dist/mcp-server.js.map +1 -1
- package/dist/oauth.d.ts +32 -33
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +196 -126
- package/dist/oauth.js.map +1 -1
- package/dist/profiles.d.ts +243 -0
- package/dist/profiles.d.ts.map +1 -0
- package/dist/profiles.js +479 -0
- package/dist/profiles.js.map +1 -0
- package/dist/project.d.ts +8 -39
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +36 -94
- package/dist/project.js.map +1 -1
- package/dist/remote.d.ts +24 -17
- package/dist/remote.d.ts.map +1 -1
- package/dist/remote.js +54 -52
- package/dist/remote.js.map +1 -1
- package/dist/resolvers.d.ts +54 -0
- package/dist/resolvers.d.ts.map +1 -0
- package/dist/resolvers.js +336 -0
- package/dist/resolvers.js.map +1 -0
- package/dist/service.d.ts +3 -2
- package/dist/service.d.ts.map +1 -1
- package/dist/skill-install.d.ts +4 -6
- package/dist/skill-install.d.ts.map +1 -1
- package/dist/skill-install.js +13 -12
- package/dist/skill-install.js.map +1 -1
- package/dist/types.d.ts +45 -15
- package/dist/types.d.ts.map +1 -1
- package/docs/cli.md +173 -196
- package/docs/mcp.md +69 -65
- package/package.json +1 -1
- package/skills/synomem/SKILL.md +29 -26
- package/skills/synomem/references/examples.md +13 -11
- package/src/backend.ts +66 -64
- package/src/cli.ts +2123 -2219
- package/src/configure.ts +62 -241
- package/src/credentials.ts +316 -85
- package/src/discover.ts +53 -59
- package/src/errors.ts +4 -0
- package/src/import.ts +5 -0
- package/src/index.ts +15 -19
- package/src/mcp/index.ts +473 -277
- package/src/mcp-server.ts +32 -114
- package/src/oauth.ts +249 -131
- package/src/profiles.ts +661 -0
- package/src/project.ts +42 -108
- package/src/remote.ts +69 -63
- package/src/resolvers.ts +385 -0
- package/src/service.ts +2 -7
- package/src/skill-install.ts +17 -18
- package/src/types.ts +40 -15
package/src/credentials.ts
CHANGED
|
@@ -1,42 +1,80 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Credential storage, indexed by connection — never by actor or workspace.
|
|
3
|
+
*
|
|
4
|
+
* One stored secret serves every profile that routes through its connection:
|
|
5
|
+
* adding a profile for another agent or workspace never copies a refresh
|
|
6
|
+
* token, and rotating a credential updates exactly one entry. Which actor and
|
|
7
|
+
* workspace an operation uses is the profile's business (see `profiles.ts`),
|
|
8
|
+
* and the server authorizes it on every request regardless of what is stored
|
|
9
|
+
* here.
|
|
10
|
+
*
|
|
11
|
+
* Two credential kinds, deliberately distinct so nothing tries to refresh a
|
|
12
|
+
* key or treat an OAuth family as a static secret:
|
|
13
|
+
*
|
|
14
|
+
* - `oauth`: an authorization-code family for one connection, refreshed
|
|
15
|
+
* under a cross-process lock with a generation counter;
|
|
16
|
+
* - `access-key`: a member-owned `syn_…` key, used exactly as stored.
|
|
17
|
+
*
|
|
18
|
+
* Backends: the macOS Keychain, Linux Secret Service (`secret-tool`), an
|
|
19
|
+
* explicit restricted file, or the process environment. There is no silent
|
|
20
|
+
* fallback from one to another: a keychain that refuses a write is an error
|
|
21
|
+
* that names the explicit alternative.
|
|
22
|
+
*/
|
|
2
23
|
import { spawn } from 'node:child_process';
|
|
24
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
25
|
+
import {
|
|
26
|
+
chmodSync,
|
|
27
|
+
closeSync,
|
|
28
|
+
existsSync,
|
|
29
|
+
linkSync,
|
|
30
|
+
mkdirSync,
|
|
31
|
+
openSync,
|
|
32
|
+
readFileSync,
|
|
33
|
+
renameSync,
|
|
34
|
+
rmSync,
|
|
35
|
+
statSync,
|
|
36
|
+
writeSync,
|
|
37
|
+
} from 'node:fs';
|
|
38
|
+
import { hostname } from 'node:os';
|
|
39
|
+
import { join } from 'node:path';
|
|
40
|
+
import { z } from 'zod';
|
|
3
41
|
import { SynomemError } from './errors.js';
|
|
4
|
-
import
|
|
42
|
+
import { atomicWriteFile } from './fs-utils.js';
|
|
5
43
|
|
|
6
44
|
const serviceName = 'ai.synomem.credentials';
|
|
7
45
|
const maximumOutputBytes = 128 * 1024;
|
|
8
46
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
*/
|
|
24
|
-
export interface StoredInstallationKey {
|
|
25
|
-
kind: 'installation-key';
|
|
26
|
-
accessToken: string;
|
|
27
|
-
}
|
|
47
|
+
export const storedOAuthCredentialSchema = z.object({
|
|
48
|
+
kind: z.literal('oauth'),
|
|
49
|
+
issuer: z.string().min(1),
|
|
50
|
+
resource: z.string().min(1),
|
|
51
|
+
clientId: z.string().min(1),
|
|
52
|
+
tokenEndpoint: z.string().min(1),
|
|
53
|
+
scope: z.string(),
|
|
54
|
+
accessToken: z.string().min(1),
|
|
55
|
+
refreshToken: z.string().min(1).optional(),
|
|
56
|
+
/** Epoch milliseconds, already reduced by a safety margin. */
|
|
57
|
+
expiresAt: z.number().int().nonnegative(),
|
|
58
|
+
/** Incremented on every successful refresh; the compare-and-swap token. */
|
|
59
|
+
generation: z.number().int().nonnegative(),
|
|
60
|
+
});
|
|
28
61
|
|
|
29
|
-
export
|
|
62
|
+
export const storedAccessKeySchema = z.object({
|
|
63
|
+
kind: z.literal('access-key'),
|
|
64
|
+
secret: z.string().min(1),
|
|
65
|
+
});
|
|
30
66
|
|
|
31
|
-
export
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
67
|
+
export const storedCredentialSchema = z.discriminatedUnion('kind', [
|
|
68
|
+
storedOAuthCredentialSchema,
|
|
69
|
+
storedAccessKeySchema,
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
export type StoredOAuthCredential = z.infer<typeof storedOAuthCredentialSchema>;
|
|
73
|
+
export type StoredAccessKey = z.infer<typeof storedAccessKeySchema>;
|
|
74
|
+
export type StoredCredential = z.infer<typeof storedCredentialSchema>;
|
|
75
|
+
|
|
76
|
+
/** Where a connection's secret lives. `environment` stores nothing. */
|
|
77
|
+
export type CredentialBackendKind = 'keychain' | 'file' | 'environment';
|
|
40
78
|
|
|
41
79
|
export interface CredentialStore {
|
|
42
80
|
get(reference: string): Promise<StoredCredential | undefined>;
|
|
@@ -44,20 +82,26 @@ export interface CredentialStore {
|
|
|
44
82
|
delete(reference: string): Promise<boolean>;
|
|
45
83
|
}
|
|
46
84
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
85
|
+
/** An opaque, non-secret handle for a stored secret. Never derived from names. */
|
|
86
|
+
export function newSecretReference(): string {
|
|
87
|
+
return `synomem-${randomUUID()}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function parseStoredCredential(value: string): StoredCredential {
|
|
91
|
+
let raw: unknown;
|
|
92
|
+
try {
|
|
93
|
+
raw = JSON.parse(value);
|
|
94
|
+
} catch {
|
|
95
|
+
throw new SynomemError('AUTH_REQUIRED', 'Stored Synomem credential is malformed.');
|
|
96
|
+
}
|
|
97
|
+
const parsed = storedCredentialSchema.safeParse(raw);
|
|
98
|
+
if (!parsed.success) {
|
|
99
|
+
throw new SynomemError(
|
|
100
|
+
'AUTH_REQUIRED',
|
|
101
|
+
'Stored Synomem credential is in an unsupported format. Run `synomem connection login` (or `connection add-key`) again.',
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return parsed.data;
|
|
61
105
|
}
|
|
62
106
|
|
|
63
107
|
interface CommandResult {
|
|
@@ -105,34 +149,28 @@ async function command(
|
|
|
105
149
|
});
|
|
106
150
|
}
|
|
107
151
|
|
|
108
|
-
|
|
109
|
-
try {
|
|
110
|
-
const parsed = JSON.parse(value) as Partial<StoredOAuthCredential>;
|
|
111
|
-
if (
|
|
112
|
-
typeof parsed.accessToken !== 'string' ||
|
|
113
|
-
typeof parsed.tokenEndpoint !== 'string' ||
|
|
114
|
-
typeof parsed.clientId !== 'string' ||
|
|
115
|
-
typeof parsed.resource !== 'string' ||
|
|
116
|
-
typeof parsed.scope !== 'string'
|
|
117
|
-
) {
|
|
118
|
-
throw new Error();
|
|
119
|
-
}
|
|
120
|
-
return parsed as StoredOAuthCredential;
|
|
121
|
-
} catch {
|
|
122
|
-
throw new SynomemError('AUTH_REQUIRED', 'Stored Synomem credentials are malformed.');
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
152
|
+
/** macOS Keychain or Linux Secret Service. */
|
|
126
153
|
export class OsCredentialStore implements CredentialStore {
|
|
127
154
|
private readonly platform: NodeJS.Platform;
|
|
128
155
|
private readonly run: CredentialCommandRunner;
|
|
129
156
|
|
|
130
157
|
constructor(options: { platform?: NodeJS.Platform; run?: CredentialCommandRunner } = {}) {
|
|
131
158
|
this.platform = options.platform ?? process.platform;
|
|
132
|
-
|
|
159
|
+
const run = options.run ?? command;
|
|
160
|
+
// A missing helper (a headless Linux box with no Secret Service) is a
|
|
161
|
+
// configuration answer, not a crash: name the explicit alternatives. There
|
|
162
|
+
// is still no fallback — the caller chooses --store file deliberately.
|
|
163
|
+
this.run = async (executable, arguments_, input) => {
|
|
164
|
+
try {
|
|
165
|
+
return await run(executable, arguments_, input);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') this.unsupported();
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
};
|
|
133
171
|
}
|
|
134
172
|
|
|
135
|
-
async get(reference: string): Promise<
|
|
173
|
+
async get(reference: string): Promise<StoredCredential | undefined> {
|
|
136
174
|
const result =
|
|
137
175
|
this.platform === 'darwin'
|
|
138
176
|
? await this.run('/usr/bin/security', [
|
|
@@ -147,11 +185,11 @@ export class OsCredentialStore implements CredentialStore {
|
|
|
147
185
|
? await this.run('secret-tool', ['lookup', 'service', serviceName, 'account', reference])
|
|
148
186
|
: this.unsupported();
|
|
149
187
|
if (result.code !== 0) return undefined;
|
|
150
|
-
return
|
|
188
|
+
return parseStoredCredential(result.stdout.trim());
|
|
151
189
|
}
|
|
152
190
|
|
|
153
|
-
async set(reference: string, credential:
|
|
154
|
-
const serialized = JSON.stringify(credential);
|
|
191
|
+
async set(reference: string, credential: StoredCredential): Promise<void> {
|
|
192
|
+
const serialized = JSON.stringify(storedCredentialSchema.parse(credential));
|
|
155
193
|
const result =
|
|
156
194
|
this.platform === 'darwin'
|
|
157
195
|
? await this.run(
|
|
@@ -163,7 +201,7 @@ export class OsCredentialStore implements CredentialStore {
|
|
|
163
201
|
'-s',
|
|
164
202
|
serviceName,
|
|
165
203
|
'-l',
|
|
166
|
-
'Synomem
|
|
204
|
+
'Synomem credential',
|
|
167
205
|
'-U',
|
|
168
206
|
'-w',
|
|
169
207
|
],
|
|
@@ -172,21 +210,14 @@ export class OsCredentialStore implements CredentialStore {
|
|
|
172
210
|
: this.platform === 'linux'
|
|
173
211
|
? await this.run(
|
|
174
212
|
'secret-tool',
|
|
175
|
-
[
|
|
176
|
-
'store',
|
|
177
|
-
'--label=Synomem OAuth credential',
|
|
178
|
-
'service',
|
|
179
|
-
serviceName,
|
|
180
|
-
'account',
|
|
181
|
-
reference,
|
|
182
|
-
],
|
|
213
|
+
['store', '--label=Synomem credential', 'service', serviceName, 'account', reference],
|
|
183
214
|
serialized,
|
|
184
215
|
)
|
|
185
216
|
: this.unsupported();
|
|
186
217
|
if (result.code !== 0) {
|
|
187
218
|
throw new SynomemError(
|
|
188
|
-
'
|
|
189
|
-
'The operating-system credential store rejected the credential.',
|
|
219
|
+
'CONFIG_INVALID',
|
|
220
|
+
'The operating-system credential store rejected the credential. Re-run with --store file to use a restricted file instead.',
|
|
190
221
|
);
|
|
191
222
|
}
|
|
192
223
|
}
|
|
@@ -208,17 +239,217 @@ export class OsCredentialStore implements CredentialStore {
|
|
|
208
239
|
}
|
|
209
240
|
|
|
210
241
|
/*
|
|
211
|
-
* Windows Credential Manager is not implemented
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
* worse than saying so plainly.
|
|
242
|
+
* Windows Credential Manager is not implemented: `cmdkey` can write a generic
|
|
243
|
+
* credential but will not read the secret back, so a store built on it would
|
|
244
|
+
* accept a credential and never return it.
|
|
215
245
|
*/
|
|
216
246
|
private unsupported(): never {
|
|
217
247
|
throw new SynomemError(
|
|
218
248
|
'CONFIG_INVALID',
|
|
219
249
|
this.platform === 'win32'
|
|
220
|
-
? 'Windows Credential Manager storage is not supported yet.
|
|
221
|
-
: '
|
|
250
|
+
? 'Windows Credential Manager storage is not supported yet. Use --store file, or --store environment with SYNOMEM_ACCESS_TOKEN.'
|
|
251
|
+
: 'No operating-system credential store is available here (it needs the macOS Keychain, or secret-tool with a Secret Service on Linux). Choose one explicitly: --store file keeps it in a 0600 file under your Synomem home; --store environment reads SYNOMEM_ACCESS_TOKEN.',
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* One restricted file per secret under `<home>/credentials/`, mode 0600 in a
|
|
258
|
+
* 0700 directory. Chosen explicitly (`--store file`), never as a fallback.
|
|
259
|
+
*/
|
|
260
|
+
export class FileCredentialStore implements CredentialStore {
|
|
261
|
+
private readonly directory: string;
|
|
262
|
+
|
|
263
|
+
constructor(home: string) {
|
|
264
|
+
this.directory = join(home, 'credentials');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private path(reference: string): string {
|
|
268
|
+
if (!/^[A-Za-z0-9_-]{1,100}$/.test(reference)) {
|
|
269
|
+
throw new SynomemError('CONFIG_INVALID', 'Credential reference is malformed.');
|
|
270
|
+
}
|
|
271
|
+
return join(this.directory, `${reference}.json`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async get(reference: string): Promise<StoredCredential | undefined> {
|
|
275
|
+
const path = this.path(reference);
|
|
276
|
+
if (!existsSync(path)) return undefined;
|
|
277
|
+
assertPrivate(this.directory, 0o700);
|
|
278
|
+
assertPrivate(path, 0o600);
|
|
279
|
+
return parseStoredCredential(readFileSync(path, 'utf8'));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async set(reference: string, credential: StoredCredential): Promise<void> {
|
|
283
|
+
if (!existsSync(this.directory)) mkdirSync(this.directory, { recursive: true, mode: 0o700 });
|
|
284
|
+
chmodSync(this.directory, 0o700);
|
|
285
|
+
atomicWriteFile(
|
|
286
|
+
this.path(reference),
|
|
287
|
+
`${JSON.stringify(storedCredentialSchema.parse(credential))}\n`,
|
|
288
|
+
0o600,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async delete(reference: string): Promise<boolean> {
|
|
293
|
+
const path = this.path(reference);
|
|
294
|
+
if (!existsSync(path)) return false;
|
|
295
|
+
rmSync(path, { force: true });
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/*
|
|
301
|
+
* Like ssh with a private key: a credential file (or its directory) that other
|
|
302
|
+
* users can read, or that another user owns, is refused rather than used. The
|
|
303
|
+
* fix is the owner's to make; silently tightening it would hide that the
|
|
304
|
+
* secret may already have been exposed.
|
|
305
|
+
*/
|
|
306
|
+
function assertPrivate(path: string, expected: number): void {
|
|
307
|
+
if (process.platform === 'win32') return;
|
|
308
|
+
const stat = statSync(path);
|
|
309
|
+
if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
|
|
310
|
+
throw new SynomemError(
|
|
311
|
+
'CONFIG_INVALID',
|
|
312
|
+
`${path} is owned by another user; refusing to use it.`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
if ((stat.mode & 0o077) !== 0) {
|
|
316
|
+
throw new SynomemError(
|
|
317
|
+
'CONFIG_INVALID',
|
|
318
|
+
`${path} is accessible to other users (mode ${(stat.mode & 0o777).toString(8)}). ` +
|
|
319
|
+
`Run \`chmod ${expected.toString(8)} ${path}\` if you are sure it was not exposed, ` +
|
|
320
|
+
'otherwise remove it and sign in again.',
|
|
222
321
|
);
|
|
223
322
|
}
|
|
224
323
|
}
|
|
324
|
+
|
|
325
|
+
export interface CredentialStores {
|
|
326
|
+
keychain: CredentialStore;
|
|
327
|
+
file: CredentialStore;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function defaultCredentialStores(home: string): CredentialStores {
|
|
331
|
+
return { keychain: new OsCredentialStore(), file: new FileCredentialStore(home) };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* A cross-process lock around one credential's refresh.
|
|
336
|
+
*
|
|
337
|
+
* `O_EXCL` creation is the mutual exclusion. The lock file records its owner
|
|
338
|
+
* (pid, host) and a random token. A lock is broken only when its owner is
|
|
339
|
+
* provably gone — a dead pid on this host — or, for an owner on another host
|
|
340
|
+
* or an unreadable file, once it is older than `staleMs`. A live owner is never
|
|
341
|
+
* preempted however long its refresh takes (the refresh request itself times
|
|
342
|
+
* out well inside `staleMs`).
|
|
343
|
+
*
|
|
344
|
+
* Breaking renames the file aside first and checks the token: if another
|
|
345
|
+
* process already broke and re-acquired it in between, the fresh lock is put
|
|
346
|
+
* back rather than deleted. Correctness against a concurrent writer is still
|
|
347
|
+
* the generation compare-and-swap in the refresh path; the lock exists so that
|
|
348
|
+
* only one process ever spends a given refresh token. Locks assume one host's
|
|
349
|
+
* filesystem; a home shared over a network filesystem is not supported.
|
|
350
|
+
*/
|
|
351
|
+
export async function withCredentialLock<T>(
|
|
352
|
+
home: string,
|
|
353
|
+
name: string,
|
|
354
|
+
operation: () => Promise<T>,
|
|
355
|
+
options: { staleMs?: number; timeoutMs?: number; pollMs?: number } = {},
|
|
356
|
+
): Promise<T> {
|
|
357
|
+
if (!/^[A-Za-z0-9_.-]{1,100}$/.test(name)) {
|
|
358
|
+
throw new SynomemError('CONFIG_INVALID', 'Credential name is malformed.');
|
|
359
|
+
}
|
|
360
|
+
const directory = join(home, 'locks');
|
|
361
|
+
if (!existsSync(directory)) mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
362
|
+
const path = join(directory, `${name}.lock`);
|
|
363
|
+
const staleMs = options.staleMs ?? 60_000;
|
|
364
|
+
const deadline = Date.now() + (options.timeoutMs ?? 30_000);
|
|
365
|
+
const pollMs = options.pollMs ?? 50;
|
|
366
|
+
const token = randomBytes(16).toString('hex');
|
|
367
|
+
const host = hostname();
|
|
368
|
+
let descriptor: number | undefined;
|
|
369
|
+
while (descriptor === undefined) {
|
|
370
|
+
try {
|
|
371
|
+
descriptor = openSync(path, 'wx', 0o600);
|
|
372
|
+
writeSync(descriptor, `${process.pid}\n${host}\n${token}\n`);
|
|
373
|
+
} catch (error) {
|
|
374
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
|
375
|
+
if (breakIfAbandoned(path, host, staleMs)) continue;
|
|
376
|
+
if (Date.now() > deadline) {
|
|
377
|
+
throw new SynomemError(
|
|
378
|
+
'DATABASE_BUSY',
|
|
379
|
+
`Another Synomem process is refreshing the ${name} credential. Try again.`,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
return await operation();
|
|
387
|
+
} finally {
|
|
388
|
+
closeSync(descriptor);
|
|
389
|
+
// Release only our own lock.
|
|
390
|
+
if (readLock(path)?.token === token) rmSync(path, { force: true });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
interface LockOwner {
|
|
395
|
+
pid: number;
|
|
396
|
+
host: string;
|
|
397
|
+
token: string;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function readLock(path: string): LockOwner | undefined {
|
|
401
|
+
try {
|
|
402
|
+
const [pid, host, token] = readFileSync(path, 'utf8').split('\n');
|
|
403
|
+
const parsed = Number(pid);
|
|
404
|
+
if (!Number.isSafeInteger(parsed) || !host || !token) return undefined;
|
|
405
|
+
return { pid: parsed, host, token };
|
|
406
|
+
} catch {
|
|
407
|
+
return undefined;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function processAlive(pid: number): boolean {
|
|
412
|
+
try {
|
|
413
|
+
process.kill(pid, 0);
|
|
414
|
+
return true;
|
|
415
|
+
} catch (error) {
|
|
416
|
+
// EPERM: it exists but belongs to someone else — alive.
|
|
417
|
+
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Returns true when the caller should immediately retry acquiring. */
|
|
422
|
+
function breakIfAbandoned(path: string, host: string, staleMs: number): boolean {
|
|
423
|
+
let age: number;
|
|
424
|
+
try {
|
|
425
|
+
age = Date.now() - statSync(path).mtimeMs;
|
|
426
|
+
} catch {
|
|
427
|
+
return true; // Released between the failed open and now.
|
|
428
|
+
}
|
|
429
|
+
const owner = readLock(path);
|
|
430
|
+
// A lock whose contents are still being written is fresh, not abandoned.
|
|
431
|
+
if (!owner && age < 1_000) return false;
|
|
432
|
+
const abandoned = owner
|
|
433
|
+
? owner.host === host
|
|
434
|
+
? !processAlive(owner.pid)
|
|
435
|
+
: age > staleMs
|
|
436
|
+
: age > staleMs;
|
|
437
|
+
if (!abandoned) return false;
|
|
438
|
+
const aside = `${path}.broken-${randomBytes(6).toString('hex')}`;
|
|
439
|
+
try {
|
|
440
|
+
renameSync(path, aside);
|
|
441
|
+
} catch {
|
|
442
|
+
return true; // Someone else broke or released it first.
|
|
443
|
+
}
|
|
444
|
+
const taken = readLock(aside);
|
|
445
|
+
if (owner && taken?.token !== owner.token) {
|
|
446
|
+
// We grabbed a lock re-acquired after our inspection: restore it.
|
|
447
|
+
try {
|
|
448
|
+
linkSync(aside, path);
|
|
449
|
+
} catch {
|
|
450
|
+
/* a third process holds the path now; ours is the one to discard */
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
rmSync(aside, { force: true });
|
|
454
|
+
return true;
|
|
455
|
+
}
|
package/src/discover.ts
CHANGED
|
@@ -1,24 +1,18 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* Finding out
|
|
3
|
-
* one from memory.
|
|
2
|
+
* Finding out what a credential can reach, so nobody has to type an id from memory.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
* person to enter that during setup is asking them to leave and go find it, and
|
|
7
|
-
* the credential they just authorized already knows the answer -- or knows
|
|
8
|
-
* enough to offer a short list.
|
|
4
|
+
* Two questions, two routes, because they carry different authority:
|
|
9
5
|
*
|
|
10
|
-
*
|
|
6
|
+
* `discoverContexts` / `describeIdentity` ask the DATA plane, with any credential
|
|
7
|
+
* (OAuth access token or `syn_` access key), which workspace/actor targets that
|
|
8
|
+
* credential's grant may use right now (identity contract §4). No context has to be
|
|
9
|
+
* chosen first — that is the point.
|
|
11
10
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* -- so `discoverAccessKeyWorkspaces` lists them off the data plane, with no
|
|
15
|
-
* workspace chosen yet, which is exactly what setup cannot supply up front.
|
|
16
|
-
*
|
|
17
|
-
* A browser sign-in also authorizes an ACCOUNT, which may reach several
|
|
18
|
-
* organizations, each with several workspaces. `discoverOrganizations` lists
|
|
19
|
-
* them for selection.
|
|
11
|
+
* `discoverOrganizations` asks the control plane which organizations an account
|
|
12
|
+
* belongs to, for first-party setup flows that hold an account session.
|
|
20
13
|
*/
|
|
21
14
|
import { SynomemError } from './errors.js';
|
|
15
|
+
import type { ContextListing, IdentityDescription } from './types.js';
|
|
22
16
|
|
|
23
17
|
export interface DiscoveredWorkspace {
|
|
24
18
|
id: string;
|
|
@@ -33,19 +27,6 @@ export interface DiscoveredOrganization {
|
|
|
33
27
|
workspaces: DiscoveredWorkspace[];
|
|
34
28
|
}
|
|
35
29
|
|
|
36
|
-
export interface WorkspaceMembership {
|
|
37
|
-
id: string;
|
|
38
|
-
displayName: string;
|
|
39
|
-
roles: string[];
|
|
40
|
-
/**
|
|
41
|
-
* Reachable right now, with this same credential, by naming it as
|
|
42
|
-
* Synomem-Workspace-Id — no new token or re-authentication. A human
|
|
43
|
-
* credential's own organization is the boundary; an agent credential has
|
|
44
|
-
* only ever one such entry, its own.
|
|
45
|
-
*/
|
|
46
|
-
addressableWithThisToken: boolean;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
30
|
export interface DiscoveryOptions {
|
|
50
31
|
baseUrl: string;
|
|
51
32
|
accessToken: string;
|
|
@@ -60,7 +41,11 @@ interface Envelope<T> {
|
|
|
60
41
|
error?: { code?: string; message?: string };
|
|
61
42
|
}
|
|
62
43
|
|
|
63
|
-
async function readJson<T>(
|
|
44
|
+
async function readJson<T>(
|
|
45
|
+
options: DiscoveryOptions,
|
|
46
|
+
path: string,
|
|
47
|
+
extraHeaders?: Record<string, string>,
|
|
48
|
+
): Promise<T> {
|
|
64
49
|
const request = options.fetch ?? globalThis.fetch;
|
|
65
50
|
const url = new URL(
|
|
66
51
|
path,
|
|
@@ -69,7 +54,11 @@ async function readJson<T>(options: DiscoveryOptions, path: string): Promise<T>
|
|
|
69
54
|
let response: Response;
|
|
70
55
|
try {
|
|
71
56
|
response = await request(url, {
|
|
72
|
-
headers: {
|
|
57
|
+
headers: {
|
|
58
|
+
authorization: `Bearer ${options.accessToken}`,
|
|
59
|
+
accept: 'application/json',
|
|
60
|
+
...extraHeaders,
|
|
61
|
+
},
|
|
73
62
|
...(options.signal ? { signal: options.signal } : {}),
|
|
74
63
|
});
|
|
75
64
|
} catch (error) {
|
|
@@ -87,32 +76,24 @@ async function readJson<T>(options: DiscoveryOptions, path: string): Promise<T>
|
|
|
87
76
|
if (!response.ok || body.ok !== true || body.data === undefined) {
|
|
88
77
|
// 401 and 403 are the ones a person can act on, so they keep their own
|
|
89
78
|
// codes rather than being flattened into a generic protocol error.
|
|
79
|
+
const reported = body.error?.code;
|
|
90
80
|
const code =
|
|
91
|
-
|
|
81
|
+
reported === 'CONTEXT_REQUIRED' ||
|
|
82
|
+
reported === 'CONTEXT_FORBIDDEN' ||
|
|
83
|
+
reported === 'CONTEXT_AMBIGUOUS' ||
|
|
84
|
+
reported === 'REAUTHORIZATION_REQUIRED'
|
|
85
|
+
? reported
|
|
86
|
+
: response.status === 401
|
|
87
|
+
? 'AUTH_REQUIRED'
|
|
88
|
+
: response.status === 403
|
|
89
|
+
? 'AUTH_FORBIDDEN'
|
|
90
|
+
: null;
|
|
92
91
|
const message = body.error?.message ?? `${url.pathname} returned ${response.status}.`;
|
|
93
92
|
throw new SynomemError(code ?? 'REMOTE_PROTOCOL', message);
|
|
94
93
|
}
|
|
95
94
|
return body.data;
|
|
96
95
|
}
|
|
97
96
|
|
|
98
|
-
/**
|
|
99
|
-
* Every workspace a member-owned access key can reach, asked before any one
|
|
100
|
-
* of them has been chosen.
|
|
101
|
-
*
|
|
102
|
-
* Answered by the data plane rather than the control plane: an access key is
|
|
103
|
-
* not an account principal the control plane recognizes, but the data plane
|
|
104
|
-
* already knows which organization owns it and can list that organization's
|
|
105
|
-
* workspaces without requiring one to be named first.
|
|
106
|
-
*/
|
|
107
|
-
export async function discoverAccessKeyWorkspaces(
|
|
108
|
-
options: DiscoveryOptions,
|
|
109
|
-
): Promise<{ organizationId: string; workspaces: DiscoveredWorkspace[] }> {
|
|
110
|
-
return await readJson<{ organizationId: string; workspaces: DiscoveredWorkspace[] }>(
|
|
111
|
-
options,
|
|
112
|
-
'v1/access-keys/workspaces',
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
97
|
/**
|
|
117
98
|
* Every organization this account belongs to, each with its workspaces.
|
|
118
99
|
*
|
|
@@ -147,19 +128,32 @@ export async function discoverOrganizations(
|
|
|
147
128
|
}
|
|
148
129
|
|
|
149
130
|
/**
|
|
150
|
-
* Every
|
|
151
|
-
*
|
|
152
|
-
* `discoverOrganizations`, and the one that actually works with an ordinary
|
|
153
|
-
* bearer token (OAuth or access key alike). `/v1/me` requires a first-party
|
|
154
|
-
* control-plane credential a CLI never holds; `/v1/identity` requires only
|
|
155
|
-
* the normal `synomem:read` scope every credential already has.
|
|
131
|
+
* Every context this credential's grant may use right now, with canonical ids
|
|
132
|
+
* (`GET /v1/contexts`). Follows `nextCursor` until the listing is complete, bounded.
|
|
156
133
|
*/
|
|
157
|
-
export async function
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
134
|
+
export async function discoverContexts(options: DiscoveryOptions): Promise<ContextListing> {
|
|
135
|
+
let listing: ContextListing | undefined;
|
|
136
|
+
let cursor: string | null | undefined;
|
|
137
|
+
for (let page = 0; page < 20; page += 1) {
|
|
138
|
+
const next: ContextListing = await readJson<ContextListing>(
|
|
139
|
+
options,
|
|
140
|
+
`v1/contexts?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`,
|
|
141
|
+
);
|
|
142
|
+
listing = listing ? { ...next, contexts: [...listing.contexts, ...next.contexts] } : next;
|
|
143
|
+
cursor = next.nextCursor;
|
|
144
|
+
if (!cursor) break;
|
|
145
|
+
}
|
|
146
|
+
return { ...listing!, nextCursor: null };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Who this credential is: account, connection, grant, and effective context if selected. */
|
|
150
|
+
export async function describeIdentity(
|
|
151
|
+
options: DiscoveryOptions & { contextId?: string },
|
|
152
|
+
): Promise<IdentityDescription> {
|
|
153
|
+
return await readJson<IdentityDescription>(
|
|
161
154
|
options,
|
|
162
155
|
'v1/identity',
|
|
156
|
+
options.contextId ? { 'synomem-context-id': options.contextId } : undefined,
|
|
163
157
|
);
|
|
164
158
|
}
|
|
165
159
|
|
package/src/errors.ts
CHANGED
|
@@ -30,6 +30,10 @@ export const errorCodes = [
|
|
|
30
30
|
'CONFIG_INVALID',
|
|
31
31
|
'AUTH_REQUIRED',
|
|
32
32
|
'AUTH_FORBIDDEN',
|
|
33
|
+
'CONTEXT_REQUIRED',
|
|
34
|
+
'CONTEXT_FORBIDDEN',
|
|
35
|
+
'CONTEXT_AMBIGUOUS',
|
|
36
|
+
'REAUTHORIZATION_REQUIRED',
|
|
33
37
|
'UNSUPPORTED_BACKEND',
|
|
34
38
|
'REMOTE_UNAVAILABLE',
|
|
35
39
|
'REMOTE_PROTOCOL',
|
package/src/import.ts
CHANGED
|
@@ -169,6 +169,8 @@ export async function createLocalImportBundle(fromHome: string): Promise<ImportB
|
|
|
169
169
|
export interface RemoteImportClientOptions {
|
|
170
170
|
baseUrl: string;
|
|
171
171
|
workspaceId: string;
|
|
172
|
+
/** The selected context; sent as Synomem-Context-Id (identity contract §3). */
|
|
173
|
+
contextId?: string;
|
|
172
174
|
credentialProvider: SynomemCredentialProvider;
|
|
173
175
|
fetch?: typeof fetch;
|
|
174
176
|
}
|
|
@@ -176,6 +178,7 @@ export interface RemoteImportClientOptions {
|
|
|
176
178
|
export class RemoteImportClient {
|
|
177
179
|
private readonly baseUrl: URL;
|
|
178
180
|
private readonly workspaceId: string;
|
|
181
|
+
private readonly contextId: string | undefined;
|
|
179
182
|
private readonly credentialProvider: SynomemCredentialProvider;
|
|
180
183
|
private readonly fetchImplementation: typeof fetch;
|
|
181
184
|
|
|
@@ -195,6 +198,7 @@ export class RemoteImportClient {
|
|
|
195
198
|
throw new SynomemError('CONFIG_INVALID', 'Remote import baseUrl must be an origin.');
|
|
196
199
|
}
|
|
197
200
|
this.workspaceId = options.workspaceId;
|
|
201
|
+
this.contextId = options.contextId;
|
|
198
202
|
this.credentialProvider = options.credentialProvider;
|
|
199
203
|
this.fetchImplementation = options.fetch ?? fetch;
|
|
200
204
|
}
|
|
@@ -224,6 +228,7 @@ export class RemoteImportClient {
|
|
|
224
228
|
accept: 'application/json',
|
|
225
229
|
authorization: `Bearer ${token}`,
|
|
226
230
|
'content-type': 'application/json',
|
|
231
|
+
...(this.contextId ? { 'synomem-context-id': this.contextId } : {}),
|
|
227
232
|
},
|
|
228
233
|
body: JSON.stringify(payload),
|
|
229
234
|
});
|