@corvio/admin-cli 0.1.0-beta.3 → 0.1.0-beta.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,3 +10,5 @@ corvio-admin catalog --json
10
10
  ```
11
11
 
12
12
  For Agents and CI, inject `CORVIO_ADMIN_API_KEY='cva_...'`; it takes precedence over the stored device-link credential. Every data query requires a purpose and returns an auditable receipt. This package contains no ordinary Workspace commands. See [the administrator CLI guide](https://corvio.ai/developers/admin-cli).
13
+
14
+ Unknown/duplicate options, malformed bounds, and invalid JSON fail locally. API calls use the same bounded transport/runtime safety policy as the Workspace CLI. `corvio-admin skill download` reads the protected manifest first and verifies the archive SHA-256 before writing the zip.
package/dist/cli.js CHANGED
@@ -1,9 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { createHash } from "node:crypto";
4
+
3
5
  import {
4
6
  ApiClient,
5
7
  CliError,
6
8
  EXIT,
9
+ assertKnownOptions,
10
+ assertPositionalCount,
7
11
  clearAdminCredential,
8
12
  listOption,
9
13
  normalizeApiBase,
@@ -12,6 +16,7 @@ import {
12
16
  option,
13
17
  readAdminCredential,
14
18
  readTextFile,
19
+ resolveNpmUpdateStatus,
15
20
  runMain,
16
21
  sleep,
17
22
  storeAdminCredential,
@@ -19,9 +24,29 @@ import {
19
24
  writeResult,
20
25
  } from "./core.js";
21
26
 
22
- const VERSION = "0.1.0-beta.3";
27
+ const VERSION = "0.1.0-beta.30";
23
28
  const DEFAULT_API_BASE = "https://api.corvio.ai/v1";
24
29
  const USER_AGENT = `corvio-admin-cli/${VERSION}`;
30
+ const GLOBAL_OPTIONS = Object.freeze(["api-base", "json", "no-input", "help", "version"]);
31
+ const COMMAND_OPTIONS = Object.freeze({
32
+ "auth:login": ["force", "scopes"],
33
+ "auth:status": [],
34
+ "auth:logout": [],
35
+ "capabilities:": [],
36
+ "update:check": [],
37
+ "skill:download": ["output"],
38
+ "catalog:": [],
39
+ "query:": ["file", "purpose", "dataset", "fields", "group-by", "metrics", "filters", "order-by", "limit", "cursor"],
40
+ "data:query": ["file", "purpose", "dataset", "fields", "group-by", "metrics", "filters", "order-by", "limit", "cursor"],
41
+ "workspaces:list": ["purpose", "workspace-id", "status", "limit", "cursor"],
42
+ "users:list": ["purpose", "workspace-id", "status", "limit", "cursor"],
43
+ "worker-jobs:list": ["purpose", "workspace-id", "status", "limit", "cursor"],
44
+ "runs:list": ["purpose", "workspace-id", "status", "limit", "cursor"],
45
+ "runs:content": ["id", "purpose"],
46
+ "usage:events": ["purpose", "workspace-id", "status", "limit", "cursor"],
47
+ "usage:summary": ["purpose", "workspace-id", "status", "limit", "cursor"],
48
+ "receipts:get": ["id"],
49
+ });
25
50
 
26
51
  const HELP = `Corvio Platform Administrator CLI ${VERSION}
27
52
 
@@ -37,6 +62,7 @@ Usage:
37
62
  corvio-admin worker-jobs list --purpose <reason>
38
63
  corvio-admin usage events|summary --purpose <reason>
39
64
  corvio-admin receipts get <id>
65
+ corvio-admin update check
40
66
 
41
67
  Authentication:
42
68
  CORVIO_ADMIN_API_KEY=cva_... takes precedence over the stored device-link login.
@@ -45,6 +71,47 @@ Authentication:
45
71
  This package contains no Workspace ask, search, docs, files, sync, or share commands.
