@descix/cli 1.0.1 → 1.0.4
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/LICENSE.md +671 -0
- package/README.md +43 -4
- package/agent-assets/guides/README.md +2 -2
- package/agent-assets/guides/dynamic-site-pattern.md +29 -14
- package/agent-assets/guides/knowledge-only-pattern.md +5 -5
- package/agent-assets/guides/microservice-pattern.md +48 -4
- package/agent-assets/guides/static-site-pattern.md +8 -0
- package/agent-assets/instructions/app-asset-validation.md +5 -5
- package/agent-assets/instructions/onboarding-flow.md +4 -4
- package/agent-assets/instructions/standard-folder-structure.md +20 -11
- package/agent-assets/reference/app-creation.md +42 -20
- package/agent-assets/reference/architecture.md +24 -22
- package/agent-assets/reference/local-dev.md +230 -67
- package/agent-assets/reference/mcp-integration.md +21 -15
- package/agent-assets/reference/platform-api-consumer.md +244 -0
- package/agent-assets/reference/workspace-config.md +7 -7
- package/agent-assets/rules/descix_mcp.template.mdc +8 -8
- package/bin/descix.js +2559 -1435
- package/bin/kb-deny-lint.mjs +91 -0
- package/bin/mcp-server.js +175 -91
- package/lib/agent-files.js +37 -25
- package/lib/api-client.js +223 -56
- package/lib/auth-guard.js +13 -3
- package/lib/chat-session.js +187 -0
- package/lib/cli-version.js +59 -0
- package/lib/commands/airdrop.js +251 -0
- package/lib/commands/auth.js +254 -72
- package/lib/commands/briefer/errors.js +88 -0
- package/lib/commands/briefer/index.js +319 -0
- package/lib/commands/briefer/sources/canonical-sources.js +129 -0
- package/lib/commands/briefer/sources/entitlements.js +220 -0
- package/lib/commands/briefer/sources/environments.js +205 -0
- package/lib/commands/briefer/sources/identifiers.js +214 -0
- package/lib/commands/briefer/sources/microservice-deploy.js +267 -0
- package/lib/commands/briefer/sources/routing.js +275 -0
- package/lib/commands/briefer/sources/what-is-not.js +223 -0
- package/lib/commands/briefer/stitcher.js +134 -0
- package/lib/commands/briefer/util/source-reader.js +502 -0
- package/lib/commands/config.js +210 -90
- package/lib/commands/corpus.js +978 -0
- package/lib/commands/credits.js +122 -0
- package/lib/commands/health.js +627 -0
- package/lib/commands/kb-list-render.js +32 -0
- package/lib/commands/kb.js +190 -413
- package/lib/commands/mcp.js +5 -5
- package/lib/commands/microservice-restart.js +359 -0
- package/lib/commands/model-config.js +313 -0
- package/lib/commands/retired-kb-sync.js +134 -0
- package/lib/commands/serve.js +26 -1
- package/lib/commands/status.js +23 -51
- package/lib/commands/update.js +36 -107
- package/lib/core/Chunker.js +491 -17
- package/lib/core/CorpusDenyLint.js +326 -0
- package/lib/core/CorpusWalker.js +357 -0
- package/lib/core/Hydrator.js +75 -29
- package/lib/core/ManifestLoader.js +533 -0
- package/lib/core/SiteWalker.js +218 -0
- package/lib/core/Syncer.js +163 -11
- package/lib/diagnostic-log.js +41 -0
- package/lib/environment-report.js +100 -0
- package/lib/global-config.js +4 -1
- package/lib/oauth-client.js +164 -0
- package/lib/origin.js +143 -0
- package/lib/service-api-client.js +95 -0
- package/lib/wallet-environment.js +49 -0
- package/lib/wallet-file.js +16 -2
- package/lib/wizard/setup.js +10 -9
- package/lib/workspace-config.js +380 -235
- package/lib/workspace-identity.js +92 -0
- package/lib/workspace-utils.js +41 -61
- package/package.json +19 -6
- package/templates/agent-claude.md +157 -9
- package/templates/agent-cline.md +68 -8
- package/templates/agent-copilot.md +68 -8
- package/templates/agent-cursor.md +68 -8
- package/templates/alignment-schema.json +20 -0
- package/templates/cloud-brain-hook.sh +20 -0
- package/templates/drive/README.md +1 -1
- package/templates/drive_templates/README.md +2 -2
- package/templates/scaffolds/README.md +6 -10
- package/templates/scaffolds/microservice/SERVICE_README_sdk.md +104 -149
- package/templates/scaffolds/microservice/app.js +51 -10
- package/templates/scaffolds/microservice/app.yaml +2 -2
- package/templates/scaffolds/microservice/defaults-config-dev.json +4 -0
- package/templates/scaffolds/microservice/dev-overrides.example.json +5 -0
- package/templates/scaffolds/microservice/manifest.json +0 -1
- package/templates/scaffolds/microservice/package.json +3 -3
- package/templates/scaffolds/microservice/scripts/register.js +56 -41
- package/templates/scaffolds/microservice/services/mcpClient.js +55 -65
- package/templates/scaffolds/microservice/services/utils.js +20 -238
- package/templates/scaffolds/microservice/templates/SERVICE_README_TEMPLATE.md +24 -0
- package/lib/commands/app-wizard.js +0 -627
- package/lib/commands/folder.js +0 -291
- package/lib/core/PathContext.js +0 -335
- package/templates/scaffolds/microservice/dev-overrides.json.example +0 -5
- package/templates/scaffolds/site/DeSciXAppSDK.js +0 -62
- package/templates/scaffolds/site/README.md +0 -51
- package/templates/scaffolds/site/app.js +0 -22
- package/templates/scaffolds/site/index.html +0 -42
- package/templates/scaffolds/site/styles.css +0 -123
package/lib/api-client.js
CHANGED
|
@@ -1,30 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DeSciX HTTP-Only API Client
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
4
|
* Self-contained HTTP client for CLI/MCP.
|
|
5
5
|
* No service imports - all communication via HTTP.
|
|
6
|
-
* Uses
|
|
6
|
+
* Uses WorkspaceConfig for workspace/config resolution.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import path from 'path';
|
|
10
10
|
import axios from 'axios';
|
|
11
|
-
import {
|
|
11
|
+
import { WorkspaceConfig } from './workspace-config.js';
|
|
12
|
+
import { resolveOrigin } from './origin.js';
|
|
13
|
+
import { reportEnvironment, EXPLICIT_ORIGIN_SOURCE } from './environment-report.js';
|
|
14
|
+
import { diag } from './diagnostic-log.js';
|
|
12
15
|
|
|
13
16
|
export class DeSciXApiClient {
|
|
14
17
|
constructor(options = {}) {
|
|
15
18
|
this.baseUrl = options.baseUrl || null; // Will be loaded async
|
|
16
19
|
this.credentials = null;
|
|
17
20
|
this.workspaceRoot = options.workspaceRoot || null; // Set by initialize()
|
|
18
|
-
this.
|
|
21
|
+
this._workspaceConfig = null; // Cached WorkspaceConfig
|
|
19
22
|
this._initialized = false;
|
|
23
|
+
// serviceMode: a headless service-context client (an app microservice reusing this
|
|
24
|
+
// api-client to call /apifront AS the developer). It NEVER falls back to interactive
|
|
25
|
+
// device login on a 401 — a Cloud Run service has no TTY — it hard-fails instead.
|
|
26
|
+
this.serviceMode = options.serviceMode === true;
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
/**
|
|
23
30
|
* Initialize the client by finding workspace root and loading config
|
|
24
31
|
* This is the primary initialization method - call before any API operations
|
|
25
|
-
*
|
|
26
|
-
* Uses
|
|
27
|
-
*
|
|
32
|
+
*
|
|
33
|
+
* Uses WorkspaceConfig for unified path resolution.
|
|
34
|
+
*
|
|
28
35
|
* @returns {Promise<void>}
|
|
29
36
|
*/
|
|
30
37
|
async initialize() {
|
|
@@ -32,19 +39,35 @@ export class DeSciXApiClient {
|
|
|
32
39
|
return;
|
|
33
40
|
}
|
|
34
41
|
|
|
35
|
-
// Step 1: Try to load
|
|
36
|
-
this.
|
|
42
|
+
// Step 1: Try to load WorkspaceConfig (finds .descix/workspace.json upward)
|
|
43
|
+
this._workspaceConfig = await WorkspaceConfig.tryLoad();
|
|
37
44
|
|
|
38
|
-
if (this.
|
|
39
|
-
this.workspaceRoot = this.
|
|
45
|
+
if (this._workspaceConfig) {
|
|
46
|
+
this.workspaceRoot = this._workspaceConfig.getWorkspaceRoot();
|
|
40
47
|
} else {
|
|
41
48
|
this.workspaceRoot = null;
|
|
42
49
|
}
|
|
43
50
|
|
|
44
|
-
// Step 2:
|
|
51
|
+
// Step 2: Settle the API origin, and SAY SO.
|
|
52
|
+
//
|
|
53
|
+
// THE PRINT LIVES HERE, NOT IN detectApiUrl(), AND THAT IS THE WHOLE POINT. detectApiUrl()
|
|
54
|
+
// is only reached when `baseUrl` was NOT supplied to the constructor. Six construction
|
|
55
|
+
// sites DO supply it (service-api-client, wizard/setup, commands/auth, commands/status,
|
|
56
|
+
// commands/doctor, bin/mcp-server), so a print hung off detectApiUrl() would go silent on
|
|
57
|
+
// exactly the paths a developer is most likely to be pointing somewhere unexpected.
|
|
58
|
+
// initialize() is the one seam both paths cross. (Enumerated across all 87
|
|
59
|
+
// `new DeSciXApiClient(` sites, 2026-08-30.)
|
|
60
|
+
let originSource;
|
|
45
61
|
if (!this.baseUrl) {
|
|
46
|
-
|
|
62
|
+
const resolved = await this.detectApiUrl();
|
|
63
|
+
this.baseUrl = resolved.origin;
|
|
64
|
+
originSource = resolved.source;
|
|
65
|
+
} else {
|
|
66
|
+
this.baseUrl = String(this.baseUrl).trim().replace(/\/+$/, '');
|
|
67
|
+
originSource = EXPLICIT_ORIGIN_SOURCE;
|
|
47
68
|
}
|
|
69
|
+
// I1 (A', rev 2): ALWAYS, not default-only. Silence was the defect; PROD was not.
|
|
70
|
+
reportEnvironment({ origin: this.baseUrl, source: originSource });
|
|
48
71
|
|
|
49
72
|
// Step 3: Load credentials from known wallet location
|
|
50
73
|
await this.loadCredentials();
|
|
@@ -53,34 +76,36 @@ export class DeSciXApiClient {
|
|
|
53
76
|
}
|
|
54
77
|
|
|
55
78
|
/**
|
|
56
|
-
* Detect API
|
|
57
|
-
*
|
|
79
|
+
* Detect the API origin from workspace config or environment.
|
|
80
|
+
*
|
|
81
|
+
* Returns the SOURCE alongside the origin, because "where did this come from" is the question
|
|
82
|
+
* every misreport in this contract failed to answer, and the caller must print it.
|
|
83
|
+
*
|
|
84
|
+
* @returns {Promise<{origin: string, source: string, isDefault: boolean}>}
|
|
58
85
|
*/
|
|
59
86
|
async detectApiUrl() {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
// Use PathContext config if loaded (but only if it has an explicit URL, not the production default)
|
|
68
|
-
if (this._pathContext) {
|
|
69
|
-
const apiUrl = this._pathContext.getApiUrl();
|
|
70
|
-
if (apiUrl && apiUrl !== PRODUCTION_URL) {
|
|
71
|
-
return apiUrl;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Check global config (set by `descix login --dev` or `descix config set-url`)
|
|
87
|
+
// Every branch of this function used to be able to yield the production origin without the
|
|
88
|
+
// developer having chosen it, and the `!== PRODUCTION_URL` comparisons made it WORSE than a
|
|
89
|
+
// plain fallback: a workspace that had deliberately set prod was treated as if it had set
|
|
90
|
+
// nothing and skipped, so "chosen" and "unconfigured" were provably indistinguishable. The
|
|
91
|
+
// precedence and the fail-loud now live in the one origin owner.
|
|
92
|
+
let globalApiUrl = null;
|
|
76
93
|
try {
|
|
77
94
|
const { GlobalConfig } = await import('./global-config.js');
|
|
78
95
|
const gc = await GlobalConfig.load();
|
|
79
|
-
|
|
96
|
+
globalApiUrl = gc.api_url || null;
|
|
80
97
|
} catch {}
|
|
81
98
|
|
|
82
|
-
//
|
|
83
|
-
|
|
99
|
+
// Never returns null and never throws for an ABSENT configuration: nothing configured is
|
|
100
|
+
// the declared default (PROD), carrying `source: default`, which the caller prints. It DOES
|
|
101
|
+
// throw OriginInvalidError when a source named an origin that cannot be used — silently
|
|
102
|
+
// discarding what the developer typed is the same defect from the other direction.
|
|
103
|
+
return resolveOrigin({
|
|
104
|
+
envVar: process.env.DESCIX_API_URL,
|
|
105
|
+
workspaceEnvApiUrl: this._workspaceConfig?.env?.apiUrl,
|
|
106
|
+
legacyApiUrl: this._workspaceConfig?.apiUrl,
|
|
107
|
+
globalApiUrl,
|
|
108
|
+
});
|
|
84
109
|
}
|
|
85
110
|
|
|
86
111
|
/**
|
|
@@ -102,6 +127,86 @@ export class DeSciXApiClient {
|
|
|
102
127
|
return this.baseUrl;
|
|
103
128
|
}
|
|
104
129
|
|
|
130
|
+
/**
|
|
131
|
+
* WS-HEADLESS-MVP-A1 (mcp-oauth-longlived-tokens design §2.4, Option 4A) — attach the
|
|
132
|
+
* cached OAuth access token as the standard `Authorization: Bearer` header when the
|
|
133
|
+
* credential cache holds a FRESH one.
|
|
134
|
+
*
|
|
135
|
+
* This REPLACES the retired gcloud-IAM transport seam (_isIamGatedOrigin/_mintIamBearer/
|
|
136
|
+
* _applyIamAuthIfNeeded): `apiFront-http-dev` now deploys --allow-unauthenticated and the
|
|
137
|
+
* OAuth bearer — verified by the app layer (verifyMcpAccessToken on /mcp; scope/exposure
|
|
138
|
+
* gate + checkCommandPermission) — is the wall. No Google identity token, no gcloud, no
|
|
139
|
+
* ~1h SSO reauth. The CLI's session credential still travels in the request BODY for the
|
|
140
|
+
* /apifront auth middleware (the wallet-sig/API_KEY headless path, CEO-D-2026-06-02).
|
|
141
|
+
*
|
|
142
|
+
* ensureSession() keeps the token fresh (silent refresh); this method only ATTACHES it.
|
|
143
|
+
* No-op when no OAuth credential is cached — never a fabricated fallback.
|
|
144
|
+
*
|
|
145
|
+
* @param {Object} axiosConfig
|
|
146
|
+
* @returns {Object}
|
|
147
|
+
*/
|
|
148
|
+
_applyOAuthBearerIfAvailable(axiosConfig) {
|
|
149
|
+
const oauthBlock = this.credentials?.oauth;
|
|
150
|
+
if (!oauthBlock?.access_token) return axiosConfig;
|
|
151
|
+
axiosConfig.headers = { ...(axiosConfig.headers || {}), Authorization: `Bearer ${oauthBlock.access_token}` };
|
|
152
|
+
return axiosConfig;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* WS-HEADLESS-MVP-A1 (design §2.2/§4.1) — OAuth silent refresh, the gcloud-ADC analogue.
|
|
157
|
+
*
|
|
158
|
+
* When the cached OAuth access token is expired (or within the 60s skew window), POST
|
|
159
|
+
* grant_type=refresh_token to /oauth/token. The AS ROTATES the refresh token on use, so
|
|
160
|
+
* BOTH returned tokens are persisted back to wallet.json immediately — losing the rotated
|
|
161
|
+
* refresh token would trip the AS's reuse-detection and revoke the whole chain.
|
|
162
|
+
*
|
|
163
|
+
* Non-interactive for up to 30 days of inactivity (sliding window under rotation). If the
|
|
164
|
+
* refresh is rejected (revoked/expired chain), we FAIL LOUD once per process and leave the
|
|
165
|
+
* wallet-sig path — which is untouched by the OAuth branch — to carry the session; the
|
|
166
|
+
* user re-runs `descix login` to re-establish OAuth.
|
|
167
|
+
*/
|
|
168
|
+
async _ensureOAuthAccessToken() {
|
|
169
|
+
const oauthBlock = this.credentials?.oauth;
|
|
170
|
+
if (!oauthBlock?.refresh_token || this._oauthRefreshFailed) return;
|
|
171
|
+
|
|
172
|
+
const { isOAuthAccessTokenFresh, refreshOAuthTokens, buildOAuthCredentialBlock } = await import('./oauth-client.js');
|
|
173
|
+
if (isOAuthAccessTokenFresh(oauthBlock)) return;
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
const tokens = await refreshOAuthTokens(this.baseUrl, {
|
|
177
|
+
refreshToken: oauthBlock.refresh_token,
|
|
178
|
+
clientId: oauthBlock.client_id,
|
|
179
|
+
});
|
|
180
|
+
const updated = buildOAuthCredentialBlock({
|
|
181
|
+
clientId: oauthBlock.client_id,
|
|
182
|
+
tokens,
|
|
183
|
+
tokenEndpointBase: this.baseUrl,
|
|
184
|
+
});
|
|
185
|
+
this.credentials.oauth = updated;
|
|
186
|
+
|
|
187
|
+
// Persist the ROTATED pair to wallet.json (same write path as the session refresh).
|
|
188
|
+
try {
|
|
189
|
+
const { WalletFileManager } = await import('./wallet-file.js');
|
|
190
|
+
const walletPath = await WalletFileManager.findWalletFile(this.workspaceRoot || process.cwd());
|
|
191
|
+
if (walletPath) {
|
|
192
|
+
const walletData = await WalletFileManager.loadWalletFile(walletPath);
|
|
193
|
+
if (walletData) {
|
|
194
|
+
walletData.oauth = updated;
|
|
195
|
+
await WalletFileManager.saveWalletFile(walletPath, walletData);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
} catch (saveErr) {
|
|
199
|
+
console.error('[ApiClient] Failed to persist rotated OAuth tokens to wallet file:', saveErr.message);
|
|
200
|
+
}
|
|
201
|
+
} catch (error) {
|
|
202
|
+
// Revoked/expired refresh chain (or AS unreachable): loud, once per process. The
|
|
203
|
+
// wallet-sig session path proceeds independently — no silent credential fabrication.
|
|
204
|
+
this._oauthRefreshFailed = true;
|
|
205
|
+
console.error(`[ApiClient] OAuth silent refresh failed: ${error.message}. ` +
|
|
206
|
+
`Run 'descix login' to re-establish OAuth credentials.`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
105
210
|
/**
|
|
106
211
|
* Build request body for API call
|
|
107
212
|
* @param {string} command - Command name
|
|
@@ -135,11 +240,19 @@ export class DeSciXApiClient {
|
|
|
135
240
|
* @returns {Promise<void>}
|
|
136
241
|
*/
|
|
137
242
|
async ensureSession() {
|
|
243
|
+
// WS-HEADLESS-MVP-A1 OAuth branch: keep the cached OAuth access token fresh (silent
|
|
244
|
+
// refresh against /oauth/token). Runs ONLY when an `oauth` credential block exists in
|
|
245
|
+
// wallet.json (written by `descix login`) — the wallet-sig session path below is
|
|
246
|
+
// untouched and remains the headless/API_KEY credential (design §2.6).
|
|
247
|
+
if (this.credentials?.oauth?.refresh_token) {
|
|
248
|
+
await this._ensureOAuthAccessToken();
|
|
249
|
+
}
|
|
250
|
+
|
|
138
251
|
// If we already have an access token, assume it's valid (will retry if expired)
|
|
139
252
|
if (this.credentials?.accessToken) {
|
|
140
253
|
return;
|
|
141
254
|
}
|
|
142
|
-
|
|
255
|
+
|
|
143
256
|
// If we have wallet credentials but no session, get one via reconnect_by_wallet
|
|
144
257
|
if (this.credentials?.walletAddress && this.credentials?.signature) {
|
|
145
258
|
try {
|
|
@@ -190,16 +303,16 @@ export class DeSciXApiClient {
|
|
|
190
303
|
* @returns {Promise<Object>} Response data
|
|
191
304
|
*/
|
|
192
305
|
async invokeRaw(command, params = {}, credentials = null, options = {}) {
|
|
193
|
-
|
|
306
|
+
diag(`[ApiClient] invoking ${command}...`);
|
|
194
307
|
await this.ensureBaseUrl();
|
|
195
|
-
|
|
308
|
+
diag(`[ApiClient] Base URL: ${this.baseUrl}`);
|
|
196
309
|
|
|
197
310
|
const requestBody = this.buildRequestBody(command, params, credentials);
|
|
198
311
|
if (options.skipSessionCheck) {
|
|
199
312
|
requestBody.access_token = null; // Force guest-like call for refresh
|
|
200
313
|
}
|
|
201
314
|
const url = `${this.baseUrl}/apifront/`;
|
|
202
|
-
|
|
315
|
+
diag(`[ApiClient] POST ${url}`);
|
|
203
316
|
|
|
204
317
|
const axiosConfig = {
|
|
205
318
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -212,16 +325,23 @@ export class DeSciXApiClient {
|
|
|
212
325
|
rejectUnauthorized: false
|
|
213
326
|
});
|
|
214
327
|
}
|
|
215
|
-
|
|
328
|
+
|
|
329
|
+
// WS-A1: OAuth bearer rides the standard Authorization header (no-op without a cache).
|
|
330
|
+
this._applyOAuthBearerIfAvailable(axiosConfig);
|
|
331
|
+
|
|
216
332
|
try {
|
|
217
333
|
const response = await axios.post(url, requestBody, axiosConfig);
|
|
218
|
-
|
|
334
|
+
diag(`[ApiClient] Response status: ${response.status}`);
|
|
219
335
|
const data = response.data;
|
|
220
336
|
|
|
221
337
|
if (data.status !== 'OK') {
|
|
222
338
|
const error = new Error(data.message || 'API request failed');
|
|
223
339
|
error.status = data.status;
|
|
224
340
|
error.response = data;
|
|
341
|
+
// WS-HEADLESS-MVP-A3: surface structured error fields (e.g. CREDITS_REQUIRED
|
|
342
|
+
// code + purchasable-action data) to CLI/MCP consumers.
|
|
343
|
+
if (data.code) error.code = data.code;
|
|
344
|
+
if (data.data) error.data = data.data;
|
|
225
345
|
throw error;
|
|
226
346
|
}
|
|
227
347
|
|
|
@@ -232,6 +352,32 @@ export class DeSciXApiClient {
|
|
|
232
352
|
}
|
|
233
353
|
}
|
|
234
354
|
|
|
355
|
+
/**
|
|
356
|
+
* WS-MCP-SURFACE-SPLIT §10.2 — fetch the PERMISSION-FILTERED MCP tool catalog for THIS caller.
|
|
357
|
+
*
|
|
358
|
+
* Calls the backend `list_mcp_tools` command over the standard /apifront session/wallet path
|
|
359
|
+
* (the proven CLI auth: device-flow session token, with automatic reconnect-by-wallet refresh).
|
|
360
|
+
* The backend returns the catalog already filtered by the SAME server-side checkCommandPermission
|
|
361
|
+
* gate the HTTP /mcp tools/list uses — ONE describe-as-permission-gate, no parallel taxonomy in
|
|
362
|
+
* the CLI. `--admin` is just the operator's wallet carrying admin entitlements (cred-gated, §9.1).
|
|
363
|
+
*
|
|
364
|
+
* NOTE we deliberately do NOT hit /mcp here: that endpoint's bearer path is OAuth-only and its
|
|
365
|
+
* signature path needs a REGISTERED api-signature — neither is the CLI's session token. The
|
|
366
|
+
* command path reuses invoke()'s working session auth.
|
|
367
|
+
*
|
|
368
|
+
* @returns {Promise<Array<{name, description, inputSchema}>>}
|
|
369
|
+
*/
|
|
370
|
+
async mcpListTools(options = {}) {
|
|
371
|
+
// The console.log monkey-patch that stood here is DELETED, not kept as insurance. It routed
|
|
372
|
+
// stdout to stderr for the duration of THIS call only — a call-site workaround for a
|
|
373
|
+
// write-site defect, which protected one path and left every other path through
|
|
374
|
+
// invoke()/invokeRaw() corrupting the stdio protocol stream. Diagnostics now go to stderr at
|
|
375
|
+
// the point of writing (lib/diagnostic-log.js), so there is nothing left to wrap.
|
|
376
|
+
const resp = await this.invoke('list_mcp_tools', {}, options);
|
|
377
|
+
const msg = resp?.message || resp;
|
|
378
|
+
return msg?.tools || [];
|
|
379
|
+
}
|
|
380
|
+
|
|
235
381
|
/**
|
|
236
382
|
* Invoke a command via HTTP with automatic session management
|
|
237
383
|
* @param {string} command - Command name
|
|
@@ -279,7 +425,10 @@ export class DeSciXApiClient {
|
|
|
279
425
|
rejectUnauthorized: false
|
|
280
426
|
});
|
|
281
427
|
}
|
|
282
|
-
|
|
428
|
+
|
|
429
|
+
// WS-A1: OAuth bearer rides the standard Authorization header (no-op without a cache).
|
|
430
|
+
this._applyOAuthBearerIfAvailable(axiosConfig);
|
|
431
|
+
|
|
283
432
|
const response = await axios.post(url, requestBody, axiosConfig);
|
|
284
433
|
|
|
285
434
|
const data = response.data;
|
|
@@ -288,6 +437,10 @@ export class DeSciXApiClient {
|
|
|
288
437
|
const error = new Error(data.message || 'API request failed');
|
|
289
438
|
error.status = data.status;
|
|
290
439
|
error.response = data;
|
|
440
|
+
// WS-HEADLESS-MVP-A3: surface structured error fields (e.g. CREDITS_REQUIRED
|
|
441
|
+
// code + purchasable-action data) to CLI/MCP consumers.
|
|
442
|
+
if (data.code) error.code = data.code;
|
|
443
|
+
if (data.data) error.data = data.data;
|
|
291
444
|
throw error;
|
|
292
445
|
}
|
|
293
446
|
|
|
@@ -316,10 +469,18 @@ export class DeSciXApiClient {
|
|
|
316
469
|
// If we got a new token, retry the original request
|
|
317
470
|
if (this.credentials?.accessToken) {
|
|
318
471
|
return this.invoke(command, params, { ...options, _retried: true });
|
|
472
|
+
} else if (this.serviceMode) {
|
|
473
|
+
// Headless service context: no interactive device login is possible. The developer's
|
|
474
|
+
// wallet_address + signature failed to mint/refresh a session — fail loud (no fallback).
|
|
475
|
+
throw new Error(
|
|
476
|
+
`[ServiceApiClient] reconnect_by_wallet failed for command '${command}': the developer ` +
|
|
477
|
+
`credential (wallet_address + signature) did not yield a session. Check the credential in ` +
|
|
478
|
+
`dev-overrides.json (dev) / the secret (prod). No interactive login fallback in a microservice.`
|
|
479
|
+
);
|
|
319
480
|
} else if (command !== 'device_request_login' && command !== 'device_check_status') {
|
|
320
481
|
// If refresh failed (e.g. signature invalid or user not found),
|
|
321
482
|
// we should trigger device login if this is a CLI environment
|
|
322
|
-
|
|
483
|
+
diag('[ApiClient] Session refresh failed, triggering device login...');
|
|
323
484
|
const { loginDevice } = await import('./commands/auth.js');
|
|
324
485
|
await loginDevice({ url: this.baseUrl, workspaceRoot: this.workspaceRoot });
|
|
325
486
|
|
|
@@ -339,6 +500,10 @@ export class DeSciXApiClient {
|
|
|
339
500
|
const apiError = new Error(errorData.message || `HTTP ${error.response.status}`);
|
|
340
501
|
apiError.status = error.response.status;
|
|
341
502
|
apiError.response = errorData;
|
|
503
|
+
// WS-HEADLESS-MVP-A3: ferry structured error fields (code + purchasable-action
|
|
504
|
+
// data, e.g. CREDITS_REQUIRED/402) through the axios error path.
|
|
505
|
+
if (errorData.code) apiError.code = errorData.code;
|
|
506
|
+
if (errorData.data) apiError.data = errorData.data;
|
|
342
507
|
throw apiError;
|
|
343
508
|
} else if (error.code === 'ECONNREFUSED') {
|
|
344
509
|
throw new Error(`Cannot connect to ${this.baseUrl}. Is the backend server running?`);
|
|
@@ -361,19 +526,18 @@ export class DeSciXApiClient {
|
|
|
361
526
|
return this.credentials;
|
|
362
527
|
}
|
|
363
528
|
|
|
364
|
-
// Use
|
|
365
|
-
if (!this.workspaceRoot && this.
|
|
366
|
-
this.workspaceRoot = this.
|
|
529
|
+
// Use WorkspaceConfig's workspace root if available
|
|
530
|
+
if (!this.workspaceRoot && this._workspaceConfig) {
|
|
531
|
+
this.workspaceRoot = this._workspaceConfig.getWorkspaceRoot();
|
|
367
532
|
}
|
|
368
533
|
|
|
369
|
-
// If still no workspace root, try:
|
|
534
|
+
// If still no workspace root, try: WorkspaceConfig → wallet.json walk
|
|
370
535
|
if (!this.workspaceRoot) {
|
|
371
|
-
const
|
|
372
|
-
if (
|
|
373
|
-
this.workspaceRoot =
|
|
374
|
-
this.
|
|
536
|
+
const wsConfig = await WorkspaceConfig.tryLoad();
|
|
537
|
+
if (wsConfig) {
|
|
538
|
+
this.workspaceRoot = wsConfig.getWorkspaceRoot();
|
|
539
|
+
this._workspaceConfig = wsConfig;
|
|
375
540
|
} else {
|
|
376
|
-
const { WorkspaceConfig } = await import('./workspace-config.js');
|
|
377
541
|
this.workspaceRoot = await WorkspaceConfig.findWorkspaceRoot();
|
|
378
542
|
}
|
|
379
543
|
// Final fallback: walk up from cwd looking for .descix/wallet.json
|
|
@@ -410,7 +574,10 @@ export class DeSciXApiClient {
|
|
|
410
574
|
walletAddress: walletData.walletAddress || null,
|
|
411
575
|
signature: walletData.signature || null,
|
|
412
576
|
communityId: walletData.communityId || null,
|
|
413
|
-
tokenSymbol: walletData.tokenSymbol || null
|
|
577
|
+
tokenSymbol: walletData.tokenSymbol || null,
|
|
578
|
+
// WS-A1: OAuth credential block (gcloud-ADC analogue) — null when never logged in
|
|
579
|
+
// via the device→OAuth bridge; the wallet-sig path is then the sole credential.
|
|
580
|
+
oauth: walletData.oauth || null
|
|
414
581
|
};
|
|
415
582
|
|
|
416
583
|
return this.credentials;
|
|
@@ -437,12 +604,12 @@ export class DeSciXApiClient {
|
|
|
437
604
|
}
|
|
438
605
|
|
|
439
606
|
/**
|
|
440
|
-
* Get the
|
|
607
|
+
* Get the WorkspaceConfig instance (if loaded)
|
|
441
608
|
* Useful for commands that need path resolution
|
|
442
|
-
* @returns {
|
|
609
|
+
* @returns {WorkspaceConfig|null}
|
|
443
610
|
*/
|
|
444
|
-
|
|
445
|
-
return this.
|
|
611
|
+
getWorkspaceConfig() {
|
|
612
|
+
return this._workspaceConfig;
|
|
446
613
|
}
|
|
447
614
|
}
|
|
448
615
|
|
package/lib/auth-guard.js
CHANGED
|
@@ -14,6 +14,16 @@ import { DeSciXApiClient } from './api-client.js';
|
|
|
14
14
|
* @returns {Promise<Object>} User information if authenticated
|
|
15
15
|
*/
|
|
16
16
|
export async function requireAuth(apiClient) {
|
|
17
|
+
// Settle (and PRINT) the origin BEFORE the credential check, not after it.
|
|
18
|
+
//
|
|
19
|
+
// MEASURED 2026-08-30: this guard called loadCredentials() directly and exited 1 on a missing
|
|
20
|
+
// credential, so initialize() never ran and the env line never printed. An unauthenticated
|
|
21
|
+
// developer was told "Authentication required" with no statement of WHICH environment they
|
|
22
|
+
// had failed to authenticate against — the exact confusion this contract exists to end, on
|
|
23
|
+
// the path where it is most likely to bite. ensureInitialized() is idempotent, so the later
|
|
24
|
+
// invoke() below re-uses this same initialisation.
|
|
25
|
+
await apiClient.ensureInitialized();
|
|
26
|
+
|
|
17
27
|
// Load credentials from wallet file
|
|
18
28
|
await apiClient.loadCredentials();
|
|
19
29
|
|
|
@@ -24,9 +34,9 @@ export async function requireAuth(apiClient) {
|
|
|
24
34
|
process.exit(1);
|
|
25
35
|
}
|
|
26
36
|
|
|
27
|
-
// Validate session is still active by calling
|
|
37
|
+
// Validate session is still active by calling validate_session via HTTP
|
|
28
38
|
try {
|
|
29
|
-
const response = await apiClient.invoke('
|
|
39
|
+
const response = await apiClient.invoke('validate_session', {}, { allowGuest: false });
|
|
30
40
|
if (response.status === 'OK' && response.message) {
|
|
31
41
|
return response.message;
|
|
32
42
|
}
|
|
@@ -58,7 +68,7 @@ export async function isAuthenticated(apiClient) {
|
|
|
58
68
|
return false;
|
|
59
69
|
}
|
|
60
70
|
// Try a lightweight check via HTTP
|
|
61
|
-
const response = await apiClient.invoke('
|
|
71
|
+
const response = await apiClient.invoke('validate_session', {}, { allowGuest: false });
|
|
62
72
|
return response.status === 'OK';
|
|
63
73
|
} catch (error) {
|
|
64
74
|
return false;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat session state — the owner module for the CLI's per-app conversation thread pointer.
|
|
3
|
+
*
|
|
4
|
+
* Conversation state itself lives SERVER-SIDE at the model provider; all the CLI keeps is an
|
|
5
|
+
* opaque DeSciX interaction token per app in `~/.descix/sessions/{community_id}_{app_id}.json`.
|
|
6
|
+
* This module owns reading, writing, and forgetting that pointer, plus the one rule for when a
|
|
7
|
+
* dead pointer may be self-healed.
|
|
8
|
+
*
|
|
9
|
+
* Extracted out of `bin/descix.js` (ws-cli-stale-thread-selfheal) so the self-heal decision is
|
|
10
|
+
* unit-testable in isolation — in particular so the 400-heals / 403-never-heals asymmetry can be
|
|
11
|
+
* proven by a test rather than asserted in a comment.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'fs/promises';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
import os from 'os';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get interaction_id from session file
|
|
20
|
+
* @param {string} communityId
|
|
21
|
+
* @param {string} appId
|
|
22
|
+
* @returns {Promise<string|null>}
|
|
23
|
+
*/
|
|
24
|
+
export async function getSessionInteractionId(communityId, appId) {
|
|
25
|
+
const sessionPath = path.join(os.homedir(), '.descix', 'sessions', `${communityId}_${appId}.json`);
|
|
26
|
+
try {
|
|
27
|
+
const data = JSON.parse(await fs.readFile(sessionPath, 'utf-8'));
|
|
28
|
+
return data.interaction_id || null;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Save interaction_id to session file
|
|
36
|
+
* @param {string} communityId
|
|
37
|
+
* @param {string} appId
|
|
38
|
+
* @param {string} interactionId
|
|
39
|
+
*/
|
|
40
|
+
async function saveSessionInteractionId(communityId, appId, interactionId) {
|
|
41
|
+
const sessionDir = path.join(os.homedir(), '.descix', 'sessions');
|
|
42
|
+
await fs.mkdir(sessionDir, { recursive: true });
|
|
43
|
+
const sessionPath = path.join(sessionDir, `${communityId}_${appId}.json`);
|
|
44
|
+
await fs.writeFile(sessionPath, JSON.stringify({
|
|
45
|
+
interaction_id: interactionId,
|
|
46
|
+
updated: Date.now()
|
|
47
|
+
}, null, 2));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Clear session file
|
|
52
|
+
* @param {string} communityId
|
|
53
|
+
* @param {string} appId
|
|
54
|
+
*/
|
|
55
|
+
async function clearSession(communityId, appId) {
|
|
56
|
+
const sessionPath = path.join(os.homedir(), '.descix', 'sessions', `${communityId}_${appId}.json`);
|
|
57
|
+
try {
|
|
58
|
+
await fs.unlink(sessionPath);
|
|
59
|
+
} catch {
|
|
60
|
+
// File doesn't exist, that's fine
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* WS-R7-PREREQS (CEO-D-2026-07-04-R7-PREREQS-RESCOPE ruling 3) session helpers.
|
|
66
|
+
*
|
|
67
|
+
* Session files are keyed {community_id}_{app_id}.json where community_id is the
|
|
68
|
+
* SERVER-resolved value (returned by app-scoped commands). Before the server response
|
|
69
|
+
* arrives the community may be unknown client-side — app_id is globally unique on the
|
|
70
|
+
* platform (product_id === app_id), so `*_{appId}.json` identifies the app's sessions
|
|
71
|
+
* unambiguously and the newest file is the current thread.
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* List existing session files for an app, newest first.
|
|
76
|
+
* @param {string} appId
|
|
77
|
+
* @returns {Promise<Array<{communityId: string, path: string, updated: number}>>}
|
|
78
|
+
*/
|
|
79
|
+
export async function findSessionsForApp(appId) {
|
|
80
|
+
const sessionDir = path.join(os.homedir(), '.descix', 'sessions');
|
|
81
|
+
const suffix = `_${appId}.json`;
|
|
82
|
+
let entries = [];
|
|
83
|
+
try {
|
|
84
|
+
entries = await fs.readdir(sessionDir);
|
|
85
|
+
} catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
const results = [];
|
|
89
|
+
for (const name of entries) {
|
|
90
|
+
if (!name.endsWith(suffix)) continue;
|
|
91
|
+
const communityId = name.slice(0, name.length - suffix.length);
|
|
92
|
+
if (!communityId) continue;
|
|
93
|
+
const filePath = path.join(sessionDir, name);
|
|
94
|
+
let updated = 0;
|
|
95
|
+
try {
|
|
96
|
+
const data = JSON.parse(await fs.readFile(filePath, 'utf-8'));
|
|
97
|
+
updated = data.updated || 0;
|
|
98
|
+
} catch {
|
|
99
|
+
continue; // unreadable session file — skip, do not guess
|
|
100
|
+
}
|
|
101
|
+
results.push({ communityId, path: filePath, updated });
|
|
102
|
+
}
|
|
103
|
+
results.sort((a, b) => b.updated - a.updated);
|
|
104
|
+
return results;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Save the session under the AUTHORITATIVE community key and self-heal: remove any
|
|
109
|
+
* session files for the same app keyed under a DIFFERENT community (e.g. the legacy
|
|
110
|
+
* hardcoded 'descix_*' mis-keys this fix removes). One app = one live session file.
|
|
111
|
+
* @param {string} communityId - server-resolved community_id
|
|
112
|
+
* @param {string} appId
|
|
113
|
+
* @param {string} interactionId
|
|
114
|
+
*/
|
|
115
|
+
export async function saveSessionAuthoritative(communityId, appId, interactionId) {
|
|
116
|
+
const stale = (await findSessionsForApp(appId)).filter(s => s.communityId !== communityId);
|
|
117
|
+
await saveSessionInteractionId(communityId, appId, interactionId);
|
|
118
|
+
for (const st of stale) {
|
|
119
|
+
try { await fs.unlink(st.path); } catch { /* already gone */ }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Clear this app's chat session(s) — the ONE owner of "forget the current thread".
|
|
125
|
+
*
|
|
126
|
+
* Mirrors the read path: with a known community it clears the exact key; without one it
|
|
127
|
+
* clears every session file for the app (app_id is globally unique, so `*_{appId}.json` is
|
|
128
|
+
* unambiguous). Previously this logic was hand-copied at three call sites (`chat --new`,
|
|
129
|
+
* `new-chat` with/without `-c`); the self-heal below would have made it four. One owner
|
|
130
|
+
* means the heal can never diverge from what `--new` does.
|
|
131
|
+
*
|
|
132
|
+
* @param {string|null} communityId
|
|
133
|
+
* @param {string} appId
|
|
134
|
+
* @returns {Promise<number>} how many session files were removed
|
|
135
|
+
*/
|
|
136
|
+
export async function clearAppSessions(communityId, appId) {
|
|
137
|
+
if (communityId) {
|
|
138
|
+
const sessionPath = path.join(os.homedir(), '.descix', 'sessions', `${communityId}_${appId}.json`);
|
|
139
|
+
let existed = false;
|
|
140
|
+
try { await fs.access(sessionPath); existed = true; } catch { /* nothing to clear */ }
|
|
141
|
+
await clearSession(communityId, appId);
|
|
142
|
+
return existed ? 1 : 0;
|
|
143
|
+
}
|
|
144
|
+
const sessions = await findSessionsForApp(appId);
|
|
145
|
+
for (const st of sessions) {
|
|
146
|
+
try { await fs.unlink(st.path); } catch { /* already gone */ }
|
|
147
|
+
}
|
|
148
|
+
return sessions.length;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* ── Stale-thread self-heal (ws-cli-stale-thread-selfheal) ────────────────────────────────
|
|
153
|
+
*
|
|
154
|
+
* Conversation state lives server-side at the model provider; the CLI holds only an opaque
|
|
155
|
+
* DeSciX interaction token in `~/.descix/sessions/{community}_{app}.json`. A stored token can
|
|
156
|
+
* stop being resumable for reasons that are nobody's fault — the provider's retention window
|
|
157
|
+
* (~55 days) elapsed, or the token predates the current token format. The backend correctly
|
|
158
|
+
* refuses to answer statelessly and pretends nothing happened; it raises this typed 400. The
|
|
159
|
+
* right client behaviour is to drop the dead pointer and start a fresh thread, telling the
|
|
160
|
+
* user plainly that continuity was lost.
|
|
161
|
+
*
|
|
162
|
+
* ⚠️ THE ASYMMETRY IS THE WHOLE DESIGN. This constant is the ONLY interaction-thread error
|
|
163
|
+
* code the CLI is allowed to know about, and it is deliberately the 400.
|
|
164
|
+
*
|
|
165
|
+
* Its 403 sibling — the one raised when a presented token was forged, tampered with, or
|
|
166
|
+
* belongs to a DIFFERENT caller — must NEVER appear in this file. Self-healing a 403 would
|
|
167
|
+
* clear the session and silently succeed on a fresh thread, converting a deliberate,
|
|
168
|
+
* carefully-typed security denial into a no-op that the user never sees. A 403 must reach
|
|
169
|
+
* the user loudly and untouched: no clear, no retry, no notice. A conformance test asserts
|
|
170
|
+
* that the 403 code appears nowhere in this file's executable source.
|
|
171
|
+
*/
|
|
172
|
+
export const INTERACTION_THREAD_INVALID = 'INTERACTION_THREAD_INVALID';
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Should this failure trigger the single stale-thread retry?
|
|
176
|
+
*
|
|
177
|
+
* Narrow by construction: an exact typed-code match (never message-substring matching —
|
|
178
|
+
* anti-pattern #6), and only when a stored token was actually sent, since with no thread to
|
|
179
|
+
* heal there is nothing this could be about.
|
|
180
|
+
*
|
|
181
|
+
* @param {Error} error - the error thrown by apiClient.invoke
|
|
182
|
+
* @param {string|null} sentInteractionId - the token that was presented, if any
|
|
183
|
+
* @returns {boolean}
|
|
184
|
+
*/
|
|
185
|
+
export function isStaleThreadError(error, sentInteractionId) {
|
|
186
|
+
return Boolean(sentInteractionId) && error?.code === INTERACTION_THREAD_INVALID;
|
|
187
|
+
}
|