synomem 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -1
- package/README.md +54 -14
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js +8 -2
- package/dist/backend.js.map +1 -1
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +451 -24
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +34 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +162 -11
- package/dist/client.js.map +1 -1
- package/dist/cloud.d.ts +16 -0
- package/dist/cloud.d.ts.map +1 -0
- package/dist/cloud.js +19 -0
- package/dist/cloud.js.map +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +8 -1
- package/dist/config.js.map +1 -1
- package/dist/configure.d.ts +55 -0
- package/dist/configure.d.ts.map +1 -0
- package/dist/configure.js +193 -0
- package/dist/configure.js.map +1 -0
- package/dist/credentials.d.ts +15 -2
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +9 -1
- package/dist/credentials.js.map +1 -1
- package/dist/discover.d.ts +48 -0
- package/dist/discover.d.ts.map +1 -0
- package/dist/discover.js +106 -0
- package/dist/discover.js.map +1 -0
- package/dist/import.d.ts +58 -43
- package/dist/import.d.ts.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +15 -6
- package/dist/mcp/index.js.map +1 -1
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +8 -1
- package/dist/oauth.js.map +1 -1
- package/dist/projections.d.ts.map +1 -1
- package/dist/projections.js +19 -11
- package/dist/projections.js.map +1 -1
- package/dist/prompt.d.ts +28 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +72 -0
- package/dist/prompt.js.map +1 -0
- package/dist/remote.d.ts +4 -0
- package/dist/remote.d.ts.map +1 -1
- package/dist/remote.js +4 -0
- package/dist/remote.js.map +1 -1
- package/dist/schemas.d.ts +101 -60
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +57 -5
- package/dist/schemas.js.map +1 -1
- package/dist/service.d.ts +6 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/storage.d.ts +19 -1
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +61 -14
- package/dist/storage.js.map +1 -1
- package/dist/types.d.ts +50 -2
- package/dist/types.d.ts.map +1 -1
- package/docs/cli.md +68 -4
- package/docs/examples.md +1 -1
- package/docs/mcp.md +1 -1
- package/docs/skill.md +1 -1
- package/docs/storage-format.md +1 -1
- package/package.json +8 -8
- package/src/backend.ts +8 -2
- package/src/cli.ts +597 -31
- package/src/client.ts +176 -12
- package/src/cloud.ts +19 -0
- package/src/config.ts +8 -1
- package/src/configure.ts +249 -0
- package/src/credentials.ts +26 -3
- package/src/discover.ts +155 -0
- package/src/index.ts +9 -1
- package/src/mcp/index.ts +19 -5
- package/src/oauth.ts +8 -1
- package/src/projections.ts +21 -11
- package/src/prompt.ts +88 -0
- package/src/remote.ts +24 -0
- package/src/schemas.ts +60 -5
- package/src/service.ts +12 -0
- package/src/storage.ts +79 -13
- package/src/types.ts +45 -2
package/src/client.ts
CHANGED
|
@@ -58,6 +58,7 @@ import type {
|
|
|
58
58
|
UpdatePostInput,
|
|
59
59
|
Diagnostic,
|
|
60
60
|
DoctorResult,
|
|
61
|
+
ProjectionStatus,
|
|
61
62
|
GiveKudosInput,
|
|
62
63
|
GiveKudosResult,
|
|
63
64
|
SendMemoInput,
|
|
@@ -106,7 +107,11 @@ export interface SynomemCoreOptions {
|
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
export class SynomemCore implements SynomemDomainService {
|
|
109
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Mutable because an agent actor is resolved to its canonical identity on
|
|
112
|
+
* init: callers name a handle, events record the opaque ID.
|
|
113
|
+
*/
|
|
114
|
+
actor: ActorIdentity;
|
|
110
115
|
private readonly repository: SynomemRepository;
|
|
111
116
|
private readonly projectionWriter: ProjectionWriter;
|
|
112
117
|
private readonly clock: () => Date;
|
|
@@ -121,6 +126,11 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
121
126
|
get: (idOrAlias: string) => this.getAgent(idOrAlias),
|
|
122
127
|
list: () => this.listAgents(),
|
|
123
128
|
resolve: (query: string) => this.resolveAgent(query),
|
|
129
|
+
archive: (idOrAlias: string) => this.setAgentStatus(idOrAlias, 'archived'),
|
|
130
|
+
restore: (idOrAlias: string) => this.setAgentStatus(idOrAlias, 'active'),
|
|
131
|
+
addAliases: (idOrAlias: string, aliases: string[]) => this.addAgentAliases(idOrAlias, aliases),
|
|
132
|
+
removeAliases: (idOrAlias: string, aliases: string[]) =>
|
|
133
|
+
this.removeAgentAliases(idOrAlias, aliases),
|
|
124
134
|
directory: () => this.agentDirectory(),
|
|
125
135
|
bindings: (idOrAlias: string) => this.listRuntimeBindings(idOrAlias),
|
|
126
136
|
bindRuntime: (input: BindRuntimeInput) => this.bindRuntime(input),
|
|
@@ -271,6 +281,30 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
271
281
|
if (this.initialized) return;
|
|
272
282
|
await this.repository.init();
|
|
273
283
|
this.initialized = true;
|
|
284
|
+
|
|
285
|
+
/*
|
|
286
|
+
* An agent actor is resolved to its canonical identity here.
|
|
287
|
+
*
|
|
288
|
+
* Callers name a handle because that is what people and harnesses know,
|
|
289
|
+
* but every event must record the opaque ID — otherwise renaming a handle
|
|
290
|
+
* would orphan the history written under the old one. The display name
|
|
291
|
+
* comes from the profile for the same reason a harness cannot assert it on
|
|
292
|
+
* the command line: the stored record is the authority, not the argument.
|
|
293
|
+
*
|
|
294
|
+
* An unresolvable name is left as given rather than rejected, so a system
|
|
295
|
+
* actor can still create the agent that does not exist yet. Writing as an
|
|
296
|
+
* unknown agent is refused later by the checks that already exist.
|
|
297
|
+
*/
|
|
298
|
+
if (this.actor.kind === 'agent') {
|
|
299
|
+
const resolved = await this.repository.resolveAgent(this.actor.id);
|
|
300
|
+
if (resolved.match) {
|
|
301
|
+
this.actor = {
|
|
302
|
+
kind: 'agent',
|
|
303
|
+
id: resolved.match.id,
|
|
304
|
+
...(resolved.match.displayName ? { displayName: resolved.match.displayName } : {}),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
}
|
|
274
308
|
}
|
|
275
309
|
|
|
276
310
|
async close(): Promise<void> {
|
|
@@ -314,20 +348,29 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
314
348
|
this.checkAbort();
|
|
315
349
|
await this.repository.assertEventCompatibility();
|
|
316
350
|
const parsed = this.validate(() => createAgentSchema.parse(input));
|
|
317
|
-
if (await this.repository.getAgent(parsed.
|
|
318
|
-
throw new SynomemError('AGENT_EXISTS', `Agent or alias already exists: ${parsed.
|
|
351
|
+
if (await this.repository.getAgent(parsed.handle)) {
|
|
352
|
+
throw new SynomemError('AGENT_EXISTS', `Agent or alias already exists: ${parsed.handle}`);
|
|
319
353
|
}
|
|
320
354
|
const aliases = [...new Set(parsed.aliases ?? [])].sort();
|
|
321
|
-
if (aliases.includes(parsed.
|
|
322
|
-
throw new SynomemError('ALIAS_CONFLICT', 'An agent cannot use its own
|
|
355
|
+
if (aliases.includes(parsed.handle)) {
|
|
356
|
+
throw new SynomemError('ALIAS_CONFLICT', 'An agent cannot use its own handle as an alias.');
|
|
323
357
|
}
|
|
324
358
|
for (const alias of aliases) {
|
|
325
359
|
if (await this.repository.getAgent(alias)) {
|
|
326
360
|
throw new SynomemError('ALIAS_CONFLICT', `Alias already belongs to an agent: ${alias}`);
|
|
327
361
|
}
|
|
328
362
|
}
|
|
363
|
+
/*
|
|
364
|
+
* The canonical ID is generated here and never supplied by the caller.
|
|
365
|
+
* Every event references it permanently, so it has to be free of meaning:
|
|
366
|
+
* a caller that could choose it could choose one that collides with an
|
|
367
|
+
* archived agent's history, and a meaningful ID becomes a handle nobody can
|
|
368
|
+
* rename.
|
|
369
|
+
*/
|
|
329
370
|
const profile: AgentProfile = {
|
|
330
|
-
id:
|
|
371
|
+
id: this.nextId(),
|
|
372
|
+
handle: parsed.handle,
|
|
373
|
+
status: 'active',
|
|
331
374
|
displayName: parsed.displayName,
|
|
332
375
|
...(aliases.length ? { aliases } : {}),
|
|
333
376
|
...(parsed.description !== undefined ? { description: parsed.description } : {}),
|
|
@@ -355,8 +398,20 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
355
398
|
const existing = await this.repository.getAgent(idOrAlias);
|
|
356
399
|
if (!existing) throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${idOrAlias}`);
|
|
357
400
|
const aliases = parsed.aliases ? [...new Set(parsed.aliases)].sort() : existing.aliases;
|
|
358
|
-
|
|
359
|
-
|
|
401
|
+
const handle = parsed.handle ?? existing.handle;
|
|
402
|
+
if (aliases?.includes(handle)) {
|
|
403
|
+
throw new SynomemError('ALIAS_CONFLICT', 'An agent cannot use its own handle as an alias.');
|
|
404
|
+
}
|
|
405
|
+
// Renaming the handle is allowed and is why the canonical ID exists, but a
|
|
406
|
+
// handle another agent already answers to is still refused.
|
|
407
|
+
if (parsed.handle && parsed.handle !== existing.handle) {
|
|
408
|
+
const owner = await this.repository.getAgent(parsed.handle);
|
|
409
|
+
if (owner && owner.id !== existing.id) {
|
|
410
|
+
throw new SynomemError(
|
|
411
|
+
'ALIAS_CONFLICT',
|
|
412
|
+
`Handle already belongs to ${owner.id}: ${parsed.handle}`,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
360
415
|
}
|
|
361
416
|
for (const alias of aliases ?? []) {
|
|
362
417
|
const owner = await this.repository.getAgent(alias);
|
|
@@ -384,6 +439,52 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
384
439
|
return updated;
|
|
385
440
|
}
|
|
386
441
|
|
|
442
|
+
/**
|
|
443
|
+
* Archiving stops an agent acting without erasing it.
|
|
444
|
+
*
|
|
445
|
+
* Events reference the actor permanently, so deleting an agent would leave
|
|
446
|
+
* history pointing at nothing. Archived agents keep their records and their
|
|
447
|
+
* handle, and can be restored.
|
|
448
|
+
*/
|
|
449
|
+
private async setAgentStatus(
|
|
450
|
+
idOrAlias: string,
|
|
451
|
+
status: 'active' | 'archived',
|
|
452
|
+
): Promise<AgentProfile> {
|
|
453
|
+
this.checkAbort();
|
|
454
|
+
await this.repository.assertEventCompatibility();
|
|
455
|
+
this.validate(() => agentLookupSchema.parse(idOrAlias));
|
|
456
|
+
const existing = await this.repository.getAgent(idOrAlias);
|
|
457
|
+
if (!existing) throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${idOrAlias}`);
|
|
458
|
+
if (existing.status === status) return existing;
|
|
459
|
+
const updated: AgentProfile = { ...existing, status };
|
|
460
|
+
await this.repository.transaction(async () => {
|
|
461
|
+
const event: SynomemEvent = {
|
|
462
|
+
...this.eventBase(existing.id, await this.repository.nextAggregateVersion(existing.id)),
|
|
463
|
+
type: 'agent.updated',
|
|
464
|
+
agentId: existing.id,
|
|
465
|
+
changes: { status },
|
|
466
|
+
};
|
|
467
|
+
await this.repository.updateAgent(updated, event.createdAt);
|
|
468
|
+
await this.repository.insertEvent(event);
|
|
469
|
+
});
|
|
470
|
+
await this.projectionWriter.syncAgent(updated.id);
|
|
471
|
+
return updated;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Adds aliases without disturbing the ones already there. */
|
|
475
|
+
private async addAgentAliases(idOrAlias: string, add: string[]): Promise<AgentProfile> {
|
|
476
|
+
const existing = await this.getAgent(idOrAlias);
|
|
477
|
+
const merged = [...new Set([...(existing.aliases ?? []), ...add])].sort();
|
|
478
|
+
return await this.updateAgent(existing.id, { aliases: merged });
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private async removeAgentAliases(idOrAlias: string, remove: string[]): Promise<AgentProfile> {
|
|
482
|
+
const existing = await this.getAgent(idOrAlias);
|
|
483
|
+
const drop = new Set(remove.map((alias) => alias.trim().toLowerCase()));
|
|
484
|
+
const kept = (existing.aliases ?? []).filter((alias) => !drop.has(alias));
|
|
485
|
+
return await this.updateAgent(existing.id, { aliases: kept });
|
|
486
|
+
}
|
|
487
|
+
|
|
387
488
|
private async getAgent(idOrAlias: string): Promise<AgentProfile> {
|
|
388
489
|
this.checkAbort();
|
|
389
490
|
this.validate(() => agentLookupSchema.parse(idOrAlias));
|
|
@@ -1441,10 +1542,34 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1441
1542
|
return await this.getTodoRecord(input.todoId);
|
|
1442
1543
|
}
|
|
1443
1544
|
|
|
1545
|
+
/**
|
|
1546
|
+
* Turns an agent name in a filter into the canonical ID the records hold.
|
|
1547
|
+
*
|
|
1548
|
+
* Callers filter by the name they know — a handle or an alias — while every
|
|
1549
|
+
* record stores the opaque ID. Without this the filter silently matches
|
|
1550
|
+
* nothing, which reads as "there is nothing here" rather than "that name
|
|
1551
|
+
* means something else now".
|
|
1552
|
+
*
|
|
1553
|
+
* An unresolvable name is passed through unchanged so it can match a legacy
|
|
1554
|
+
* name-shaped ID rather than being swallowed.
|
|
1555
|
+
*/
|
|
1556
|
+
private async canonicalAgentId(name: string | undefined): Promise<string | undefined> {
|
|
1557
|
+
if (!name) return name;
|
|
1558
|
+
const resolved = await this.repository.resolveAgent(name);
|
|
1559
|
+
return resolved.match?.id ?? name;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1444
1562
|
private async listItems(input: ItemListInput): Promise<Page<ItemSummary>> {
|
|
1445
1563
|
this.checkAbort();
|
|
1446
1564
|
const parsed = this.validate(() => itemListInputSchema.parse(input));
|
|
1447
|
-
|
|
1565
|
+
const resolved = {
|
|
1566
|
+
...parsed,
|
|
1567
|
+
...(parsed.participantAgentId
|
|
1568
|
+
? { participantAgentId: await this.canonicalAgentId(parsed.participantAgentId) }
|
|
1569
|
+
: {}),
|
|
1570
|
+
...(parsed.actorId ? { actorId: await this.canonicalAgentId(parsed.actorId) } : {}),
|
|
1571
|
+
};
|
|
1572
|
+
return await this.repository.listItemSummaries(resolved, this.actor);
|
|
1448
1573
|
}
|
|
1449
1574
|
private async listItemChanges(input: ChangesInput): Promise<ChangePage> {
|
|
1450
1575
|
this.checkAbort();
|
|
@@ -1539,8 +1664,8 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1539
1664
|
* check that silently lags the migration runner reports a healthy database as
|
|
1540
1665
|
* broken.
|
|
1541
1666
|
*/
|
|
1542
|
-
const CURRENT_SCHEMA_VERSION =
|
|
1543
|
-
const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5, 6];
|
|
1667
|
+
const CURRENT_SCHEMA_VERSION = 7;
|
|
1668
|
+
const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5, 6, 7];
|
|
1544
1669
|
|
|
1545
1670
|
export class SynomemClient extends SynomemCore implements SynomemService {
|
|
1546
1671
|
readonly home: string;
|
|
@@ -1568,6 +1693,41 @@ export class SynomemClient extends SynomemCore implements SynomemService {
|
|
|
1568
1693
|
this.projections = projections;
|
|
1569
1694
|
}
|
|
1570
1695
|
|
|
1696
|
+
/*
|
|
1697
|
+
* Answers "would a rebuild change anything, and when did one last run?"
|
|
1698
|
+
*
|
|
1699
|
+
* The comparison is against the manifest rather than a directory walk, so a
|
|
1700
|
+
* file a person dropped into the projection tree by hand is not reported as
|
|
1701
|
+
* drift -- Synomem only claims authority over what it wrote.
|
|
1702
|
+
*/
|
|
1703
|
+
async projectionStatus(): Promise<ProjectionStatus> {
|
|
1704
|
+
this.checkAbort();
|
|
1705
|
+
const expected = this.projections.expectedPaths();
|
|
1706
|
+
const entries = this.storage.projectionManifestEntries();
|
|
1707
|
+
const manifest = entries.map((entry) => entry.path);
|
|
1708
|
+
const inManifest = new Set(manifest);
|
|
1709
|
+
const inExpected = new Set(expected);
|
|
1710
|
+
const missing = expected.filter(
|
|
1711
|
+
(path) => !inManifest.has(path) || !existsSync(join(this.home, path)),
|
|
1712
|
+
);
|
|
1713
|
+
const unexpected = manifest.filter((path) => !inExpected.has(path));
|
|
1714
|
+
const limit = 20;
|
|
1715
|
+
return {
|
|
1716
|
+
directory: this.home,
|
|
1717
|
+
settings: { ...this.storage.config.projection },
|
|
1718
|
+
current: missing.length === 0 && unexpected.length === 0,
|
|
1719
|
+
...(entries[0] ? { lastRebuiltAt: entries[0].generatedAt } : {}),
|
|
1720
|
+
counts: {
|
|
1721
|
+
expected: expected.length,
|
|
1722
|
+
manifest: manifest.length,
|
|
1723
|
+
missing: missing.length,
|
|
1724
|
+
unexpected: unexpected.length,
|
|
1725
|
+
},
|
|
1726
|
+
missing: missing.slice(0, limit),
|
|
1727
|
+
unexpected: unexpected.slice(0, limit),
|
|
1728
|
+
};
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1571
1731
|
async doctor(): Promise<DoctorResult> {
|
|
1572
1732
|
this.checkAbort();
|
|
1573
1733
|
const diagnostics: Diagnostic[] = [];
|
|
@@ -1667,7 +1827,11 @@ export class SynomemClient extends SynomemCore implements SynomemService {
|
|
|
1667
1827
|
: 'Projection manifest is current.',
|
|
1668
1828
|
});
|
|
1669
1829
|
for (const profile of this.storage.listAgents()) {
|
|
1670
|
-
|
|
1830
|
+
// Named by handle, which is what the projection writers create. Using
|
|
1831
|
+
// the canonical ID here checks a directory that does not exist, which
|
|
1832
|
+
// makes the check pass on a workspace whose agent directory really has
|
|
1833
|
+
// been replaced with a symbolic link.
|
|
1834
|
+
const directory = join(this.home, profile.handle);
|
|
1671
1835
|
try {
|
|
1672
1836
|
assertNoSymlinkEscape(this.home, directory);
|
|
1673
1837
|
if (existsSync(directory) && lstatSync(directory).isSymbolicLink()) {
|
package/src/cloud.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synomem Cloud, as a constant rather than a question.
|
|
3
|
+
*
|
|
4
|
+
* Public onboarding must never ask for a service URL. A person setting up
|
|
5
|
+
* Synomem has no way to know whether an address they were given is the real
|
|
6
|
+
* one, and a prompt that accepts any origin is a prompt that can be phished.
|
|
7
|
+
* The hosted service therefore has one address, compiled in.
|
|
8
|
+
*
|
|
9
|
+
* `SYNOMEM_API_URL` remains for development and private deployments. It is
|
|
10
|
+
* deliberately undocumented in the README, the public docs, the packaged skill
|
|
11
|
+
* and ordinary help output — a private deployment is configured by whoever runs
|
|
12
|
+
* it, not discovered by an ordinary user.
|
|
13
|
+
*/
|
|
14
|
+
export const SYNOMEM_CLOUD_API_URL = 'https://api.synomem.ai';
|
|
15
|
+
|
|
16
|
+
export function cloudApiUrl(env: NodeJS.ProcessEnv = process.env): string {
|
|
17
|
+
const override = env.SYNOMEM_API_URL?.trim();
|
|
18
|
+
return override || SYNOMEM_CLOUD_API_URL;
|
|
19
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -73,7 +73,14 @@ export const configSchema = policySchema.extend({
|
|
|
73
73
|
});
|
|
74
74
|
|
|
75
75
|
export function resolveHome(explicitHome?: string): string {
|
|
76
|
-
|
|
76
|
+
/*
|
|
77
|
+
* `~/.synomem`, resolved as an exact directory.
|
|
78
|
+
*
|
|
79
|
+
* No detection of or migration from `~/.agents`: the project is greenfield,
|
|
80
|
+
* and code that quietly moves somebody's database is worse than a clear
|
|
81
|
+
* message telling them where the new home is.
|
|
82
|
+
*/
|
|
83
|
+
const candidate = explicitHome ?? process.env.SYNOMEM_HOME ?? resolve(homedir(), '.synomem');
|
|
77
84
|
if (candidate.includes('\0')) throw new SynomemError('UNSAFE_PATH', 'Storage home contains NUL.');
|
|
78
85
|
return resolve(candidate);
|
|
79
86
|
}
|
package/src/configure.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `synomem config` — the onboarding wizard, and its deterministic equivalent.
|
|
3
|
+
*
|
|
4
|
+
* Two rules shape this file. Every interactive step has a non-interactive
|
|
5
|
+
* counterpart, so an agent can configure a machine without a terminal. And the
|
|
6
|
+
* wizard refuses to run at all when nobody is there to answer: a setup program
|
|
7
|
+
* that blocks forever on a pipe is worse than one that says which flags it
|
|
8
|
+
* needs.
|
|
9
|
+
*/
|
|
10
|
+
import { chmodSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { cloudApiUrl } from './cloud.js';
|
|
13
|
+
import { SynomemError } from './errors.js';
|
|
14
|
+
import { resolveHome } from './config.js';
|
|
15
|
+
import { ask, askSecret, confirm, select, type PromptIo } from './prompt.js';
|
|
16
|
+
|
|
17
|
+
export type BackendChoice = 'local' | 'remote';
|
|
18
|
+
export type AuthChoice = 'browser' | 'access-key';
|
|
19
|
+
export type CredentialStoreChoice = 'auto' | 'keychain' | 'file' | 'environment';
|
|
20
|
+
|
|
21
|
+
export interface ConfigInitOptions {
|
|
22
|
+
backend?: BackendChoice;
|
|
23
|
+
home?: string;
|
|
24
|
+
auth?: AuthChoice;
|
|
25
|
+
workspace?: string;
|
|
26
|
+
accessToken?: string;
|
|
27
|
+
credentialStore?: CredentialStoreChoice;
|
|
28
|
+
yes?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ConfigPlan {
|
|
32
|
+
backend: BackendChoice;
|
|
33
|
+
home: string;
|
|
34
|
+
serviceUrl?: string;
|
|
35
|
+
auth?: AuthChoice;
|
|
36
|
+
workspaceId?: string;
|
|
37
|
+
credentialStore?: CredentialStoreChoice;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Where a credential can actually be kept on this platform.
|
|
42
|
+
*
|
|
43
|
+
* Reported rather than assumed: offering macOS Keychain on Linux, or a Secret
|
|
44
|
+
* Service that is not running, produces a setup that appears to succeed and
|
|
45
|
+
* then cannot read its own credential back.
|
|
46
|
+
*/
|
|
47
|
+
export function credentialStoreChoices(
|
|
48
|
+
platform: NodeJS.Platform = process.platform,
|
|
49
|
+
): Array<{ value: CredentialStoreChoice; label: string; detail?: string }> {
|
|
50
|
+
const native =
|
|
51
|
+
platform === 'darwin'
|
|
52
|
+
? { value: 'keychain' as const, label: 'macOS Keychain', detail: 'Recommended.' }
|
|
53
|
+
: platform === 'win32'
|
|
54
|
+
? /*
|
|
55
|
+
* Windows has no native option here yet, so the restricted file is
|
|
56
|
+
* the recommendation rather than Credential Manager. Offering a
|
|
57
|
+
* store the credential layer cannot actually read back would fail
|
|
58
|
+
* at the first use, after the wizard had already told the person
|
|
59
|
+
* their credential was safely stored.
|
|
60
|
+
*/
|
|
61
|
+
{
|
|
62
|
+
value: 'file' as const,
|
|
63
|
+
label: 'A restricted file in the Synomem home',
|
|
64
|
+
detail: 'Recommended on Windows until Credential Manager support lands.',
|
|
65
|
+
}
|
|
66
|
+
: {
|
|
67
|
+
value: 'keychain' as const,
|
|
68
|
+
label: 'Secret Service (libsecret)',
|
|
69
|
+
detail: 'Recommended where a desktop keyring is running.',
|
|
70
|
+
};
|
|
71
|
+
return [
|
|
72
|
+
native,
|
|
73
|
+
...(native.value === 'file'
|
|
74
|
+
? []
|
|
75
|
+
: [
|
|
76
|
+
{
|
|
77
|
+
value: 'file' as const,
|
|
78
|
+
label: 'A restricted file in the Synomem home',
|
|
79
|
+
detail: 'Mode 0600. Use on headless machines with no keyring.',
|
|
80
|
+
},
|
|
81
|
+
]),
|
|
82
|
+
{
|
|
83
|
+
value: 'environment',
|
|
84
|
+
label: 'Print environment-variable instructions',
|
|
85
|
+
detail: 'Nothing is stored. Synomem never edits your shell profile.',
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Refuses to guess when there is nobody to ask. */
|
|
91
|
+
export function assertInteractive(io: PromptIo): void {
|
|
92
|
+
if (io.interactive) return;
|
|
93
|
+
throw new SynomemError(
|
|
94
|
+
'INVALID_INPUT',
|
|
95
|
+
[
|
|
96
|
+
'synomem config needs an interactive terminal.',
|
|
97
|
+
'',
|
|
98
|
+
'For automation, use the deterministic form instead:',
|
|
99
|
+
'',
|
|
100
|
+
' synomem config init --backend local --yes',
|
|
101
|
+
'',
|
|
102
|
+
' synomem config init --backend remote --auth access-key \\',
|
|
103
|
+
' --workspace <workspace-id> --access-token-stdin --yes',
|
|
104
|
+
'',
|
|
105
|
+
'Pipe the token in rather than passing it as an argument: an argument is',
|
|
106
|
+
'kept by both the shell history and the process list.',
|
|
107
|
+
].join('\n'),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Stores an access key in a restricted file.
|
|
113
|
+
*
|
|
114
|
+
* Separate from `config.json` so a configuration file can be read, copied or
|
|
115
|
+
* pasted into an issue without carrying a secret with it.
|
|
116
|
+
*/
|
|
117
|
+
export function writeCredentialFile(home: string, token: string): string {
|
|
118
|
+
const directory = join(home, 'credentials');
|
|
119
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
120
|
+
chmodSync(directory, 0o700);
|
|
121
|
+
const path = join(directory, 'installation.json');
|
|
122
|
+
writeFileSync(path, `${JSON.stringify({ accessToken: token }, null, 2)}\n`, { mode: 0o600 });
|
|
123
|
+
chmodSync(path, 0o600);
|
|
124
|
+
return path;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A key's identifying prefix. Never the key. */
|
|
128
|
+
export function credentialFingerprint(token: string): string {
|
|
129
|
+
const head = token.slice(0, 12);
|
|
130
|
+
return `${head}${token.length > 12 ? '\u2026' : ''}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function environmentInstructions(token: string): string {
|
|
134
|
+
return [
|
|
135
|
+
'Set this value for the current shell:',
|
|
136
|
+
'',
|
|
137
|
+
` export SYNOMEM_ACCESS_TOKEN='${token}'`,
|
|
138
|
+
'',
|
|
139
|
+
'To persist it, add that to a secret-aware shell configuration or to your',
|
|
140
|
+
'agent runtime environment. Synomem will not edit your shell profile for',
|
|
141
|
+
'you: silently rewriting a dotfile is not a thing a setup program should do.',
|
|
142
|
+
].join('\n');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The interactive flow, returning the plan it settled on. */
|
|
146
|
+
export async function runConfigWizard(
|
|
147
|
+
io: PromptIo,
|
|
148
|
+
options: { home?: string; env?: NodeJS.ProcessEnv } = {},
|
|
149
|
+
): Promise<ConfigPlan> {
|
|
150
|
+
assertInteractive(io);
|
|
151
|
+
const env = options.env ?? process.env;
|
|
152
|
+
|
|
153
|
+
io.output.write(
|
|
154
|
+
[
|
|
155
|
+
'',
|
|
156
|
+
'Welcome to Synomem',
|
|
157
|
+
'',
|
|
158
|
+
'Synomem gives agents durable notes, messages, tasks, todos, kudos,',
|
|
159
|
+
'and shared coordination.',
|
|
160
|
+
'',
|
|
161
|
+
].join('\n'),
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const backend = await select<BackendChoice>(io, 'Where should Synomem store canonical state?', [
|
|
165
|
+
{
|
|
166
|
+
value: 'local',
|
|
167
|
+
label: 'Local \u2014 SQLite on this machine',
|
|
168
|
+
detail: 'Nothing is uploaded. One implicit workspace.',
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
value: 'remote',
|
|
172
|
+
label: 'Synomem Cloud \u2014 shared across machines and agents',
|
|
173
|
+
detail: 'Organizations, workspaces, roles and administration.',
|
|
174
|
+
},
|
|
175
|
+
]);
|
|
176
|
+
|
|
177
|
+
const home = await ask(io, 'Where should Synomem store its data?', resolveHome(options.home));
|
|
178
|
+
|
|
179
|
+
if (backend === 'local') {
|
|
180
|
+
return { backend, home };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const serviceUrl = cloudApiUrl(env);
|
|
184
|
+
io.output.write(`\nConnecting to Synomem Cloud at ${serviceUrl}\n`);
|
|
185
|
+
|
|
186
|
+
const auth = await select<AuthChoice>(io, 'How would you like to sign in?', [
|
|
187
|
+
{
|
|
188
|
+
value: 'browser',
|
|
189
|
+
label: 'Sign in with your browser',
|
|
190
|
+
detail: 'Opens the authorization server and returns through a loopback callback.',
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
value: 'access-key',
|
|
194
|
+
label: 'Use an installation access key',
|
|
195
|
+
detail: 'Create one at https://portal.synomem.ai/installations',
|
|
196
|
+
},
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
/*
|
|
200
|
+
* Deliberately no workspace prompt here.
|
|
201
|
+
*
|
|
202
|
+
* A hosted workspace ID looks like `ws-04psqx2rkt8ttft7a1t2z69r97`, and the
|
|
203
|
+
* credential authorized in the next step already knows which workspace it
|
|
204
|
+
* reaches -- an installation key is bound to exactly one, and a browser
|
|
205
|
+
* sign-in can list the ones the account belongs to. Asking first means
|
|
206
|
+
* asking a person to go and look something up that we are about to be told.
|
|
207
|
+
*/
|
|
208
|
+
|
|
209
|
+
const credentialStore =
|
|
210
|
+
auth === 'access-key'
|
|
211
|
+
? await select<CredentialStoreChoice>(
|
|
212
|
+
io,
|
|
213
|
+
'Where should Synomem store this credential?',
|
|
214
|
+
credentialStoreChoices(),
|
|
215
|
+
)
|
|
216
|
+
: 'auto';
|
|
217
|
+
|
|
218
|
+
return { backend, home, serviceUrl, auth, credentialStore };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Reads an access key without ever accepting it as an argument. */
|
|
222
|
+
export async function readAccessToken(io: PromptIo): Promise<string> {
|
|
223
|
+
const token = await askSecret(io, 'Installation access key');
|
|
224
|
+
if (!token) throw new SynomemError('INVALID_INPUT', 'No access key was provided.');
|
|
225
|
+
return token;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function confirmPlan(io: PromptIo, plan: ConfigPlan): Promise<boolean> {
|
|
229
|
+
io.output.write(
|
|
230
|
+
[
|
|
231
|
+
'',
|
|
232
|
+
'Synomem will be configured as:',
|
|
233
|
+
'',
|
|
234
|
+
` Backend: ${plan.backend === 'local' ? 'Local SQLite' : 'Synomem Cloud'}`,
|
|
235
|
+
` Home: ${plan.home}`,
|
|
236
|
+
...(plan.serviceUrl ? [` Service: ${plan.serviceUrl}`] : []),
|
|
237
|
+
...(plan.workspaceId ? [` Workspace: ${plan.workspaceId}`] : []),
|
|
238
|
+
...(plan.credentialStore && plan.credentialStore !== 'auto'
|
|
239
|
+
? [` Credential: ${plan.credentialStore}`]
|
|
240
|
+
: []),
|
|
241
|
+
'',
|
|
242
|
+
].join('\n'),
|
|
243
|
+
);
|
|
244
|
+
return await confirm(io, 'Apply this?');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function homeExists(home: string): boolean {
|
|
248
|
+
return existsSync(home);
|
|
249
|
+
}
|
package/src/credentials.ts
CHANGED
|
@@ -6,6 +6,21 @@ import type { ActorIdentity } from './types.js';
|
|
|
6
6
|
const serviceName = 'ai.synomem.credentials';
|
|
7
7
|
const maximumOutputBytes = 128 * 1024;
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* An installation access key.
|
|
11
|
+
*
|
|
12
|
+
* Not an OAuth credential: it has no refresh, no token endpoint and no client,
|
|
13
|
+
* and it authorizes a MACHINE rather than a person. Keeping it a distinct shape
|
|
14
|
+
* stops code treating it as refreshable, which would mean silently failing to
|
|
15
|
+
* renew something that never expires that way.
|
|
16
|
+
*/
|
|
17
|
+
export interface StoredInstallationKey {
|
|
18
|
+
kind: 'installation-key';
|
|
19
|
+
accessToken: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type StoredCredential = StoredOAuthCredential | StoredInstallationKey;
|
|
23
|
+
|
|
9
24
|
export interface StoredOAuthCredential {
|
|
10
25
|
accessToken: string;
|
|
11
26
|
refreshToken?: string;
|
|
@@ -17,8 +32,8 @@ export interface StoredOAuthCredential {
|
|
|
17
32
|
}
|
|
18
33
|
|
|
19
34
|
export interface CredentialStore {
|
|
20
|
-
get(reference: string): Promise<
|
|
21
|
-
set(reference: string, credential:
|
|
35
|
+
get(reference: string): Promise<StoredCredential | undefined>;
|
|
36
|
+
set(reference: string, credential: StoredCredential): Promise<void>;
|
|
22
37
|
delete(reference: string): Promise<boolean>;
|
|
23
38
|
}
|
|
24
39
|
|
|
@@ -185,10 +200,18 @@ export class OsCredentialStore implements CredentialStore {
|
|
|
185
200
|
return result.code === 0;
|
|
186
201
|
}
|
|
187
202
|
|
|
203
|
+
/*
|
|
204
|
+
* Windows Credential Manager is not implemented yet. `cmdkey` can write a
|
|
205
|
+
* generic credential but deliberately will not read the secret back, so a
|
|
206
|
+
* store built on it would accept a credential and then never return it --
|
|
207
|
+
* worse than saying so plainly.
|
|
208
|
+
*/
|
|
188
209
|
private unsupported(): never {
|
|
189
210
|
throw new SynomemError(
|
|
190
211
|
'CONFIG_INVALID',
|
|
191
|
-
|
|
212
|
+
this.platform === 'win32'
|
|
213
|
+
? 'Windows Credential Manager storage is not supported yet. Run `synomem config init` and choose the restricted-file store, or set SYNOMEM_ACCESS_TOKEN.'
|
|
214
|
+
: 'Operating-system credential storage needs the macOS Keychain, or secret-tool on Linux. Run `synomem config init` and choose the restricted-file store, or set SYNOMEM_ACCESS_TOKEN.',
|
|
192
215
|
);
|
|
193
216
|
}
|
|
194
217
|
}
|