46
72
  `;
47
73
 
74
+ function resourcePath(value) {
75
+ return encodeURIComponent(String(value));
76
+ }
77
+
78
+ function validateInvocation(options, positionals) {
79
+ const [command, rawAction] = positionals;
80
+ if (!command || command === "help" || command === "version" || options.help || options.version) return;
81
+ const defaultActions = {
82
+ update: "check",
83
+ workspaces: "list",
84
+ users: "list",
85
+ "worker-jobs": "list",
86
+ };
87
+ const action = ["capabilities", "catalog", "query"].includes(command)
88
+ ? ""
89
+ : rawAction || defaultActions[command] || "";
90
+ const schema = COMMAND_OPTIONS[`${command}:${action}`];
91
+ if (!schema) {
92
+ throw new CliError(`Unknown administrator command: ${positionals.join(" ")}`, {
93
+ code: "unknown_command",
94
+ });
95
+ }
96
+ assertKnownOptions(options, [...GLOBAL_OPTIONS, ...schema]);
97
+ const maximum = new Set(["runs:content", "receipts:get"]).has(`${command}:${action}`)
98
+ ? 3
99
+ : action
100
+ ? 2
101
+ : 1;
102
+ assertPositionalCount(positionals, maximum);
103
+ }
104
+
105
+ function parseJsonValue(value, optionName) {
106
+ try {
107
+ return JSON.parse(String(value));
108
+ } catch {
109
+ throw new CliError(`Option --${optionName} must contain valid JSON.`, {
110
+ code: "invalid_json_option",
111
+ });
112
+ }
113
+ }
114
+
48
115
  function baseUrl(options, credential = null) {
49
116
  return normalizeApiBase(
50
117
  options["api-base"]
@@ -74,7 +141,7 @@ function listQuery(options) {
74
141
  purpose: option(options, "purpose", { required: true }),
75
142
  workspace_id: options["workspace-id"],
76
143
  status: options.status,
77
- limit: numberOption(options, "limit", 100),
144
+ limit: numberOption(options, "limit", 100, { maximum: 200 }),
78
145
  cursor: options.cursor,
79
146
  };
80
147
  }
@@ -192,6 +259,20 @@ async function capabilities(options) {
192
259
  writeResult({ cli_version: VERSION, auth_source: credential.source, api: index, principal: identity }, { json: options.json });
193
260
  }
194
261
 
262
+ async function updateCheck(options) {
263
+ const { api } = await client(options);
264
+ const index = await api.request("/admin");
265
+ const result = await resolveNpmUpdateStatus({
266
+ currentVersion: VERSION,
267
+ policy: index?.distribution?.admin_cli || {
268
+ package: "@corvio/admin-cli",
269
+ dist_tag: "beta",
270
+ },
271
+ userAgent: USER_AGENT,
272
+ });
273
+ writeResult(result, { json: options.json });
274
+ }
275
+
195
276
  async function catalog(options) {
196
277
  const { api } = await client(options);
197
278
  writeResult(await api.request("/admin/data/catalog"), { json: options.json });
@@ -200,7 +281,15 @@ async function catalog(options) {
200
281
  async function query(options) {
201
282
  let body;
202
283
  if (options.file) {
203
- body = JSON.parse(await readTextFile(options.file));
284
+ try {
285
+ body = JSON.parse(await readTextFile(options.file));
286
+ } catch (error) {
287
+ if (error?.code === "ENOENT") throw error;
288
+ throw new CliError("--file must contain a valid JSON request object.", { code: "invalid_json_file" });
289
+ }
290
+ if (!body || Array.isArray(body) || typeof body !== "object") {
291
+ throw new CliError("--file must contain a JSON object.", { code: "invalid_json_file" });
292
+ }
204
293
  if (options.purpose) body.purpose = options.purpose;
205
294
  } else {
206
295
  body = {
@@ -208,10 +297,10 @@ async function query(options) {
208
297
  purpose: option(options, "purpose", { required: true }),
209
298
  fields: listOption(options, "fields"),
210
299
  group_by: listOption(options, "group-by"),
211
- metrics: options.metrics ? JSON.parse(options.metrics) : [],
212
- filters: options.filters ? JSON.parse(options.filters) : [],
213
- order_by: options["order-by"] ? JSON.parse(options["order-by"]) : [],
214
- limit: numberOption(options, "limit", 100),
300
+ metrics: options.metrics ? parseJsonValue(options.metrics, "metrics") : [],
301
+ filters: options.filters ? parseJsonValue(options.filters, "filters") : [],
302
+ order_by: options["order-by"] ? parseJsonValue(options["order-by"], "order-by") : [],
303
+ limit: numberOption(options, "limit", 100, { maximum: 200 }),
215
304
  cursor: options.cursor || null,
216
305
  };
217
306
  }
@@ -227,7 +316,15 @@ async function listResource(options, resource) {
227
316
  async function downloadSkill(options) {
228
317
  const { api } = await client(options);
229
318
  const output = String(options.output || "corvio-admin-data.zip");
319
+ const manifest = await api.request("/admin/skill/manifest.json");
230
320
  const bytes = await api.requestBytes("/admin/skill/corvio-admin-data.zip");
321
+ const actualHash = createHash("sha256").update(bytes).digest("hex");
322
+ if (!manifest.archive_sha256 || actualHash !== manifest.archive_sha256) {
323
+ throw new CliError("Administrator Skill archive did not match its manifest SHA-256.", {
324
+ code: "admin_skill_integrity_mismatch",
325
+ exitCode: EXIT.REMOTE,
326
+ });
327
+ }
231
328
  const path = await writeBinaryFile(output, bytes);
232
329
  writeResult({
233
330
  downloaded: true,
@@ -240,6 +337,7 @@ async function main({ options, positionals }) {
240
337
  const [command, action, id] = positionals;
241
338
  if (options.version || command === "version") return writeResult(VERSION, { json: options.json });
242
339
  if (options.help || !command || command === "help") return writeResult(HELP.trimEnd(), { json: false });
340
+ validateInvocation(options, positionals);
243
341
  if (command === "auth") {
244
342
  if (action === "login") return authLogin(options);
245
343
  if (action === "status") return authStatus(options);
@@ -247,6 +345,7 @@ async function main({ options, positionals }) {
247
345
  throw new CliError("auth action must be login, status, or logout.");
248
346
  }
249
347
  if (command === "capabilities") return capabilities(options);
348
+ if (command === "update" && (action || "check") === "check") return updateCheck(options);
250
349
  if (command === "skill" && action === "download") return downloadSkill(options);
251
350
  if (command === "catalog") return catalog(options);
252
351
  if (command === "query") return query(options);
@@ -259,7 +358,7 @@ async function main({ options, positionals }) {
259
358
  const runId = id || option(options, "id", { required: true });
260
359
  const purpose = option(options, "purpose", { required: true });
261
360
  const { api } = await client(options);
262
- return writeResult(await api.request(`/admin/runs/${runId}/content`, { query: { purpose } }), { json: options.json });
361
+ return writeResult(await api.request(`/admin/runs/${resourcePath(runId)}/content`, { query: { purpose } }), { json: options.json });
263
362
  }
264
363
  if (command === "usage" && action === "events") return listResource(options, "usage/events");
265
364
  if (command === "usage" && action === "summary") {
@@ -269,7 +368,7 @@ async function main({ options, positionals }) {
269
368
  if (command === "receipts" && action === "get") {
270
369
  const receiptId = id || option(options, "id", { required: true });
271
370
  const { api } = await client(options);
272
- return writeResult(await api.request(`/admin/data/query-receipts/${receiptId}`), { json: options.json });
371
+ return writeResult(await api.request(`/admin/data/query-receipts/${resourcePath(receiptId)}`), { json: options.json });
273
372
  }
274
373
  throw new CliError(`Unknown administrator command: ${positionals.join(" ")}`);
275
374
  }
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,40 @@ export function parseArgv(argv) {
40
93
  "no-input",
41
94
  "yes",
42
95
  "allow-actions",
96
+ "content",
97
+ "organize",
43
98
  "private-link",
44
99
  "force",
100
+ "once",
101
+ "include-revoked",
102
+ "recover-credential",
103
+ "no-listen",
104
+ "start-new-chain",
45
105
  "help",
46
106
  "version",
47
107
  ]);
48
108
  for (let index = 0; index < argv.length; index += 1) {
49
109
  const value = argv[index];
110
+ if (value === "--") {
111
+ positionals.push(...argv.slice(index + 1));
112
+ break;
113
+ }
50
114
  if (!value.startsWith("--")) {
51
115
  positionals.push(value);
52
116
  continue;
53
117
  }
54
118
  const separator = value.indexOf("=");
55
119
  const name = value.slice(2, separator === -1 ? undefined : separator);
120
+ if (!name) throw new CliError("Option name must not be empty.", { code: "invalid_option" });
121
+ if (Object.hasOwn(options, name)) {
122
+ throw new CliError(`Option --${name} was provided more than once.`, { code: "duplicate_option" });
123
+ }
56
124
  if (booleanOptions.has(name)) {
57
- options[name] = separator === -1 ? true : value.slice(separator + 1) !== "false";
125
+ const booleanValue = separator === -1 ? "true" : value.slice(separator + 1);
126
+ if (booleanValue !== "true" && booleanValue !== "false") {
127
+ throw new CliError(`Option --${name} must be true or false.`, { code: "invalid_boolean_option" });
128
+ }
129
+ options[name] = booleanValue === "true";
58
130
  continue;
59
131
  }
60
132
  if (separator !== -1) {
@@ -71,6 +143,21 @@ export function parseArgv(argv) {
71
143
  return { options, positionals };
72
144
  }
73
145
 
146
+ export function assertKnownOptions(options, allowed) {
147
+ const allowedSet = new Set(allowed);
148
+ for (const name of Object.keys(options)) {
149
+ if (!allowedSet.has(name)) {
150
+ throw new CliError(`Unknown option --${name}.`, { code: "unknown_option" });
151
+ }
152
+ }
153
+ }
154
+
155
+ export function assertPositionalCount(positionals, maximum) {
156
+ if (positionals.length > maximum) {
157
+ throw new CliError(`Unexpected argument: ${positionals[maximum]}`, { code: "unexpected_argument" });
158
+ }
159
+ }
160
+
74
161
  export function option(options, name, { required = false, fallback = undefined } = {}) {
75
162
  const value = options[name] ?? fallback;
76
163
  if (required && (value === undefined || String(value).trim() === "")) {
@@ -85,35 +172,152 @@ export function listOption(options, name, fallback = []) {
85
172
  return String(value).split(",").map((item) => item.trim()).filter(Boolean);
86
173
  }
87
174
 
88
- export function numberOption(options, name, fallback) {
175
+ export function numberOption(options, name, fallback, { minimum = 1, maximum = Number.MAX_SAFE_INTEGER } = {}) {
89
176
  const value = options[name];
90
177
  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" });
178
+ const normalized = String(value).trim();
179
+ const parsed = /^\d+$/.test(normalized) ? Number(normalized) : Number.NaN;
180
+ if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
181
+ throw new CliError(
182
+ `Option --${name} must be an integer between ${minimum} and ${maximum}.`,
183
+ { code: "invalid_number_option" },
184
+ );
94
185
  }
95
186
  return parsed;
96
187
  }
97
188
 
98
189
  export function normalizeApiBase(value, fallback) {
99
190
  const raw = String(value || fallback).trim().replace(/\/+$/, "");
100
- if (!/^https:\/\//i.test(raw) && !/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?/i.test(raw)) {
191
+ let parsed;
192
+ try {
193
+ parsed = new URL(raw);
194
+ } catch {
195
+ throw new CliError("Corvio API base must be a valid URL.", { code: "invalid_api_base" });
196
+ }
197
+ const localhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]";
198
+ if (parsed.username || parsed.password || (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && localhost))) {
101
199
  throw new CliError("Corvio API base must use HTTPS (localhost is allowed for development).", {
102
200
  code: "insecure_api_base",
103
201
  });
104
202
  }
105
- return raw.endsWith("/v1") ? raw : `${raw}/v1`;
203
+ if (parsed.search || parsed.hash) {
204
+ throw new CliError("Corvio API base must not include a query string or fragment.", { code: "invalid_api_base" });
205
+ }
206
+ const normalized = parsed.toString().replace(/\/+$/, "");
207
+ return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
208
+ }
209
+
210
+ function parseSemver(value) {
211
+ 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-]+)*)?$/);
212
+ if (!match) return null;
213
+ return {
214
+ core: match.slice(1, 4).map((item) => Number.parseInt(item, 10)),
215
+ pre: match[4] ? match[4].split(".") : [],
216
+ };
217
+ }
218
+
219
+ export function compareVersions(left, right) {
220
+ const a = parseSemver(left);
221
+ const b = parseSemver(right);
222
+ if (!a || !b) return 0;
223
+ for (let index = 0; index < 3; index += 1) {
224
+ if (a.core[index] !== b.core[index]) return a.core[index] > b.core[index] ? 1 : -1;
225
+ }
226
+ if (a.pre.length === 0 && b.pre.length > 0) return 1;
227
+ if (b.pre.length === 0 && a.pre.length > 0) return -1;
228
+ for (let index = 0; index < Math.max(a.pre.length, b.pre.length); index += 1) {
229
+ if (a.pre[index] === undefined) return -1;
230
+ if (b.pre[index] === undefined) return 1;
231
+ if (a.pre[index] === b.pre[index]) continue;
232
+ const aNumber = /^\d+$/.test(a.pre[index]) ? Number.parseInt(a.pre[index], 10) : null;
233
+ const bNumber = /^\d+$/.test(b.pre[index]) ? Number.parseInt(b.pre[index], 10) : null;
234
+ if (aNumber !== null && bNumber !== null) return aNumber > bNumber ? 1 : -1;
235
+ if (aNumber !== null) return -1;
236
+ if (bNumber !== null) return 1;
237
+ return a.pre[index] > b.pre[index] ? 1 : -1;
238
+ }
239
+ return 0;
240
+ }
241
+
242
+ export async function resolveNpmUpdateStatus({
243
+ currentVersion,
244
+ policy,
245
+ userAgent,
246
+ allowRegistryFailure = false,
247
+ checkRegistry = true,
248
+ fetchImpl = globalThis.fetch,
249
+ registryBase = process.env.CORVIO_NPM_REGISTRY_BASE || "https://registry.npmjs.org",
250
+ }) {
251
+ const packageName = String(policy?.package || "").trim();
252
+ const distTag = String(policy?.dist_tag || "beta").trim();
253
+ if (!packageName) throw new CliError("The API did not advertise an npm package.", { code: "update_policy_invalid" });
254
+ const encoded = encodeURIComponent(packageName).replace("%40", "@");
255
+ let latestVersion = null;
256
+ let registryError = null;
257
+ if (checkRegistry) {
258
+ try {
259
+ const registry = String(registryBase).replace(/\/+$/, "");
260
+ const response = await fetchImpl(`${registry}/${encoded}`, {
261
+ headers: { accept: "application/json", "user-agent": userAgent },
262
+ signal: AbortSignal.timeout(2500),
263
+ });
264
+ if (!response.ok) throw new Error(`npm registry returned ${response.status}`);
265
+ const metadata = await response.json();
266
+ latestVersion = String(metadata?.["dist-tags"]?.[distTag] || "").trim() || null;
267
+ } catch (error) {
268
+ registryError = error instanceof Error ? error.message : String(error);
269
+ if (!allowRegistryFailure) {
270
+ throw new CliError(`Unable to check npm for updates: ${registryError}`, {
271
+ code: "update_check_failed",
272
+ exitCode: EXIT.REMOTE,
273
+ });
274
+ }
275
+ }
276
+ }
277
+ const minimum = String(policy.minimum_supported_version || "").trim() || null;
278
+ return {
279
+ package: packageName,
280
+ channel: String(policy.channel || distTag),
281
+ dist_tag: distTag,
282
+ current_version: currentVersion,
283
+ latest_published_version: latestVersion,
284
+ registry_checked: checkRegistry,
285
+ reviewed_version: String(policy.reviewed_version || "").trim() || null,
286
+ minimum_supported_version: minimum,
287
+ update_available: Boolean(latestVersion && compareVersions(latestVersion, currentVersion) > 0),
288
+ supported: minimum ? compareVersions(currentVersion, minimum) >= 0 : true,
289
+ install_command: String(policy.install_command || `npm install --global ${packageName}@${distTag}`),
290
+ automatic_install: false,
291
+ ...(registryError ? { registry_error: registryError } : {}),
292
+ };
106
293
  }
107
294
 
108
295
  export class ApiClient {
109
- constructor({ baseUrl, credential = null, userAgent, fetchImpl = globalThis.fetch }) {
296
+ constructor({
297
+ baseUrl,
298
+ credential = null,
299
+ workspaceId = null,
300
+ userAgent,
301
+ fetchImpl = globalThis.fetch,
302
+ timeoutMs = process.env.CORVIO_REQUEST_TIMEOUT_MS,
303
+ }) {
110
304
  this.baseUrl = baseUrl.replace(/\/+$/, "");
111
305
  this.credential = credential;
306
+ this.workspaceId = String(workspaceId || "").trim() || null;
112
307
  this.userAgent = userAgent;
113
308
  this.fetchImpl = fetchImpl;
309
+ this.timeoutMs = normalizeRequestTimeout(timeoutMs);
114
310
  }
115
311
 
116
- async request(path, { method = "GET", body, query, headers = {}, auth = true } = {}) {
312
+ async request(path, {
313
+ method = "GET",
314
+ body,
315
+ query,
316
+ headers = {},
317
+ auth = true,
318
+ retrySafe = false,
319
+ timeoutMs = this.timeoutMs,
320
+ } = {}) {
117
321
  const url = new URL(`${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
118
322
  for (const [key, value] of Object.entries(query || {})) {
119
323
  if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
@@ -129,19 +333,61 @@ export class ApiClient {
129
333
  throw new CliError("Corvio authentication is required.", { code: "auth_required", exitCode: EXIT.AUTH });
130
334
  }
131
335
  requestHeaders.authorization = `Bearer ${this.credential}`;
336
+ if (this.workspaceId) {
337
+ requestHeaders["X-Corvio-Workspace-Id"] = this.workspaceId;
338
+ requestHeaders["X-Corvio-Workspace-Context-Contract"] = "required-v1";
339
+ }
132
340
  }
341
+ const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
133
342
  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
- });
343
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
344
+ try {
345
+ response = await this.fetchImpl(url, {
346
+ method,
347
+ headers: requestHeaders,
348
+ body: body === undefined ? undefined : JSON.stringify(body),
349
+ signal: AbortSignal.timeout(normalizedTimeout),
350
+ });
351
+ const safety = networkFailureDetails({
352
+ cause: null,
353
+ url,
354
+ method,
355
+ requestHeaders,
356
+ body,
357
+ retrySafe,
358
+ });
359
+ if (attempt === 1 && safety.safe_to_retry && RETRYABLE_HTTP_STATUSES.has(response.status)) {
360
+ await response.body?.cancel().catch(() => {});
361
+ const retryAfterValue = response.headers.get("retry-after");
362
+ const retryAfterSeconds = retryAfterValue === null ? Number.NaN : Number(retryAfterValue);
363
+ const delayMs = Number.isFinite(retryAfterSeconds)
364
+ ? Math.min(2_000, Math.max(0, retryAfterSeconds * 1000))
365
+ : 150;
366
+ await sleep(delayMs);
367
+ continue;
368
+ }
369
+ break;
370
+ } catch (cause) {
371
+ const details = networkFailureDetails({ cause, url, method, requestHeaders, body, retrySafe });
372
+ if (attempt === 1 && details.safe_to_retry) {
373
+ await sleep(150);
374
+ continue;
375
+ }
376
+ throw new CliError(
377
+ details.timed_out
378
+ ? `Corvio did not respond within ${normalizedTimeout} ms.`
379
+ : `Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`,
380
+ {
381
+ code: details.timed_out ? "request_timeout" : "network_error",
382
+ exitCode: EXIT.REMOTE,
383
+ details: {
384
+ ...details,
385
+ attempts: attempt,
386
+ automatic_retry_attempted: attempt > 1,
387
+ },
388
+ },
389
+ );
390
+ }
145
391
  }
