@corvio/admin-cli 0.1.0-beta.2 → 0.1.0-beta.21

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/dist/core.js CHANGED
@@ -32,6 +32,59 @@ function exitCodeForStatus(status) {
32
32
  return EXIT.REMOTE;
33
33
  }
34
34
 
35
+ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
36
+ const MAX_REQUEST_TIMEOUT_MS = 10 * 60_000;
37
+ const RETRYABLE_HTTP_STATUSES = new Set([408, 429, 502, 503, 504]);
38
+
39
+ function normalizeRequestTimeout(value, fallback = DEFAULT_REQUEST_TIMEOUT_MS) {
40
+ const raw = value === undefined || value === null || value === "" ? fallback : Number(value);
41
+ if (!Number.isSafeInteger(raw) || raw < 1 || raw > MAX_REQUEST_TIMEOUT_MS) {
42
+ throw new CliError(
43
+ `Request timeout must be an integer between 1 and ${MAX_REQUEST_TIMEOUT_MS} milliseconds.`,
44
+ { code: "invalid_request_timeout" },
45
+ );
46
+ }
47
+ return raw;
48
+ }
49
+
50
+ function networkFailureDetails({
51
+ cause,
52
+ url,
53
+ method,
54
+ requestHeaders = {},
55
+ body = undefined,
56
+ retrySafe = false,
57
+ }) {
58
+ const nested = cause && typeof cause === "object" ? cause.cause : null;
59
+ const causeCode = String(nested?.code || cause?.code || "").trim() || null;
60
+ const syscall = String(nested?.syscall || cause?.syscall || "").trim() || null;
61
+ const normalizedMethod = String(method || "GET").toUpperCase();
62
+ const operationKey = String(requestHeaders["idempotency-key"] || requestHeaders["Idempotency-Key"] || "").trim();
63
+ const revision = body?.expected_content_revision;
64
+ const revisionGuarded = normalizedMethod === "PUT" && Number.isSafeInteger(revision) && revision >= 0;
65
+ const safeToRetry = retrySafe || ["GET", "HEAD"].includes(normalizedMethod) || Boolean(operationKey) || revisionGuarded;
66
+ const timedOut = cause?.name === "TimeoutError" || cause?.name === "AbortError";
67
+ return {
68
+ method: normalizedMethod,
69
+ origin: new URL(String(url)).origin,
70
+ response_received: false,
71
+ timed_out: timedOut,
72
+ safe_to_retry: safeToRetry,
73
+ retry_safety: operationKey
74
+ ? "idempotency_key"
75
+ : revisionGuarded
76
+ ? "revision_guard"
77
+ : retrySafe
78
+ ? "operation_contract"
79
+ : safeToRetry
80
+ ? "read_only"
81
+ : "unknown_commit_state",
82
+ ...(operationKey ? { idempotency_key: operationKey } : {}),
83
+ ...(causeCode ? { cause_code: causeCode } : {}),
84
+ ...(syscall ? { syscall } : {}),
85
+ };
86
+ }
87
+
35
88
  export function parseArgv(argv) {
36
89
  const options = Object.create(null);
37
90
  const positionals = [];
@@ -40,21 +93,39 @@ export function parseArgv(argv) {
40
93
  "no-input",
41
94
  "yes",
42
95
  "allow-actions",
96
+ "organize",
43
97
  "private-link",
44
98
  "force",
99
+ "once",
100
+ "include-revoked",
101
+ "recover-credential",
102
+ "no-listen",
103
+ "start-new-chain",
45
104
  "help",
46
105
  "version",
47
106
  ]);
