@descix/cli 1.0.9 → 1.0.11
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/agent-assets/instructions/onboarding-flow.md +6 -0
- package/agent-assets/reference/local-dev.md +41 -1
- package/agent-assets/reference/workspace-config.md +1 -1
- package/bin/descix.js +32 -0
- package/bin/mcp-server.js +68 -9
- package/lib/commands/corpus.js +95 -2
- package/lib/commands/dev-certs.js +93 -0
- package/lib/commands/doctor.js +31 -3
- package/lib/core/RetrievalCanary.js +128 -0
- package/lib/dev-cert-resolver.js +50 -0
- package/lib/mcp-origin-stamp.js +84 -0
- package/lib/verb-invokes.generated.json +4 -1
- package/package.json +3 -3
- package/templates/agent-claude.md +10 -0
- package/templates/agent-cline.md +10 -0
- package/templates/agent-copilot.md +10 -0
- package/templates/agent-cursor.md +10 -0
|
@@ -294,6 +294,12 @@ User is trying to configure an app they don't own. Suggest purchase.
|
|
|
294
294
|
### "Folder already configured"
|
|
295
295
|
Check if configuration matches intended app. Offer to update or skip.
|
|
296
296
|
|
|
297
|
+
### Powch sign-in under `descix serve` fails ("User cancelled", or "WebAuthn is not supported on sites with TLS certificate errors")
|
|
298
|
+
The gateway's dev certificate is not trusted. Run `descix dev-certs check`. If it is not trusted,
|
|
299
|
+
give the user exactly `descix dev-certs trust` to run (macOS password prompt), tell them to quit
|
|
300
|
+
and reopen Chrome, then run `descix dev-certs check` again. Details and Linux/Windows notes:
|
|
301
|
+
`reference/local-dev.md` §3.3.
|
|
302
|
+
|
|
297
303
|
## Next Steps After Onboarding
|
|
298
304
|
|
|
299
305
|
Once configured, guide user to:
|
|
@@ -187,11 +187,51 @@ a different case and FAILS LOUD naming the remedy, rather than quietly becoming
|
|
|
187
187
|
|
|
188
188
|
The derived Shell rule is the important one: point the API at a cloud environment and the shell comes from that same origin, so one origin carries shell + app + `/apifront` with nothing else configured. A stale `env.platform.site` block does not hijack the root once the API is remote. Platform developers opt IN to a local shell by naming it (`--site-url https://localhost:5174` or `env.siteUrl`).
|
|
189
189
|
|
|
190
|
+
### 3.3. The dev certificate and passkey sign-in
|
|
191
|
+
|
|
192
|
+
The gateway's HTTPS uses one certificate: the pair named by `env.devCerts`
|
|
193
|
+
(`descix config set-dev-certs`), else the SDK's shipped self-signed pair (SAN `localhost`,
|
|
194
|
+
`127.0.0.1`, `::1`). The same pair serves every app dev server behind the gateway.
|
|
195
|
+
|
|
196
|
+
**Chrome refuses WebAuthn on a tab whose certificate is untrusted, and it judges the whole tab.**
|
|
197
|
+
The Powch sign-in iframe on `powch.descix.net` therefore fails inside an untrusted
|
|
198
|
+
`https://localhost:<port>` tab, even though Powch's own origin is trusted.
|
|
199
|
+
|
|
200
|
+
| Symptom | Meaning |
|
|
201
|
+
|---|---|
|
|
202
|
+
| Powch/ChatWidget log shows only `User cancelled` on sign-in | Powch's panel-close rejection; the real error is in the browser console |
|
|
203
|
+
| Browser console: `NotAllowedError: WebAuthn is not supported on sites with TLS certificate errors` | the gateway certificate is not trusted (or has expired) |
|
|
204
|
+
| `descix serve` banner: `Passkey sign-in will fail: <reason>. Run: descix dev-certs trust` | same, detected before the browser opens |
|
|
205
|
+
| `descix doctor` row "Dev certificate" not passing | same |
|
|
206
|
+
|
|
207
|
+
| Command | Who runs it | Effect |
|
|
208
|
+
|---|---|---|
|
|
209
|
+
| `descix dev-certs check [--json]` | the assisting agent | resolves the cert the gateway uses; exits 0 only when it is trusted; reports `unverifiable` with the reason on Linux and Windows |
|
|
210
|
+
| `descix doctor` | the assisting agent | includes the same result as the "Dev certificate" check |
|
|
211
|
+
| `descix dev-certs trust` | **the human** | macOS: trusts that cert in the login keychain (password prompt), re-checks, and says to quit and reopen Chrome |
|
|
212
|
+
|
|
213
|
+
**Assisting-agent flow:**
|
|
214
|
+
|
|
215
|
+
1. Run `descix doctor` (or `descix dev-certs check`).
|
|
216
|
+
2. If the dev certificate is trusted, continue. Passkey failures then have another cause.
|
|
217
|
+
3. If it is not trusted, hand the human exactly one command to run in their own terminal:
|
|
218
|
+
`descix dev-certs trust`. It needs their macOS login password, so the agent does not run it.
|
|
219
|
+
4. When they report it succeeded, tell them to **quit Chrome completely and reopen it** (a running
|
|
220
|
+
Chrome keeps its old verdict), reload the `descix serve` URL, and sign in again.
|
|
221
|
+
5. Run `descix dev-certs check` again and confirm exit 0 before calling sign-in fixed.
|
|
222
|
+
|
|
223
|
+
**Linux and Windows:** the check reports `unverifiable`, and `descix dev-certs trust` is macOS-only.
|
|
224
|
+
Trusting the cert is a manual step that has not been verified on DeSciX: import the gateway's
|
|
225
|
+
`cert.pem` into the store Chrome reads (Linux: the NSS database,
|
|
226
|
+
`certutil -d sql:$HOME/.pki/nssdb -A -t "P,," -n descix-localhost -i <cert.pem>`; Windows: the
|
|
227
|
+
Current User "Trusted Root Certification Authorities" store), then restart the browser. Tell the
|
|
228
|
+
human it is unverified.
|
|
229
|
+
|
|
190
230
|
## 4. Scenarios
|
|
191
231
|
|
|
192
232
|
### Scenario 1: App Dev (the common case)
|
|
193
233
|
* **User**: App developer. No platform checkout.
|
|
194
|
-
* **Setup**: `descix config set-env dev`, `descix app init`, `descix app set-site`, then `cd my-app && descix serve`.
|
|
234
|
+
* **Setup**: `descix config set-env dev`, `descix app init`, `descix app set-site`, `descix dev-certs check` (if not trusted, the human runs `descix dev-certs trust` once — §3.3), then `cd my-app && descix serve`.
|
|
195
235
|
* **Result**: one HTTPS origin. `/` → the cloud App Shell, `/apifront` → the cloud API, `/p/my-app` → your app, and the shell boots **standalone into your app** — its INITIAL VIEW is your app rather than the store, with the platform views still reachable from there.
|
|
196
236
|
|
|
197
237
|
### Scenario 2: Platform Dev ("Dogfooding")
|
|
@@ -33,7 +33,7 @@ is the schema and the verb that owns each key.
|
|
|
33
33
|
| `env.environment` | `DEV`, `DEMO` or `PROD` | `descix config init --env dev\|demo\|prod` |
|
|
34
34
|
| `env.apiUrl` | the API origin this workspace talks to | `descix config init --env …` (known envs) / `descix config set-env <name> --url <origin>` (custom) |
|
|
35
35
|
| `env.gateway.port` | the port `descix serve` listens on | `descix config set-gateway-port <port>` |
|
|
36
|
-
| `env.devCerts` | `dir`, or `cert` + `key` — the TLS pair for the gateway and every app behind it | `descix config set-dev-certs --dir\|--cert\|--key\|--clear` |
|
|
36
|
+
| `env.devCerts` | `dir`, or `cert` + `key` — the TLS pair for the gateway and every app behind it; unset means the SDK's shipped pair. Passkey sign-in needs it trusted: `descix dev-certs check`, then the human runs `descix dev-certs trust` (see `reference/local-dev.md` §3.3) | `descix config set-dev-certs --dir\|--cert\|--key\|--clear` |
|
|
37
37
|
| `env.powchUrl` | Powch's own origin (optional; Powch is cross-origin from the shell by design) | `descix config set-powch-url <url>` |
|
|
38
38
|
| `env.siteUrl` | the App Shell origin the gateway proxies `/` to (optional; default is the API origin) | `descix config set-site-url <url>` |
|
|
39
39
|
| `env.products[]` | one entry per app in this workspace, see below | `descix app init`, `app set-site`, `app set-port`, `app set-localpath`, `app unmap` |
|
package/bin/descix.js
CHANGED
|
@@ -2187,6 +2187,7 @@ corpusCommand
|
|
|
2187
2187
|
.option('--dry-run', 'Enumerate would-be-purged file_ids and would-be-upserted chunks without ANY Pinecone writes. Exit 0 if no drift, 1 if drift. Read-only.')
|
|
2188
2188
|
.option('--show-walk', 'Print the resolved ref + the first 50 walked files BEFORE any Pinecone operations. Useful for verifying --ref / manifest source resolution.')
|
|
2189
2189
|
.option('--yes', 'Skip the interactive purge confirmation in --rebuild mode. Use in scripting/CI.')
|
|
2190
|
+
.option('--skip-retrieval-canary', 'Skip the post-sync retrieval canary that confirms a synced chunk actually retrieves before reporting success (measured 2026-09-17: presence in Pinecone is not the same as searchable — the index can lag ~20 minutes after a large upsert/purge). Skipping costs nothing but SAVES one credit-metered query_knowledge_base call and up to ~25s; the sync then reports success on presence only, same as before this check existed. Prefer leaving this ON for anything user-facing; use it for CI/cost-sensitive automation that will verify separately.')
|
|
2190
2191
|
.action(async (options) => {
|
|
2191
2192
|
try {
|
|
2192
2193
|
const apiClient = new DeSciXApiClient();
|
|
@@ -4493,6 +4494,37 @@ program
|
|
|
4493
4494
|
}
|
|
4494
4495
|
});
|
|
4495
4496
|
|
|
4497
|
+
// ============ Dev Certs Commands ============
|
|
4498
|
+
|
|
4499
|
+
const devCertsCommand = program
|
|
4500
|
+
.command('dev-certs')
|
|
4501
|
+
.description('TLS dev-cert trust status for `descix serve` — passkey sign-in needs this cert trusted once per machine');
|
|
4502
|
+
|
|
4503
|
+
devCertsCommand
|
|
4504
|
+
.command('check')
|
|
4505
|
+
.description('Check whether the dev-server cert is trusted for https://localhost (exit 0 only when trusted)')
|
|
4506
|
+
.option('--json', 'Output raw JSON')
|
|
4507
|
+
.action(async (options) => {
|
|
4508
|
+
try {
|
|
4509
|
+
const { runDevCertsCheck } = await import('../lib/commands/dev-certs.js');
|
|
4510
|
+
await runDevCertsCheck(options);
|
|
4511
|
+
} catch (error) {
|
|
4512
|
+
fail(error);
|
|
4513
|
+
}
|
|
4514
|
+
});
|
|
4515
|
+
|
|
4516
|
+
devCertsCommand
|
|
4517
|
+
.command('trust')
|
|
4518
|
+
.description('Trust the dev-server cert in the macOS keychain (prompts for your password)')
|
|
4519
|
+
.action(async () => {
|
|
4520
|
+
try {
|
|
4521
|
+
const { runDevCertsTrust } = await import('../lib/commands/dev-certs.js');
|
|
4522
|
+
await runDevCertsTrust();
|
|
4523
|
+
} catch (error) {
|
|
4524
|
+
fail(error);
|
|
4525
|
+
}
|
|
4526
|
+
});
|
|
4527
|
+
|
|
4496
4528
|
// ============ Quickstart Command ============
|
|
4497
4529
|
|
|
4498
4530
|
program
|
package/bin/mcp-server.js
CHANGED
|
@@ -21,6 +21,9 @@ import { WorkspaceConfig } from '../lib/workspace-config.js';
|
|
|
21
21
|
import { DeSciXApiClient } from '../lib/api-client.js';
|
|
22
22
|
import { WalletFileManager } from '../lib/wallet-file.js';
|
|
23
23
|
import { CLI_VERSION } from '../lib/cli-version.js';
|
|
24
|
+
import { checkDevCert } from '@descix/app-sdk/dev';
|
|
25
|
+
import { resolveGatewayCertContext } from '../lib/dev-cert-resolver.js';
|
|
26
|
+
import { originStampFor, stampToolResult } from '../lib/mcp-origin-stamp.js';
|
|
24
27
|
import * as fs from 'fs/promises';
|
|
25
28
|
import * as path from 'path';
|
|
26
29
|
// WS-MCP-SSOT-TIER2 (audit §5.B-1): the curated HTTP-valid core tools are the SINGLE SOURCE
|
|
@@ -151,7 +154,7 @@ try {
|
|
|
151
154
|
// descix_doctor — local diagnostic (no backend call)
|
|
152
155
|
// -------------------------------------------------------------------------
|
|
153
156
|
|
|
154
|
-
async function runDoctor(args) {
|
|
157
|
+
async function runDoctor(args, originStamp) {
|
|
155
158
|
const verifyRemote = args?.verify_remote !== false;
|
|
156
159
|
const report = {
|
|
157
160
|
auth: { connected: false },
|
|
@@ -197,7 +200,12 @@ try {
|
|
|
197
200
|
app_id: freshContext.appId,
|
|
198
201
|
community_id: freshContext.communityId,
|
|
199
202
|
kb_id: freshContext.kbId || 'General',
|
|
200
|
-
|
|
203
|
+
// ONE OWNER: `freshConfig.apiUrl` is a legacy top-level key WorkspaceConfig never
|
|
204
|
+
// populates (the real value lives at `env.apiUrl`, read via `resolveOrigin()`) — the
|
|
205
|
+
// `||` here was always falling through to `apiClient.baseUrl`. Read the resolved
|
|
206
|
+
// origin stamp directly rather than keeping a dead alternative that looked like a
|
|
207
|
+
// second source.
|
|
208
|
+
api_url: originStamp?.origin ?? apiClient.baseUrl,
|
|
201
209
|
local_apps: [
|
|
202
210
|
platform.appId,
|
|
203
211
|
...products.map(p => p.appId),
|
|
@@ -322,6 +330,28 @@ try {
|
|
|
322
330
|
}
|
|
323
331
|
}
|
|
324
332
|
|
|
333
|
+
// Dev certificate — the ONE owner (checkDevCert, shared with `descix
|
|
334
|
+
// dev-certs`, `descix doctor` and the `descix serve` banner). An untrusted
|
|
335
|
+
// cert is invisible from inside a WebAuthn ceremony: Chrome judges the
|
|
336
|
+
// whole tab's TLS state and Powch only ever sees "User cancelled", so an
|
|
337
|
+
// agent assisting a stuck developer needs this named explicitly, not
|
|
338
|
+
// inferred from a login failure.
|
|
339
|
+
try {
|
|
340
|
+
const { certPath } = resolveGatewayCertContext(checkRoot);
|
|
341
|
+
const certResult = checkDevCert({ certPath });
|
|
342
|
+
report.dev_cert = { cert_path: certPath, ...certResult };
|
|
343
|
+
if (certResult.status !== 'trusted') {
|
|
344
|
+
report.warnings.push(
|
|
345
|
+
'Dev certificate not trusted: passkey sign-in under descix serve will fail ' +
|
|
346
|
+
'(Powch logs "User cancelled"; the console says "WebAuthn is not supported on sites ' +
|
|
347
|
+
'with TLS certificate errors"). Ask the user to run exactly: descix dev-certs trust ' +
|
|
348
|
+
'— then quit and reopen Chrome, and re-run descix dev-certs check.'
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
} catch (err) {
|
|
352
|
+
report.dev_cert = { status: 'unverifiable', detail: `Could not resolve dev cert: ${err.message}`, next: null };
|
|
353
|
+
}
|
|
354
|
+
|
|
325
355
|
// Suggest cloud brain alignment when workspace is configured but alignment is missing
|
|
326
356
|
if (!report.alignment && report.workspace?.configured) {
|
|
327
357
|
report.suggestions = report.suggestions || [];
|
|
@@ -337,6 +367,13 @@ try {
|
|
|
337
367
|
report.status = 'warnings';
|
|
338
368
|
}
|
|
339
369
|
|
|
370
|
+
// The same origin stamp every other tool result carries (see mcp-origin-stamp.js) — doctor
|
|
371
|
+
// is the diagnostic surface, so it states the fact explicitly rather than only via the
|
|
372
|
+
// appended content block every tool gets.
|
|
373
|
+
if (originStamp) {
|
|
374
|
+
report.origin_stamp = originStamp;
|
|
375
|
+
}
|
|
376
|
+
|
|
340
377
|
return report;
|
|
341
378
|
}
|
|
342
379
|
|
|
@@ -383,13 +420,35 @@ try {
|
|
|
383
420
|
const { name, arguments: args } = request.params;
|
|
384
421
|
console.error(`[MCP] tools/call — ${name}`);
|
|
385
422
|
|
|
423
|
+
// Resolve the origin stamp ONCE per call, from the one owner (api-client.js's own
|
|
424
|
+
// resolution — the same baseUrl/originSource the CLI's `env:` line reports), and carry it
|
|
425
|
+
// onto EVERY result this call returns, success or error. MEASURED 2026-09-17: two sessions
|
|
426
|
+
// spent hours reading DEV through this connector while believing they were reading PROD
|
|
427
|
+
// because no tool result said where it came from — see mcp-origin-stamp.js.
|
|
428
|
+
let originStamp = null;
|
|
429
|
+
let originStampError = null;
|
|
430
|
+
try {
|
|
431
|
+
await apiClient.ensureInitialized();
|
|
432
|
+
originStamp = originStampFor(apiClient);
|
|
433
|
+
} catch (err) {
|
|
434
|
+
// The origin itself failed to resolve (e.g. a configured-but-invalid origin). There is
|
|
435
|
+
// nothing valid to stamp — the error's own message already names the fix — so this call
|
|
436
|
+
// fails loud below rather than stamping a guess.
|
|
437
|
+
originStampError = err;
|
|
438
|
+
}
|
|
439
|
+
const withStamp = (result) => (originStamp ? stampToolResult(result, originStamp) : result);
|
|
440
|
+
|
|
386
441
|
try {
|
|
442
|
+
if (originStampError) {
|
|
443
|
+
throw originStampError;
|
|
444
|
+
}
|
|
445
|
+
|
|
387
446
|
// descix_doctor is handled locally
|
|
388
447
|
if (name === 'descix_doctor') {
|
|
389
|
-
const report = await runDoctor(args);
|
|
390
|
-
return {
|
|
448
|
+
const report = await runDoctor(args, originStamp);
|
|
449
|
+
return withStamp({
|
|
391
450
|
content: [{ type: 'text', text: JSON.stringify(report, null, 2) }],
|
|
392
|
-
};
|
|
451
|
+
});
|
|
393
452
|
}
|
|
394
453
|
|
|
395
454
|
// Merge default context for convenience (caller can override). Only auto-fill when the
|
|
@@ -431,12 +490,12 @@ try {
|
|
|
431
490
|
});
|
|
432
491
|
|
|
433
492
|
const result = await apiClient.invoke(name, params);
|
|
434
|
-
return {
|
|
493
|
+
return withStamp({
|
|
435
494
|
content: [{
|
|
436
495
|
type: 'text',
|
|
437
496
|
text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
|
|
438
497
|
}],
|
|
439
|
-
};
|
|
498
|
+
});
|
|
440
499
|
} catch (error) {
|
|
441
500
|
console.error(`[MCP] Tool error: ${error.message}`);
|
|
442
501
|
// WS-HEADLESS-MVP-A3: when the backend attached structured error fields (code +
|
|
@@ -447,10 +506,10 @@ try {
|
|
|
447
506
|
const structured = (error.code || error.data)
|
|
448
507
|
? `\n${JSON.stringify({ code: error.code, data: error.data }, null, 2)}`
|
|
449
508
|
: '';
|
|
450
|
-
return {
|
|
509
|
+
return withStamp({
|
|
451
510
|
content: [{ type: 'text', text: `Error: ${error.message}${structured}` }],
|
|
452
511
|
isError: true,
|
|
453
|
-
};
|
|
512
|
+
});
|
|
454
513
|
}
|
|
455
514
|
});
|
|
456
515
|
|
package/lib/commands/corpus.js
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
isReportedCount, accumulateReportedCount, UNREPORTED_COUNT
|
|
33
33
|
} from '../core/Syncer.js';
|
|
34
34
|
import { lintManifestForDenyClasses, assertNoViolations } from '../core/CorpusDenyLint.js';
|
|
35
|
+
import { pickCanaryChunk, runRetrievalCanary, DEFAULT_CANARY_DELAYS_MS } from '../core/RetrievalCanary.js';
|
|
35
36
|
|
|
36
37
|
/**
|
|
37
38
|
* Resolve community_id for corpus sync — same precedence as site upload / update kb:
|
|
@@ -467,6 +468,11 @@ export async function runCorpusSync(apiClient, options) {
|
|
|
467
468
|
let totalChunksCreated = 0;
|
|
468
469
|
let totalFilesSkipped = 0;
|
|
469
470
|
let totalFilesDeleted = 0;
|
|
471
|
+
// The retrieval canary's verdict per KB — a sync/rebuild does not get to report a bare
|
|
472
|
+
// success while any of these are unverified (see RetrievalCanary.js). Collected across
|
|
473
|
+
// manifests so ONE loud failure at the end names every KB still unconfirmed, rather than
|
|
474
|
+
// failing fast on the first and leaving later manifests unsynced.
|
|
475
|
+
const canaryResults = [];
|
|
470
476
|
|
|
471
477
|
for (const rawManifest of manifests) {
|
|
472
478
|
// ── Deliverable B: resolve --ref override (CLI > manifest > default) ──
|
|
@@ -877,6 +883,49 @@ export async function runCorpusSync(apiClient, options) {
|
|
|
877
883
|
}
|
|
878
884
|
}
|
|
879
885
|
|
|
886
|
+
// 3f-2. RETRIEVAL CANARY (measured 2026-09-17): presence in Pinecone (reconciled above)
|
|
887
|
+
// is NOT searchability — the PROD index can take ~20 minutes to catch up after a
|
|
888
|
+
// purge+re-upsert, during which every status surface still says healthy. A sync does not
|
|
889
|
+
// get to report bare success until a chunk it just wrote actually RETRIEVES. Bounded
|
|
890
|
+
// (RetrievalCanary.js) rather than a loop: query_knowledge_base is credit-metered.
|
|
891
|
+
let retrievalCanary = null;
|
|
892
|
+
if (allChunks.length > 0 && options.skipRetrievalCanary) {
|
|
893
|
+
console.log(chalk.gray(' [canary] Skipped (--skip-retrieval-canary): searchability NOT confirmed.'));
|
|
894
|
+
} else if (allChunks.length > 0) {
|
|
895
|
+
const canaryChunk = pickCanaryChunk(allChunks, syncFailures);
|
|
896
|
+
if (canaryChunk) {
|
|
897
|
+
const canarySpinner = ora(' [canary] Confirming a synced chunk actually retrieves...').start();
|
|
898
|
+
// TEST SEAM ONLY: options._canaryDelaysMs / options._canarySleep let tests exercise
|
|
899
|
+
// this path without real wall-clock waits or a live query_knowledge_base backend.
|
|
900
|
+
// Production callers never set these — runRetrievalCanary defaults to the real
|
|
901
|
+
// bounded backoff and real timers when they are undefined.
|
|
902
|
+
const result = await runRetrievalCanary(apiClient, {
|
|
903
|
+
appId, kbName, chunk: canaryChunk,
|
|
904
|
+
...(options._canaryDelaysMs ? { delaysMs: options._canaryDelaysMs } : {}),
|
|
905
|
+
...(options._canarySleep ? { sleep: options._canarySleep } : {}),
|
|
906
|
+
});
|
|
907
|
+
retrievalCanary = { ...result, checked_at: new Date().toISOString() };
|
|
908
|
+
if (result.ok) {
|
|
909
|
+
canarySpinner.succeed(
|
|
910
|
+
` [canary] Confirmed searchable after ${(result.elapsedMs / 1000).toFixed(1)}s ` +
|
|
911
|
+
`(attempt ${result.attempts}/${DEFAULT_CANARY_DELAYS_MS.length})`
|
|
912
|
+
);
|
|
913
|
+
} else {
|
|
914
|
+
canarySpinner.warn(
|
|
915
|
+
` [canary] NOT YET SEARCHABLE after ${(result.elapsedMs / 1000).toFixed(1)}s and ` +
|
|
916
|
+
`${result.attempts} attempt(s)${result.lastError ? ` (last error: ${result.lastError})` : ''}. ` +
|
|
917
|
+
`The vectors ARE present in Pinecone (reconciled above) but the index has not caught up yet — ` +
|
|
918
|
+
`this is a known ~20-minute propagation delay after a large upsert/purge. ` +
|
|
919
|
+
`Re-run 'descix kb corpus sync -a ${appId} -k ${kbName}' later to re-check ` +
|
|
920
|
+
`(unchanged files are cheap; this canary re-runs).`
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
canaryResults.push({ kbName, ...retrievalCanary });
|
|
924
|
+
} else {
|
|
925
|
+
console.log(chalk.yellow(' [canary] Skipped: every upsert batch failed, nothing new to confirm.'));
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
880
929
|
// 3g. Save sync state with blob SHAs for future delta computation
|
|
881
930
|
const allSyncedBlobShas = [...localBlobShas]; // All current files
|
|
882
931
|
await saveSyncState(appRoot, kbName, {
|
|
@@ -894,7 +943,10 @@ export async function runCorpusSync(apiClient, options) {
|
|
|
894
943
|
files_unchanged: unchangedCount,
|
|
895
944
|
chunks_upserted: isReportedCount(upserted) ? upserted : null,
|
|
896
945
|
chunks_deleted: isReportedCount(deleted) ? deleted : null,
|
|
897
|
-
synced_blob_shas: allSyncedBlobShas
|
|
946
|
+
synced_blob_shas: allSyncedBlobShas,
|
|
947
|
+
// The retrieval canary's verdict — read this, not `total_chunks`, to know whether the
|
|
948
|
+
// last sync's content was CONFIRMED searchable or only confirmed present.
|
|
949
|
+
retrieval_canary: retrievalCanary
|
|
898
950
|
});
|
|
899
951
|
|
|
900
952
|
totalFilesProcessed += newOrChangedFiles.length;
|
|
@@ -935,6 +987,26 @@ export async function runCorpusSync(apiClient, options) {
|
|
|
935
987
|
const deletedLine = ` Chunks deleted: ${renderCount(totalFilesDeleted)}`;
|
|
936
988
|
console.log(totalFilesDeleted === 0 ? chalk.gray(deletedLine) : chalk.white(deletedLine));
|
|
937
989
|
console.log('');
|
|
990
|
+
|
|
991
|
+
// NEVER A BARE SUCCESS: if any KB's retrieval canary did not confirm searchability, this
|
|
992
|
+
// command fails loud here rather than letting the green summary above stand as the last
|
|
993
|
+
// word. The summary itself is left intact (it is a true report of what was written) —
|
|
994
|
+
// this is a SEPARATE fact ("can it be found yet") that gets its own loud verdict.
|
|
995
|
+
const unverified = canaryResults.filter((r) => !r.ok);
|
|
996
|
+
if (unverified.length > 0) {
|
|
997
|
+
throw new Error(
|
|
998
|
+
`Sync wrote vectors successfully, but ${unverified.length} KB(s) did NOT confirm ` +
|
|
999
|
+
`searchable within the canary window:\n` +
|
|
1000
|
+
unverified.map((r) =>
|
|
1001
|
+
` - ${r.kbName}: not found after ${(r.elapsedMs / 1000).toFixed(1)}s / ${r.attempts} attempt(s)` +
|
|
1002
|
+
(r.lastError ? ` (last error: ${r.lastError})` : '')
|
|
1003
|
+
).join('\n') +
|
|
1004
|
+
`\nThe vectors ARE present in Pinecone (reconciled above) — this is the store's own ` +
|
|
1005
|
+
`~20-minute post-upsert propagation delay, not data loss. Re-run the same sync command ` +
|
|
1006
|
+
`later to re-check; unchanged files make the re-run cheap and it re-runs this canary.`
|
|
1007
|
+
);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
938
1010
|
return { dryRun: false };
|
|
939
1011
|
|
|
940
1012
|
} catch (error) {
|
|
@@ -1017,11 +1089,32 @@ export async function runCorpusStatus(apiClient, options) {
|
|
|
1017
1089
|
console.log(chalk.white(` Last sync: ${syncState.timestamp}`));
|
|
1018
1090
|
console.log(chalk.white(` Commit: ${syncState.last_sync_commit?.substring(0, 8) || 'unknown'}`));
|
|
1019
1091
|
console.log(chalk.white(` Files tracked: ${syncState.synced_files_count}`));
|
|
1092
|
+
// NAME THE SOURCE OF THIS NUMBER: this is a LOCAL RECORD of what the store reported
|
|
1093
|
+
// AT THE TIME OF THE LAST SYNC's reconcile — not a fresh read of the store now. It can be
|
|
1094
|
+
// stale (the store's contents can change from other syncs/purges) and, even when fresh,
|
|
1095
|
+
// "present" is not "searchable" (see the Retrieval line below). `descix kb doctor -a
|
|
1096
|
+
// <app> -k <kb> --live` reads Pinecone directly for the current truth.
|
|
1020
1097
|
console.log(chalk.white(
|
|
1021
|
-
` Chunks
|
|
1098
|
+
` Chunks (local record, as of last sync): ${Number.isFinite(syncState.total_chunks)
|
|
1022
1099
|
? syncState.total_chunks
|
|
1023
1100
|
: 'unknown (the last sync reported no live count)'}`
|
|
1024
1101
|
));
|
|
1102
|
+
// Retrieval canary verdict from the last sync (see RetrievalCanary.js) — the only field
|
|
1103
|
+
// in this file that speaks to SEARCHABILITY rather than presence.
|
|
1104
|
+
const canary = syncState.retrieval_canary;
|
|
1105
|
+
if (canary === undefined) {
|
|
1106
|
+
console.log(chalk.gray(' Retrieval: unknown (synced before the retrieval-canary check existed)'));
|
|
1107
|
+
} else if (canary === null) {
|
|
1108
|
+
console.log(chalk.gray(' Retrieval: not checked (last sync upserted nothing new)'));
|
|
1109
|
+
} else if (canary.ok) {
|
|
1110
|
+
console.log(chalk.green(` Retrieval: CONFIRMED searchable (as of last sync, ${canary.checked_at})`));
|
|
1111
|
+
} else {
|
|
1112
|
+
console.log(chalk.yellow(
|
|
1113
|
+
` Retrieval: NOT CONFIRMED as of last sync (${canary.checked_at}) — vectors were ` +
|
|
1114
|
+
`present but did not retrieve within the canary window. Re-run 'descix kb corpus sync ` +
|
|
1115
|
+
`-a ${appId} -k ${kbName}' to re-check.`
|
|
1116
|
+
));
|
|
1117
|
+
}
|
|
1025
1118
|
|
|
1026
1119
|
// Quick change detection: walk and compare blob SHAs
|
|
1027
1120
|
if (options.verbose) {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dev-certs — report and (on trust) apply this machine's TLS trust status for
|
|
3
|
+
* the cert `descix serve` uses for its localhost HTTPS origin.
|
|
4
|
+
*
|
|
5
|
+
* Cert resolution is the ONE owner shared with `descix doctor` and the
|
|
6
|
+
* `descix serve` banner: lib/dev-cert-resolver.js -> @descix/app-sdk/dev's
|
|
7
|
+
* resolveDevCertOptions + resolveCertPaths. This file never re-derives which
|
|
8
|
+
* cert is "the" dev cert, and never mints or replaces one — the shipped
|
|
9
|
+
* SDK-tracked pair (descix-app-sdk/src/dev/certs/) is the only cert this
|
|
10
|
+
* command ever points at, same as every other consumer.
|
|
11
|
+
*/
|
|
12
|
+
import { execSync } from 'child_process';
|
|
13
|
+
import chalk from 'chalk';
|
|
14
|
+
import { checkDevCert, trustCertCommand } from '@descix/app-sdk/dev';
|
|
15
|
+
import { resolveGatewayCertContext } from '../dev-cert-resolver.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `descix dev-certs check [--json]` — exits 0 only when the cert is trusted.
|
|
19
|
+
*/
|
|
20
|
+
export async function runDevCertsCheck(options = {}) {
|
|
21
|
+
const { certPath, port } = resolveGatewayCertContext(process.cwd());
|
|
22
|
+
const result = checkDevCert({ certPath });
|
|
23
|
+
|
|
24
|
+
if (options.json) {
|
|
25
|
+
console.log(JSON.stringify({ certPath, port, ...result }, null, 2));
|
|
26
|
+
} else {
|
|
27
|
+
console.log(chalk.cyan('\nDev certificate\n'));
|
|
28
|
+
console.log(` Cert: ${certPath}`);
|
|
29
|
+
console.log(` Status: ${result.status === 'trusted' ? chalk.green(result.status) : chalk.red(result.status)}`);
|
|
30
|
+
console.log(` Detail: ${result.detail}`);
|
|
31
|
+
if (result.next) {
|
|
32
|
+
console.log(`\n Next:\n ${String(result.next).replace(/\n/g, '\n ')}`);
|
|
33
|
+
}
|
|
34
|
+
console.log('');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
process.exitCode = result.status === 'trusted' ? 0 : 1;
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* `descix dev-certs trust` — the ONE final human command. Runs the exact
|
|
43
|
+
* trustCertCommand() string (macOS prompts for the keychain password), then
|
|
44
|
+
* re-checks and tells the human to reload Chrome. Refuses to run against a
|
|
45
|
+
* cert that is structurally broken (missing / no SAN / expired) or on a
|
|
46
|
+
* platform this cannot verify — those are not this command's job to paper
|
|
47
|
+
* over, and running `security add-trusted-cert` on a bad cert would silently
|
|
48
|
+
* "succeed" while passkeys still fail.
|
|
49
|
+
*/
|
|
50
|
+
export async function runDevCertsTrust() {
|
|
51
|
+
const { certPath, port } = resolveGatewayCertContext(process.cwd());
|
|
52
|
+
const before = checkDevCert({ certPath });
|
|
53
|
+
|
|
54
|
+
if (before.status === 'trusted') {
|
|
55
|
+
console.log(chalk.green(`\n${certPath} is already trusted.\n`));
|
|
56
|
+
return before;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (before.status === 'unverifiable') {
|
|
60
|
+
// `security add-trusted-cert` is macOS-only — there is no automated trust
|
|
61
|
+
// step on this platform. Refuse loud rather than silently no-op, and hand
|
|
62
|
+
// the human the exact file so they can import it into their own OS/browser
|
|
63
|
+
// trust store by hand.
|
|
64
|
+
console.error(chalk.red(`\nCannot trust automatically on ${process.platform}: ${before.detail}`));
|
|
65
|
+
console.error(`\nImport this certificate into your OS/browser trust store manually:\n ${certPath}\n`);
|
|
66
|
+
process.exitCode = 1;
|
|
67
|
+
return before;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (before.status !== 'untrusted') {
|
|
71
|
+
console.error(chalk.red(`\nCannot trust this cert — status: ${before.status}`));
|
|
72
|
+
console.error(` ${before.detail}`);
|
|
73
|
+
if (before.next) {
|
|
74
|
+
console.error(`\n Next:\n ${String(before.next).replace(/\n/g, '\n ')}\n`);
|
|
75
|
+
}
|
|
76
|
+
process.exitCode = 1;
|
|
77
|
+
return before;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
console.log(chalk.cyan('\nTrusting the dev certificate — macOS will prompt for your password.\n'));
|
|
81
|
+
execSync(trustCertCommand(certPath), { stdio: 'inherit' });
|
|
82
|
+
|
|
83
|
+
const after = checkDevCert({ certPath });
|
|
84
|
+
if (after.status === 'trusted') {
|
|
85
|
+
console.log(chalk.green('\nTrusted.'));
|
|
86
|
+
console.log(`Quit and reopen Chrome, then reload https://localhost:${port}/\n`);
|
|
87
|
+
} else {
|
|
88
|
+
console.error(chalk.red(`\nStill not trusted after running the trust command (${after.status}).`));
|
|
89
|
+
console.error(` ${after.detail}\n`);
|
|
90
|
+
process.exitCode = 1;
|
|
91
|
+
}
|
|
92
|
+
return after;
|
|
93
|
+
}
|
package/lib/commands/doctor.js
CHANGED
|
@@ -14,6 +14,8 @@ import { exec } from 'child_process';
|
|
|
14
14
|
import { promisify } from 'util';
|
|
15
15
|
import { DeSciXApiClient } from '../api-client.js';
|
|
16
16
|
import { isAuthenticated } from '../auth-guard.js';
|
|
17
|
+
import { checkDevCert } from '@descix/app-sdk/dev';
|
|
18
|
+
import { resolveGatewayCertContext } from '../dev-cert-resolver.js';
|
|
17
19
|
|
|
18
20
|
const execAsync = promisify(exec);
|
|
19
21
|
|
|
@@ -223,6 +225,26 @@ export async function checkAdc() {
|
|
|
223
225
|
}
|
|
224
226
|
}
|
|
225
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Check the TLS cert `descix serve` would load for the cwd. An untrusted or
|
|
230
|
+
* structurally broken cert breaks passkey (WebAuthn) sign-in — the only
|
|
231
|
+
* DeSciX auth — so this is a real failure, not an admin-only advisory.
|
|
232
|
+
* Resolution mirrors the gateway exactly (dev-cert-resolver.js, the ONE
|
|
233
|
+
* owner shared with `descix dev-certs` and the `descix serve` banner).
|
|
234
|
+
*/
|
|
235
|
+
async function checkDevCertificate() {
|
|
236
|
+
const { certPath } = resolveGatewayCertContext(process.cwd());
|
|
237
|
+
const result = checkDevCert({ certPath });
|
|
238
|
+
if (result.status === 'trusted') {
|
|
239
|
+
return { ok: true, message: `${certPath} (trusted)` };
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
ok: false,
|
|
243
|
+
message: `${result.status} — ${result.detail}`,
|
|
244
|
+
remediation: result.next || 'Run: descix dev-certs check',
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
226
248
|
/**
|
|
227
249
|
* Run the doctor command
|
|
228
250
|
*/
|
|
@@ -282,7 +304,12 @@ export async function runDoctor(options = {}) {
|
|
|
282
304
|
spinner.text = 'Checking ADC Credentials...';
|
|
283
305
|
const adcRes = await checkAdc();
|
|
284
306
|
results.push({ label: 'ADC Credentials', ...adcRes });
|
|
285
|
-
|
|
307
|
+
|
|
308
|
+
// 7. Dev certificate (passkey sign-in under `descix serve`)
|
|
309
|
+
spinner.text = 'Checking dev certificate...';
|
|
310
|
+
const devCertRes = await checkDevCertificate();
|
|
311
|
+
results.push({ label: 'Dev certificate', ...devCertRes });
|
|
312
|
+
|
|
286
313
|
spinner.stop();
|
|
287
314
|
|
|
288
315
|
// Print Results
|
|
@@ -317,8 +344,9 @@ export async function runDoctor(options = {}) {
|
|
|
317
344
|
// the pass stops.
|
|
318
345
|
console.log(chalk.gray(
|
|
319
346
|
'Checked: Node version, API origin resolution + reachability, session, local write\n' +
|
|
320
|
-
'permissions
|
|
321
|
-
'checked: your app\'s own build, chain state, or any
|
|
347
|
+
'permissions, dev-cert trust for `descix serve`. [-] rows are admin-only dependencies and\n' +
|
|
348
|
+
'never fail this command. NOT checked: your app\'s own build, chain state, or any\n' +
|
|
349
|
+
'credential\'s permissions at the API.'
|
|
322
350
|
));
|
|
323
351
|
console.log();
|
|
324
352
|
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RetrievalCanary — a corpus sync does not report success until a KNOWN chunk it just
|
|
3
|
+
* wrote actually RETRIEVES from the store.
|
|
4
|
+
*
|
|
5
|
+
* MEASURED 2026-09-17: `descix kb corpus sync --rebuild` purges and re-upserts, then
|
|
6
|
+
* reports success and a reconciled live count, while the PROD search index takes ~20
|
|
7
|
+
* minutes to catch up. Every status surface said healthy over a KB that returned zero
|
|
8
|
+
* results for hours. The reconcile step (`get_kb_rag_status {reconcile:true}`, driven from
|
|
9
|
+
* `corpus.js::runCorpusSync`) counts by Pinecone ID PREFIX — presence, not searchability —
|
|
10
|
+
* so a clean reconcile is not evidence the index can answer a query yet.
|
|
11
|
+
*
|
|
12
|
+
* THE CANARY: after upserting, pick ONE chunk that was actually upserted (never one from a
|
|
13
|
+
* failed batch), run `query_knowledge_base` scoped to that chunk's own document
|
|
14
|
+
* (`file_filter`) using the chunk's own text as the query, and check the result set for a
|
|
15
|
+
* row whose metadata identifies THIS chunk (blob_sha + chunk_idx — the two fields
|
|
16
|
+
* `corpus.js::chunkCorpusFile` always writes, so this needs no second identity scheme).
|
|
17
|
+
*
|
|
18
|
+
* BOUNDED, NOT A LOOP: `query_knowledge_base` is a real, credit-metered read (WS-HEADLESS-MVP-A3
|
|
19
|
+
* flat debit per call). A canary that polled every few seconds for the full ~20-minute
|
|
20
|
+
* propagation window would multiply that cost by hundreds of calls for a single sync. This
|
|
21
|
+
* canary makes a SMALL, FIXED number of attempts (`DEFAULT_CANARY_DELAYS_MS`, 3 calls across
|
|
22
|
+
* ~25s) — enough to catch the common case where the index catches up quickly — and REFUSES to
|
|
23
|
+
* claim success if it does not, rather than waiting out the full window synchronously inside a
|
|
24
|
+
* CLI command. See corpus.js's hand-back for the case this does not close and the remedy it
|
|
25
|
+
* names when it doesn't (re-run the sync; unchanged blob SHAs make the re-run cheap and it
|
|
26
|
+
* re-runs this same canary).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Fixed, small backoff schedule — see the module doc for why this is bounded, not a loop. */
|
|
30
|
+
export const DEFAULT_CANARY_DELAYS_MS = [3000, 7000, 15000];
|
|
31
|
+
|
|
32
|
+
/** Pinecone metadata budget note in chunkCorpusFile caps chunk text at 35000 bytes; a canary
|
|
33
|
+
* query only needs enough text to be a near-exact self-match, so it is truncated far below that
|
|
34
|
+
* — a long query costs more to embed for no better discrimination.
|
|
35
|
+
*/
|
|
36
|
+
const MAX_CANARY_QUERY_CHARS = 300;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The query text for a canary check: the chunk's own text is guaranteed to retrieve itself
|
|
40
|
+
* with the highest similarity of anything in the index, so no second corpus of "known good"
|
|
41
|
+
* queries is needed.
|
|
42
|
+
*
|
|
43
|
+
* @param {{text: string}} chunk
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
export function canaryQueryText(chunk) {
|
|
47
|
+
return String(chunk.text || '').trim().slice(0, MAX_CANARY_QUERY_CHARS);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Does this `query_knowledge_base` result row identify the given chunk?
|
|
52
|
+
*
|
|
53
|
+
* Matches on `blob_sha` + `chunk_idx` — the two fields `chunkCorpusFile()` always writes as
|
|
54
|
+
* Pinecone metadata, content-addressed and stable regardless of any server-side text
|
|
55
|
+
* normalization. This is the SAME identity the sync's own delta computation uses, not a
|
|
56
|
+
* second one invented for this check.
|
|
57
|
+
*
|
|
58
|
+
* @param {{metadata?: Record<string, any>}} row - one row of `query_knowledge_base`'s `results`
|
|
59
|
+
* @param {{blob_sha: string, chunk_idx: number}} chunk
|
|
60
|
+
* @returns {boolean}
|
|
61
|
+
*/
|
|
62
|
+
export function chunkMatchesCanary(row, chunk) {
|
|
63
|
+
const md = row?.metadata || {};
|
|
64
|
+
return md.blob_sha === chunk.blob_sha && md.chunk_idx === chunk.chunk_idx;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Pick one upserted chunk to canary-check: the first chunk that was NOT part of a batch the
|
|
69
|
+
* store reported as failed. Returns null when there is nothing safe to check (every batch
|
|
70
|
+
* failed) — the caller's existing batch-failure reporting already covers that case loudly;
|
|
71
|
+
* this module does not re-report it.
|
|
72
|
+
*
|
|
73
|
+
* @param {Array<{id: string}>} allChunks - every chunk this sync attempted to upsert
|
|
74
|
+
* @param {Array<{chunks: string[]}>} syncFailures - failed batches, each naming its chunk ids
|
|
75
|
+
* @returns {object|null}
|
|
76
|
+
*/
|
|
77
|
+
export function pickCanaryChunk(allChunks, syncFailures) {
|
|
78
|
+
const failedIds = new Set((syncFailures || []).flatMap((f) => f.chunks || []));
|
|
79
|
+
return allChunks.find((c) => !failedIds.has(c.id)) || null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Run the bounded-backoff retrieval canary against a freshly-upserted chunk.
|
|
84
|
+
*
|
|
85
|
+
* @param {object} apiClient - an initialized DeSciXApiClient
|
|
86
|
+
* @param {object} args
|
|
87
|
+
* @param {string} args.appId
|
|
88
|
+
* @param {string} args.kbName
|
|
89
|
+
* @param {object} args.chunk - one entry from `allChunks` (must carry text/file_id/blob_sha/chunk_idx)
|
|
90
|
+
* @param {number[]} [args.delaysMs] - injectable for tests; production default is
|
|
91
|
+
* DEFAULT_CANARY_DELAYS_MS
|
|
92
|
+
* @param {(ms: number) => Promise<void>} [args.sleep] - injectable for tests (real timers by default)
|
|
93
|
+
* @returns {Promise<{ok: boolean, attempts: number, elapsedMs: number, lastError: string|null}>}
|
|
94
|
+
*/
|
|
95
|
+
export async function runRetrievalCanary(apiClient, {
|
|
96
|
+
appId, kbName, chunk,
|
|
97
|
+
delaysMs = DEFAULT_CANARY_DELAYS_MS,
|
|
98
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
99
|
+
}) {
|
|
100
|
+
const startedAt = Date.now();
|
|
101
|
+
let attempts = 0;
|
|
102
|
+
let lastError = null;
|
|
103
|
+
|
|
104
|
+
for (const delay of delaysMs) {
|
|
105
|
+
attempts++;
|
|
106
|
+
await sleep(delay);
|
|
107
|
+
try {
|
|
108
|
+
const resp = await apiClient.invoke('query_knowledge_base', {
|
|
109
|
+
app_id: appId,
|
|
110
|
+
kb_id: kbName,
|
|
111
|
+
query: canaryQueryText(chunk),
|
|
112
|
+
limit: 5,
|
|
113
|
+
file_filter: chunk.file_id,
|
|
114
|
+
}, { allowGuest: false });
|
|
115
|
+
const data = resp?.message || resp;
|
|
116
|
+
const results = data?.results || [];
|
|
117
|
+
if (results.some((row) => chunkMatchesCanary(row, chunk))) {
|
|
118
|
+
return { ok: true, attempts, elapsedMs: Date.now() - startedAt, lastError: null };
|
|
119
|
+
}
|
|
120
|
+
} catch (err) {
|
|
121
|
+
// A transient failure on ONE attempt does not end the canary early — the schedule is
|
|
122
|
+
// already bounded by delaysMs.length, so this is not an unbounded retry-on-error loop.
|
|
123
|
+
lastError = err.message;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { ok: false, attempts, elapsedMs: Date.now() - startedAt, lastError };
|
|
128
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* resolveGatewayCertContext — the CLI-side mirror of which cert `descix serve`
|
|
3
|
+
* will load for a given cwd. ONE owner within the CLI: `doctor.js` and
|
|
4
|
+
* `dev-certs.js` both call this instead of re-deriving workspace lookup +
|
|
5
|
+
* resolveDevCertOptions + resolveCertPaths by hand — the same two-derivations
|
|
6
|
+
* risk `getViteHttpsConfig.js` names for the gateway itself.
|
|
7
|
+
*
|
|
8
|
+
* Mirrors gateway.js exactly: resolveDevCertOptions(workspaceRoot, {}, config)
|
|
9
|
+
* then resolveCertPaths(certOpts) — both from @descix/app-sdk/dev, the actual
|
|
10
|
+
* owner of "which TLS cert a local dev server uses". This module only supplies
|
|
11
|
+
* the workspaceRoot/config a CLI command has to hand; it never re-derives the
|
|
12
|
+
* precedence itself.
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
findWorkspaceRoot,
|
|
16
|
+
readWorkspaceConfig,
|
|
17
|
+
resolveDevCertOptions,
|
|
18
|
+
resolveCertPaths,
|
|
19
|
+
resolveGatewayPort,
|
|
20
|
+
} from '@descix/app-sdk/dev';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} cwd
|
|
24
|
+
* @returns {{
|
|
25
|
+
* certPath: string,
|
|
26
|
+
* keyPath: string,
|
|
27
|
+
* workspaceRoot: string|null,
|
|
28
|
+
* config: Object|null,
|
|
29
|
+
* port: number,
|
|
30
|
+
* portSource: string,
|
|
31
|
+
* }}
|
|
32
|
+
*/
|
|
33
|
+
export function resolveGatewayCertContext(cwd) {
|
|
34
|
+
const workspaceRoot = findWorkspaceRoot(cwd);
|
|
35
|
+
let config = null;
|
|
36
|
+
if (workspaceRoot) {
|
|
37
|
+
try {
|
|
38
|
+
config = readWorkspaceConfig(workspaceRoot);
|
|
39
|
+
} catch {
|
|
40
|
+
// A malformed workspace is the workspace resolver's problem to report
|
|
41
|
+
// elsewhere; this resolver just falls back to "no workspace opinion",
|
|
42
|
+
// same as resolveDevCertOptions does for a missing/malformed file.
|
|
43
|
+
config = null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const certOpts = resolveDevCertOptions(workspaceRoot || cwd, {}, config);
|
|
47
|
+
const { certPath, keyPath } = resolveCertPaths(certOpts);
|
|
48
|
+
const { port, portSource } = resolveGatewayPort(config || {}, {});
|
|
49
|
+
return { certPath, keyPath, workspaceRoot, config, port, portSource };
|
|
50
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE ORIGIN STAMP EVERY MCP TOOL RESULT CARRIES.
|
|
3
|
+
*
|
|
4
|
+
* MEASURED 2026-09-17: two sessions spent hours investigating a knowledge base as broken.
|
|
5
|
+
* Both were reading DEV through the MCP connector while believing they were reading PROD,
|
|
6
|
+
* because tool results said nothing about where they came from. `bin/mcp-server.js` returned
|
|
7
|
+
* every result as a bare `JSON.stringify(result)` — no origin, no environment. The envelope
|
|
8
|
+
* carries `community_id`/`app_id`, which READ like provenance and are not: they say which app,
|
|
9
|
+
* never which deployment of the platform answered.
|
|
10
|
+
*
|
|
11
|
+
* By contrast the CLI prints `env: <name> (<source>) <origin>` on EVERY network-bound command,
|
|
12
|
+
* unprompted (`lib/environment-report.js` `reportEnvironment()`), sourced from the ONE owner of
|
|
13
|
+
* "which origin did this invocation resolve to" (`lib/origin.js` `resolveOrigin()`, consumed by
|
|
14
|
+
* `DeSciXApiClient.initialize()` and kept on the client as `baseUrl` + `originSource`).
|
|
15
|
+
*
|
|
16
|
+
* This module adds NO second derivation. It reads the SAME two facts the CLI already keeps on
|
|
17
|
+
* an initialized `DeSciXApiClient` and combines them with the SAME env-name owner
|
|
18
|
+
* (`environmentNameFor`) the CLI's own status line uses. There is exactly one answer to "what
|
|
19
|
+
* origin, what environment" for a given client, and every surface — the CLI's stderr line, the
|
|
20
|
+
* MCP stamp — reads it from here.
|
|
21
|
+
*/
|
|
22
|
+
import { environmentNameFor } from './environment-report.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build the origin stamp for an MCP tool result FROM the one owner: an already-initialized
|
|
26
|
+
* `DeSciXApiClient`'s `baseUrl` + `originSource` (set once, in `initialize()`, by
|
|
27
|
+
* `resolveOrigin()` — see `api-client.js`).
|
|
28
|
+
*
|
|
29
|
+
* @param {import('./api-client.js').DeSciXApiClient} apiClient - must already be initialized
|
|
30
|
+
* (`ensureInitialized()` resolved) so `baseUrl`/`originSource` are populated. Calling this
|
|
31
|
+
* before initialization is a caller defect, not a state to paper over with a guess.
|
|
32
|
+
* @returns {{origin: string, source: string, environment: string}}
|
|
33
|
+
*/
|
|
34
|
+
export function originStampFor(apiClient) {
|
|
35
|
+
const origin = apiClient?.baseUrl;
|
|
36
|
+
const source = apiClient?.originSource;
|
|
37
|
+
if (!origin || !source) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
'originStampFor: apiClient has no resolved baseUrl/originSource — ' +
|
|
40
|
+
'call ensureInitialized() before stamping a result.',
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return { origin, source, environment: environmentNameFor(origin) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The ONE spelling of the human-visible stamp line. Mirrors `formatEnvLine()`
|
|
48
|
+
* (`environment-report.js`) in shape so a reader who has seen the CLI's own `env:` line
|
|
49
|
+
* recognizes this as the same fact, not a second convention.
|
|
50
|
+
*
|
|
51
|
+
* @param {{origin: string, source: string, environment: string}} stamp
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
export function formatOriginStampLine({ origin, source, environment }) {
|
|
55
|
+
return `[DeSciX origin] env: ${environment} (${source}) ${origin}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Attach the origin stamp to an MCP `CallToolResult` — success or error alike.
|
|
60
|
+
*
|
|
61
|
+
* TWO PLACES, DELIBERATELY, per the same fact:
|
|
62
|
+
* 1. An appended `content` block (visible text). This is the fix for the actual measured
|
|
63
|
+
* failure: the reader is an LLM/agent consuming the tool's TEXT output, not a program
|
|
64
|
+
* inspecting `_meta` — a stamp only a machine can see would not have caught the two
|
|
65
|
+
* sessions this closes. The ORIGINAL content entries are left untouched and in place, so
|
|
66
|
+
* an existing caller's `JSON.parse(content[0].text)` keeps working exactly as before —
|
|
67
|
+
* this only ever APPENDS a new block, never rewrites or reorders the old one.
|
|
68
|
+
* 2. `_meta.descix_origin` — the MCP-spec extension point (`CallToolResult._meta`, a sibling
|
|
69
|
+
* of `content`, not nested inside it) for a programmatic caller that wants the fact as
|
|
70
|
+
* structured data without re-parsing the human-readable line.
|
|
71
|
+
*
|
|
72
|
+
* @param {{content?: Array<object>, isError?: boolean, [k: string]: any}} result
|
|
73
|
+
* @param {{origin: string, source: string, environment: string}} stamp
|
|
74
|
+
* @returns {object} a NEW result object; `result` is not mutated.
|
|
75
|
+
*/
|
|
76
|
+
export function stampToolResult(result, stamp) {
|
|
77
|
+
const content = Array.isArray(result.content) ? [...result.content] : [];
|
|
78
|
+
content.push({ type: 'text', text: formatOriginStampLine(stamp) });
|
|
79
|
+
return {
|
|
80
|
+
...result,
|
|
81
|
+
content,
|
|
82
|
+
_meta: { ...(result._meta || {}), descix_origin: stamp },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -116,6 +116,8 @@
|
|
|
116
116
|
"credits refund": [
|
|
117
117
|
"refund_credits"
|
|
118
118
|
],
|
|
119
|
+
"dev-certs check": [],
|
|
120
|
+
"dev-certs trust": [],
|
|
119
121
|
"doctor": [],
|
|
120
122
|
"drive pull": [],
|
|
121
123
|
"drive push": [],
|
|
@@ -134,7 +136,8 @@
|
|
|
134
136
|
"kb_delete_chunks",
|
|
135
137
|
"kb_list_file_ids",
|
|
136
138
|
"kb_sync_chunks",
|
|
137
|
-
"list_knowledge_bases"
|
|
139
|
+
"list_knowledge_bases",
|
|
140
|
+
"query_knowledge_base"
|
|
138
141
|
],
|
|
139
142
|
"kb delete": [
|
|
140
143
|
"delete_knowledge_base"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@descix/cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.11",
|
|
4
4
|
"description": "DeSciX CLI and MCP Server - Unified command-line interface for DeSciX platform",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"descix-mcp": "bin/mcp-server.js"
|
|
15
15
|
},
|
|
16
16
|
"scripts": {
|
|
17
|
-
"test": "node --test \"tests/*.test.js\"",
|
|
17
|
+
"test": "node --experimental-test-module-mocks --test \"tests/*.test.js\"",
|
|
18
18
|
"pack": "npm pack",
|
|
19
19
|
"check:doc-paths": "node scripts/check-doc-paths.js",
|
|
20
20
|
"briefer": "node scripts/briefer/run.mjs"
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"author": "DeSciX",
|
|
31
31
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@descix/app-sdk": "^0.1.
|
|
33
|
+
"@descix/app-sdk": "^0.1.8",
|
|
34
34
|
"@descix/sdk": "^2.0.0",
|
|
35
35
|
"@modelcontextprotocol/sdk": "^1.25.1",
|
|
36
36
|
"axios": "^1.7.9",
|
|
@@ -174,6 +174,16 @@ resolves `--port` > `env.gateway.port` > 5173 and is printed with its source, so
|
|
|
174
174
|
never hardcode `:5173`. Set your framework's base path to `/p/<appId>` — the
|
|
175
175
|
gateway does not rewrite paths.
|
|
176
176
|
|
|
177
|
+
**Passkey sign-in needs a trusted dev certificate.** If Powch sign-in under
|
|
178
|
+
`descix serve` fails with `User cancelled`, or the browser console says
|
|
179
|
+
`WebAuthn is not supported on sites with TLS certificate errors`, the gateway's
|
|
180
|
+
certificate is not trusted. Run `descix dev-certs check` (or `descix doctor`, row
|
|
181
|
+
"Dev certificate"). If it is not trusted, do not try to trust it yourself: give
|
|
182
|
+
the user exactly `descix dev-certs trust` to run (macOS asks for their password),
|
|
183
|
+
tell them to quit and reopen Chrome, then run `descix dev-certs check` again. On
|
|
184
|
+
Linux and Windows the check reports `unverifiable` and trusting is a manual,
|
|
185
|
+
unverified step — see `.descix/sdk-assets/reference/local-dev.md` §3.3.
|
|
186
|
+
|
|
177
187
|
## Integrating Existing Content
|
|
178
188
|
|
|
179
189
|
If the repo already has content (an HTML page, a React app, an SDK with docs),
|
package/templates/agent-cline.md
CHANGED
|
@@ -136,6 +136,16 @@ resolves `--port` > `env.gateway.port` > 5173 and is printed with its source, so
|
|
|
136
136
|
never hardcode `:5173`. Set your framework's base path to `/p/<appId>` — the
|
|
137
137
|
gateway does not rewrite paths.
|
|
138
138
|
|
|
139
|
+
**Passkey sign-in needs a trusted dev certificate.** If Powch sign-in under
|
|
140
|
+
`descix serve` fails with `User cancelled`, or the browser console says
|
|
141
|
+
`WebAuthn is not supported on sites with TLS certificate errors`, the gateway's
|
|
142
|
+
certificate is not trusted. Run `descix dev-certs check` (or `descix doctor`, row
|
|
143
|
+
"Dev certificate"). If it is not trusted, do not try to trust it yourself: give
|
|
144
|
+
the user exactly `descix dev-certs trust` to run (macOS asks for their password),
|
|
145
|
+
tell them to quit and reopen Chrome, then run `descix dev-certs check` again. On
|
|
146
|
+
Linux and Windows the check reports `unverifiable` and trusting is a manual,
|
|
147
|
+
unverified step — see `.descix/sdk-assets/reference/local-dev.md` §3.3.
|
|
148
|
+
|
|
139
149
|
## MCP Tools
|
|
140
150
|
|
|
141
151
|
- `descix_doctor` — Startup diagnostic (call first)
|
|
@@ -136,6 +136,16 @@ resolves `--port` > `env.gateway.port` > 5173 and is printed with its source, so
|
|
|
136
136
|
never hardcode `:5173`. Set your framework's base path to `/p/<appId>` — the
|
|
137
137
|
gateway does not rewrite paths.
|
|
138
138
|
|
|
139
|
+
**Passkey sign-in needs a trusted dev certificate.** If Powch sign-in under
|
|
140
|
+
`descix serve` fails with `User cancelled`, or the browser console says
|
|
141
|
+
`WebAuthn is not supported on sites with TLS certificate errors`, the gateway's
|
|
142
|
+
certificate is not trusted. Run `descix dev-certs check` (or `descix doctor`, row
|
|
143
|
+
"Dev certificate"). If it is not trusted, do not try to trust it yourself: give
|
|
144
|
+
the user exactly `descix dev-certs trust` to run (macOS asks for their password),
|
|
145
|
+
tell them to quit and reopen Chrome, then run `descix dev-certs check` again. On
|
|
146
|
+
Linux and Windows the check reports `unverifiable` and trusting is a manual,
|
|
147
|
+
unverified step — see `.descix/sdk-assets/reference/local-dev.md` §3.3.
|
|
148
|
+
|
|
139
149
|
## MCP Tools
|
|
140
150
|
|
|
141
151
|
- `descix_doctor` — Startup diagnostic (call first)
|
|
@@ -136,6 +136,16 @@ resolves `--port` > `env.gateway.port` > 5173 and is printed with its source, so
|
|
|
136
136
|
never hardcode `:5173`. Set your framework's base path to `/p/<appId>` — the
|
|
137
137
|
gateway does not rewrite paths.
|
|
138
138
|
|
|
139
|
+
**Passkey sign-in needs a trusted dev certificate.** If Powch sign-in under
|
|
140
|
+
`descix serve` fails with `User cancelled`, or the browser console says
|
|
141
|
+
`WebAuthn is not supported on sites with TLS certificate errors`, the gateway's
|
|
142
|
+
certificate is not trusted. Run `descix dev-certs check` (or `descix doctor`, row
|
|
143
|
+
"Dev certificate"). If it is not trusted, do not try to trust it yourself: give
|
|
144
|
+
the user exactly `descix dev-certs trust` to run (macOS asks for their password),
|
|
145
|
+
tell them to quit and reopen Chrome, then run `descix dev-certs check` again. On
|
|
146
|
+
Linux and Windows the check reports `unverifiable` and trusting is a manual,
|
|
147
|
+
unverified step — see `.descix/sdk-assets/reference/local-dev.md` §3.3.
|
|
148
|
+
|
|
139
149
|
## MCP Tools
|
|
140
150
|
|
|
141
151
|
- `descix_doctor` — Startup diagnostic (call first)
|