@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.
Files changed (100) hide show
  1. package/LICENSE.md +671 -0
  2. package/README.md +43 -4
  3. package/agent-assets/guides/README.md +2 -2
  4. package/agent-assets/guides/dynamic-site-pattern.md +29 -14
  5. package/agent-assets/guides/knowledge-only-pattern.md +5 -5
  6. package/agent-assets/guides/microservice-pattern.md +48 -4
  7. package/agent-assets/guides/static-site-pattern.md +8 -0
  8. package/agent-assets/instructions/app-asset-validation.md +5 -5
  9. package/agent-assets/instructions/onboarding-flow.md +4 -4
  10. package/agent-assets/instructions/standard-folder-structure.md +20 -11
  11. package/agent-assets/reference/app-creation.md +42 -20
  12. package/agent-assets/reference/architecture.md +24 -22
  13. package/agent-assets/reference/local-dev.md +230 -67
  14. package/agent-assets/reference/mcp-integration.md +21 -15
  15. package/agent-assets/reference/platform-api-consumer.md +244 -0
  16. package/agent-assets/reference/workspace-config.md +7 -7
  17. package/agent-assets/rules/descix_mcp.template.mdc +8 -8
  18. package/bin/descix.js +2559 -1435
  19. package/bin/kb-deny-lint.mjs +91 -0
  20. package/bin/mcp-server.js +175 -91
  21. package/lib/agent-files.js +37 -25
  22. package/lib/api-client.js +223 -56
  23. package/lib/auth-guard.js +13 -3
  24. package/lib/chat-session.js +187 -0
  25. package/lib/cli-version.js +59 -0
  26. package/lib/commands/airdrop.js +251 -0
  27. package/lib/commands/auth.js +254 -72
  28. package/lib/commands/briefer/errors.js +88 -0
  29. package/lib/commands/briefer/index.js +319 -0
  30. package/lib/commands/briefer/sources/canonical-sources.js +129 -0
  31. package/lib/commands/briefer/sources/entitlements.js +220 -0
  32. package/lib/commands/briefer/sources/environments.js +205 -0
  33. package/lib/commands/briefer/sources/identifiers.js +214 -0
  34. package/lib/commands/briefer/sources/microservice-deploy.js +267 -0
  35. package/lib/commands/briefer/sources/routing.js +275 -0
  36. package/lib/commands/briefer/sources/what-is-not.js +223 -0
  37. package/lib/commands/briefer/stitcher.js +134 -0
  38. package/lib/commands/briefer/util/source-reader.js +502 -0
  39. package/lib/commands/config.js +210 -90
  40. package/lib/commands/corpus.js +978 -0
  41. package/lib/commands/credits.js +122 -0
  42. package/lib/commands/health.js +627 -0
  43. package/lib/commands/kb-list-render.js +32 -0
  44. package/lib/commands/kb.js +190 -413
  45. package/lib/commands/mcp.js +5 -5
  46. package/lib/commands/microservice-restart.js +359 -0
  47. package/lib/commands/model-config.js +313 -0
  48. package/lib/commands/retired-kb-sync.js +134 -0
  49. package/lib/commands/serve.js +26 -1
  50. package/lib/commands/status.js +23 -51
  51. package/lib/commands/update.js +36 -107
  52. package/lib/core/Chunker.js +491 -17
  53. package/lib/core/CorpusDenyLint.js +326 -0
  54. package/lib/core/CorpusWalker.js +357 -0
  55. package/lib/core/Hydrator.js +75 -29
  56. package/lib/core/ManifestLoader.js +533 -0
  57. package/lib/core/SiteWalker.js +218 -0
  58. package/lib/core/Syncer.js +163 -11
  59. package/lib/diagnostic-log.js +41 -0
  60. package/lib/environment-report.js +100 -0
  61. package/lib/global-config.js +4 -1
  62. package/lib/oauth-client.js +164 -0
  63. package/lib/origin.js +143 -0
  64. package/lib/service-api-client.js +95 -0
  65. package/lib/wallet-environment.js +49 -0
  66. package/lib/wallet-file.js +16 -2
  67. package/lib/wizard/setup.js +10 -9
  68. package/lib/workspace-config.js +380 -235
  69. package/lib/workspace-identity.js +92 -0
  70. package/lib/workspace-utils.js +41 -61
  71. package/package.json +19 -6
  72. package/templates/agent-claude.md +157 -9
  73. package/templates/agent-cline.md +68 -8
  74. package/templates/agent-copilot.md +68 -8
  75. package/templates/agent-cursor.md +68 -8
  76. package/templates/alignment-schema.json +20 -0
  77. package/templates/cloud-brain-hook.sh +20 -0
  78. package/templates/drive/README.md +1 -1
  79. package/templates/drive_templates/README.md +2 -2
  80. package/templates/scaffolds/README.md +6 -10
  81. package/templates/scaffolds/microservice/SERVICE_README_sdk.md +104 -149
  82. package/templates/scaffolds/microservice/app.js +51 -10
  83. package/templates/scaffolds/microservice/app.yaml +2 -2
  84. package/templates/scaffolds/microservice/defaults-config-dev.json +4 -0
  85. package/templates/scaffolds/microservice/dev-overrides.example.json +5 -0
  86. package/templates/scaffolds/microservice/manifest.json +0 -1
  87. package/templates/scaffolds/microservice/package.json +3 -3
  88. package/templates/scaffolds/microservice/scripts/register.js +56 -41
  89. package/templates/scaffolds/microservice/services/mcpClient.js +55 -65
  90. package/templates/scaffolds/microservice/services/utils.js +20 -238
  91. package/templates/scaffolds/microservice/templates/SERVICE_README_TEMPLATE.md +24 -0
  92. package/lib/commands/app-wizard.js +0 -627
  93. package/lib/commands/folder.js +0 -291
  94. package/lib/core/PathContext.js +0 -335
  95. package/templates/scaffolds/microservice/dev-overrides.json.example +0 -5
  96. package/templates/scaffolds/site/DeSciXAppSDK.js +0 -62
  97. package/templates/scaffolds/site/README.md +0 -51
  98. package/templates/scaffolds/site/app.js +0 -22
  99. package/templates/scaffolds/site/index.html +0 -42
  100. 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 PathContext for workspace/config resolution.
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 { PathContext } from './core/PathContext.js';
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._pathContext = null; // Cached PathContext
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 PathContext for unified path resolution.
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 PathContext (will find IDE marker and load workspace.json)
36
- this._pathContext = await PathContext.tryLoad();
42
+ // Step 1: Try to load WorkspaceConfig (finds .descix/workspace.json upward)
43
+ this._workspaceConfig = await WorkspaceConfig.tryLoad();
37
44
 
