synomem 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +42 -0
- package/ARCHITECTURE.md +126 -0
- package/CHANGELOG.md +35 -0
- package/CODE_OF_CONDUCT.md +29 -0
- package/CONTRIBUTING.md +48 -0
- package/LICENSE +21 -0
- package/README.md +247 -0
- package/SECURITY.md +117 -0
- package/dist/backend.d.ts +9 -0
- package/dist/backend.d.ts.map +1 -0
- package/dist/backend.js +60 -0
- package/dist/backend.js.map +1 -0
- package/dist/cli.d.ts +34 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1040 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +168 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1031 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +33 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +153 -0
- package/dist/config.js.map +1 -0
- package/dist/credentials.d.ts +36 -0
- package/dist/credentials.d.ts.map +1 -0
- package/dist/credentials.js +127 -0
- package/dist/credentials.js.map +1 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +60 -0
- package/dist/errors.js.map +1 -0
- package/dist/fs-utils.d.ts +7 -0
- package/dist/fs-utils.d.ts.map +1 -0
- package/dist/fs-utils.js +68 -0
- package/dist/fs-utils.js.map +1 -0
- package/dist/import.d.ts +655 -0
- package/dist/import.d.ts.map +1 -0
- package/dist/import.js +226 -0
- package/dist/import.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/index.d.ts +14 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +698 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/mcp-server.d.ts +3 -0
- package/dist/mcp-server.d.ts.map +1 -0
- package/dist/mcp-server.js +45 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/oauth.d.ts +27 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +201 -0
- package/dist/oauth.js.map +1 -0
- package/dist/ports/projections.d.ts +6 -0
- package/dist/ports/projections.d.ts.map +1 -0
- package/dist/ports/projections.js +2 -0
- package/dist/ports/projections.js.map +1 -0
- package/dist/ports/repository.d.ts +31 -0
- package/dist/ports/repository.d.ts.map +1 -0
- package/dist/ports/repository.js +2 -0
- package/dist/ports/repository.js.map +1 -0
- package/dist/projections.d.ts +24 -0
- package/dist/projections.d.ts.map +1 -0
- package/dist/projections.js +531 -0
- package/dist/projections.js.map +1 -0
- package/dist/remote.d.ts +120 -0
- package/dist/remote.d.ts.map +1 -0
- package/dist/remote.js +286 -0
- package/dist/remote.js.map +1 -0
- package/dist/schemas.d.ts +901 -0
- package/dist/schemas.d.ts.map +1 -0
- package/dist/schemas.js +424 -0
- package/dist/schemas.js.map +1 -0
- package/dist/service.d.ts +126 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +2 -0
- package/dist/service.js.map +1 -0
- package/dist/skill-install.d.ts +34 -0
- package/dist/skill-install.d.ts.map +1 -0
- package/dist/skill-install.js +256 -0
- package/dist/skill-install.js.map +1 -0
- package/dist/storage.d.ts +94 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +1229 -0
- package/dist/storage.js.map +1 -0
- package/dist/types.d.ts +442 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +14 -0
- package/dist/version.js.map +1 -0
- package/docs/cli.md +180 -0
- package/docs/examples.md +70 -0
- package/docs/mcp.md +96 -0
- package/docs/recovery.md +45 -0
- package/docs/releasing.md +61 -0
- package/docs/skill.md +93 -0
- package/docs/storage-format.md +83 -0
- package/openapi/synomem-v1.yaml +674 -0
- package/package.json +100 -0
- package/skills/synomem/SKILL.md +71 -0
- package/skills/synomem/agents/openai.yaml +8 -0
- package/skills/synomem/references/examples.md +28 -0
- package/src/backend.ts +91 -0
- package/src/cli.ts +1623 -0
- package/src/client.ts +1261 -0
- package/src/config.ts +170 -0
- package/src/credentials.ts +194 -0
- package/src/errors.ts +64 -0
- package/src/fs-utils.ts +85 -0
- package/src/import.ts +284 -0
- package/src/index.ts +61 -0
- package/src/mcp/index.ts +973 -0
- package/src/mcp-server.ts +46 -0
- package/src/oauth.ts +252 -0
- package/src/ports/projections.ts +6 -0
- package/src/ports/repository.ts +67 -0
- package/src/projections.ts +624 -0
- package/src/remote.ts +516 -0
- package/src/schemas.ts +457 -0
- package/src/service.ts +140 -0
- package/src/skill-install.ts +332 -0
- package/src/storage.ts +1603 -0
- package/src/types.ts +458 -0
- package/src/version.ts +16 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { SynomemError } from './errors.js';
|
|
5
|
+
import type { SynomemConfig, SynomemConfigOverrides } from './types.js';
|
|
6
|
+
|
|
7
|
+
export const defaultConfig: SynomemConfig = {
|
|
8
|
+
schemaVersion: 3,
|
|
9
|
+
backend: { kind: 'local' },
|
|
10
|
+
workspaceId: 'workspace',
|
|
11
|
+
defaultVisibility: 'workspace',
|
|
12
|
+
allowSelfAwards: false,
|
|
13
|
+
allowCrossAgentTodos: true,
|
|
14
|
+
allowAgentCreationViaMcp: false,
|
|
15
|
+
allowRebuildViaMcp: false,
|
|
16
|
+
includePrivateInStats: false,
|
|
17
|
+
projection: {
|
|
18
|
+
writeWinsMarkdown: true,
|
|
19
|
+
writeMemoryMarkdown: true,
|
|
20
|
+
writeTodosMarkdown: true,
|
|
21
|
+
writeInboxEntries: true,
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const backendSchema = z.discriminatedUnion('kind', [
|
|
26
|
+
z.object({ kind: z.literal('local') }),
|
|
27
|
+
z.object({
|
|
28
|
+
kind: z.literal('remote'),
|
|
29
|
+
baseUrl: z.url().refine(
|
|
30
|
+
(value) => {
|
|
31
|
+
try {
|
|
32
|
+
const parsed = new URL(value);
|
|
33
|
+
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname);
|
|
34
|
+
return (
|
|
35
|
+
(parsed.protocol === 'https:' || (parsed.protocol === 'http:' && loopback)) &&
|
|
36
|
+
!parsed.username &&
|
|
37
|
+
!parsed.password &&
|
|
38
|
+
!parsed.search &&
|
|
39
|
+
!parsed.hash &&
|
|
40
|
+
parsed.pathname === '/'
|
|
41
|
+
);
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
{ message: 'Remote baseUrl must be an HTTPS origin (loopback HTTP is allowed).' },
|
|
47
|
+
),
|
|
48
|
+
workspaceId: z.string().trim().min(1).max(100),
|
|
49
|
+
}),
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
const policySchema = z.object({
|
|
53
|
+
workspaceId: z.string().trim().min(1).max(100),
|
|
54
|
+
defaultVisibility: z.enum(['private', 'workspace', 'public']),
|
|
55
|
+
allowSelfAwards: z.boolean(),
|
|
56
|
+
allowCrossAgentTodos: z.boolean(),
|
|
57
|
+
allowAgentCreationViaMcp: z.boolean(),
|
|
58
|
+
allowRebuildViaMcp: z.boolean(),
|
|
59
|
+
includePrivateInStats: z.boolean(),
|
|
60
|
+
projection: z.object({
|
|
61
|
+
writeWinsMarkdown: z.boolean(),
|
|
62
|
+
writeMemoryMarkdown: z.boolean(),
|
|
63
|
+
writeTodosMarkdown: z.boolean(),
|
|
64
|
+
writeInboxEntries: z.boolean(),
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const legacyConfigSchema = policySchema.extend({ schemaVersion: z.literal(2) });
|
|
69
|
+
|
|
70
|
+
export const configSchema = policySchema.extend({
|
|
71
|
+
schemaVersion: z.literal(3),
|
|
72
|
+
backend: backendSchema,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export function resolveHome(explicitHome?: string): string {
|
|
76
|
+
const candidate = explicitHome ?? process.env.SYNOMEM_HOME ?? resolve(homedir(), '.agents');
|
|
77
|
+
if (candidate.includes('\0')) throw new SynomemError('UNSAFE_PATH', 'Storage home contains NUL.');
|
|
78
|
+
return resolve(candidate);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function mergeConfig(
|
|
82
|
+
fileConfig: unknown,
|
|
83
|
+
explicit?: SynomemConfigOverrides,
|
|
84
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
85
|
+
): SynomemConfig {
|
|
86
|
+
const parsedFile = parseFileConfig(fileConfig);
|
|
87
|
+
if (!parsedFile.success) {
|
|
88
|
+
throw new SynomemError('CONFIG_INVALID', 'Synomem configuration file is invalid.', {
|
|
89
|
+
issues: parsedFile.error.issues,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const fromFile = parsedFile.data;
|
|
93
|
+
const fromEnvironment = environmentConfig(env);
|
|
94
|
+
const merged: SynomemConfig = {
|
|
95
|
+
...fromFile,
|
|
96
|
+
...fromEnvironment,
|
|
97
|
+
...explicit,
|
|
98
|
+
projection: {
|
|
99
|
+
...fromFile.projection,
|
|
100
|
+
...fromEnvironment.projection,
|
|
101
|
+
...explicit?.projection,
|
|
102
|
+
},
|
|
103
|
+
schemaVersion: 3,
|
|
104
|
+
};
|
|
105
|
+
const result = configSchema.safeParse(merged);
|
|
106
|
+
if (!result.success) {
|
|
107
|
+
throw new SynomemError('CONFIG_INVALID', 'Synomem configuration is invalid.', {
|
|
108
|
+
issues: result.error.issues,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return result.data;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseFileConfig(fileConfig: unknown) {
|
|
115
|
+
if (fileConfig === undefined) return { success: true as const, data: defaultConfig };
|
|
116
|
+
const current = configSchema.safeParse(fileConfig);
|
|
117
|
+
if (current.success) return current;
|
|
118
|
+
const legacy = legacyConfigSchema.safeParse(fileConfig);
|
|
119
|
+
if (legacy.success) {
|
|
120
|
+
return {
|
|
121
|
+
success: true as const,
|
|
122
|
+
data: { ...legacy.data, schemaVersion: 3 as const, backend: { kind: 'local' as const } },
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
return current;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function optionalBoolean(env: NodeJS.ProcessEnv, name: string): boolean | undefined {
|
|
129
|
+
const value = env[name];
|
|
130
|
+
if (value === undefined) return undefined;
|
|
131
|
+
if (value === 'true') return true;
|
|
132
|
+
if (value === 'false') return false;
|
|
133
|
+
throw new SynomemError('CONFIG_INVALID', `${name} must be true or false.`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function environmentConfig(env: NodeJS.ProcessEnv): SynomemConfigOverrides {
|
|
137
|
+
const visibility = env.SYNOMEM_DEFAULT_VISIBILITY;
|
|
138
|
+
if (visibility && !['private', 'workspace', 'public'].includes(visibility)) {
|
|
139
|
+
throw new SynomemError(
|
|
140
|
+
'CONFIG_INVALID',
|
|
141
|
+
'SYNOMEM_DEFAULT_VISIBILITY must be private, workspace, or public.',
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const writeWinsMarkdown = optionalBoolean(env, 'SYNOMEM_WRITE_WINS_MARKDOWN');
|
|
146
|
+
const writeMemoryMarkdown = optionalBoolean(env, 'SYNOMEM_WRITE_MEMORY_MARKDOWN');
|
|
147
|
+
const writeTodosMarkdown = optionalBoolean(env, 'SYNOMEM_WRITE_TODOS_MARKDOWN');
|
|
148
|
+
const writeInboxEntries = optionalBoolean(env, 'SYNOMEM_WRITE_INBOX_ENTRIES');
|
|
149
|
+
const allowSelfAwards = optionalBoolean(env, 'SYNOMEM_ALLOW_SELF_AWARDS');
|
|
150
|
+
const allowCrossAgentTodos = optionalBoolean(env, 'SYNOMEM_ALLOW_CROSS_AGENT_TODOS');
|
|
151
|
+
const allowAgentCreationViaMcp = optionalBoolean(env, 'SYNOMEM_ALLOW_AGENT_CREATION_VIA_MCP');
|
|
152
|
+
const allowRebuildViaMcp = optionalBoolean(env, 'SYNOMEM_ALLOW_REBUILD_VIA_MCP');
|
|
153
|
+
const includePrivateInStats = optionalBoolean(env, 'SYNOMEM_INCLUDE_PRIVATE_IN_STATS');
|
|
154
|
+
const projection = {
|
|
155
|
+
...(writeWinsMarkdown !== undefined ? { writeWinsMarkdown } : {}),
|
|
156
|
+
...(writeMemoryMarkdown !== undefined ? { writeMemoryMarkdown } : {}),
|
|
157
|
+
...(writeTodosMarkdown !== undefined ? { writeTodosMarkdown } : {}),
|
|
158
|
+
...(writeInboxEntries !== undefined ? { writeInboxEntries } : {}),
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
...(visibility ? { defaultVisibility: visibility as SynomemConfig['defaultVisibility'] } : {}),
|
|
163
|
+
...(allowSelfAwards !== undefined ? { allowSelfAwards } : {}),
|
|
164
|
+
...(allowCrossAgentTodos !== undefined ? { allowCrossAgentTodos } : {}),
|
|
165
|
+
...(allowAgentCreationViaMcp !== undefined ? { allowAgentCreationViaMcp } : {}),
|
|
166
|
+
...(allowRebuildViaMcp !== undefined ? { allowRebuildViaMcp } : {}),
|
|
167
|
+
...(includePrivateInStats !== undefined ? { includePrivateInStats } : {}),
|
|
168
|
+
...(Object.keys(projection).length ? { projection } : {}),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { SynomemError } from './errors.js';
|
|
4
|
+
import type { ActorIdentity } from './types.js';
|
|
5
|
+
|
|
6
|
+
const serviceName = 'ai.synomem.credentials';
|
|
7
|
+
const maximumOutputBytes = 128 * 1024;
|
|
8
|
+
|
|
9
|
+
export interface StoredOAuthCredential {
|
|
10
|
+
accessToken: string;
|
|
11
|
+
refreshToken?: string;
|
|
12
|
+
expiresAt?: number;
|
|
13
|
+
tokenEndpoint: string;
|
|
14
|
+
clientId: string;
|
|
15
|
+
resource: string;
|
|
16
|
+
scope: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CredentialStore {
|
|
20
|
+
get(reference: string): Promise<StoredOAuthCredential | undefined>;
|
|
21
|
+
set(reference: string, credential: StoredOAuthCredential): Promise<void>;
|
|
22
|
+
delete(reference: string): Promise<boolean>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function credentialReference(
|
|
26
|
+
baseUrl: string,
|
|
27
|
+
workspaceId: string,
|
|
28
|
+
actor: ActorIdentity,
|
|
29
|
+
): string {
|
|
30
|
+
return createHash('sha256')
|
|
31
|
+
.update(
|
|
32
|
+
JSON.stringify({
|
|
33
|
+
baseUrl: new URL(baseUrl).origin,
|
|
34
|
+
workspaceId,
|
|
35
|
+
actor: { kind: actor.kind, id: actor.id },
|
|
36
|
+
}),
|
|
37
|
+
)
|
|
38
|
+
.digest('base64url');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface CommandResult {
|
|
42
|
+
stdout: string;
|
|
43
|
+
stderr: string;
|
|
44
|
+
code: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type CredentialCommandRunner = (
|
|
48
|
+
executable: string,
|
|
49
|
+
arguments_: string[],
|
|
50
|
+
input?: string,
|
|
51
|
+
) => Promise<CommandResult>;
|
|
52
|
+
|
|
53
|
+
async function command(
|
|
54
|
+
executable: string,
|
|
55
|
+
arguments_: string[],
|
|
56
|
+
input?: string,
|
|
57
|
+
): Promise<CommandResult> {
|
|
58
|
+
return await new Promise((resolve, reject) => {
|
|
59
|
+
const child = spawn(executable, arguments_, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
60
|
+
const stdout: Buffer[] = [];
|
|
61
|
+
const stderr: Buffer[] = [];
|
|
62
|
+
let bytes = 0;
|
|
63
|
+
const collect = (target: Buffer[]) => (chunk: Buffer) => {
|
|
64
|
+
bytes += chunk.byteLength;
|
|
65
|
+
if (bytes > maximumOutputBytes) {
|
|
66
|
+
child.kill();
|
|
67
|
+
reject(new SynomemError('INTERNAL_ERROR', 'Credential helper output was too large.'));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
target.push(chunk);
|
|
71
|
+
};
|
|
72
|
+
child.stdout.on('data', collect(stdout));
|
|
73
|
+
child.stderr.on('data', collect(stderr));
|
|
74
|
+
child.once('error', reject);
|
|
75
|
+
child.once('close', (code) =>
|
|
76
|
+
resolve({
|
|
77
|
+
stdout: Buffer.concat(stdout).toString('utf8'),
|
|
78
|
+
stderr: Buffer.concat(stderr).toString('utf8'),
|
|
79
|
+
code: code ?? 1,
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
child.stdin.end(input);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseCredential(value: string): StoredOAuthCredential {
|
|
87
|
+
try {
|
|
88
|
+
const parsed = JSON.parse(value) as Partial<StoredOAuthCredential>;
|
|
89
|
+
if (
|
|
90
|
+
typeof parsed.accessToken !== 'string' ||
|
|
91
|
+
typeof parsed.tokenEndpoint !== 'string' ||
|
|
92
|
+
typeof parsed.clientId !== 'string' ||
|
|
93
|
+
typeof parsed.resource !== 'string' ||
|
|
94
|
+
typeof parsed.scope !== 'string'
|
|
95
|
+
) {
|
|
96
|
+
throw new Error();
|
|
97
|
+
}
|
|
98
|
+
return parsed as StoredOAuthCredential;
|
|
99
|
+
} catch {
|
|
100
|
+
throw new SynomemError('AUTH_REQUIRED', 'Stored Synomem credentials are malformed.');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export class OsCredentialStore implements CredentialStore {
|
|
105
|
+
private readonly platform: NodeJS.Platform;
|
|
106
|
+
private readonly run: CredentialCommandRunner;
|
|
107
|
+
|
|
108
|
+
constructor(options: { platform?: NodeJS.Platform; run?: CredentialCommandRunner } = {}) {
|
|
109
|
+
this.platform = options.platform ?? process.platform;
|
|
110
|
+
this.run = options.run ?? command;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async get(reference: string): Promise<StoredOAuthCredential | undefined> {
|
|
114
|
+
const result =
|
|
115
|
+
this.platform === 'darwin'
|
|
116
|
+
? await this.run('/usr/bin/security', [
|
|
117
|
+
'find-generic-password',
|
|
118
|
+
'-a',
|
|
119
|
+
reference,
|
|
120
|
+
'-s',
|
|
121
|
+
serviceName,
|
|
122
|
+
'-w',
|
|
123
|
+
])
|
|
124
|
+
: this.platform === 'linux'
|
|
125
|
+
? await this.run('secret-tool', ['lookup', 'service', serviceName, 'account', reference])
|
|
126
|
+
: this.unsupported();
|
|
127
|
+
if (result.code !== 0) return undefined;
|
|
128
|
+
return parseCredential(result.stdout.trim());
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async set(reference: string, credential: StoredOAuthCredential): Promise<void> {
|
|
132
|
+
const serialized = JSON.stringify(credential);
|
|
133
|
+
const result =
|
|
134
|
+
this.platform === 'darwin'
|
|
135
|
+
? await this.run(
|
|
136
|
+
'/usr/bin/security',
|
|
137
|
+
[
|
|
138
|
+
'add-generic-password',
|
|
139
|
+
'-a',
|
|
140
|
+
reference,
|
|
141
|
+
'-s',
|
|
142
|
+
serviceName,
|
|
143
|
+
'-l',
|
|
144
|
+
'Synomem OAuth credential',
|
|
145
|
+
'-U',
|
|
146
|
+
'-w',
|
|
147
|
+
],
|
|
148
|
+
`${serialized}\n`,
|
|
149
|
+
)
|
|
150
|
+
: this.platform === 'linux'
|
|
151
|
+
? await this.run(
|
|
152
|
+
'secret-tool',
|
|
153
|
+
[
|
|
154
|
+
'store',
|
|
155
|
+
'--label=Synomem OAuth credential',
|
|
156
|
+
'service',
|
|
157
|
+
serviceName,
|
|
158
|
+
'account',
|
|
159
|
+
reference,
|
|
160
|
+
],
|
|
161
|
+
serialized,
|
|
162
|
+
)
|
|
163
|
+
: this.unsupported();
|
|
164
|
+
if (result.code !== 0) {
|
|
165
|
+
throw new SynomemError(
|
|
166
|
+
'AUTH_REQUIRED',
|
|
167
|
+
'The operating-system credential store rejected the credential.',
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async delete(reference: string): Promise<boolean> {
|
|
173
|
+
const result =
|
|
174
|
+
this.platform === 'darwin'
|
|
175
|
+
? await this.run('/usr/bin/security', [
|
|
176
|
+
'delete-generic-password',
|
|
177
|
+
'-a',
|
|
178
|
+
reference,
|
|
179
|
+
'-s',
|
|
180
|
+
serviceName,
|
|
181
|
+
])
|
|
182
|
+
: this.platform === 'linux'
|
|
183
|
+
? await this.run('secret-tool', ['clear', 'service', serviceName, 'account', reference])
|
|
184
|
+
: this.unsupported();
|
|
185
|
+
return result.code === 0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private unsupported(): never {
|
|
189
|
+
throw new SynomemError(
|
|
190
|
+
'CONFIG_INVALID',
|
|
191
|
+
'Interactive credential storage is currently supported on macOS and Linux with secret-tool.',
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { ZodError } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const errorCodes = [
|
|
4
|
+
'INVALID_INPUT',
|
|
5
|
+
'INVALID_AGENT_ID',
|
|
6
|
+
'INVALID_EVENT',
|
|
7
|
+
'UNSUPPORTED_EVENT',
|
|
8
|
+
'AGENT_NOT_FOUND',
|
|
9
|
+
'AGENT_EXISTS',
|
|
10
|
+
'ALIAS_CONFLICT',
|
|
11
|
+
'KUDOS_NOT_FOUND',
|
|
12
|
+
'ITEM_NOT_FOUND',
|
|
13
|
+
'MEMO_NOT_FOUND',
|
|
14
|
+
'NOTE_NOT_FOUND',
|
|
15
|
+
'TODO_NOT_FOUND',
|
|
16
|
+
'REVISION_CONFLICT',
|
|
17
|
+
'IDEMPOTENCY_CONFLICT',
|
|
18
|
+
'MUTATION_FORBIDDEN',
|
|
19
|
+
'SELF_AWARD_FORBIDDEN',
|
|
20
|
+
'ACKNOWLEDGMENT_FORBIDDEN',
|
|
21
|
+
'REVOCATION_FORBIDDEN',
|
|
22
|
+
'READ_ONLY',
|
|
23
|
+
'DATABASE_BUSY',
|
|
24
|
+
'DATABASE_CORRUPT',
|
|
25
|
+
'UNSUPPORTED_SCHEMA',
|
|
26
|
+
'UNSAFE_PATH',
|
|
27
|
+
'POLICY_FORBIDDEN',
|
|
28
|
+
'CONFIG_INVALID',
|
|
29
|
+
'AUTH_REQUIRED',
|
|
30
|
+
'AUTH_FORBIDDEN',
|
|
31
|
+
'REMOTE_UNAVAILABLE',
|
|
32
|
+
'REMOTE_PROTOCOL',
|
|
33
|
+
'RATE_LIMITED',
|
|
34
|
+
'INTERNAL_ERROR',
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
export type SynomemErrorCode = (typeof errorCodes)[number];
|
|
38
|
+
|
|
39
|
+
export class SynomemError extends Error {
|
|
40
|
+
readonly code: SynomemErrorCode;
|
|
41
|
+
readonly details?: Record<string, unknown>;
|
|
42
|
+
|
|
43
|
+
constructor(code: SynomemErrorCode, message: string, details?: Record<string, unknown>) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = 'SynomemError';
|
|
46
|
+
this.code = code;
|
|
47
|
+
this.details = details;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function asSynomemError(error: unknown): SynomemError {
|
|
52
|
+
if (error instanceof SynomemError) return error;
|
|
53
|
+
if (error instanceof ZodError) {
|
|
54
|
+
return new SynomemError('INVALID_INPUT', 'Input validation failed.', { issues: error.issues });
|
|
55
|
+
}
|
|
56
|
+
const message = error instanceof Error ? error.message : 'Unexpected Synomem error';
|
|
57
|
+
if (/SQLITE_BUSY|database is locked/i.test(message)) {
|
|
58
|
+
return new SynomemError('DATABASE_BUSY', 'The Synomem database is busy; retry shortly.');
|
|
59
|
+
}
|
|
60
|
+
if (/malformed|not a database|database disk image/i.test(message)) {
|
|
61
|
+
return new SynomemError('DATABASE_CORRUPT', 'The Synomem database failed an integrity check.');
|
|
62
|
+
}
|
|
63
|
+
return new SynomemError('INTERNAL_ERROR', message);
|
|
64
|
+
}
|
package/src/fs-utils.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
fsyncSync,
|
|
5
|
+
lstatSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
openSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
realpathSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from 'node:fs';
|
|
14
|
+
import { dirname, relative, resolve, sep } from 'node:path';
|
|
15
|
+
import { SynomemError } from './errors.js';
|
|
16
|
+
|
|
17
|
+
export function ensureDirectory(path: string): void {
|
|
18
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function assertNoSymlinkEscape(home: string, target: string): void {
|
|
22
|
+
const root = realpathSync(home);
|
|
23
|
+
const lexicalRoot = resolve(home);
|
|
24
|
+
const resolved = resolve(target);
|
|
25
|
+
const rel = relative(lexicalRoot, resolved);
|
|
26
|
+
if (rel === '..' || rel.startsWith(`..${sep}`) || resolve(lexicalRoot, rel) !== resolved) {
|
|
27
|
+
throw new SynomemError('UNSAFE_PATH', 'Path escapes the configured Synomem home.');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let current = root;
|
|
31
|
+
for (const part of rel.split(sep).filter(Boolean)) {
|
|
32
|
+
current = resolve(current, part);
|
|
33
|
+
if (existsSync(current) && lstatSync(current).isSymbolicLink()) {
|
|
34
|
+
throw new SynomemError(
|
|
35
|
+
'UNSAFE_PATH',
|
|
36
|
+
`Refusing to traverse symbolic link: ${relative(root, current)}`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function atomicWriteFile(path: string, content: string, mode = 0o600): void {
|
|
43
|
+
atomicWrite(path, content, mode, true);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Atomically replace rebuildable derived data without forcing it to stable storage. */
|
|
47
|
+
export function atomicWriteDerivedFile(path: string, content: string, mode = 0o600): void {
|
|
48
|
+
atomicWrite(path, content, mode, false);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function atomicWrite(path: string, content: string, mode: number, durable: boolean): void {
|
|
52
|
+
ensureDirectory(dirname(path));
|
|
53
|
+
const temporary = resolve(
|
|
54
|
+
dirname(path),
|
|
55
|
+
`.${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`,
|
|
56
|
+
);
|
|
57
|
+
let descriptor: number | undefined;
|
|
58
|
+
try {
|
|
59
|
+
descriptor = openSync(temporary, 'wx', mode);
|
|
60
|
+
writeFileSync(descriptor, content, 'utf8');
|
|
61
|
+
if (durable) fsyncSync(descriptor);
|
|
62
|
+
closeSync(descriptor);
|
|
63
|
+
descriptor = undefined;
|
|
64
|
+
renameSync(temporary, path);
|
|
65
|
+
if (durable)
|
|
66
|
+
try {
|
|
67
|
+
const directoryDescriptor = openSync(dirname(path), 'r');
|
|
68
|
+
try {
|
|
69
|
+
fsyncSync(directoryDescriptor);
|
|
70
|
+
} finally {
|
|
71
|
+
closeSync(directoryDescriptor);
|
|
72
|
+
}
|
|
73
|
+
} catch (error) {
|
|
74
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
75
|
+
if (!['EINVAL', 'EISDIR', 'EPERM'].includes(code ?? '')) throw error;
|
|
76
|
+
}
|
|
77
|
+
} finally {
|
|
78
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
79
|
+
if (existsSync(temporary)) unlinkSync(temporary);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function readJsonFile(path: string): unknown {
|
|
84
|
+
return JSON.parse(readFileSync(path, 'utf8')) as unknown;
|
|
85
|
+
}
|