@zleap-ai/dsh-sag 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/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +58 -0
- package/README.zh.md +58 -0
- package/THIRD_PARTY_NOTICES +671 -0
- package/cordis.patch.yml +5 -0
- package/docs/embedded.md +61 -0
- package/lib/brand.d.ts +13 -0
- package/lib/brand.js +15 -0
- package/lib/cli/runtime.d.ts +40 -0
- package/lib/cli/runtime.js +121 -0
- package/lib/cli.d.ts +21 -0
- package/lib/cli.js +265 -0
- package/lib/config.d.ts +60 -0
- package/lib/config.js +120 -0
- package/lib/connection/descriptor.d.ts +4 -0
- package/lib/connection/descriptor.js +66 -0
- package/lib/connection/discovery.d.ts +40 -0
- package/lib/connection/discovery.js +139 -0
- package/lib/connection/guidance.d.ts +7 -0
- package/lib/connection/guidance.js +7 -0
- package/lib/connection/manager.d.ts +67 -0
- package/lib/connection/manager.js +273 -0
- package/lib/connection/store.d.ts +42 -0
- package/lib/connection/store.js +164 -0
- package/lib/connection/types.d.ts +35 -0
- package/lib/connection/types.js +2 -0
- package/lib/dsh-sag-cli.js +32202 -0
- package/lib/index.d.ts +11 -0
- package/lib/index.js +68 -0
- package/lib/local/api-client.d.ts +157 -0
- package/lib/local/api-client.js +338 -0
- package/lib/local/gateway.d.ts +43 -0
- package/lib/local/gateway.js +34 -0
- package/lib/local/mcp-probe.d.ts +27 -0
- package/lib/local/mcp-probe.js +92 -0
- package/lib/presentation.d.ts +8 -0
- package/lib/presentation.js +7 -0
- package/lib/runtime/client.d.ts +22 -0
- package/lib/runtime/client.js +117 -0
- package/lib/runtime/protocol.d.ts +113 -0
- package/lib/runtime/protocol.js +118 -0
- package/lib/runtime/supervisor.d.ts +30 -0
- package/lib/runtime/supervisor.js +107 -0
- package/lib/tools/documents.d.ts +8 -0
- package/lib/tools/documents.js +134 -0
- package/lib/tools/ingest.d.ts +5 -0
- package/lib/tools/ingest.js +35 -0
- package/lib/tools/local.d.ts +40 -0
- package/lib/tools/local.js +111 -0
- package/lib/tools/output.d.ts +96 -0
- package/lib/tools/output.js +67 -0
- package/lib/tools/read.d.ts +6 -0
- package/lib/tools/read.js +84 -0
- package/lib/tools/search.d.ts +6 -0
- package/lib/tools/search.js +107 -0
- package/lib/tools/sources.d.ts +5 -0
- package/lib/tools/sources.js +61 -0
- package/lib/tools/status.d.ts +5 -0
- package/lib/tools/status.js +28 -0
- package/lib/tools/upload.d.ts +6 -0
- package/lib/tools/upload.js +68 -0
- package/package.json +96 -0
- package/runtime/pyproject.toml +29 -0
- package/runtime/src/dsh_sag_runtime/__init__.py +3 -0
- package/runtime/src/dsh_sag_runtime/__main__.py +80 -0
- package/runtime/src/dsh_sag_runtime/engines.py +139 -0
- package/runtime/src/dsh_sag_runtime/errors.py +47 -0
- package/runtime/src/dsh_sag_runtime/evidence.py +53 -0
- package/runtime/src/dsh_sag_runtime/protocol.py +161 -0
- package/runtime/src/dsh_sag_runtime/read.py +61 -0
- package/runtime/src/dsh_sag_runtime/search.py +76 -0
- package/runtime/src/dsh_sag_runtime/server.py +136 -0
- package/runtime/uv.lock +2310 -0
- package/scripts/setup-runtime.mjs +47 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { SagApiClient, SagApiError } from '../local/api-client.js';
|
|
2
|
+
import { createSagGateway } from '../local/gateway.js';
|
|
3
|
+
import { McpProbeIncompatibleError, probeMcp, } from '../local/mcp-probe.js';
|
|
4
|
+
function errorMessage(error, descriptor) {
|
|
5
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
6
|
+
return message.split(descriptor.accessToken).join('<redacted>');
|
|
7
|
+
}
|
|
8
|
+
/** Run health, readiness, capabilities, source count, and MCP compatibility checks without mutations. */
|
|
9
|
+
export async function inspectSagConnection(descriptor, signal, gateway = createSagGateway(new SagApiClient(descriptor)), mcpProbe = (candidate, candidateSignal, capabilities) => probeMcp(candidate, candidateSignal, undefined, capabilities)) {
|
|
10
|
+
const errors = [];
|
|
11
|
+
let health = false;
|
|
12
|
+
try {
|
|
13
|
+
await gateway.health(signal);
|
|
14
|
+
health = true;
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
errors.push(errorMessage(error, descriptor));
|
|
18
|
+
return { status: 'unreachable', health, ready: false, sourceCount: 0, errors };
|
|
19
|
+
}
|
|
20
|
+
const [readyResult, capabilitiesResult] = await Promise.allSettled([
|
|
21
|
+
gateway.ready(signal),
|
|
22
|
+
gateway.capabilities(signal),
|
|
23
|
+
]);
|
|
24
|
+
if (signal.aborted)
|
|
25
|
+
throw signal.reason;
|
|
26
|
+
const supportsSourceList = capabilitiesResult.status === 'fulfilled'
|
|
27
|
+
&& capabilitiesResult.value.capabilities.includes('sources.list');
|
|
28
|
+
const sourcesResult = supportsSourceList
|
|
29
|
+
? await gateway.listSources(signal).then(value => ({ status: 'fulfilled', value }), reason => ({ status: 'rejected', reason }))
|
|
30
|
+
: { status: 'fulfilled', value: [] };
|
|
31
|
+
if (signal.aborted)
|
|
32
|
+
throw signal.reason;
|
|
33
|
+
const mcpResult = capabilitiesResult.status === 'fulfilled'
|
|
34
|
+
? await mcpProbe(descriptor, signal, capabilitiesResult.value.capabilities).then(value => ({ status: 'fulfilled', value }), reason => ({ status: 'rejected', reason }))
|
|
35
|
+
: { status: 'rejected', reason: capabilitiesResult.reason };
|
|
36
|
+
if (signal.aborted)
|
|
37
|
+
throw signal.reason;
|
|
38
|
+
const ready = readyResult.status === 'fulfilled';
|
|
39
|
+
const capabilities = capabilitiesResult.status === 'fulfilled' ? capabilitiesResult.value : undefined;
|
|
40
|
+
const sourceCount = sourcesResult.status === 'fulfilled' ? sourcesResult.value.length : 0;
|
|
41
|
+
const mcp = mcpResult.status === 'fulfilled' ? mcpResult.value : undefined;
|
|
42
|
+
for (const result of [readyResult, capabilitiesResult, ...(supportsSourceList ? [sourcesResult] : []), ...(capabilitiesResult.status === 'fulfilled' ? [mcpResult] : [])]) {
|
|
43
|
+
if (result.status === 'rejected')
|
|
44
|
+
errors.push(errorMessage(result.reason, descriptor));
|
|
45
|
+
}
|
|
46
|
+
let status;
|
|
47
|
+
if (!ready || (supportsSourceList && sourcesResult.status === 'rejected')) {
|
|
48
|
+
status = 'unreachable';
|
|
49
|
+
}
|
|
50
|
+
else if (capabilitiesResult.status === 'rejected') {
|
|
51
|
+
status = capabilitiesResult.reason instanceof SagApiError ? 'unreachable' : 'incompatible';
|
|
52
|
+
}
|
|
53
|
+
else if (mcpResult.status === 'rejected') {
|
|
54
|
+
status = mcpResult.reason instanceof McpProbeIncompatibleError ? 'incompatible' : 'unreachable';
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
status = 'ready';
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
status,
|
|
61
|
+
health,
|
|
62
|
+
ready,
|
|
63
|
+
sourceCount,
|
|
64
|
+
...(capabilities === undefined ? {} : { capabilities }),
|
|
65
|
+
...(mcp === undefined ? {} : { mcp }),
|
|
66
|
+
...(errors.length === 0 ? {} : { errors }),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function waitForCaller(promise, signal) {
|
|
70
|
+
if (signal.aborted)
|
|
71
|
+
return Promise.reject(signal.reason);
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
const onAbort = () => reject(signal.reason);
|
|
74
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
75
|
+
promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort)).catch(() => undefined);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
/** Lazy, single-flight manager for saved and automatically discovered local SAG connections. */
|
|
79
|
+
export class SagConnectionManager {
|
|
80
|
+
deps;
|
|
81
|
+
flight;
|
|
82
|
+
connected;
|
|
83
|
+
gateway;
|
|
84
|
+
requestTimeoutMs;
|
|
85
|
+
readyCacheTtlMs;
|
|
86
|
+
connectedAt = 0;
|
|
87
|
+
generation = 0;
|
|
88
|
+
requestSequence = 0;
|
|
89
|
+
latestAdmittedRequest = 0;
|
|
90
|
+
/** @param deps - saved-connection reader, discovery, and optional testable network seams. */
|
|
91
|
+
constructor(deps) {
|
|
92
|
+
this.deps = deps;
|
|
93
|
+
this.gateway = deps.gateway ?? (descriptor => createSagGateway(new SagApiClient(descriptor)));
|
|
94
|
+
this.requestTimeoutMs = deps.requestTimeoutMs ?? 30_000;
|
|
95
|
+
this.readyCacheTtlMs = deps.readyCacheTtlMs ?? 5_000;
|
|
96
|
+
}
|
|
97
|
+
report(descriptor, gateway, inspection, discovery) {
|
|
98
|
+
return {
|
|
99
|
+
...inspection,
|
|
100
|
+
descriptor,
|
|
101
|
+
gateway,
|
|
102
|
+
...(discovery === undefined ? {} : { discovery }),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
async inspectCandidate(descriptor, signal, assertOwned) {
|
|
106
|
+
const gateway = this.gateway(descriptor);
|
|
107
|
+
try {
|
|
108
|
+
const inspection = this.deps.inspect === undefined
|
|
109
|
+
? await inspectSagConnection(descriptor, signal, gateway)
|
|
110
|
+
: await this.deps.inspect(descriptor, signal);
|
|
111
|
+
assertOwned();
|
|
112
|
+
return { inspection, gateway };
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
this.closeDetached(gateway);
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async connect(saved, signal, assertOwned) {
|
|
120
|
+
let savedInspection;
|
|
121
|
+
if (saved !== undefined) {
|
|
122
|
+
const candidate = await this.inspectCandidate(saved, signal, assertOwned);
|
|
123
|
+
savedInspection = candidate.inspection;
|
|
124
|
+
if (savedInspection.status === 'ready')
|
|
125
|
+
return this.report(saved, candidate.gateway, savedInspection);
|
|
126
|
+
this.closeDetached(candidate.gateway);
|
|
127
|
+
}
|
|
128
|
+
const discovery = await this.deps.discover(signal);
|
|
129
|
+
assertOwned();
|
|
130
|
+
if (discovery.descriptor === undefined) {
|
|
131
|
+
if (saved !== undefined && savedInspection !== undefined)
|
|
132
|
+
return { ...savedInspection, descriptor: saved, discovery };
|
|
133
|
+
return { status: 'not-found', health: false, ready: false, sourceCount: 0, discovery };
|
|
134
|
+
}
|
|
135
|
+
const candidate = await this.inspectCandidate(discovery.descriptor, signal, assertOwned);
|
|
136
|
+
const report = this.report(discovery.descriptor, candidate.gateway, candidate.inspection, discovery);
|
|
137
|
+
return report;
|
|
138
|
+
}
|
|
139
|
+
deadline(operation) {
|
|
140
|
+
const controller = new AbortController();
|
|
141
|
+
let timer;
|
|
142
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
143
|
+
timer = setTimeout(() => {
|
|
144
|
+
const error = new Error(`dsh-sag: SAG connection inspection timed out after ${this.requestTimeoutMs} ms`);
|
|
145
|
+
controller.abort(error);
|
|
146
|
+
reject(error);
|
|
147
|
+
}, this.requestTimeoutMs);
|
|
148
|
+
});
|
|
149
|
+
const running = operation(controller.signal);
|
|
150
|
+
running.catch(() => undefined);
|
|
151
|
+
return Promise.race([running, timeout]).finally(() => {
|
|
152
|
+
if (timer !== undefined)
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
static fingerprint(descriptor) {
|
|
157
|
+
if (descriptor === undefined)
|
|
158
|
+
return 'discover';
|
|
159
|
+
return JSON.stringify([
|
|
160
|
+
descriptor.schemaVersion, descriptor.name, descriptor.apiUrl, descriptor.mcpUrl,
|
|
161
|
+
descriptor.accessToken, descriptor.defaultSourceId ?? null,
|
|
162
|
+
]);
|
|
163
|
+
}
|
|
164
|
+
closeDetached(gateway) {
|
|
165
|
+
if (gateway?.close === undefined)
|
|
166
|
+
return;
|
|
167
|
+
try {
|
|
168
|
+
const closing = Promise.resolve(gateway.close());
|
|
169
|
+
const bounded = new Promise(resolve => {
|
|
170
|
+
const timer = setTimeout(resolve, Math.min(this.requestTimeoutMs, 1_000));
|
|
171
|
+
timer.unref?.();
|
|
172
|
+
});
|
|
173
|
+
void Promise.race([closing, bounded]).catch(() => undefined);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// Obsolete optional gateway cleanup cannot invalidate a new ready connection.
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
replaceConnected(report) {
|
|
180
|
+
const previous = this.connected;
|
|
181
|
+
this.connected = report.status === 'ready' ? report : undefined;
|
|
182
|
+
this.connectedAt = report.status === 'ready' ? Date.now() : 0;
|
|
183
|
+
if (previous?.gateway !== report.gateway)
|
|
184
|
+
this.closeDetached(previous?.gateway);
|
|
185
|
+
if (report.status !== 'ready')
|
|
186
|
+
this.closeDetached(report.gateway);
|
|
187
|
+
}
|
|
188
|
+
reusableConnected(saved) {
|
|
189
|
+
if (this.connected === undefined || Date.now() - this.connectedAt >= this.readyCacheTtlMs)
|
|
190
|
+
return undefined;
|
|
191
|
+
const matches = saved === undefined
|
|
192
|
+
? this.connected.discovery?.descriptor !== undefined
|
|
193
|
+
: SagConnectionManager.fingerprint(this.connected.descriptor) === SagConnectionManager.fingerprint(saved);
|
|
194
|
+
return matches ? this.connected : undefined;
|
|
195
|
+
}
|
|
196
|
+
/** Load the caller's latest saved descriptor, then share only a matching connection flight. */
|
|
197
|
+
async ensureConnected(signal) {
|
|
198
|
+
if (signal.aborted)
|
|
199
|
+
return Promise.reject(signal.reason);
|
|
200
|
+
const request = ++this.requestSequence;
|
|
201
|
+
const admission = this.deadline(async (callerDeadlineSignal) => {
|
|
202
|
+
const assertCallerActive = () => {
|
|
203
|
+
if (signal.aborted)
|
|
204
|
+
throw signal.reason;
|
|
205
|
+
if (callerDeadlineSignal.aborted)
|
|
206
|
+
throw callerDeadlineSignal.reason;
|
|
207
|
+
};
|
|
208
|
+
const saved = await this.deps.store.load();
|
|
209
|
+
assertCallerActive();
|
|
210
|
+
const key = SagConnectionManager.fingerprint(saved);
|
|
211
|
+
if (request < this.latestAdmittedRequest) {
|
|
212
|
+
const reusable = this.reusableConnected(saved);
|
|
213
|
+
if (reusable !== undefined)
|
|
214
|
+
return reusable;
|
|
215
|
+
if (this.flight?.key === key)
|
|
216
|
+
return waitForCaller(this.flight.promise, callerDeadlineSignal);
|
|
217
|
+
throw new Error('dsh-sag: connection request was superseded');
|
|
218
|
+
}
|
|
219
|
+
this.latestAdmittedRequest = request;
|
|
220
|
+
if (this.flight !== undefined && this.flight.key !== key) {
|
|
221
|
+
++this.generation;
|
|
222
|
+
this.flight = undefined;
|
|
223
|
+
}
|
|
224
|
+
const reusable = this.reusableConnected(saved);
|
|
225
|
+
if (reusable !== undefined)
|
|
226
|
+
return reusable;
|
|
227
|
+
if (this.flight?.key === key)
|
|
228
|
+
return waitForCaller(this.flight.promise, callerDeadlineSignal);
|
|
229
|
+
const generation = ++this.generation;
|
|
230
|
+
const promise = this.deadline(async (sharedSignal) => {
|
|
231
|
+
const assertOwned = () => {
|
|
232
|
+
if (sharedSignal.aborted)
|
|
233
|
+
throw sharedSignal.reason;
|
|
234
|
+
if (generation !== this.generation)
|
|
235
|
+
throw new Error('dsh-sag: connection flight was superseded');
|
|
236
|
+
};
|
|
237
|
+
let report;
|
|
238
|
+
try {
|
|
239
|
+
report = await this.connect(saved, sharedSignal, assertOwned);
|
|
240
|
+
assertOwned();
|
|
241
|
+
this.replaceConnected(report);
|
|
242
|
+
return report;
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
if (report?.gateway !== this.connected?.gateway)
|
|
246
|
+
this.closeDetached(report?.gateway);
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
const flight = { key, promise };
|
|
251
|
+
this.flight = flight;
|
|
252
|
+
promise.finally(() => {
|
|
253
|
+
if (this.flight === flight)
|
|
254
|
+
this.flight = undefined;
|
|
255
|
+
}).catch(() => undefined);
|
|
256
|
+
return waitForCaller(promise, callerDeadlineSignal);
|
|
257
|
+
});
|
|
258
|
+
return waitForCaller(admission, signal);
|
|
259
|
+
}
|
|
260
|
+
/** Re-run only non-mutating connection checks for setup and diagnostics. */
|
|
261
|
+
async doctor(signal) {
|
|
262
|
+
if (signal.aborted)
|
|
263
|
+
return Promise.reject(signal.reason);
|
|
264
|
+
return waitForCaller(this.deadline(async (sharedSignal) => {
|
|
265
|
+
const assertActive = () => { if (sharedSignal.aborted)
|
|
266
|
+
throw sharedSignal.reason; };
|
|
267
|
+
const saved = await this.deps.store.load();
|
|
268
|
+
assertActive();
|
|
269
|
+
return this.connect(saved, sharedSignal, assertActive);
|
|
270
|
+
}), signal);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
//# sourceMappingURL=manager.js.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import { type CredentialProvider } from '@deepseek-ai/dsh-credentials';
|
|
3
|
+
import { type SettingsScope } from '@deepseek-ai/dsh-settings';
|
|
4
|
+
import type { SagConnectionDescriptor, SagLocalSettings } from './types.js';
|
|
5
|
+
/** The fixed settings namespace for the locally managed SAG connection. */
|
|
6
|
+
export declare const SAG_SETTINGS_NAMESPACE: import("@deepseek-ai/dsh-settings").SettingsNamespace;
|
|
7
|
+
/** The only credential record owned by the local SAG connection. */
|
|
8
|
+
export declare const SAG_CREDENTIAL_KEY: import("@deepseek-ai/dsh-credentials").CredentialKey;
|
|
9
|
+
/** Machine-readable error for saved connection data that cannot be used safely. */
|
|
10
|
+
export declare class SagConnectionConfigurationError extends Error {
|
|
11
|
+
/** Stable error code for interactive configuration surfaces. */
|
|
12
|
+
readonly code = "SAG_CONNECTION_CONFIG_INVALID";
|
|
13
|
+
/** Stored location that failed validation. */
|
|
14
|
+
readonly field: string;
|
|
15
|
+
/**
|
|
16
|
+
* @param field - stored location that failed validation.
|
|
17
|
+
* @param message - human-readable validation failure.
|
|
18
|
+
*/
|
|
19
|
+
constructor(field: string, message: string);
|
|
20
|
+
}
|
|
21
|
+
/** The services the store uses; kept small so callers can provide real providers or focused in-memory fakes. */
|
|
22
|
+
export interface SagConnectionStoreDeps {
|
|
23
|
+
readonly credentials: Pick<CredentialProvider, 'readRecord' | 'modifyRecord' | 'deleteRecord'>;
|
|
24
|
+
readonly settings: SettingsScope<SagLocalSettings>;
|
|
25
|
+
}
|
|
26
|
+
/** Register the non-secret local connection settings in the fixed plugin namespace. */
|
|
27
|
+
export declare function registerSagSettings(ctx: Context): SettingsScope<SagLocalSettings>;
|
|
28
|
+
/** Store one local connection with endpoints in Settings and its bearer token in Credentials. */
|
|
29
|
+
export declare class SagConnectionStore {
|
|
30
|
+
private readonly deps;
|
|
31
|
+
/**
|
|
32
|
+
* @param deps - live settings and credential providers.
|
|
33
|
+
*/
|
|
34
|
+
constructor(deps: SagConnectionStoreDeps);
|
|
35
|
+
/** Read the saved connection, resolving the credential record again for every call. */
|
|
36
|
+
load(): Promise<SagConnectionDescriptor | undefined>;
|
|
37
|
+
/** Persist a complete connection, deliberately keeping its token outside Settings. */
|
|
38
|
+
save(descriptor: SagConnectionDescriptor): Promise<void>;
|
|
39
|
+
/** Remove the saved local connection and its owned credential record. */
|
|
40
|
+
clear(): Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { credentialKey } from '@deepseek-ai/dsh-credentials';
|
|
2
|
+
import { settingsNamespace } from '@deepseek-ai/dsh-settings';
|
|
3
|
+
import z from '@deepseek-ai/schemastery';
|
|
4
|
+
import { parseConnectionDescriptor } from './descriptor.js';
|
|
5
|
+
/** The fixed settings namespace for the locally managed SAG connection. */
|
|
6
|
+
export const SAG_SETTINGS_NAMESPACE = settingsNamespace('dsh-sag');
|
|
7
|
+
/** The only credential record owned by the local SAG connection. */
|
|
8
|
+
export const SAG_CREDENTIAL_KEY = credentialKey('dsh-sag', 'local');
|
|
9
|
+
/** Machine-readable error for saved connection data that cannot be used safely. */
|
|
10
|
+
export class SagConnectionConfigurationError extends Error {
|
|
11
|
+
/** Stable error code for interactive configuration surfaces. */
|
|
12
|
+
code = 'SAG_CONNECTION_CONFIG_INVALID';
|
|
13
|
+
/** Stored location that failed validation. */
|
|
14
|
+
field;
|
|
15
|
+
/**
|
|
16
|
+
* @param field - stored location that failed validation.
|
|
17
|
+
* @param message - human-readable validation failure.
|
|
18
|
+
*/
|
|
19
|
+
constructor(field, message) {
|
|
20
|
+
super(`dsh-sag: saved connection ${field} ${message}`);
|
|
21
|
+
this.name = 'SagConnectionConfigurationError';
|
|
22
|
+
this.field = field;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const LocalSettingsSchema = z.object({
|
|
26
|
+
schemaVersion: z.const(1).default(1),
|
|
27
|
+
mode: z.const('local').default('local'),
|
|
28
|
+
name: z.string().default(''),
|
|
29
|
+
apiUrl: z.string().default(''),
|
|
30
|
+
mcpUrl: z.string().default(''),
|
|
31
|
+
defaultSourceId: z.union([z.string(), z.const(null)]).default(null),
|
|
32
|
+
credentialId: z.const('local').default('local'),
|
|
33
|
+
});
|
|
34
|
+
/** Register the non-secret local connection settings in the fixed plugin namespace. */
|
|
35
|
+
export function registerSagSettings(ctx) {
|
|
36
|
+
return ctx.settings.register(SAG_SETTINGS_NAMESPACE, LocalSettingsSchema);
|
|
37
|
+
}
|
|
38
|
+
function plainObject(value) {
|
|
39
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
40
|
+
return false;
|
|
41
|
+
const prototype = Object.getPrototypeOf(value);
|
|
42
|
+
return prototype === Object.prototype || prototype === null;
|
|
43
|
+
}
|
|
44
|
+
function localSettings(value) {
|
|
45
|
+
if (!plainObject(value)) {
|
|
46
|
+
throw new SagConnectionConfigurationError('settings', 'must be an object');
|
|
47
|
+
}
|
|
48
|
+
const expected = new Set(['schemaVersion', 'mode', 'name', 'apiUrl', 'mcpUrl', 'defaultSourceId', 'credentialId']);
|
|
49
|
+
for (const key of Object.keys(value)) {
|
|
50
|
+
if (!expected.has(key)) {
|
|
51
|
+
throw new SagConnectionConfigurationError(`settings.${key}`, 'is not supported');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (value.schemaVersion !== 1) {
|
|
55
|
+
throw new SagConnectionConfigurationError('settings.schemaVersion', 'must be 1');
|
|
56
|
+
}
|
|
57
|
+
if (value.mode !== 'local') {
|
|
58
|
+
throw new SagConnectionConfigurationError('settings.mode', 'must be local');
|
|
59
|
+
}
|
|
60
|
+
if (value.credentialId !== 'local') {
|
|
61
|
+
throw new SagConnectionConfigurationError('settings.credentialId', 'must be local');
|
|
62
|
+
}
|
|
63
|
+
for (const field of ['name', 'apiUrl', 'mcpUrl']) {
|
|
64
|
+
if (typeof value[field] !== 'string') {
|
|
65
|
+
throw new SagConnectionConfigurationError(`settings.${field}`, 'must be a string');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const defaultSourceId = value.defaultSourceId;
|
|
69
|
+
if (defaultSourceId !== null && (typeof defaultSourceId !== 'string' || !defaultSourceId.trim())) {
|
|
70
|
+
throw new SagConnectionConfigurationError('settings.defaultSourceId', 'must be a non-empty string or null');
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
schemaVersion: 1,
|
|
74
|
+
mode: 'local',
|
|
75
|
+
name: value.name,
|
|
76
|
+
apiUrl: value.apiUrl,
|
|
77
|
+
mcpUrl: value.mcpUrl,
|
|
78
|
+
defaultSourceId,
|
|
79
|
+
credentialId: 'local',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function credentialPayload(record) {
|
|
83
|
+
if (record.kind !== 'grant') {
|
|
84
|
+
throw new SagConnectionConfigurationError('credential.kind', 'must be grant');
|
|
85
|
+
}
|
|
86
|
+
if (!plainObject(record.payload)) {
|
|
87
|
+
throw new SagConnectionConfigurationError('credential.payload', 'must be an object');
|
|
88
|
+
}
|
|
89
|
+
const expected = new Set(['schemaVersion', 'accessToken']);
|
|
90
|
+
for (const key of Object.keys(record.payload)) {
|
|
91
|
+
if (!expected.has(key)) {
|
|
92
|
+
throw new SagConnectionConfigurationError(`credential.payload.${key}`, 'is not supported');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (record.payload.schemaVersion !== 1) {
|
|
96
|
+
throw new SagConnectionConfigurationError('credential.payload.schemaVersion', 'must be 1');
|
|
97
|
+
}
|
|
98
|
+
if (typeof record.payload.accessToken !== 'string' || !record.payload.accessToken.trim()) {
|
|
99
|
+
throw new SagConnectionConfigurationError('credential.payload.accessToken', 'must be a non-empty string');
|
|
100
|
+
}
|
|
101
|
+
return { schemaVersion: 1, accessToken: record.payload.accessToken };
|
|
102
|
+
}
|
|
103
|
+
function descriptorErrorField(error) {
|
|
104
|
+
const message = error instanceof Error ? error.message : '';
|
|
105
|
+
for (const field of ['name', 'apiUrl', 'mcpUrl', 'defaultSourceId']) {
|
|
106
|
+
if (message.startsWith(`dsh-sag: connection descriptor ${field} `)) {
|
|
107
|
+
return `settings.${field}`;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return 'settings';
|
|
111
|
+
}
|
|
112
|
+
/** Store one local connection with endpoints in Settings and its bearer token in Credentials. */
|
|
113
|
+
export class SagConnectionStore {
|
|
114
|
+
deps;
|
|
115
|
+
/**
|
|
116
|
+
* @param deps - live settings and credential providers.
|
|
117
|
+
*/
|
|
118
|
+
constructor(deps) {
|
|
119
|
+
this.deps = deps;
|
|
120
|
+
}
|
|
121
|
+
/** Read the saved connection, resolving the credential record again for every call. */
|
|
122
|
+
async load() {
|
|
123
|
+
const record = await this.deps.credentials.readRecord(SAG_CREDENTIAL_KEY);
|
|
124
|
+
if (record === undefined)
|
|
125
|
+
return undefined;
|
|
126
|
+
const settings = localSettings(this.deps.settings.get());
|
|
127
|
+
const credential = credentialPayload(record);
|
|
128
|
+
try {
|
|
129
|
+
return parseConnectionDescriptor({
|
|
130
|
+
schemaVersion: settings.schemaVersion,
|
|
131
|
+
name: settings.name,
|
|
132
|
+
apiUrl: settings.apiUrl,
|
|
133
|
+
mcpUrl: settings.mcpUrl,
|
|
134
|
+
accessToken: credential.accessToken,
|
|
135
|
+
defaultSourceId: settings.defaultSourceId,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
throw new SagConnectionConfigurationError(descriptorErrorField(error), error instanceof Error ? error.message : 'is invalid');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Persist a complete connection, deliberately keeping its token outside Settings. */
|
|
143
|
+
async save(descriptor) {
|
|
144
|
+
await this.deps.credentials.modifyRecord(SAG_CREDENTIAL_KEY, async () => ({
|
|
145
|
+
kind: 'grant',
|
|
146
|
+
payload: { schemaVersion: 1, accessToken: descriptor.accessToken },
|
|
147
|
+
}));
|
|
148
|
+
await this.deps.settings.update({
|
|
149
|
+
schemaVersion: 1,
|
|
150
|
+
mode: 'local',
|
|
151
|
+
name: descriptor.name,
|
|
152
|
+
apiUrl: descriptor.apiUrl,
|
|
153
|
+
mcpUrl: descriptor.mcpUrl,
|
|
154
|
+
defaultSourceId: descriptor.defaultSourceId ?? null,
|
|
155
|
+
credentialId: 'local',
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
/** Remove the saved local connection and its owned credential record. */
|
|
159
|
+
async clear() {
|
|
160
|
+
await this.deps.credentials.deleteRecord(SAG_CREDENTIAL_KEY);
|
|
161
|
+
await this.deps.settings.replace({});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** A versioned local SAG connection, as exported by the SAG application. */
|
|
2
|
+
export interface SagConnectionDescriptor {
|
|
3
|
+
readonly schemaVersion: 1;
|
|
4
|
+
readonly name: string;
|
|
5
|
+
readonly apiUrl: string;
|
|
6
|
+
readonly mcpUrl: string;
|
|
7
|
+
readonly accessToken: string;
|
|
8
|
+
readonly defaultSourceId?: string | null;
|
|
9
|
+
}
|
|
10
|
+
/** The non-sensitive portion of a saved local SAG connection. */
|
|
11
|
+
export interface SagLocalSettings {
|
|
12
|
+
readonly schemaVersion: 1;
|
|
13
|
+
readonly mode: 'local';
|
|
14
|
+
readonly name: string;
|
|
15
|
+
readonly apiUrl: string;
|
|
16
|
+
readonly mcpUrl: string;
|
|
17
|
+
readonly defaultSourceId?: string | null;
|
|
18
|
+
readonly credentialId: 'local';
|
|
19
|
+
}
|
|
20
|
+
/** The credential payload kept separately from local settings. */
|
|
21
|
+
export interface SagCredentialPayload {
|
|
22
|
+
readonly schemaVersion: 1;
|
|
23
|
+
readonly accessToken: string;
|
|
24
|
+
}
|
|
25
|
+
/** The versioned dsh integration capabilities advertised by SAG. */
|
|
26
|
+
export interface SagCapabilityDescriptor {
|
|
27
|
+
readonly schemaVersion: 1;
|
|
28
|
+
readonly capabilities: readonly string[];
|
|
29
|
+
readonly upload?: {
|
|
30
|
+
readonly maxMb: number;
|
|
31
|
+
readonly extensions: readonly string[];
|
|
32
|
+
};
|
|
33
|
+
readonly defaultSourceId?: string | null;
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=types.d.ts.map
|