38
- if (this._pathContext) {
39
- this.workspaceRoot = this._pathContext.getWorkspaceRoot();
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: Set API URL from PathContext config or environment (only if not already set)
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
- this.baseUrl = await this.detectApiUrl();
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 URL from workspace config or environment
57
- * @returns {Promise<string>} API base URL
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
- const PRODUCTION_URL = 'https://descix.net';
61
-
62
- // Check environment variable first (highest priority)
63
- if (process.env.DESCIX_API_URL) {
64
- return process.env.DESCIX_API_URL;
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
- if (gc.api_url && gc.api_url !== PRODUCTION_URL) return gc.api_url;
96
+ globalApiUrl = gc.api_url || null;
80
97
  } catch {}
81
98
 
82
- // Default to production
83
- return PRODUCTION_URL;
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
- console.log(`[ApiClient] invoking ${command}...`);
306
+ diag(`[ApiClient] invoking ${command}...`);
194
307
  await this.ensureBaseUrl();
195
- console.log(`[ApiClient] Base URL: ${this.baseUrl}`);
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
- console.log(`[ApiClient] POST ${url}`);
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
- console.log(`[ApiClient] Response status: ${response.status}`);
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
- console.log('[ApiClient] Session refresh failed, triggering device login...');
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 PathContext's workspace root if available
365
- if (!this.workspaceRoot && this._pathContext) {
366
- this.workspaceRoot = this._pathContext.getWorkspaceRoot();
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: PathContext (IDE markers) → workspace.json → wallet.json
534
+ // If still no workspace root, try: WorkspaceConfig → wallet.json walk
370
535
  if (!this.workspaceRoot) {
371
- const ctx = await PathContext.tryLoad();
372
- if (ctx) {
373
- this.workspaceRoot = ctx.getWorkspaceRoot();
374
- this._pathContext = ctx;
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 PathContext instance (if loaded)
607
+ * Get the WorkspaceConfig instance (if loaded)
441
608
  * Useful for commands that need path resolution
442
- * @returns {PathContext|null}
609
+ * @returns {WorkspaceConfig|null}
443
610
  */
444
- getPathContext() {
445
- return this._pathContext;
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 check_staked_status via HTTP
37
+ // Validate session is still active by calling validate_session via HTTP
28
38
  try {
29
- const response = await apiClient.invoke('check_staked_status', {}, { allowGuest: false });
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('check_staked_status', {}, { allowGuest: false });
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
+ }