146
392
  const text = await response.text();
147
393
  let payload = null;
@@ -158,13 +404,18 @@ export class ApiClient {
158
404
  code: String(envelope.code || `http_${response.status}`),
159
405
  status: response.status,
160
406
  exitCode: exitCodeForStatus(response.status),
161
- details: envelope.details || null,
407
+ details: {
408
+ ...(envelope.details || {}),
409
+ ...(envelope.request_id || response.headers.get("x-request-id")
410
+ ? { request_id: envelope.request_id || response.headers.get("x-request-id") }
411
+ : {}),
412
+ },
162
413
  });
163
414
  }
164
415
  return payload;
165
416
  }
166
417
 
167
- async requestBytes(path, { query, headers = {}, auth = true } = {}) {
418
+ async requestBytes(path, { query, headers = {}, auth = true, timeoutMs = this.timeoutMs } = {}) {
168
419
  const url = new URL(`${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
169
420
  for (const [key, value] of Object.entries(query || {})) {
170
421
  if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
@@ -179,15 +430,43 @@ export class ApiClient {
179
430
  throw new CliError("Corvio authentication is required.", { code: "auth_required", exitCode: EXIT.AUTH });
180
431
  }
181
432
  requestHeaders.authorization = `Bearer ${this.credential}`;
433
+ if (this.workspaceId) {
434
+ requestHeaders["X-Corvio-Workspace-Id"] = this.workspaceId;
435
+ requestHeaders["X-Corvio-Workspace-Context-Contract"] = "required-v1";
436
+ }
182
437
  }
438
+ const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
183
439
  let response;
184
- try {
185
- response = await this.fetchImpl(url, { method: "GET", headers: requestHeaders });
186
- } catch (cause) {
187
- throw new CliError(`Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`, {
188
- code: "network_error",
189
- exitCode: EXIT.REMOTE,
190
- });
440
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
441
+ try {
442
+ response = await this.fetchImpl(url, {
443
+ method: "GET",
444
+ headers: requestHeaders,
445
+ signal: AbortSignal.timeout(normalizedTimeout),
446
+ });
447
+ if (attempt === 1 && RETRYABLE_HTTP_STATUSES.has(response.status)) {
448
+ await response.body?.cancel().catch(() => {});
449
+ await sleep(150);
450
+ continue;
451
+ }
452
+ break;
453
+ } catch (cause) {
454
+ const details = networkFailureDetails({ cause, url, method: "GET", requestHeaders });
455
+ if (attempt === 1) {
456
+ await sleep(150);
457
+ continue;
458
+ }
459
+ throw new CliError(
460
+ details.timed_out
461
+ ? `Corvio did not respond within ${normalizedTimeout} ms.`
462
+ : `Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`,
463
+ {
464
+ code: details.timed_out ? "request_timeout" : "network_error",
465
+ exitCode: EXIT.REMOTE,
466
+ details: { ...details, attempts: attempt, automatic_retry_attempted: true },
467
+ },
468
+ );
469
+ }
191
470
  }
192
471
  const bytes = new Uint8Array(await response.arrayBuffer());
193
472
  if (!response.ok) {
@@ -203,11 +482,68 @@ export class ApiClient {
203
482
  code: String(envelope.code || `http_${response.status}`),
204
483
  status: response.status,
205
484
  exitCode: exitCodeForStatus(response.status),
206
- details: envelope.details || null,
485
+ details: {
486
+ ...(envelope.details || {}),
487
+ ...(envelope.request_id || response.headers.get("x-request-id")
488
+ ? { request_id: envelope.request_id || response.headers.get("x-request-id") }
489
+ : {}),
490
+ },
207
491
  });
208
492
  }
209
493
  return bytes;
210
494
  }
495
+
496
+ async putUrl(url, content, { headers = {}, timeoutMs = this.timeoutMs } = {}) {
497
+ const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
498
+ let response;
499
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
500
+ try {
501
+ response = await this.fetchImpl(String(url), {
502
+ method: "PUT",
503
+ headers,
504
+ body: content,
505
+ signal: AbortSignal.timeout(normalizedTimeout),
506
+ });
507
+ if (attempt === 1 && RETRYABLE_HTTP_STATUSES.has(response.status)) {
508
+ await response.body?.cancel().catch(() => {});
509
+ await sleep(150);
510
+ continue;
511
+ }
512
+ break;
513
+ } catch (cause) {
514
+ const details = networkFailureDetails({
515
+ cause,
516
+ url,
517
+ method: "PUT",
518
+ requestHeaders: headers,
519
+ retrySafe: true,
520
+ });
521
+ if (attempt === 1) {
522
+ await sleep(150);
523
+ continue;
524
+ }
525
+ throw new CliError(
526
+ details.timed_out
527
+ ? `The file upload did not respond within ${normalizedTimeout} ms.`
528
+ : `Unable to upload the file: ${cause instanceof Error ? cause.message : String(cause)}`,
529
+ {
530
+ code: details.timed_out ? "upload_timeout" : "upload_network_error",
531
+ exitCode: EXIT.REMOTE,
532
+ details: { ...details, attempts: attempt, automatic_retry_attempted: true },
533
+ },
534
+ );
535
+ }
536
+ }
537
+ if (!response.ok) {
538
+ const message = (await response.text()).slice(0, 500);
539
+ throw new CliError(message || `File upload failed (${response.status}).`, {
540
+ code: "upload_failed",
541
+ status: response.status,
542
+ exitCode: EXIT.REMOTE,
543
+ });
544
+ }
545
+ return { ok: true, status: response.status };
546
+ }
211
547
  }
212
548
 
213
549
  function configRoot(env = process.env) {
@@ -220,15 +556,27 @@ export function workspaceCredentialPath(env = process.env) {
220
556
  return join(configRoot(env), "credentials.json");
221
557
  }
222
558
 
559
+ export function workspaceContextPath(env = process.env) {
560
+ return join(configRoot(env), "workspace-context.json");
561
+ }
562
+
223
563
  export function adminCredentialPath(env = process.env) {
224
564
  return join(configRoot(env), "admin-credentials.json");
225
565
  }
226
566
 
567
+ export function agentCredentialPath(env = process.env) {
568
+ return join(configRoot(env), "agent-credentials.json");
569
+ }
570
+
571
+ export function conversationRoutingPath(env = process.env) {
572
+ return join(configRoot(env), "conversation-routing.json");
573
+ }
574
+
227
575
  export async function readWorkspaceCredential(env = process.env) {
228
576
  const environmentKey = String(env.CORVIO_API_KEY || "").trim();
229
577
  if (environmentKey) {
230
- if (!environmentKey.startsWith("cvk_")) {
231
- throw new CliError("CORVIO_API_KEY must be a Workspace cvk_ key.", {
578
+ if (!environmentKey.startsWith("cvu_") && !environmentKey.startsWith("cvk_")) {
579
+ throw new CliError("CORVIO_API_KEY must be a user cvu_ key or legacy Workspace cvk_ key.", {
232
580
  code: "invalid_workspace_key",
233
581
  exitCode: EXIT.AUTH,
234
582
  });
@@ -245,14 +593,162 @@ export async function readWorkspaceCredential(env = process.env) {
245
593
  }
246
594
  const profileName = String(parsed.active_profile || "default");
247
595
  const profile = parsed.profiles?.[profileName];
248
- const key = String(profile?.api_key || "").trim();
249
- if (!key.startsWith("cvk_")) {
596
+ const metadata = String(parsed.api_key || "").trim() ? parsed : profile;
597
+ const key = String(metadata?.api_key || "").trim();
598
+ if (!key.startsWith("cvu_") && !key.startsWith("cvk_")) {
250
599
  throw new CliError("Stored Corvio credentials are invalid; run `corvio auth login` again.", {
251
600
  code: "credential_store_invalid",
252
601
  exitCode: EXIT.AUTH,
253
602
  });
254
603
  }
255
- return { key, source: "device_link", profile: profileName, metadata: profile };
604
+ return { key, source: "device_link", profile: profileName, metadata };
605
+ }
606
+
607
+ export async function readAgentCredential(
608
+ env = process.env,
609
+ {
610
+ required = true,
611
+ bindingId = null,
612
+ agentId = null,
613
+ projectRoot = null,
614
+ fallbackToActive = true,
615
+ } = {},
616
+ ) {
617
+ const key = String(env.CORVIO_AGENT_API_KEY || "").trim();
618
+ if (key) {
619
+ if (!key.startsWith("cvg_")) {
620
+ throw new CliError("CORVIO_AGENT_API_KEY must be an Agent cvg_ key.", {
621
+ code: "invalid_agent_key",
622
+ exitCode: EXIT.AUTH,
623
+ });
624
+ }
625
+ return { key, source: "environment", profile: null, metadata: null };
626
+ }
627
+ const path = agentCredentialPath(env);
628
+ let parsed;
629
+ try {
630
+ parsed = JSON.parse(await readFile(path, "utf8"));
631
+ } catch (cause) {
632
+ if (cause?.code === "ENOENT" && !required) return null;
633
+ if (cause?.code === "ENOENT") {
634
+ throw new CliError(
635
+ "No Agent credential found. Run `corvio agents connect` or set CORVIO_AGENT_API_KEY.",
636
+ { code: "agent_auth_required", exitCode: EXIT.AUTH },
637
+ );
638
+ }
639
+ throw new CliError("Stored Agent credentials are unreadable.", {
640
+ code: "agent_credential_store_invalid",
641
+ exitCode: EXIT.AUTH,
642
+ });
643
+ }
644
+ let selected = parsed;
645
+ let selectedProfile = "default";
646
+ if (Number(parsed?.version) >= 2 && parsed?.credentials && typeof parsed.credentials === "object") {
647
+ const entries = Object.entries(parsed.credentials);
648
+ let match = null;
649
+ if (bindingId) {
650
+ match = entries.find(([key, value]) => key === bindingId || value?.binding_id === bindingId) || null;
651
+ }
652
+ if (!match && agentId) {
653
+ match = entries.find(([, value]) => String(value?.agent_id || "") === String(agentId)) || null;
654
+ }
655
+ if (!match && projectRoot) {
656
+ const normalizedRoot = resolve(String(projectRoot));
657
+ const candidates = entries
658
+ .filter(([, value]) => {
659
+ const storedRoot = String(value?.project_root || "").trim();
660
+ if (!storedRoot) return false;
661
+ const normalizedStored = resolve(storedRoot);
662
+ return normalizedRoot === normalizedStored || normalizedRoot.startsWith(`${normalizedStored}${process.platform === "win32" ? "\\" : "/"}`);
663
+ })
664
+ .sort((left, right) => String(right[1]?.project_root || "").length - String(left[1]?.project_root || "").length);
665
+ match = candidates[0] || null;
666
+ }
667
+ if (!match && fallbackToActive && parsed.active_binding_id) {
668
+ const value = parsed.credentials[parsed.active_binding_id];
669
+ if (value) match = [parsed.active_binding_id, value];
670
+ }
671
+ if (!match && fallbackToActive && entries.length === 1) match = entries[0];
672
+ if (!match) {
673
+ if (!required) return null;
674
+ throw new CliError(
675
+ "Multiple Agent project bindings are stored. Run from a connected project or pass --agent <id>.",
676
+ { code: "agent_routing_ambiguous", exitCode: EXIT.AUTH },
677
+ );
678
+ }
679
+ [selectedProfile, selected] = match;
680
+ }
681
+ const storedKey = String(selected?.api_key || "").trim();
682
+ if (!storedKey.startsWith("cvg_")) {
683
+ throw new CliError(
684
+ "Stored Agent credentials are invalid; run `corvio agents connect --recover-credential`.",
685
+ { code: "agent_credential_store_invalid", exitCode: EXIT.AUTH },
686
+ );
687
+ }
688
+ return { key: storedKey, source: "secure_store", profile: selectedProfile, metadata: selected };
689
+ }
690
+
691
+ export async function storeAgentCredential(payload, env = process.env) {
692
+ const secret = String(payload?.secret || "").trim();
693
+ if (!secret.startsWith("cvg_")) {
694
+ throw new CliError("Agent connection did not return a valid cvg_ credential.", {
695
+ code: "agent_credential_invalid",
696
+ exitCode: EXIT.AUTH,
697
+ });
698
+ }
699
+ const path = agentCredentialPath(env);
700
+ const root = dirname(path);
701
+ await mkdir(root, { recursive: true, mode: 0o700 });
702
+ await chmod(root, 0o700).catch(() => {});
703
+ const credential = {
704
+ api_key: secret,
705
+ agent_id: String(payload.agent_id || ""),
706
+ binding_id: String(payload.binding_id || "").trim() || null,
707
+ project_root: String(payload.project_root || "").trim() || null,
708
+ project_label: String(payload.project_label || "").trim() || null,
709
+ project_fingerprint: String(payload.project_fingerprint || "").trim() || null,
710
+ display_name: String(payload.display_name || ""),
711
+ provider: String(payload.provider || "custom"),
712
+ permission_profile: String(payload.permission_profile || "read_only"),
713
+ trigger_policy: String(payload.trigger_policy || "owner_only"),
714
+ skills_scope: String(payload.skills_scope || "project"),
715
+ memory_scope: String(payload.memory_scope || "off"),
716
+ connector_scope: String(payload.connector_scope || "off"),
717
+ display_prefix: String(payload.display_prefix || "cvg"),
718
+ created_at: payload.created_at || new Date().toISOString(),
719
+ source: "agent_connect",
720
+ };
721
+ let current = null;
722
+ try {
723
+ current = JSON.parse(await readFile(path, "utf8"));
724
+ } catch (error) {
725
+ if (error?.code !== "ENOENT") {
726
+ throw new CliError("Stored Agent credentials are unreadable.", {
727
+ code: "agent_credential_store_invalid",
728
+ exitCode: EXIT.AUTH,
729
+ });
730
+ }
731
+ }
732
+ const credentials = Number(current?.version) >= 2 && current?.credentials && typeof current.credentials === "object"
733
+ ? { ...current.credentials }
734
+ : {};
735
+ if (String(current?.api_key || "").startsWith("cvg_") && current?.agent_id) {
736
+ const legacyKey = String(current.binding_id || `legacy:${current.agent_id}`);
737
+ credentials[legacyKey] = { ...current, binding_id: current.binding_id || null };
738
+ }
739
+ const profile = credential.binding_id || `agent:${credential.agent_id}`;
740
+ credentials[profile] = credential;
741
+ const document = {
742
+ version: 2,
743
+ active_binding_id: profile,
744
+ credentials,
745
+ };
746
+ const temporary = `${path}.${randomUUID()}.tmp`;
747
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, flag: "wx" });
748
+ await chmod(temporary, 0o600);
749
+ await rename(temporary, path);
750
+ await chmod(path, 0o600);
751
+ return { path, profile, metadata: credential };
256
752
  }
257
753
 
258
754
  export async function storeWorkspaceCredential(payload, env = process.env) {
@@ -260,28 +756,150 @@ export async function storeWorkspaceCredential(payload, env = process.env) {
260
756
  const root = dirname(path);
261
757
  await mkdir(root, { recursive: true, mode: 0o700 });
262
758
  await chmod(root, 0o700).catch(() => {});
263
- const profileName = String(payload.workspace_id || "default");
759
+ const document = {
760
+ version: 2,
761
+ api_key: payload.secret,
762
+ key_id: payload.key_id,
763
+ audience: payload.audience || "user",
764
+ scopes: payload.scopes || [],
765
+ expires_at: payload.expires_at || null,
766
+ created_at: payload.created_at || new Date().toISOString(),
767
+ source: "device_link",
768
+ };
769
+ const temporary = `${path}.${randomUUID()}.tmp`;
770
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, flag: "wx" });
771
+ await chmod(temporary, 0o600);
772
+ await rename(temporary, path);
773
+ await chmod(path, 0o600);
774
+ return { path, profile: "default" };
775
+ }
776
+
777
+ export async function readWorkspaceContext(env = process.env) {
778
+ const environmentWorkspaceId = String(env.CORVIO_WORKSPACE_ID || "").trim();
779
+ if (environmentWorkspaceId) {
780
+ return { workspaceId: environmentWorkspaceId, workspaceName: null, source: "environment" };
781
+ }
782
+ const path = workspaceContextPath(env);
783
+ try {
784
+ const parsed = JSON.parse(await readFile(path, "utf8"));
785
+ const workspaceId = String(parsed.active_workspace_id || "").trim();
786
+ if (!workspaceId) return null;
787
+ return {
788
+ workspaceId,
789
+ workspaceName: String(parsed.active_workspace_name || "").trim() || null,
790
+ source: "stored",
791
+ routingPreference: parsed.routing_preference || null,
792
+ };
793
+ } catch (cause) {
794
+ if (cause?.code === "ENOENT") return null;
795
+ throw new CliError("Stored Corvio Workspace selection is unreadable.", {
796
+ code: "workspace_context_store_invalid",
797
+ exitCode: EXIT.AUTH,
798
+ });
799
+ }
800
+ }
801
+
802
+ export async function storeWorkspaceContext({ workspaceId, workspaceName = null, routingPreference = null }, env = process.env) {
803
+ const normalizedId = String(workspaceId || "").trim();
804
+ if (!normalizedId) throw new CliError("Workspace ID is required.", { code: "workspace_selection_required" });
805
+ const path = workspaceContextPath(env);
806
+ const root = dirname(path);
807
+ await mkdir(root, { recursive: true, mode: 0o700 });
808
+ await chmod(root, 0o700).catch(() => {});
264
809
  const document = {
265
810
  version: 1,
266
- active_profile: profileName,
267
- profiles: {
268
- [profileName]: {
269
- api_key: payload.secret,
270
- key_id: payload.key_id,
271
- workspace_id: payload.workspace_id,
272
- workspace_name: payload.workspace_name,
273
- scopes: payload.scopes || [],
274
- created_at: payload.created_at || new Date().toISOString(),
275
- source: "device_link",
276
- },
811
+ active_workspace_id: normalizedId,
812
+ active_workspace_name: String(workspaceName || "").trim() || null,
813
+ routing_preference: routingPreference || {
814
+ workspace_id: normalizedId,
815
+ provenance: "user_confirmed_cli",
816
+ confidence: 1,
817
+ scope: "global",
818
+ expires_at: null,
277
819
  },
820
+ updated_at: new Date().toISOString(),
278
821
  };
279
822
  const temporary = `${path}.${randomUUID()}.tmp`;
280
823
  await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, flag: "wx" });
281
824
  await chmod(temporary, 0o600);
282
825
  await rename(temporary, path);
283
826
  await chmod(path, 0o600);
284
- return { path, profile: profileName };
827
+ return { path, workspaceId: normalizedId };
828
+ }
829
+
830
+ export async function readConversationRoutingReceipt(conversationId, env = process.env) {
831
+ const normalizedId = String(conversationId || "").trim();
832
+ if (!normalizedId) return null;
833
+ const path = conversationRoutingPath(env);
834
+ try {
835
+ const parsed = JSON.parse(await readFile(path, "utf8"));
836
+ const receipt = parsed?.receipts?.[normalizedId];
837
+ const workspaceId = String(receipt?.workspace_id || "").trim();
838
+ if (!workspaceId) return null;
839
+ return {
840
+ conversationId: normalizedId,
841
+ workspaceId,
842
+ agentId: String(receipt.agent_id || "").trim() || null,
843
+ apiBase: String(receipt.api_base || "").trim() || null,
844
+ recordedAt: String(receipt.recorded_at || "").trim() || null,
845
+ };
846
+ } catch (cause) {
847
+ if (cause?.code === "ENOENT") return null;
848
+ throw new CliError("Stored Corvio conversation routing receipts are unreadable.", {
849
+ code: "conversation_routing_store_invalid",
850
+ exitCode: EXIT.AUTH,
851
+ });
852
+ }
853
+ }
854
+
855
+ export async function storeConversationRoutingReceipt({ conversationId, workspaceId, agentId = null, apiBase = null }, env = process.env) {
856
+ const normalizedConversationId = String(conversationId || "").trim();
857
+ const normalizedWorkspaceId = String(workspaceId || "").trim();
858
+ if (!normalizedConversationId || !normalizedWorkspaceId) {
859
+ throw new CliError("Conversation and Workspace IDs are required for routing continuity.", {
860
+ code: "conversation_routing_receipt_invalid",
861
+ });
862
+ }
863
+ const path = conversationRoutingPath(env);
864
+ const root = dirname(path);
865
+ await mkdir(root, { recursive: true, mode: 0o700 });
866
+ await chmod(root, 0o700).catch(() => {});
867
+ let existing = {};
868
+ try {
869
+ const parsed = JSON.parse(await readFile(path, "utf8"));
870
+ if (parsed?.receipts && typeof parsed.receipts === "object" && !Array.isArray(parsed.receipts)) {
871
+ existing = parsed.receipts;
872
+ }
873
+ } catch (cause) {
874
+ if (cause?.code !== "ENOENT") {
875
+ throw new CliError("Stored Corvio conversation routing receipts are unreadable.", {
876
+ code: "conversation_routing_store_invalid",
877
+ exitCode: EXIT.AUTH,
878
+ });
879
+ }
880
+ }
881
+ const ordered = Object.entries({
882
+ ...existing,
883
+ [normalizedConversationId]: {
884
+ workspace_id: normalizedWorkspaceId,
885
+ agent_id: String(agentId || "").trim() || null,
886
+ api_base: String(apiBase || "").trim() || null,
887
+ recorded_at: new Date().toISOString(),
888
+ },
889
+ }).sort((left, right) => String(right[1]?.recorded_at || "").localeCompare(String(left[1]?.recorded_at || "")));
890
+ const document = { version: 1, receipts: Object.fromEntries(ordered.slice(0, 500)) };
891
+ const temporary = `${path}.${randomUUID()}.tmp`;
892
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, flag: "wx" });
893
+ await chmod(temporary, 0o600);
894
+ await rename(temporary, path);
895
+ await chmod(path, 0o600);
896
+ return { path, conversationId: normalizedConversationId, workspaceId: normalizedWorkspaceId };
897
+ }
898
+
899
+ export async function clearWorkspaceContext(env = process.env) {
900
+ const path = workspaceContextPath(env);
901
+ await rm(path, { force: true });
902
+ return path;
285
903
  }
286
904
 
287
905
  export async function clearWorkspaceCredential(env = process.env) {
@@ -369,7 +987,21 @@ export function writeError(error, { json = false, stderr = process.stderr } = {}
369
987
  ...(error instanceof CliError && error.details ? { details: error.details } : {}),
370
988
  },
371
989
  };
372
- stderr.write(json ? `${JSON.stringify(safe)}\n` : `Error: ${safe.error.message}\n`);
990
+ if (json) {
991
+ stderr.write(`${JSON.stringify(safe)}\n`);
992
+ return;
993
+ }
994
+ stderr.write(`Error: ${safe.error.message}\n`);
995
+ if (safe.error.details?.request_id) stderr.write(`Request ID: ${safe.error.details.request_id}\n`);
996
+ if (safe.error.details?.safe_to_retry && safe.error.details?.idempotency_key) {
997
+ stderr.write(
998
+ `Safe retry: rerun the command with --idempotency-key ${safe.error.details.idempotency_key}\n`,
999
+ );
1000
+ } else if (safe.error.details?.retry_safety === "revision_guard") {
1001
+ stderr.write("Safe retry: rerun with the same --expected-revision; Corvio will reject an already-committed replacement.\n");
1002
+ } else if (safe.error.details?.retry_safety === "unknown_commit_state") {
1003
+ stderr.write("Retry state is unknown; read the remote object before repeating this mutation.\n");
1004
+ }
373
1005
  }
374
1006
 
375
1007
  export async function runMain(main, argv = process.argv.slice(2)) {
@@ -386,15 +1018,26 @@ export async function runMain(main, argv = process.argv.slice(2)) {
386
1018
 
387
1019
  export async function openBrowser(url) {
388
1020
  const target = String(url);
389
- const command = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open";
390
- const args = platform() === "win32" ? ["/c", "start", "", target] : [target];
391
- try {
392
- const child = spawn(command, args, { detached: true, stdio: "ignore" });
393
- child.unref();
394
- return true;
395
- } catch {
396
- return false;
397
- }
1021
+ const command = platform() === "darwin" ? "open" : platform() === "win32" ? "rundll32.exe" : "xdg-open";
1022
+ const args = platform() === "win32" ? ["url.dll,FileProtocolHandler", target] : [target];
1023
+ return new Promise((resolvePromise) => {
1024
+ let settled = false;
1025
+ const finish = (opened) => {
1026
+ if (settled) return;
1027
+ settled = true;
1028
+ resolvePromise(opened);
1029
+ };
1030
+ try {
1031
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
1032
+ child.once("error", () => finish(false));
1033
+ child.once("spawn", () => {
1034
+ child.unref();
1035
+ finish(true);
1036
+ });
1037
+ } catch {
1038
+ finish(false);
1039
+ }
1040
+ });
398
1041
  }
399
1042
 
400
1043
  export async function sleep(milliseconds) {
@@ -408,14 +1051,28 @@ export async function readTextFile(path) {
408
1051
  export async function writeTextFile(path, content) {
409
1052
  const target = resolve(String(path));
410
1053
  await mkdir(dirname(target), { recursive: true });
411
- await writeFile(target, content, "utf8");
1054
+ const temporary = `${target}.${randomUUID()}.tmp`;
1055
+ try {
1056
+ await writeFile(temporary, content, { encoding: "utf8", flag: "wx" });
1057
+ await rename(temporary, target);
1058
+ } catch (error) {
1059
+ await rm(temporary, { force: true }).catch(() => {});
1060
+ throw error;
1061
+ }
412
1062
  return target;
413
1063
  }
414
1064
 
415
1065
  export async function writeBinaryFile(path, content) {
416
1066
  const target = resolve(String(path));
417
1067
  await mkdir(dirname(target), { recursive: true });
418
- await writeFile(target, content);
1068
+ const temporary = `${target}.${randomUUID()}.tmp`;
1069
+ try {
1070
+ await writeFile(temporary, content, { flag: "wx" });
1071
+ await rename(temporary, target);
1072
+ } catch (error) {
1073
+ await rm(temporary, { force: true }).catch(() => {});
1074
+ throw error;
1075
+ }
419
1076
  return target;
420
1077
  }
421
1078
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@corvio/admin-cli",
3
- "version": "0.1.0-beta.3",
3
+ "version": "0.1.0-beta.30",
4
4
  "description": "Official read-only Corvio platform administrator CLI.",
5
5
  "type": "module",
6
6
  "bin": {