48
107
  for (let index = 0; index < argv.length; index += 1) {
49
108
  const value = argv[index];
109
+ if (value === "--") {
110
+ positionals.push(...argv.slice(index + 1));
111
+ break;
112
+ }
50
113
  if (!value.startsWith("--")) {
51
114
  positionals.push(value);
52
115
  continue;
53
116
  }
54
117
  const separator = value.indexOf("=");
55
118
  const name = value.slice(2, separator === -1 ? undefined : separator);
119
+ if (!name) throw new CliError("Option name must not be empty.", { code: "invalid_option" });
120
+ if (Object.hasOwn(options, name)) {
121
+ throw new CliError(`Option --${name} was provided more than once.`, { code: "duplicate_option" });
122
+ }
56
123
  if (booleanOptions.has(name)) {
57
- options[name] = separator === -1 ? true : value.slice(separator + 1) !== "false";
124
+ const booleanValue = separator === -1 ? "true" : value.slice(separator + 1);
125
+ if (booleanValue !== "true" && booleanValue !== "false") {
126
+ throw new CliError(`Option --${name} must be true or false.`, { code: "invalid_boolean_option" });
127
+ }
128
+ options[name] = booleanValue === "true";
58
129
  continue;
59
130
  }
60
131
  if (separator !== -1) {
@@ -71,6 +142,21 @@ export function parseArgv(argv) {
71
142
  return { options, positionals };
72
143
  }
73
144
 
145
+ export function assertKnownOptions(options, allowed) {
146
+ const allowedSet = new Set(allowed);
147
+ for (const name of Object.keys(options)) {
148
+ if (!allowedSet.has(name)) {
149
+ throw new CliError(`Unknown option --${name}.`, { code: "unknown_option" });
150
+ }
151
+ }
152
+ }
153
+
154
+ export function assertPositionalCount(positionals, maximum) {
155
+ if (positionals.length > maximum) {
156
+ throw new CliError(`Unexpected argument: ${positionals[maximum]}`, { code: "unexpected_argument" });
157
+ }
158
+ }
159
+
74
160
  export function option(options, name, { required = false, fallback = undefined } = {}) {
75
161
  const value = options[name] ?? fallback;
76
162
  if (required && (value === undefined || String(value).trim() === "")) {
@@ -85,35 +171,148 @@ export function listOption(options, name, fallback = []) {
85
171
  return String(value).split(",").map((item) => item.trim()).filter(Boolean);
86
172
  }
87
173
 
88
- export function numberOption(options, name, fallback) {
174
+ export function numberOption(options, name, fallback, { minimum = 1, maximum = Number.MAX_SAFE_INTEGER } = {}) {
89
175
  const value = options[name];
90
176
  if (value === undefined) return fallback;
91
- const parsed = Number.parseInt(String(value), 10);
92
- if (!Number.isFinite(parsed) || parsed < 1) {
93
- throw new CliError(`Option --${name} must be a positive integer.`, { code: "invalid_number_option" });
177
+ const normalized = String(value).trim();
178
+ const parsed = /^\d+$/.test(normalized) ? Number(normalized) : Number.NaN;
179
+ if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
180
+ throw new CliError(
181
+ `Option --${name} must be an integer between ${minimum} and ${maximum}.`,
182
+ { code: "invalid_number_option" },
183
+ );
94
184
  }
95
185
  return parsed;
96
186
  }
97
187
 
98
188
  export function normalizeApiBase(value, fallback) {
99
189
  const raw = String(value || fallback).trim().replace(/\/+$/, "");
100
- if (!/^https:\/\//i.test(raw) && !/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?/i.test(raw)) {
190
+ let parsed;
191
+ try {
192
+ parsed = new URL(raw);
193
+ } catch {
194
+ throw new CliError("Corvio API base must be a valid URL.", { code: "invalid_api_base" });
195
+ }
196
+ const localhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]";
197
+ if (parsed.username || parsed.password || (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && localhost))) {
101
198
  throw new CliError("Corvio API base must use HTTPS (localhost is allowed for development).", {
102
199
  code: "insecure_api_base",
103
200
  });
104
201
  }
105
- return raw.endsWith("/v1") ? raw : `${raw}/v1`;
202
+ if (parsed.search || parsed.hash) {
203
+ throw new CliError("Corvio API base must not include a query string or fragment.", { code: "invalid_api_base" });
204
+ }
205
+ const normalized = parsed.toString().replace(/\/+$/, "");
206
+ return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
207
+ }
208
+
209
+ function parseSemver(value) {
210
+ const match = String(value || "").trim().match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/);
211
+ if (!match) return null;
212
+ return {
213
+ core: match.slice(1, 4).map((item) => Number.parseInt(item, 10)),
214
+ pre: match[4] ? match[4].split(".") : [],
215
+ };
216
+ }
217
+
218
+ export function compareVersions(left, right) {
219
+ const a = parseSemver(left);
220
+ const b = parseSemver(right);
221
+ if (!a || !b) return 0;
222
+ for (let index = 0; index < 3; index += 1) {
223
+ if (a.core[index] !== b.core[index]) return a.core[index] > b.core[index] ? 1 : -1;
224
+ }
225
+ if (a.pre.length === 0 && b.pre.length > 0) return 1;
226
+ if (b.pre.length === 0 && a.pre.length > 0) return -1;
227
+ for (let index = 0; index < Math.max(a.pre.length, b.pre.length); index += 1) {
228
+ if (a.pre[index] === undefined) return -1;
229
+ if (b.pre[index] === undefined) return 1;
230
+ if (a.pre[index] === b.pre[index]) continue;
231
+ const aNumber = /^\d+$/.test(a.pre[index]) ? Number.parseInt(a.pre[index], 10) : null;
232
+ const bNumber = /^\d+$/.test(b.pre[index]) ? Number.parseInt(b.pre[index], 10) : null;
233
+ if (aNumber !== null && bNumber !== null) return aNumber > bNumber ? 1 : -1;
234
+ if (aNumber !== null) return -1;
235
+ if (bNumber !== null) return 1;
236
+ return a.pre[index] > b.pre[index] ? 1 : -1;
237
+ }
238
+ return 0;
239
+ }
240
+
241
+ export async function resolveNpmUpdateStatus({
242
+ currentVersion,
243
+ policy,
244
+ userAgent,
245
+ allowRegistryFailure = false,
246
+ fetchImpl = globalThis.fetch,
247
+ registryBase = process.env.CORVIO_NPM_REGISTRY_BASE || "https://registry.npmjs.org",
248
+ }) {
249
+ const packageName = String(policy?.package || "").trim();
250
+ const distTag = String(policy?.dist_tag || "beta").trim();
251
+ if (!packageName) throw new CliError("The API did not advertise an npm package.", { code: "update_policy_invalid" });
252
+ const encoded = encodeURIComponent(packageName).replace("%40", "@");
253
+ let latestVersion = null;
254
+ let registryError = null;
255
+ try {
256
+ const registry = String(registryBase).replace(/\/+$/, "");
257
+ const response = await fetchImpl(`${registry}/${encoded}`, {
258
+ headers: { accept: "application/json", "user-agent": userAgent },
259
+ signal: AbortSignal.timeout(2500),
260
+ });
261
+ if (!response.ok) throw new Error(`npm registry returned ${response.status}`);
262
+ const metadata = await response.json();
263
+ latestVersion = String(metadata?.["dist-tags"]?.[distTag] || "").trim() || null;
264
+ } catch (error) {
265
+ registryError = error instanceof Error ? error.message : String(error);
266
+ if (!allowRegistryFailure) {
267
+ throw new CliError(`Unable to check npm for updates: ${registryError}`, {
268
+ code: "update_check_failed",
269
+ exitCode: EXIT.REMOTE,
270
+ });
271
+ }
272
+ }
273
+ const minimum = String(policy.minimum_supported_version || "").trim() || null;
274
+ return {
275
+ package: packageName,
276
+ channel: String(policy.channel || distTag),
277
+ dist_tag: distTag,
278
+ current_version: currentVersion,
279
+ latest_published_version: latestVersion,
280
+ reviewed_version: String(policy.reviewed_version || "").trim() || null,
281
+ minimum_supported_version: minimum,
282
+ update_available: Boolean(latestVersion && compareVersions(latestVersion, currentVersion) > 0),
283
+ supported: minimum ? compareVersions(currentVersion, minimum) >= 0 : true,
284
+ install_command: String(policy.install_command || `npm install --global ${packageName}@${distTag}`),
285
+ automatic_install: false,
286
+ ...(registryError ? { registry_error: registryError } : {}),
287
+ };
106
288
  }
107
289
 
108
290
  export class ApiClient {
109
- constructor({ baseUrl, credential = null, userAgent, fetchImpl = globalThis.fetch }) {
291
+ constructor({
292
+ baseUrl,
293
+ credential = null,
294
+ workspaceId = null,
295
+ userAgent,
296
+ fetchImpl = globalThis.fetch,
297
+ timeoutMs = process.env.CORVIO_REQUEST_TIMEOUT_MS,
298
+ }) {
110
299
  this.baseUrl = baseUrl.replace(/\/+$/, "");
111
300
  this.credential = credential;
301
+ this.workspaceId = String(workspaceId || "").trim() || null;
112
302
  this.userAgent = userAgent;
113
303
  this.fetchImpl = fetchImpl;
304
+ this.timeoutMs = normalizeRequestTimeout(timeoutMs);
114
305
  }
115
306
 
116
- async request(path, { method = "GET", body, query, headers = {}, auth = true } = {}) {
307
+ async request(path, {
308
+ method = "GET",
309
+ body,
310
+ query,
311
+ headers = {},
312
+ auth = true,
313
+ retrySafe = false,
314
+ timeoutMs = this.timeoutMs,
315
+ } = {}) {
117
316
  const url = new URL(`${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
118
317
  for (const [key, value] of Object.entries(query || {})) {
119
318
  if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
@@ -129,19 +328,61 @@ export class ApiClient {
129
328
  throw new CliError("Corvio authentication is required.", { code: "auth_required", exitCode: EXIT.AUTH });
130
329
  }
131
330
  requestHeaders.authorization = `Bearer ${this.credential}`;
331
+ if (this.workspaceId) {
332
+ requestHeaders["X-Corvio-Workspace-Id"] = this.workspaceId;
333
+ requestHeaders["X-Corvio-Workspace-Context-Contract"] = "required-v1";
334
+ }
132
335
  }
336
+ const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
133
337
  let response;
134
- try {
135
- response = await this.fetchImpl(url, {
136
- method,
137
- headers: requestHeaders,
138
- body: body === undefined ? undefined : JSON.stringify(body),
139
- });
140
- } catch (cause) {
141
- throw new CliError(`Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`, {
142
- code: "network_error",
143
- exitCode: EXIT.REMOTE,
144
- });
338
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
339
+ try {
340
+ response = await this.fetchImpl(url, {
341
+ method,
342
+ headers: requestHeaders,
343
+ body: body === undefined ? undefined : JSON.stringify(body),
344
+ signal: AbortSignal.timeout(normalizedTimeout),
345
+ });
346
+ const safety = networkFailureDetails({
347
+ cause: null,
348
+ url,
349
+ method,
350
+ requestHeaders,
351
+ body,
352
+ retrySafe,
353
+ });
354
+ if (attempt === 1 && safety.safe_to_retry && RETRYABLE_HTTP_STATUSES.has(response.status)) {
355
+ await response.body?.cancel().catch(() => {});
356
+ const retryAfterValue = response.headers.get("retry-after");
357
+ const retryAfterSeconds = retryAfterValue === null ? Number.NaN : Number(retryAfterValue);
358
+ const delayMs = Number.isFinite(retryAfterSeconds)
359
+ ? Math.min(2_000, Math.max(0, retryAfterSeconds * 1000))
360
+ : 150;
361
+ await sleep(delayMs);
362
+ continue;
363
+ }
364
+ break;
365
+ } catch (cause) {
366
+ const details = networkFailureDetails({ cause, url, method, requestHeaders, body, retrySafe });
367
+ if (attempt === 1 && details.safe_to_retry) {
368
+ await sleep(150);
369
+ continue;
370
+ }
371
+ throw new CliError(
372
+ details.timed_out
373
+ ? `Corvio did not respond within ${normalizedTimeout} ms.`
374
+ : `Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`,
375
+ {
376
+ code: details.timed_out ? "request_timeout" : "network_error",
377
+ exitCode: EXIT.REMOTE,
378
+ details: {
379
+ ...details,
380
+ attempts: attempt,
381
+ automatic_retry_attempted: attempt > 1,
382
+ },
383
+ },
384
+ );
385
+ }
145
386
  }
146
387
  const text = await response.text();
147
388
  let payload = null;
@@ -158,11 +399,146 @@ export class ApiClient {
158
399
  code: String(envelope.code || `http_${response.status}`),
159
400
  status: response.status,
160
401
  exitCode: exitCodeForStatus(response.status),
161
- details: envelope.details || null,
402
+ details: {
403
+ ...(envelope.details || {}),
404
+ ...(envelope.request_id || response.headers.get("x-request-id")
405
+ ? { request_id: envelope.request_id || response.headers.get("x-request-id") }
406
+ : {}),
407
+ },
162
408
  });
163
409
  }
164
410
  return payload;
165
411
  }
412
+
413
+ async requestBytes(path, { query, headers = {}, auth = true, timeoutMs = this.timeoutMs } = {}) {
414
+ const url = new URL(`${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
415
+ for (const [key, value] of Object.entries(query || {})) {
416
+ if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
417
+ }
418
+ const requestHeaders = {
419
+ accept: "application/zip, application/octet-stream",
420
+ "user-agent": this.userAgent,
421
+ ...headers,
422
+ };
423
+ if (auth) {
424
+ if (!this.credential) {
425
+ throw new CliError("Corvio authentication is required.", { code: "auth_required", exitCode: EXIT.AUTH });
426
+ }
427
+ requestHeaders.authorization = `Bearer ${this.credential}`;
428
+ if (this.workspaceId) {
429
+ requestHeaders["X-Corvio-Workspace-Id"] = this.workspaceId;
430
+ requestHeaders["X-Corvio-Workspace-Context-Contract"] = "required-v1";
431
+ }
432
+ }
433
+ const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
434
+ let response;
435
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
436
+ try {
437
+ response = await this.fetchImpl(url, {
438
+ method: "GET",
439
+ headers: requestHeaders,
440
+ signal: AbortSignal.timeout(normalizedTimeout),
441
+ });
442
+ if (attempt === 1 && RETRYABLE_HTTP_STATUSES.has(response.status)) {
443
+ await response.body?.cancel().catch(() => {});
444
+ await sleep(150);
445
+ continue;
446
+ }
447
+ break;
448
+ } catch (cause) {
449
+ const details = networkFailureDetails({ cause, url, method: "GET", requestHeaders });
450
+ if (attempt === 1) {
451
+ await sleep(150);
452
+ continue;
453
+ }
454
+ throw new CliError(
455
+ details.timed_out
456
+ ? `Corvio did not respond within ${normalizedTimeout} ms.`
457
+ : `Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`,
458
+ {
459
+ code: details.timed_out ? "request_timeout" : "network_error",
460
+ exitCode: EXIT.REMOTE,
461
+ details: { ...details, attempts: attempt, automatic_retry_attempted: true },
462
+ },
463
+ );
464
+ }
465
+ }
466
+ const bytes = new Uint8Array(await response.arrayBuffer());
467
+ if (!response.ok) {
468
+ const text = new TextDecoder().decode(bytes);
469
+ let payload = {};
470
+ try {
471
+ payload = JSON.parse(text);
472
+ } catch {
473
+ payload = { message: text.slice(0, 500) };
474
+ }
475
+ const envelope = payload?.error || payload?.detail || payload || {};
476
+ throw new CliError(String(envelope.message || envelope.code || `Corvio request failed (${response.status}).`), {
477
+ code: String(envelope.code || `http_${response.status}`),
478
+ status: response.status,
479
+ exitCode: exitCodeForStatus(response.status),
480
+ details: {
481
+ ...(envelope.details || {}),
482
+ ...(envelope.request_id || response.headers.get("x-request-id")
483
+ ? { request_id: envelope.request_id || response.headers.get("x-request-id") }
484
+ : {}),
485
+ },
486
+ });
487
+ }
488
+ return bytes;
489
+ }
490
+
491
+ async putUrl(url, content, { headers = {}, timeoutMs = this.timeoutMs } = {}) {
492
+ const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
493
+ let response;
494
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
495
+ try {
496
+ response = await this.fetchImpl(String(url), {
497
+ method: "PUT",
498
+ headers,
499
+ body: content,
500
+ signal: AbortSignal.timeout(normalizedTimeout),
501
+ });
502
+ if (attempt === 1 && RETRYABLE_HTTP_STATUSES.has(response.status)) {
503
+ await response.body?.cancel().catch(() => {});
504
+ await sleep(150);
505
+ continue;
506
+ }
507
+ break;
508
+ } catch (cause) {
509
+ const details = networkFailureDetails({
510
+ cause,
511
+ url,
512
+ method: "PUT",
513
+ requestHeaders: headers,
514
+ retrySafe: true,
515
+ });
516
+ if (attempt === 1) {
517
+ await sleep(150);
518
+ continue;
519
+ }
520
+ throw new CliError(
521
+ details.timed_out
522
+ ? `The file upload did not respond within ${normalizedTimeout} ms.`
523
+ : `Unable to upload the file: ${cause instanceof Error ? cause.message : String(cause)}`,
524
+ {
525
+ code: details.timed_out ? "upload_timeout" : "upload_network_error",
526
+ exitCode: EXIT.REMOTE,
527
+ details: { ...details, attempts: attempt, automatic_retry_attempted: true },
528
+ },
529
+ );
530
+ }
531
+ }
532
+ if (!response.ok) {
533
+ const message = (await response.text()).slice(0, 500);
534
+ throw new CliError(message || `File upload failed (${response.status}).`, {
535
+ code: "upload_failed",
536
+ status: response.status,
537
+ exitCode: EXIT.REMOTE,
538
+ });
539
+ }
540
+ return { ok: true, status: response.status };
541
+ }
166
542
  }
167
543
 
168
544
  function configRoot(env = process.env) {
@@ -175,11 +551,27 @@ export function workspaceCredentialPath(env = process.env) {
175
551
  return join(configRoot(env), "credentials.json");
176
552
  }
177
553
 
554
+ export function workspaceContextPath(env = process.env) {
555
+ return join(configRoot(env), "workspace-context.json");
556
+ }
557
+
558
+ export function adminCredentialPath(env = process.env) {
559
+ return join(configRoot(env), "admin-credentials.json");
560
+ }
561
+
562
+ export function agentCredentialPath(env = process.env) {
563
+ return join(configRoot(env), "agent-credentials.json");
564
+ }
565
+
566
+ export function conversationRoutingPath(env = process.env) {
567
+ return join(configRoot(env), "conversation-routing.json");
568
+ }
569
+
178
570
  export async function readWorkspaceCredential(env = process.env) {
179
571
  const environmentKey = String(env.CORVIO_API_KEY || "").trim();
180
572
  if (environmentKey) {
181
- if (!environmentKey.startsWith("cvk_")) {
182
- throw new CliError("CORVIO_API_KEY must be a Workspace cvk_ key.", {
573
+ if (!environmentKey.startsWith("cvu_") && !environmentKey.startsWith("cvk_")) {
574
+ throw new CliError("CORVIO_API_KEY must be a user cvu_ key or legacy Workspace cvk_ key.", {
183
575
  code: "invalid_workspace_key",
184
576
  exitCode: EXIT.AUTH,
185
577
  });
@@ -196,14 +588,162 @@ export async function readWorkspaceCredential(env = process.env) {
196
588
  }
197
589
  const profileName = String(parsed.active_profile || "default");
198
590
  const profile = parsed.profiles?.[profileName];
199
- const key = String(profile?.api_key || "").trim();
200
- if (!key.startsWith("cvk_")) {
591
+ const metadata = String(parsed.api_key || "").trim() ? parsed : profile;
592
+ const key = String(metadata?.api_key || "").trim();
593
+ if (!key.startsWith("cvu_") && !key.startsWith("cvk_")) {
201
594
  throw new CliError("Stored Corvio credentials are invalid; run `corvio auth login` again.", {
202
595
  code: "credential_store_invalid",
203
596
  exitCode: EXIT.AUTH,
204
597
  });
205
598
  }
206
- return { key, source: "device_link", profile: profileName, metadata: profile };
599
+ return { key, source: "device_link", profile: profileName, metadata };
600
+ }
601
+
602
+ export async function readAgentCredential(
603
+ env = process.env,
604
+ {
605
+ required = true,
606
+ bindingId = null,
607
+ agentId = null,
608
+ projectRoot = null,
609
+ fallbackToActive = true,
610
+ } = {},
611
+ ) {
612
+ const key = String(env.CORVIO_AGENT_API_KEY || "").trim();
613
+ if (key) {
614
+ if (!key.startsWith("cvg_")) {
615
+ throw new CliError("CORVIO_AGENT_API_KEY must be an Agent cvg_ key.", {
616
+ code: "invalid_agent_key",
617
+ exitCode: EXIT.AUTH,
618
+ });
619
+ }
620
+ return { key, source: "environment", profile: null, metadata: null };
621
+ }
622
+ const path = agentCredentialPath(env);
623
+ let parsed;
624
+ try {
625
+ parsed = JSON.parse(await readFile(path, "utf8"));
626
+ } catch (cause) {
627
+ if (cause?.code === "ENOENT" && !required) return null;
628
+ if (cause?.code === "ENOENT") {
629
+ throw new CliError(
630
+ "No Agent credential found. Run `corvio agents connect` or set CORVIO_AGENT_API_KEY.",
631
+ { code: "agent_auth_required", exitCode: EXIT.AUTH },
632
+ );
633
+ }
634
+ throw new CliError("Stored Agent credentials are unreadable.", {
635
+ code: "agent_credential_store_invalid",
636
+ exitCode: EXIT.AUTH,
637
+ });
638
+ }
639
+ let selected = parsed;
640
+ let selectedProfile = "default";
641
+ if (Number(parsed?.version) >= 2 && parsed?.credentials && typeof parsed.credentials === "object") {
642
+ const entries = Object.entries(parsed.credentials);
643
+ let match = null;
644
+ if (bindingId) {
645
+ match = entries.find(([key, value]) => key === bindingId || value?.binding_id === bindingId) || null;
646
+ }
647
+ if (!match && agentId) {
648
+ match = entries.find(([, value]) => String(value?.agent_id || "") === String(agentId)) || null;
649
+ }
650
+ if (!match && projectRoot) {
651
+ const normalizedRoot = resolve(String(projectRoot));
652
+ const candidates = entries
653
+ .filter(([, value]) => {
654
+ const storedRoot = String(value?.project_root || "").trim();
655
+ if (!storedRoot) return false;
656
+ const normalizedStored = resolve(storedRoot);
657
+ return normalizedRoot === normalizedStored || normalizedRoot.startsWith(`${normalizedStored}${process.platform === "win32" ? "\\" : "/"}`);
658
+ })
659
+ .sort((left, right) => String(right[1]?.project_root || "").length - String(left[1]?.project_root || "").length);
660
+ match = candidates[0] || null;
661
+ }
662
+ if (!match && fallbackToActive && parsed.active_binding_id) {
663
+ const value = parsed.credentials[parsed.active_binding_id];
664
+ if (value) match = [parsed.active_binding_id, value];
665
+ }
666
+ if (!match && fallbackToActive && entries.length === 1) match = entries[0];
667
+ if (!match) {
668
+ if (!required) return null;
669
+ throw new CliError(
670
+ "Multiple Agent project bindings are stored. Run from a connected project or pass --agent <id>.",
671
+ { code: "agent_routing_ambiguous", exitCode: EXIT.AUTH },
672
+ );
673
+ }
674
+ [selectedProfile, selected] = match;
675
+ }
676
+ const storedKey = String(selected?.api_key || "").trim();
677
+ if (!storedKey.startsWith("cvg_")) {
678
+ throw new CliError(
679
+ "Stored Agent credentials are invalid; run `corvio agents connect --recover-credential`.",
680
+ { code: "agent_credential_store_invalid", exitCode: EXIT.AUTH },
681
+ );
682
+ }
683
+ return { key: storedKey, source: "secure_store", profile: selectedProfile, metadata: selected };
684
+ }
685
+
686
+ export async function storeAgentCredential(payload, env = process.env) {
687
+ const secret = String(payload?.secret || "").trim();
688
+ if (!secret.startsWith("cvg_")) {
689
+ throw new CliError("Agent connection did not return a valid cvg_ credential.", {
690
+ code: "agent_credential_invalid",
691
+ exitCode: EXIT.AUTH,
692
+ });
693
+ }
694
+ const path = agentCredentialPath(env);
695
+ const root = dirname(path);
696
+ await mkdir(root, { recursive: true, mode: 0o700 });
697
+ await chmod(root, 0o700).catch(() => {});
698
+ const credential = {
699
+ api_key: secret,
700
+ agent_id: String(payload.agent_id || ""),
701
+ binding_id: String(payload.binding_id || "").trim() || null,
702
+ project_root: String(payload.project_root || "").trim() || null,
703
+ project_label: String(payload.project_label || "").trim() || null,
704
+ project_fingerprint: String(payload.project_fingerprint || "").trim() || null,
705
+ display_name: String(payload.display_name || ""),
706
+ provider: String(payload.provider || "custom"),
707
+ permission_profile: String(payload.permission_profile || "read_only"),
708
+ trigger_policy: String(payload.trigger_policy || "owner_only"),
709
+ skills_scope: String(payload.skills_scope || "project"),
710
+ memory_scope: String(payload.memory_scope || "off"),
711
+ connector_scope: String(payload.connector_scope || "off"),
712
+ display_prefix: String(payload.display_prefix || "cvg"),
713
+ created_at: payload.created_at || new Date().toISOString(),
714
+ source: "agent_connect",
715
+ };
716
+ let current = null;
717
+ try {
718
+ current = JSON.parse(await readFile(path, "utf8"));
719
+ } catch (error) {
720
+ if (error?.code !== "ENOENT") {
721
+ throw new CliError("Stored Agent credentials are unreadable.", {
722
+ code: "agent_credential_store_invalid",
723
+ exitCode: EXIT.AUTH,
724
+ });
725
+ }
726
+ }
727
+ const credentials = Number(current?.version) >= 2 && current?.credentials && typeof current.credentials === "object"
728
+ ? { ...current.credentials }
729
+ : {};
730
+ if (String(current?.api_key || "").startsWith("cvg_") && current?.agent_id) {
731
+ const legacyKey = String(current.binding_id || `legacy:${current.agent_id}`);
732
+ credentials[legacyKey] = { ...current, binding_id: current.binding_id || null };
733
+ }
734
+ const profile = credential.binding_id || `agent:${credential.agent_id}`;
735
+ credentials[profile] = credential;
736
+ const document = {
737
+ version: 2,
738
+ active_binding_id: profile,
739
+ credentials,
740
+ };
741
+ const temporary = `${path}.${randomUUID()}.tmp`;
742
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, flag: "wx" });
743
+ await chmod(temporary, 0o600);
744
+ await rename(temporary, path);
745
+ await chmod(path, 0o600);
746
+ return { path, profile, metadata: credential };
207
747
  }
208
748
 
209
749
  export async function storeWorkspaceCredential(payload, env = process.env) {
@@ -211,28 +751,150 @@ export async function storeWorkspaceCredential(payload, env = process.env) {
211
751
  const root = dirname(path);
212
752
  await mkdir(root, { recursive: true, mode: 0o700 });
213
753
  await chmod(root, 0o700).catch(() => {});
214
- const profileName = String(payload.workspace_id || "default");
754
+ const document = {
755
+ version: 2,
756
+ api_key: payload.secret,
757
+ key_id: payload.key_id,
758
+ audience: payload.audience || "user",
759
+ scopes: payload.scopes || [],
760
+ expires_at: payload.expires_at || null,
761
+ created_at: payload.created_at || new Date().toISOString(),
762
+ source: "device_link",
763
+ };
764
+ const temporary = `${path}.${randomUUID()}.tmp`;
765
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, flag: "wx" });
766
+ await chmod(temporary, 0o600);
767
+ await rename(temporary, path);
768
+ await chmod(path, 0o600);
769
+ return { path, profile: "default" };
770
+ }
771
+
772
+ export async function readWorkspaceContext(env = process.env) {
773
+ const environmentWorkspaceId = String(env.CORVIO_WORKSPACE_ID || "").trim();
774
+ if (environmentWorkspaceId) {
775
+ return { workspaceId: environmentWorkspaceId, workspaceName: null, source: "environment" };
776
+ }
777
+ const path = workspaceContextPath(env);
778
+ try {
779
+ const parsed = JSON.parse(await readFile(path, "utf8"));
780
+ const workspaceId = String(parsed.active_workspace_id || "").trim();
781
+ if (!workspaceId) return null;
782
+ return {
783
+ workspaceId,
784
+ workspaceName: String(parsed.active_workspace_name || "").trim() || null,
785
+ source: "stored",
786
+ routingPreference: parsed.routing_preference || null,
787
+ };
788
+ } catch (cause) {
789
+ if (cause?.code === "ENOENT") return null;
790
+ throw new CliError("Stored Corvio Workspace selection is unreadable.", {
791
+ code: "workspace_context_store_invalid",
792
+ exitCode: EXIT.AUTH,
793
+ });
794
+ }
795
+ }
796
+
797
+ export async function storeWorkspaceContext({ workspaceId, workspaceName = null, routingPreference = null }, env = process.env) {
798
+ const normalizedId = String(workspaceId || "").trim();
799
+ if (!normalizedId) throw new CliError("Workspace ID is required.", { code: "workspace_selection_required" });
800
+ const path = workspaceContextPath(env);
801
+ const root = dirname(path);
802
+ await mkdir(root, { recursive: true, mode: 0o700 });
803
+ await chmod(root, 0o700).catch(() => {});
215
804
  const document = {
216
805
  version: 1,
217
- active_profile: profileName,
218
- profiles: {
219
- [profileName]: {
220
- api_key: payload.secret,
221
- key_id: payload.key_id,
222
- workspace_id: payload.workspace_id,
223
- workspace_name: payload.workspace_name,
224
- scopes: payload.scopes || [],
225
- created_at: payload.created_at || new Date().toISOString(),
226
- source: "device_link",
227
- },
806
+ active_workspace_id: normalizedId,
807
+ active_workspace_name: String(workspaceName || "").trim() || null,
808
+ routing_preference: routingPreference || {
809
+ workspace_id: normalizedId,
810
+ provenance: "user_confirmed_cli",
811
+ confidence: 1,
812
+ scope: "global",
813
+ expires_at: null,
228
814
  },
815
+ updated_at: new Date().toISOString(),
229
816
  };
230
817
  const temporary = `${path}.${randomUUID()}.tmp`;
231
818
  await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, flag: "wx" });
232
819
  await chmod(temporary, 0o600);
233
820
  await rename(temporary, path);
234
821
  await chmod(path, 0o600);
235
- return { path, profile: profileName };
822
+ return { path, workspaceId: normalizedId };
823
+ }
824
+
825
+ export async function readConversationRoutingReceipt(conversationId, env = process.env) {
826
+ const normalizedId = String(conversationId || "").trim();
827
+ if (!normalizedId) return null;
828
+ const path = conversationRoutingPath(env);
829
+ try {
830
+ const parsed = JSON.parse(await readFile(path, "utf8"));
831
+ const receipt = parsed?.receipts?.[normalizedId];
832
+ const workspaceId = String(receipt?.workspace_id || "").trim();
833
+ if (!workspaceId) return null;
834
+ return {
835
+ conversationId: normalizedId,
836
+ workspaceId,
837
+ agentId: String(receipt.agent_id || "").trim() || null,
838
+ apiBase: String(receipt.api_base || "").trim() || null,
839
+ recordedAt: String(receipt.recorded_at || "").trim() || null,
840
+ };
841
+ } catch (cause) {
842
+ if (cause?.code === "ENOENT") return null;
843
+ throw new CliError("Stored Corvio conversation routing receipts are unreadable.", {
844
+ code: "conversation_routing_store_invalid",
845
+ exitCode: EXIT.AUTH,
846
+ });
847
+ }
848
+ }
849
+
850
+ export async function storeConversationRoutingReceipt({ conversationId, workspaceId, agentId = null, apiBase = null }, env = process.env) {
851
+ const normalizedConversationId = String(conversationId || "").trim();
852
+ const normalizedWorkspaceId = String(workspaceId || "").trim();
853
+ if (!normalizedConversationId || !normalizedWorkspaceId) {
854
+ throw new CliError("Conversation and Workspace IDs are required for routing continuity.", {
855
+ code: "conversation_routing_receipt_invalid",
856
+ });
857
+ }
858
+ const path = conversationRoutingPath(env);
859
+ const root = dirname(path);
860
+ await mkdir(root, { recursive: true, mode: 0o700 });
861
+ await chmod(root, 0o700).catch(() => {});
862
+ let existing = {};
863
+ try {
864
+ const parsed = JSON.parse(await readFile(path, "utf8"));
865
+ if (parsed?.receipts && typeof parsed.receipts === "object" && !Array.isArray(parsed.receipts)) {
866
+ existing = parsed.receipts;
867
+ }
868
+ } catch (cause) {
869
+ if (cause?.code !== "ENOENT") {
870
+ throw new CliError("Stored Corvio conversation routing receipts are unreadable.", {
871
+ code: "conversation_routing_store_invalid",
872
+ exitCode: EXIT.AUTH,
873
+ });
874
+ }
875
+ }
876
+ const ordered = Object.entries({
877
+ ...existing,
878
+ [normalizedConversationId]: {
879
+ workspace_id: normalizedWorkspaceId,
880
+ agent_id: String(agentId || "").trim() || null,
881
+ api_base: String(apiBase || "").trim() || null,
882
+ recorded_at: new Date().toISOString(),
883
+ },
884
+ }).sort((left, right) => String(right[1]?.recorded_at || "").localeCompare(String(left[1]?.recorded_at || "")));
885
+ const document = { version: 1, receipts: Object.fromEntries(ordered.slice(0, 500)) };
886
+ const temporary = `${path}.${randomUUID()}.tmp`;
887
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, flag: "wx" });
888
+ await chmod(temporary, 0o600);
889
+ await rename(temporary, path);
890
+ await chmod(path, 0o600);
891
+ return { path, conversationId: normalizedConversationId, workspaceId: normalizedWorkspaceId };
892
+ }
893
+
894
+ export async function clearWorkspaceContext(env = process.env) {
895
+ const path = workspaceContextPath(env);
896
+ await rm(path, { force: true });
897
+ return path;
236
898
  }
237
899
 
238
900
  export async function clearWorkspaceCredential(env = process.env) {
@@ -241,16 +903,66 @@ export async function clearWorkspaceCredential(env = process.env) {
241
903
  return path;
242
904
  }
243
905
 
244
- export function readAdminCredential(env = process.env) {
906
+ export async function readAdminCredential(env = process.env) {
245
907
  const key = String(env.CORVIO_ADMIN_API_KEY || "").trim();
246
- if (!key) return null;
247
- if (!key.startsWith("cva_")) {
248
- throw new CliError("CORVIO_ADMIN_API_KEY must be a platform administrator cva_ key.", {
249
- code: "invalid_admin_key",
908
+ if (key) {
909
+ if (!key.startsWith("cva_")) {
910
+ throw new CliError("CORVIO_ADMIN_API_KEY must be a platform administrator cva_ key.", {
911
+ code: "invalid_admin_key",
912
+ exitCode: EXIT.AUTH,
913
+ });
914
+ }
915
+ return { key, source: "environment", metadata: null };
916
+ }
917
+ const path = adminCredentialPath(env);
918
+ let parsed;
919
+ try {
920
+ parsed = JSON.parse(await readFile(path, "utf8"));
921
+ } catch (cause) {
922
+ if (cause?.code === "ENOENT") return null;
923
+ throw new CliError("Stored Corvio administrator credentials are unreadable.", {
924
+ code: "admin_credential_store_invalid",
925
+ exitCode: EXIT.AUTH,
926
+ });
927
+ }
928
+ const storedKey = String(parsed.api_key || "").trim();
929
+ if (!storedKey.startsWith("cva_")) {
930
+ throw new CliError("Stored Corvio administrator credentials are invalid; run `corvio-admin auth login` again.", {
931
+ code: "admin_credential_store_invalid",
250
932
  exitCode: EXIT.AUTH,
251
933
  });
252
934
  }
253
- return { key, source: "environment" };
935
+ return { key: storedKey, source: "device_link", metadata: parsed };
936
+ }
937
+
938
+ export async function storeAdminCredential(payload, env = process.env) {
939
+ const path = adminCredentialPath(env);
940
+ const root = dirname(path);
941
+ await mkdir(root, { recursive: true, mode: 0o700 });
942
+ await chmod(root, 0o700).catch(() => {});
943
+ const document = {
944
+ version: 1,
945
+ api_key: payload.secret,
946
+ key_id: payload.key_id,
947
+ audience: payload.audience,
948
+ api_base: payload.api_base,
949
+ scopes: payload.scopes || [],
950
+ expires_at: payload.expires_at,
951
+ created_at: payload.created_at || new Date().toISOString(),
952
+ source: "device_link",
953
+ };
954
+ const temporary = `${path}.${randomUUID()}.tmp`;
955
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, flag: "wx" });
956
+ await chmod(temporary, 0o600);
957
+ await rename(temporary, path);
958
+ await chmod(path, 0o600);
959
+ return { path };
960
+ }
961
+
962
+ export async function clearAdminCredential(env = process.env) {
963
+ const path = adminCredentialPath(env);
964
+ await rm(path, { force: true });
965
+ return path;
254
966
  }
255
967
 
256
968
  export function writeResult(value, { json = false, stdout = process.stdout } = {}) {
@@ -270,7 +982,21 @@ export function writeError(error, { json = false, stderr = process.stderr } = {}
270
982
  ...(error instanceof CliError && error.details ? { details: error.details } : {}),
271
983
  },
272
984
  };
273
- stderr.write(json ? `${JSON.stringify(safe)}\n` : `Error: ${safe.error.message}\n`);
985
+ if (json) {
986
+ stderr.write(`${JSON.stringify(safe)}\n`);
987
+ return;
988
+ }
989
+ stderr.write(`Error: ${safe.error.message}\n`);
990
+ if (safe.error.details?.request_id) stderr.write(`Request ID: ${safe.error.details.request_id}\n`);
991
+ if (safe.error.details?.safe_to_retry && safe.error.details?.idempotency_key) {
992
+ stderr.write(
993
+ `Safe retry: rerun the command with --idempotency-key ${safe.error.details.idempotency_key}\n`,
994
+ );
995
+ } else if (safe.error.details?.retry_safety === "revision_guard") {
996
+ stderr.write("Safe retry: rerun with the same --expected-revision; Corvio will reject an already-committed replacement.\n");
997
+ } else if (safe.error.details?.retry_safety === "unknown_commit_state") {
998
+ stderr.write("Retry state is unknown; read the remote object before repeating this mutation.\n");
999
+ }
274
1000
  }
275
1001
 
276
1002
  export async function runMain(main, argv = process.argv.slice(2)) {
@@ -287,15 +1013,26 @@ export async function runMain(main, argv = process.argv.slice(2)) {
287
1013
 
288
1014
  export async function openBrowser(url) {
289
1015
  const target = String(url);
290
- const command = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open";
291
- const args = platform() === "win32" ? ["/c", "start", "", target] : [target];
292
- try {
293
- const child = spawn(command, args, { detached: true, stdio: "ignore" });
294
- child.unref();
295
- return true;
296
- } catch {
297
- return false;
298
- }
1016
+ const command = platform() === "darwin" ? "open" : platform() === "win32" ? "rundll32.exe" : "xdg-open";
1017
+ const args = platform() === "win32" ? ["url.dll,FileProtocolHandler", target] : [target];
1018
+ return new Promise((resolvePromise) => {
1019
+ let settled = false;
1020
+ const finish = (opened) => {
1021
+ if (settled) return;
1022
+ settled = true;
1023
+ resolvePromise(opened);
1024
+ };
1025
+ try {
1026
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
1027
+ child.once("error", () => finish(false));
1028
+ child.once("spawn", () => {
1029
+ child.unref();
1030
+ finish(true);
1031
+ });
1032
+ } catch {
1033
+ finish(false);
1034
+ }
1035
+ });
299
1036
  }
300
1037
 
301
1038
  export async function sleep(milliseconds) {
@@ -309,7 +1046,28 @@ export async function readTextFile(path) {
309
1046
  export async function writeTextFile(path, content) {
310
1047
  const target = resolve(String(path));
311
1048
  await mkdir(dirname(target), { recursive: true });
312
- await writeFile(target, content, "utf8");
1049
+ const temporary = `${target}.${randomUUID()}.tmp`;
1050
+ try {
1051
+ await writeFile(temporary, content, { encoding: "utf8", flag: "wx" });
1052
+ await rename(temporary, target);
1053
+ } catch (error) {
1054
+ await rm(temporary, { force: true }).catch(() => {});
1055
+ throw error;
1056
+ }
1057
+ return target;
1058
+ }
1059
+
1060
+ export async function writeBinaryFile(path, content) {
1061
+ const target = resolve(String(path));
1062
+ await mkdir(dirname(target), { recursive: true });
1063
+ const temporary = `${target}.${randomUUID()}.tmp`;
1064
+ try {
1065
+ await writeFile(temporary, content, { flag: "wx" });
1066
+ await rename(temporary, target);
1067
+ } catch (error) {
1068
+ await rm(temporary, { force: true }).catch(() => {});
1069
+ throw error;
1070
+ }
313
1071
  return target;
314
1072
  }
315
1073