@gmickel/gno 1.12.4 → 1.14.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/README.md +126 -59
- package/assets/skill/SKILL.md +8 -1
- package/assets/skill/cli-reference.md +18 -7
- package/assets/skill/mcp-reference.md +22 -3
- package/package.json +3 -1
- package/src/app/constants.ts +43 -10
- package/src/app/index-name.ts +127 -0
- package/src/cli/commands/doctor-activation.ts +151 -0
- package/src/cli/commands/doctor.ts +41 -16
- package/src/cli/commands/get.ts +18 -0
- package/src/cli/commands/mcp/atomic-config-write.ts +118 -0
- package/src/cli/commands/mcp/config-discovery.ts +42 -0
- package/src/cli/commands/mcp/config-editors.ts +432 -0
- package/src/cli/commands/mcp/config.ts +63 -160
- package/src/cli/commands/mcp/install.ts +75 -37
- package/src/cli/commands/mcp/paths.ts +141 -136
- package/src/cli/commands/mcp/server-entry.ts +66 -0
- package/src/cli/commands/mcp/status.ts +189 -57
- package/src/cli/commands/mcp/target-display.ts +30 -0
- package/src/cli/commands/mcp/uninstall.ts +29 -31
- package/src/cli/commands/mcp/yaml-config-editor.ts +257 -0
- package/src/cli/commands/mcp/yaml-layout-scanner.ts +447 -0
- package/src/cli/commands/multi-get.ts +31 -6
- package/src/cli/commands/status.ts +107 -11
- package/src/cli/program.ts +66 -20
- package/src/core/activation-connector-health.ts +19 -0
- package/src/core/activation-probe-plan.ts +321 -0
- package/src/core/activation-probe.ts +138 -0
- package/src/core/activation-receipt-store.ts +39 -0
- package/src/core/activation-status.ts +513 -0
- package/src/core/activation-verifier.ts +416 -0
- package/src/core/connector-environment.ts +68 -0
- package/src/core/connector-policy.ts +233 -0
- package/src/core/connector-verification-target.ts +150 -0
- package/src/core/connector-verifier.ts +497 -0
- package/src/core/indexed-reference.ts +33 -8
- package/src/core/runtime-entrypoint.ts +24 -0
- package/src/mcp/activation-verification-mode.ts +4 -0
- package/src/mcp/server.ts +9 -2
- package/src/sdk/client.ts +7 -0
- package/src/sdk/types.ts +1 -0
- package/src/serve/activation-health.ts +91 -0
- package/src/serve/background-runtime.ts +11 -1
- package/src/serve/connectors.ts +164 -19
- package/src/serve/public/components/BootstrapStatus.tsx +94 -1
- package/src/serve/public/components/FirstRunWizard.tsx +13 -51
- package/src/serve/public/components/HealthCenter.tsx +8 -2
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/pages/Connectors.tsx +216 -55
- package/src/serve/public/pages/Dashboard.tsx +1 -0
- package/src/serve/routes/api.ts +152 -8
- package/src/serve/server.ts +44 -9
- package/src/serve/status-model.ts +4 -0
- package/src/serve/status.ts +79 -35
- package/src/store/activation-receipts.ts +390 -0
- package/src/store/index.ts +8 -0
- package/src/store/migrations/012-activation-receipts.ts +38 -0
- package/src/store/migrations/013-fts-sync-marker.ts +39 -0
- package/src/store/migrations/index.ts +4 -0
- package/src/store/sqlite/adapter.ts +313 -53
- package/src/store/types.ts +118 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/** Connector target normalization and privacy-bounded receipt identity. */
|
|
2
|
+
|
|
3
|
+
// node:path has no Bun equivalent for portable resolved path identity.
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
import type { ConnectorWorkspaceEnvironment } from "./connector-environment";
|
|
7
|
+
|
|
8
|
+
import { normalizeConnectorWorkspaceEnvironment } from "./connector-environment";
|
|
9
|
+
|
|
10
|
+
const CONNECTOR_VERIFIER_IMPLEMENTATION_ID = "mcp-stdio-readonly-v2";
|
|
11
|
+
|
|
12
|
+
interface ConnectorTargetBase {
|
|
13
|
+
id: string;
|
|
14
|
+
target: string;
|
|
15
|
+
scope: "user" | "project";
|
|
16
|
+
configPath: string;
|
|
17
|
+
configError?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface McpConnectorVerificationTarget extends ConnectorTargetBase {
|
|
21
|
+
kind: "mcp";
|
|
22
|
+
configured: boolean;
|
|
23
|
+
serverEntry?: {
|
|
24
|
+
command: string;
|
|
25
|
+
args: string[];
|
|
26
|
+
env?: ConnectorWorkspaceEnvironment;
|
|
27
|
+
};
|
|
28
|
+
/** Privacy-bounded identity of the complete client entry. */
|
|
29
|
+
configIdentity?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SkillConnectorVerificationTarget extends ConnectorTargetBase {
|
|
33
|
+
kind: "skill";
|
|
34
|
+
installed: boolean;
|
|
35
|
+
/** Reserved for a future client-owned, read-only runtime verification hook. */
|
|
36
|
+
runtimeHook?: never;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type ConnectorVerificationTarget =
|
|
40
|
+
| McpConnectorVerificationTarget
|
|
41
|
+
| SkillConnectorVerificationTarget;
|
|
42
|
+
|
|
43
|
+
export interface ConnectorTargetIdentity {
|
|
44
|
+
connectorTarget: string;
|
|
45
|
+
normalized: Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sha256(value: string): string {
|
|
49
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
50
|
+
hasher.update(value);
|
|
51
|
+
return hasher.digest("hex");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function targetIdentity(
|
|
55
|
+
target: ConnectorVerificationTarget
|
|
56
|
+
): ConnectorTargetIdentity {
|
|
57
|
+
const configPathIdentity = sha256(resolve(target.configPath));
|
|
58
|
+
const normalized = {
|
|
59
|
+
kind: target.kind,
|
|
60
|
+
id: target.id,
|
|
61
|
+
target: target.target,
|
|
62
|
+
scope: target.scope,
|
|
63
|
+
configPathIdentity,
|
|
64
|
+
configError: target.configError === true,
|
|
65
|
+
...(target.kind === "mcp"
|
|
66
|
+
? {
|
|
67
|
+
configured: target.configured,
|
|
68
|
+
command: target.serverEntry?.command ?? null,
|
|
69
|
+
args: target.serverEntry?.args ?? [],
|
|
70
|
+
env: target.serverEntry?.env ?? {},
|
|
71
|
+
configIdentity: target.configIdentity ?? null,
|
|
72
|
+
}
|
|
73
|
+
: { installed: target.installed }),
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
connectorTarget: `${target.kind}:${target.target}:${target.scope}:${configPathIdentity}`,
|
|
77
|
+
normalized,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function normalizeConnectorTarget(
|
|
82
|
+
target: ConnectorVerificationTarget
|
|
83
|
+
): ConnectorVerificationTarget {
|
|
84
|
+
if (target.kind !== "mcp") {
|
|
85
|
+
return target;
|
|
86
|
+
}
|
|
87
|
+
if (!target.configured) {
|
|
88
|
+
return { ...target, serverEntry: undefined };
|
|
89
|
+
}
|
|
90
|
+
const entry: unknown = target.serverEntry;
|
|
91
|
+
if (!entry || typeof entry !== "object") {
|
|
92
|
+
return { ...target, configured: false, configError: true };
|
|
93
|
+
}
|
|
94
|
+
const record = entry as { command?: unknown; args?: unknown; env?: unknown };
|
|
95
|
+
const entryKeys = Object.keys(record);
|
|
96
|
+
const env = normalizeConnectorWorkspaceEnvironment(record.env);
|
|
97
|
+
if (
|
|
98
|
+
typeof record.command !== "string" ||
|
|
99
|
+
record.command.length === 0 ||
|
|
100
|
+
!Array.isArray(record.args) ||
|
|
101
|
+
!record.args.every((argument) => typeof argument === "string") ||
|
|
102
|
+
entryKeys.some(
|
|
103
|
+
(key) => key !== "command" && key !== "args" && key !== "env"
|
|
104
|
+
) ||
|
|
105
|
+
env === null
|
|
106
|
+
) {
|
|
107
|
+
return {
|
|
108
|
+
...target,
|
|
109
|
+
configured: false,
|
|
110
|
+
serverEntry: undefined,
|
|
111
|
+
configIdentity:
|
|
112
|
+
target.configIdentity ?? sha256(JSON.stringify(entry) ?? "undefined"),
|
|
113
|
+
configError: true,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
...target,
|
|
118
|
+
serverEntry: {
|
|
119
|
+
command: record.command,
|
|
120
|
+
args: record.args,
|
|
121
|
+
...(Object.keys(env).length > 0 ? { env } : {}),
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function connectorFingerprint(
|
|
127
|
+
lexicalFingerprint: string,
|
|
128
|
+
normalizedTarget: Record<string, unknown>
|
|
129
|
+
): string {
|
|
130
|
+
return sha256(
|
|
131
|
+
JSON.stringify({
|
|
132
|
+
lexicalFingerprint,
|
|
133
|
+
connectorVerifier: CONNECTOR_VERIFIER_IMPLEMENTATION_ID,
|
|
134
|
+
target: normalizedTarget,
|
|
135
|
+
})
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Pure lookup key for reading one target's fingerprint-current receipt. */
|
|
140
|
+
export function getConnectorActivationReceiptLookup(
|
|
141
|
+
lexicalFingerprint: string,
|
|
142
|
+
target: ConnectorVerificationTarget
|
|
143
|
+
): { connectorTarget: string; fingerprint: string } {
|
|
144
|
+
const normalizedTarget = normalizeConnectorTarget(target);
|
|
145
|
+
const identity = targetIdentity(normalizedTarget);
|
|
146
|
+
return {
|
|
147
|
+
connectorTarget: identity.connectorTarget,
|
|
148
|
+
fingerprint: connectorFingerprint(lexicalFingerprint, identity.normalized),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
/** Read-only, privacy-bounded connector activation verification. */
|
|
2
|
+
|
|
3
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
4
|
+
import {
|
|
5
|
+
getDefaultEnvironment,
|
|
6
|
+
StdioClientTransport,
|
|
7
|
+
} from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
ActivationStageReceipt,
|
|
11
|
+
ActivationVerificationReceipt,
|
|
12
|
+
StorePort,
|
|
13
|
+
StoreResult,
|
|
14
|
+
} from "../store/types";
|
|
15
|
+
import type { ConnectorWorkspaceEnvironment } from "./connector-environment";
|
|
16
|
+
import type { ConnectorVerificationTarget } from "./connector-verification-target";
|
|
17
|
+
|
|
18
|
+
import { DEFAULT_INDEX_NAME, parseUri } from "../app/constants";
|
|
19
|
+
import { MCP_ACTIVATION_VERIFICATION_ENV } from "../mcp/activation-verification-mode";
|
|
20
|
+
import { err, ok } from "../store/types";
|
|
21
|
+
import {
|
|
22
|
+
createEphemeralActivationProbePlan,
|
|
23
|
+
findEphemeralActivationProbeMatch,
|
|
24
|
+
revalidateEphemeralActivationProbePlan,
|
|
25
|
+
} from "./activation-probe-plan";
|
|
26
|
+
import { persistActivationReceiptForKnownCollection } from "./activation-receipt-store";
|
|
27
|
+
import { verifyLexicalActivation } from "./activation-verifier";
|
|
28
|
+
import {
|
|
29
|
+
type ConnectorCommandPolicyOptions,
|
|
30
|
+
type ConnectorVerificationCode,
|
|
31
|
+
isSafeLocalGnoMcpCommand,
|
|
32
|
+
} from "./connector-policy";
|
|
33
|
+
import {
|
|
34
|
+
connectorFingerprint,
|
|
35
|
+
getConnectorActivationReceiptLookup,
|
|
36
|
+
normalizeConnectorTarget,
|
|
37
|
+
targetIdentity,
|
|
38
|
+
} from "./connector-verification-target";
|
|
39
|
+
import { indexesMatch } from "./indexed-reference";
|
|
40
|
+
|
|
41
|
+
export {
|
|
42
|
+
getConnectorVerificationRemediation,
|
|
43
|
+
isSafeLocalGnoMcpCommand,
|
|
44
|
+
} from "./connector-policy";
|
|
45
|
+
export type { ConnectorVerificationCode } from "./connector-policy";
|
|
46
|
+
export { getConnectorActivationReceiptLookup } from "./connector-verification-target";
|
|
47
|
+
export type {
|
|
48
|
+
ConnectorVerificationTarget,
|
|
49
|
+
McpConnectorVerificationTarget,
|
|
50
|
+
SkillConnectorVerificationTarget,
|
|
51
|
+
} from "./connector-verification-target";
|
|
52
|
+
|
|
53
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
54
|
+
const CONCURRENT_INDEX_CHANGE_MESSAGE =
|
|
55
|
+
"Activation index changed during connector verification; retry";
|
|
56
|
+
const REQUIRED_TOOLS = new Set(["gno_status", "gno_search"]);
|
|
57
|
+
|
|
58
|
+
export interface ConnectorVerifierOptions {
|
|
59
|
+
force?: boolean;
|
|
60
|
+
timeoutMs?: number;
|
|
61
|
+
now?: () => Date;
|
|
62
|
+
monotonicNow?: () => number;
|
|
63
|
+
/** Trusted installer/runtime entries added to the default provenance set. */
|
|
64
|
+
commandPolicy?: ConnectorCommandPolicyOptions;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface McpProofInput {
|
|
68
|
+
command: string;
|
|
69
|
+
args: string[];
|
|
70
|
+
env?: ConnectorWorkspaceEnvironment;
|
|
71
|
+
collection: string;
|
|
72
|
+
/** Sensitive corpus-derived term. Never serialize or log. */
|
|
73
|
+
term: string;
|
|
74
|
+
expectedUri: string;
|
|
75
|
+
expectedSourceHash: string;
|
|
76
|
+
expectedIndexName: string;
|
|
77
|
+
timeoutMs: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type McpProofResult =
|
|
81
|
+
| { ok: true }
|
|
82
|
+
| { ok: false; code: ConnectorVerificationCode };
|
|
83
|
+
|
|
84
|
+
function elapsedMs(startedAt: number, monotonicNow: () => number): number {
|
|
85
|
+
return Math.max(0, Math.round(monotonicNow() - startedAt));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function connectorStage(
|
|
89
|
+
status: "passed" | "failed" | "skipped",
|
|
90
|
+
startedAt: string | null,
|
|
91
|
+
completedAt: string,
|
|
92
|
+
latencyMs: number | null,
|
|
93
|
+
code?: ConnectorVerificationCode
|
|
94
|
+
): ActivationStageReceipt {
|
|
95
|
+
return {
|
|
96
|
+
status,
|
|
97
|
+
startedAt,
|
|
98
|
+
completedAt,
|
|
99
|
+
latencyMs,
|
|
100
|
+
...(code ? { code } : {}),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function buildConnectorReceipt(input: {
|
|
105
|
+
base: ActivationVerificationReceipt;
|
|
106
|
+
fingerprint: string;
|
|
107
|
+
connectorTarget: string;
|
|
108
|
+
generatedAt: string;
|
|
109
|
+
connector: ActivationStageReceipt;
|
|
110
|
+
}): ActivationVerificationReceipt {
|
|
111
|
+
return {
|
|
112
|
+
...input.base,
|
|
113
|
+
fingerprint: input.fingerprint,
|
|
114
|
+
generatedAt: input.generatedAt,
|
|
115
|
+
stages: { ...input.base.stages, connector: input.connector },
|
|
116
|
+
evidence: {
|
|
117
|
+
...input.base.evidence,
|
|
118
|
+
connectorTarget: input.connectorTarget,
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function persistConnectorReceipt(
|
|
124
|
+
store: StorePort,
|
|
125
|
+
receipt: ActivationVerificationReceipt
|
|
126
|
+
): Promise<StoreResult<ActivationVerificationReceipt>> {
|
|
127
|
+
return persistActivationReceiptForKnownCollection(store, receipt);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function discardObsoleteConnectorReceipt(
|
|
131
|
+
store: StorePort,
|
|
132
|
+
collection: string,
|
|
133
|
+
currentLexicalFingerprint: string,
|
|
134
|
+
identity: {
|
|
135
|
+
connectorTarget: string;
|
|
136
|
+
normalized: Record<string, unknown>;
|
|
137
|
+
}
|
|
138
|
+
): Promise<StoreResult<void>> {
|
|
139
|
+
const currentConnectorFingerprint = connectorFingerprint(
|
|
140
|
+
currentLexicalFingerprint,
|
|
141
|
+
identity.normalized
|
|
142
|
+
);
|
|
143
|
+
const current = await store.getActivationReceipt(
|
|
144
|
+
collection,
|
|
145
|
+
currentConnectorFingerprint,
|
|
146
|
+
identity.connectorTarget
|
|
147
|
+
);
|
|
148
|
+
return current.ok
|
|
149
|
+
? ok(undefined)
|
|
150
|
+
: err(current.error.code, current.error.message, current.error.cause);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isTimeoutError(error: unknown): boolean {
|
|
154
|
+
if (!(error instanceof Error)) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
return (
|
|
158
|
+
error.name === "AbortError" ||
|
|
159
|
+
error.name === "TimeoutError" ||
|
|
160
|
+
error.message.toLowerCase().includes("timeout") ||
|
|
161
|
+
error.message.toLowerCase().includes("timed out")
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function hasExpectedStatusIndex(
|
|
166
|
+
response: unknown,
|
|
167
|
+
expectedIndexName: string
|
|
168
|
+
): boolean {
|
|
169
|
+
if (!response || typeof response !== "object") {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
const structured = (response as { structuredContent?: unknown })
|
|
173
|
+
.structuredContent;
|
|
174
|
+
return (
|
|
175
|
+
!!structured &&
|
|
176
|
+
typeof structured === "object" &&
|
|
177
|
+
typeof (structured as { indexName?: unknown }).indexName === "string" &&
|
|
178
|
+
indexesMatch(
|
|
179
|
+
(structured as { indexName: string }).indexName,
|
|
180
|
+
expectedIndexName
|
|
181
|
+
)
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function hasExpectedResult(
|
|
186
|
+
response: unknown,
|
|
187
|
+
expectedUri: string,
|
|
188
|
+
expectedSourceHash: string,
|
|
189
|
+
expectedIndexName: string
|
|
190
|
+
): boolean {
|
|
191
|
+
if (!response || typeof response !== "object") {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
const structured = (response as { structuredContent?: unknown })
|
|
195
|
+
.structuredContent;
|
|
196
|
+
if (!structured || typeof structured !== "object") {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
const results = (structured as { results?: unknown }).results;
|
|
200
|
+
if (!Array.isArray(results)) {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
const expected = parseUri(expectedUri);
|
|
204
|
+
if (!expected) {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
return results.some((result) => {
|
|
208
|
+
if (!result || typeof result !== "object") {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
const record = result as {
|
|
212
|
+
uri?: unknown;
|
|
213
|
+
source?: { sourceHash?: unknown };
|
|
214
|
+
};
|
|
215
|
+
const uri = typeof record.uri === "string" ? parseUri(record.uri) : null;
|
|
216
|
+
const resultIndexName = uri?.indexName ?? DEFAULT_INDEX_NAME;
|
|
217
|
+
return (
|
|
218
|
+
uri?.collection === expected.collection &&
|
|
219
|
+
uri.path === expected.path &&
|
|
220
|
+
indexesMatch(resultIndexName, expectedIndexName) &&
|
|
221
|
+
record.source?.sourceHash === expectedSourceHash
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function executeMcpProof(input: McpProofInput): Promise<McpProofResult> {
|
|
227
|
+
const client = new Client({
|
|
228
|
+
name: "gno-activation-verifier",
|
|
229
|
+
version: "1.0.0",
|
|
230
|
+
});
|
|
231
|
+
const transport = new StdioClientTransport({
|
|
232
|
+
command: input.command,
|
|
233
|
+
args: input.args,
|
|
234
|
+
env: {
|
|
235
|
+
...getDefaultEnvironment(),
|
|
236
|
+
...input.env,
|
|
237
|
+
[MCP_ACTIVATION_VERIFICATION_ENV]: "1",
|
|
238
|
+
},
|
|
239
|
+
stderr: "ignore",
|
|
240
|
+
});
|
|
241
|
+
const requestOptions = { timeout: input.timeoutMs };
|
|
242
|
+
let phase: "start" | "tools" | "status" | "search" = "start";
|
|
243
|
+
try {
|
|
244
|
+
await client.connect(transport, requestOptions);
|
|
245
|
+
phase = "tools";
|
|
246
|
+
const tools = await client.listTools(undefined, requestOptions);
|
|
247
|
+
const available = new Set(tools.tools.map(({ name }) => name));
|
|
248
|
+
if (![...REQUIRED_TOOLS].every((name) => available.has(name))) {
|
|
249
|
+
return { ok: false, code: "connector_missing_tools" };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
phase = "status";
|
|
253
|
+
const status = await client.callTool(
|
|
254
|
+
{ name: "gno_status", arguments: {} },
|
|
255
|
+
undefined,
|
|
256
|
+
requestOptions
|
|
257
|
+
);
|
|
258
|
+
if (
|
|
259
|
+
status.isError === true ||
|
|
260
|
+
!hasExpectedStatusIndex(status, input.expectedIndexName)
|
|
261
|
+
) {
|
|
262
|
+
return { ok: false, code: "connector_status_failed" };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
phase = "search";
|
|
266
|
+
let searchResponse: unknown = await client.callTool(
|
|
267
|
+
{
|
|
268
|
+
name: "gno_search",
|
|
269
|
+
arguments: {
|
|
270
|
+
query: input.term,
|
|
271
|
+
collection: input.collection,
|
|
272
|
+
limit: 8,
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
undefined,
|
|
276
|
+
requestOptions
|
|
277
|
+
);
|
|
278
|
+
const searchFailed =
|
|
279
|
+
!!searchResponse &&
|
|
280
|
+
typeof searchResponse === "object" &&
|
|
281
|
+
(searchResponse as { isError?: unknown }).isError === true;
|
|
282
|
+
const matched =
|
|
283
|
+
!searchFailed &&
|
|
284
|
+
hasExpectedResult(
|
|
285
|
+
searchResponse,
|
|
286
|
+
input.expectedUri,
|
|
287
|
+
input.expectedSourceHash,
|
|
288
|
+
input.expectedIndexName
|
|
289
|
+
);
|
|
290
|
+
searchResponse = undefined;
|
|
291
|
+
if (searchFailed) {
|
|
292
|
+
return { ok: false, code: "connector_search_failed" };
|
|
293
|
+
}
|
|
294
|
+
return matched
|
|
295
|
+
? { ok: true }
|
|
296
|
+
: { ok: false, code: "connector_result_mismatch" };
|
|
297
|
+
} catch (error) {
|
|
298
|
+
if (isTimeoutError(error)) {
|
|
299
|
+
return { ok: false, code: "connector_timeout" };
|
|
300
|
+
}
|
|
301
|
+
if (phase === "status") {
|
|
302
|
+
return { ok: false, code: "connector_status_failed" };
|
|
303
|
+
}
|
|
304
|
+
if (phase === "search") {
|
|
305
|
+
return { ok: false, code: "connector_search_failed" };
|
|
306
|
+
}
|
|
307
|
+
if (phase === "tools") {
|
|
308
|
+
return { ok: false, code: "connector_missing_tools" };
|
|
309
|
+
}
|
|
310
|
+
return { ok: false, code: "connector_start_failed" };
|
|
311
|
+
} finally {
|
|
312
|
+
await client.close().catch(async () => transport.close().catch(() => {}));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Verify one installed connector without changing its config or crossing a
|
|
318
|
+
* user trust prompt. Skill-only targets remain explicitly unverifiable.
|
|
319
|
+
*/
|
|
320
|
+
export async function verifyConnectorActivation(
|
|
321
|
+
store: StorePort,
|
|
322
|
+
collection: string,
|
|
323
|
+
target: ConnectorVerificationTarget,
|
|
324
|
+
options: ConnectorVerifierOptions = {}
|
|
325
|
+
): Promise<StoreResult<ActivationVerificationReceipt>> {
|
|
326
|
+
const now = options.now ?? (() => new Date());
|
|
327
|
+
const monotonicNow = options.monotonicNow ?? (() => performance.now());
|
|
328
|
+
const lexical = await verifyLexicalActivation(store, collection, {
|
|
329
|
+
force: options.force,
|
|
330
|
+
now,
|
|
331
|
+
monotonicNow,
|
|
332
|
+
});
|
|
333
|
+
if (!lexical.ok) {
|
|
334
|
+
return lexical;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const normalizedTarget = normalizeConnectorTarget(target);
|
|
338
|
+
const identity = targetIdentity(normalizedTarget);
|
|
339
|
+
const fingerprint = connectorFingerprint(
|
|
340
|
+
lexical.value.fingerprint,
|
|
341
|
+
identity.normalized
|
|
342
|
+
);
|
|
343
|
+
if (!options.force) {
|
|
344
|
+
const current = await store.getActivationReceipt(
|
|
345
|
+
collection,
|
|
346
|
+
fingerprint,
|
|
347
|
+
identity.connectorTarget
|
|
348
|
+
);
|
|
349
|
+
if (!current.ok) {
|
|
350
|
+
return current;
|
|
351
|
+
}
|
|
352
|
+
if (current.value?.stages.connector.status === "passed") {
|
|
353
|
+
const currentPlan = await createEphemeralActivationProbePlan(
|
|
354
|
+
store,
|
|
355
|
+
collection,
|
|
356
|
+
{ collectCandidates: false }
|
|
357
|
+
);
|
|
358
|
+
if (!currentPlan.ok) {
|
|
359
|
+
return currentPlan;
|
|
360
|
+
}
|
|
361
|
+
if (currentPlan.value.fingerprint !== lexical.value.fingerprint) {
|
|
362
|
+
const cleanup = await discardObsoleteConnectorReceipt(
|
|
363
|
+
store,
|
|
364
|
+
collection,
|
|
365
|
+
currentPlan.value.fingerprint,
|
|
366
|
+
identity
|
|
367
|
+
);
|
|
368
|
+
return cleanup.ok
|
|
369
|
+
? err("INTERNAL", CONCURRENT_INDEX_CHANGE_MESSAGE)
|
|
370
|
+
: cleanup;
|
|
371
|
+
}
|
|
372
|
+
return ok(current.value);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const startedAt = now().toISOString();
|
|
377
|
+
const startedClock = monotonicNow();
|
|
378
|
+
const finish = async (
|
|
379
|
+
status: "passed" | "failed" | "skipped",
|
|
380
|
+
code?: ConnectorVerificationCode
|
|
381
|
+
): Promise<StoreResult<ActivationVerificationReceipt>> => {
|
|
382
|
+
const completedAt = now().toISOString();
|
|
383
|
+
return persistConnectorReceipt(
|
|
384
|
+
store,
|
|
385
|
+
buildConnectorReceipt({
|
|
386
|
+
base: lexical.value,
|
|
387
|
+
fingerprint,
|
|
388
|
+
connectorTarget: identity.connectorTarget,
|
|
389
|
+
generatedAt: completedAt,
|
|
390
|
+
connector: connectorStage(
|
|
391
|
+
status,
|
|
392
|
+
startedAt,
|
|
393
|
+
completedAt,
|
|
394
|
+
elapsedMs(startedClock, monotonicNow),
|
|
395
|
+
code
|
|
396
|
+
),
|
|
397
|
+
})
|
|
398
|
+
);
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
if (normalizedTarget.configError) {
|
|
402
|
+
return finish("failed", "connector_unsupported_config");
|
|
403
|
+
}
|
|
404
|
+
if (normalizedTarget.kind === "skill") {
|
|
405
|
+
return finish(
|
|
406
|
+
"skipped",
|
|
407
|
+
normalizedTarget.installed
|
|
408
|
+
? "target_runtime_unverifiable"
|
|
409
|
+
: "connector_not_configured"
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
if (!normalizedTarget.configured) {
|
|
413
|
+
return finish(
|
|
414
|
+
normalizedTarget.configError ? "failed" : "skipped",
|
|
415
|
+
normalizedTarget.configError
|
|
416
|
+
? "connector_unsupported_config"
|
|
417
|
+
: "connector_not_configured"
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
if (
|
|
421
|
+
!normalizedTarget.serverEntry ||
|
|
422
|
+
!(await isSafeLocalGnoMcpCommand(
|
|
423
|
+
normalizedTarget.serverEntry,
|
|
424
|
+
options.commandPolicy
|
|
425
|
+
))
|
|
426
|
+
) {
|
|
427
|
+
return finish("failed", "connector_unsupported_config");
|
|
428
|
+
}
|
|
429
|
+
if (!lexical.value.ready) {
|
|
430
|
+
return finish("skipped", "connector_probe_unavailable");
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const plan = await createEphemeralActivationProbePlan(store, collection);
|
|
434
|
+
if (!plan.ok) {
|
|
435
|
+
return plan;
|
|
436
|
+
}
|
|
437
|
+
if (plan.value.fingerprint !== lexical.value.fingerprint) {
|
|
438
|
+
return err("INTERNAL", CONCURRENT_INDEX_CHANGE_MESSAGE);
|
|
439
|
+
}
|
|
440
|
+
const match = await findEphemeralActivationProbeMatch(store, plan.value);
|
|
441
|
+
if (!match.ok) {
|
|
442
|
+
return finish("failed", "connector_probe_unavailable");
|
|
443
|
+
}
|
|
444
|
+
if (match.value.kind !== "matched") {
|
|
445
|
+
return finish("failed", "connector_probe_unavailable");
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const proof = await executeMcpProof({
|
|
449
|
+
command: normalizedTarget.serverEntry.command,
|
|
450
|
+
args: normalizedTarget.serverEntry.args,
|
|
451
|
+
env: normalizedTarget.serverEntry.env,
|
|
452
|
+
collection,
|
|
453
|
+
term: match.value.value.term,
|
|
454
|
+
expectedUri: match.value.value.resultUri,
|
|
455
|
+
expectedSourceHash: match.value.value.resultSourceHash,
|
|
456
|
+
expectedIndexName: plan.value.identity.indexName,
|
|
457
|
+
timeoutMs: Math.max(100, options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
458
|
+
});
|
|
459
|
+
const beforePersistence = await revalidateEphemeralActivationProbePlan(
|
|
460
|
+
store,
|
|
461
|
+
plan.value
|
|
462
|
+
);
|
|
463
|
+
if (!beforePersistence.ok) {
|
|
464
|
+
return beforePersistence;
|
|
465
|
+
}
|
|
466
|
+
if (!beforePersistence.value.stable) {
|
|
467
|
+
return err("INTERNAL", CONCURRENT_INDEX_CHANGE_MESSAGE);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const persisted = proof.ok
|
|
471
|
+
? await finish("passed")
|
|
472
|
+
: await finish("failed", proof.code);
|
|
473
|
+
if (!persisted.ok) {
|
|
474
|
+
return persisted;
|
|
475
|
+
}
|
|
476
|
+
const afterPersistence = await revalidateEphemeralActivationProbePlan(
|
|
477
|
+
store,
|
|
478
|
+
plan.value
|
|
479
|
+
);
|
|
480
|
+
if (!afterPersistence.ok) {
|
|
481
|
+
return afterPersistence;
|
|
482
|
+
}
|
|
483
|
+
if (afterPersistence.value.stable) {
|
|
484
|
+
return persisted;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const cleanup = await discardObsoleteConnectorReceipt(
|
|
488
|
+
store,
|
|
489
|
+
collection,
|
|
490
|
+
afterPersistence.value.currentPlan.fingerprint,
|
|
491
|
+
identity
|
|
492
|
+
);
|
|
493
|
+
if (!cleanup.ok) {
|
|
494
|
+
return cleanup;
|
|
495
|
+
}
|
|
496
|
+
return err("INTERNAL", CONCURRENT_INDEX_CHANGE_MESSAGE);
|
|
497
|
+
}
|