@cruxy/cli 1.8.1 → 1.10.0
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 +1 -1
- package/dist/agent/loop.js +16 -1
- package/dist/agent/session.js +62 -9
- package/dist/approval/classify.js +170 -40
- package/dist/approval/prompt.js +52 -6
- package/dist/approval/service.js +1 -11
- package/dist/budget/session-budget.js +10 -1
- package/dist/checkpoint/coverage.js +147 -4
- package/dist/cli/commands/limits.js +76 -0
- package/dist/cli/commands/login.js +18 -5
- package/dist/cli/commands/pr.js +10 -1
- package/dist/cli/commands/rollback.js +10 -2
- package/dist/cli/commands/run.js +55 -5
- package/dist/cli/commands/sessions.js +156 -0
- package/dist/cli/program.js +4 -0
- package/dist/cli/repl.js +25 -0
- package/dist/cli/session-factory.js +31 -10
- package/dist/config/credential-lifetime.js +42 -0
- package/dist/config/credentials.js +66 -0
- package/dist/config/schema.js +141 -9
- package/dist/constants.js +12 -2
- package/dist/errors/boundary.js +4 -4
- package/dist/errors/constructors.js +136 -57
- package/dist/errors/types.js +18 -0
- package/dist/index.js +27 -1
- package/dist/jobs/manager.js +269 -17
- package/dist/limits/cache.js +21 -5
- package/dist/mcp/client.js +16 -0
- package/dist/onboarding/flow.js +121 -6
- package/dist/onboarding/steps.js +112 -0
- package/dist/render/limits-report.js +213 -0
- package/dist/render/limits-view.js +125 -0
- package/dist/sandbox/service.js +9 -0
- package/dist/sandbox/types.js +15 -0
- package/dist/session/index.js +3 -1
- package/dist/session/list.js +20 -6
- package/dist/session/log.js +120 -21
- package/dist/session/prune.js +106 -0
- package/dist/session/resume.js +5 -0
- package/dist/subagent/orchestrator.js +71 -31
- package/dist/subagent/spawn-tool.js +11 -4
- package/dist/tools/schema-depth.js +18 -0
- package/dist/tui/limits-panel.js +62 -30
- package/dist/usage/collect.js +20 -1
- package/dist/usage/summary.js +48 -1
- package/dist/usage/types.js +27 -0
- package/package.json +2 -2
|
@@ -365,7 +365,8 @@ opts = {}) {
|
|
|
365
365
|
const executionSemaphore = new Semaphore(config.subagent.maxConcurrency);
|
|
366
366
|
// The ONE weighted-token budget for the whole session (P10 track 3 / cli#212).
|
|
367
367
|
// `/budget` reads and sets it; `Session` narrows each turn's token guard by it;
|
|
368
|
-
// the orchestrator refuses to dispatch a fan-out it cannot
|
|
368
|
+
// the orchestrator refuses to dispatch a fan-out (or a single spawn) it cannot
|
|
369
|
+
// cover; the job manager refuses a background job it cannot. Four
|
|
369
370
|
// consumers, one object — a session cap and a fan-out bound that could disagree
|
|
370
371
|
// would be the failure this exists to prevent. Its server denominator is
|
|
371
372
|
// attached later (see `attachLimits`), because the limits cache does not exist
|
|
@@ -378,6 +379,21 @@ opts = {}) {
|
|
|
378
379
|
// Outside `resumeLineAfterApproval`, so the live region is restored
|
|
379
380
|
// before a preview block is committed into it.
|
|
380
381
|
previewSilentApprovals(resumeLineAfterApproval((action) => approval.requestApproval(action), renderer), renderer), checkpoints, workspace), approvalMutex, cwd);
|
|
382
|
+
// THE LATE-BINDING HANDLE FOR THE SESSION BUILT AT THE END OF THIS FUNCTION.
|
|
383
|
+
//
|
|
384
|
+
// Declared here, above the first construction that needs it, rather than down
|
|
385
|
+
// beside the plan wiring it was written for. Three things now late-bind to the
|
|
386
|
+
// session — the approval policy's live mode read, and (cli#244) the job
|
|
387
|
+
// manager's session id — and the session cannot exist yet because it needs the
|
|
388
|
+
// ctx, the orchestrator and the job manager that are built between here and
|
|
389
|
+
// there.
|
|
390
|
+
//
|
|
391
|
+
// `autoApprove`: the policy has to read the LIVE mode, since the user can
|
|
392
|
+
// leave auto-approve between two actions of a single turn. Before the session
|
|
393
|
+
// exists there is nothing to approve, so the `false` fallback is a closed door
|
|
394
|
+
// rather than a gap.
|
|
395
|
+
const holder = {};
|
|
396
|
+
const autoApprove = () => holder.session?.getAutoApprove() ?? false;
|
|
381
397
|
// Subagent orchestration (C.14): spawn_subagent goes on the main registry
|
|
382
398
|
// only when depth allows (maxDepth 0 disables the feature structurally).
|
|
383
399
|
// Registered before the plan wiring so plan-mode execution steps can
|
|
@@ -406,7 +422,6 @@ opts = {}) {
|
|
|
406
422
|
makeChildApproval: () => gate(new ApprovalService({
|
|
407
423
|
cwd,
|
|
408
424
|
interactive: ttyInteractive,
|
|
409
|
-
io,
|
|
410
425
|
policy: new InteractivePolicy(new SessionAllowlist(), io, autoApprove),
|
|
411
426
|
})),
|
|
412
427
|
});
|
|
@@ -432,6 +447,20 @@ opts = {}) {
|
|
|
432
447
|
approvalMutex,
|
|
433
448
|
foregroundInteractive: ttyInteractive,
|
|
434
449
|
promptIO: io,
|
|
450
|
+
// The SAME budget the turn and the fan-out seam ask (cli#245). A job
|
|
451
|
+
// drawing on the weighted pool with no admission check was the last
|
|
452
|
+
// surface running `runAgent` that neither asked it nor moved it.
|
|
453
|
+
budget: sessionBudget,
|
|
454
|
+
// ...and the SAME usage sink the session's turns publish through
|
|
455
|
+
// (cli#244), so a job's spend is counted by `/usage` and not only by
|
|
456
|
+
// `/budget`. A job gets a record of its own because it can outlive the
|
|
457
|
+
// turn that launched it; see the note on `JobManagerDeps.onRunUsage`.
|
|
458
|
+
onRunUsage,
|
|
459
|
+
// Read lazily: the session is constructed below and does not exist yet.
|
|
460
|
+
// Without this the job's record persists with no `sessionId` and is then
|
|
461
|
+
// invisible to in-session `/usage` and to `--session` — on disk, and
|
|
462
|
+
// unreadable by the two surfaces most likely to look for it.
|
|
463
|
+
sessionId: () => holder.session?.sessionId,
|
|
435
464
|
})
|
|
436
465
|
: undefined;
|
|
437
466
|
const jobTool = jobManager ? makeRunInBackgroundTool(jobManager) : undefined;
|
|
@@ -465,19 +494,11 @@ opts = {}) {
|
|
|
465
494
|
// One allowlist shared by the plan-approval prompt and the per-action gate, so
|
|
466
495
|
// a grant recorded during execution is honored by U.3's own check.
|
|
467
496
|
const allowlist = new SessionAllowlist();
|
|
468
|
-
// Late-bound to the session constructed below. The policy has to read the LIVE
|
|
469
|
-
// mode — the user can leave auto-approve between two actions of a single turn
|
|
470
|
-
// — and the session cannot exist yet because it needs the ctx this policy is
|
|
471
|
-
// wired into. Before it exists there is nothing to approve, so the `false`
|
|
472
|
-
// fallback is a closed door rather than a gap.
|
|
473
|
-
const holder = {};
|
|
474
|
-
const autoApprove = () => holder.session?.getAutoApprove() ?? false;
|
|
475
497
|
const planPolicy = new PlanExecutionPolicy(allowlist, new InteractivePolicy(allowlist, io, autoApprove));
|
|
476
498
|
const approval = new ApprovalService({
|
|
477
499
|
cwd,
|
|
478
500
|
interactive: ttyInteractive,
|
|
479
501
|
policy: planPolicy,
|
|
480
|
-
io,
|
|
481
502
|
});
|
|
482
503
|
const ctx = {
|
|
483
504
|
cwd,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How close a stored credential is to the end of its life — a pure function of a
|
|
3
|
+
* timestamp and a clock.
|
|
4
|
+
*
|
|
5
|
+
* IT LIVES IN ITS OWN MODULE, AND IMPORTS NOTHING, so that `errors/` can reach
|
|
6
|
+
* it. `config/credentials.ts` imports `errors/` (for the unprotected-store
|
|
7
|
+
* refusal), so an error constructor importing the credentials store back would
|
|
8
|
+
* close a cycle. The rule this module needs to state is not about the store at
|
|
9
|
+
* all — it is arithmetic on one timestamp — so it sits below both and each side
|
|
10
|
+
* imports it directly.
|
|
11
|
+
*
|
|
12
|
+
* ONE CLASSIFIER, because every caller that acts on expiry must reach the same
|
|
13
|
+
* verdict from the same value: the 401 classifier deciding whether to say "your
|
|
14
|
+
* key is wrong" or "your login expired", the pre-session nudge deciding whether
|
|
15
|
+
* to warn, and the limits panel deciding what to render. Three private
|
|
16
|
+
* `Date.parse` comparisons would be three chances to disagree about whether a
|
|
17
|
+
* credential is dead.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* How long before expiry a credential is worth mentioning.
|
|
21
|
+
*
|
|
22
|
+
* Seven days against the gateway's 90-day device-key lifetime: long enough that
|
|
23
|
+
* someone who uses cruxy weekly sees the notice at least once before anything
|
|
24
|
+
* breaks, short enough that it is not background noise for the other 83 days.
|
|
25
|
+
*/
|
|
26
|
+
export const CREDENTIAL_EXPIRY_WARNING_MS = 7 * 24 * 60 * 60 * 1000;
|
|
27
|
+
export function classifyCredentialLifetime(expiresAt, now = Date.now(), warnWithinMs = CREDENTIAL_EXPIRY_WARNING_MS) {
|
|
28
|
+
if (expiresAt === undefined || expiresAt === "")
|
|
29
|
+
return { state: "unknown" };
|
|
30
|
+
const at = Date.parse(expiresAt);
|
|
31
|
+
// A timestamp this build cannot read is a thing we do not know, not a thing
|
|
32
|
+
// that has happened.
|
|
33
|
+
if (Number.isNaN(at))
|
|
34
|
+
return { state: "unknown" };
|
|
35
|
+
const msRemaining = at - now;
|
|
36
|
+
if (msRemaining <= 0)
|
|
37
|
+
return { state: "expired", expiresAt };
|
|
38
|
+
if (msRemaining <= warnWithinMs) {
|
|
39
|
+
return { state: "expiring", expiresAt, msRemaining };
|
|
40
|
+
}
|
|
41
|
+
return { state: "live", expiresAt, msRemaining };
|
|
42
|
+
}
|
|
@@ -3,8 +3,13 @@ import { dirname, join } from "node:path";
|
|
|
3
3
|
import { CREDENTIALS_FILE_NAME } from "../constants.js";
|
|
4
4
|
import { credentialsUnprotected } from "../errors/index.js";
|
|
5
5
|
import { logger } from "../utils/logger.js";
|
|
6
|
+
import { classifyCredentialLifetime, } from "./credential-lifetime.js";
|
|
6
7
|
import { enforceOwnerOnly, isOwnerOnly } from "./owner-only.js";
|
|
7
8
|
import { globalDir } from "./paths.js";
|
|
9
|
+
// Re-exported so `config/index.js` remains the one import site for credential
|
|
10
|
+
// concerns; the classifier itself lives in a leaf module that imports nothing,
|
|
11
|
+
// because `errors/` needs it too and this file imports `errors/`.
|
|
12
|
+
export { classifyCredentialLifetime, CREDENTIAL_EXPIRY_WARNING_MS, } from "./credential-lifetime.js";
|
|
8
13
|
/**
|
|
9
14
|
* The credentials store (U.6) — the one place a provider API key is persisted.
|
|
10
15
|
* It lives **outside** `config.json` on purpose: config is secret-free by design
|
|
@@ -89,15 +94,76 @@ export function writeMcpCredential(ref, token, file = credentialsPath()) {
|
|
|
89
94
|
store.mcp[ref] = token;
|
|
90
95
|
}, "mcp");
|
|
91
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* What we know about the stored key for `provider`, or `undefined`.
|
|
99
|
+
*
|
|
100
|
+
* Tolerant of a store written by a newer build or corrupted by hand: an entry
|
|
101
|
+
* that is not an object, or whose fields are the wrong type, reads as absent
|
|
102
|
+
* rather than throwing. This backs an expiry warning, and a warning that can
|
|
103
|
+
* crash the CLI is worse than no warning. Never throws.
|
|
104
|
+
*/
|
|
105
|
+
export function readCredentialMeta(provider, file = credentialsPath()) {
|
|
106
|
+
const store = readStore(file);
|
|
107
|
+
const raw = store?.keyMeta?.[provider];
|
|
108
|
+
if (raw === null || typeof raw !== "object")
|
|
109
|
+
return undefined;
|
|
110
|
+
const entry = raw;
|
|
111
|
+
const meta = {};
|
|
112
|
+
if (typeof entry.expiresAt === "string" && entry.expiresAt !== "") {
|
|
113
|
+
meta.expiresAt = entry.expiresAt;
|
|
114
|
+
}
|
|
115
|
+
if (typeof entry.keyId === "string" && entry.keyId !== "") {
|
|
116
|
+
meta.keyId = entry.keyId;
|
|
117
|
+
}
|
|
118
|
+
if (entry.source === "device" || entry.source === "paste") {
|
|
119
|
+
meta.source = entry.source;
|
|
120
|
+
}
|
|
121
|
+
return meta;
|
|
122
|
+
}
|
|
92
123
|
/**
|
|
93
124
|
* Persist `key` for `provider`, merging into any existing store. Written
|
|
94
125
|
* owner-only (see {@link writeInto}); refuses loudly if that can't be enforced.
|
|
126
|
+
*
|
|
127
|
+
* A bare write CLEARS any metadata held for that provider, because a write with
|
|
128
|
+
* nothing to say about the key's lifetime is a statement that we do not know it.
|
|
129
|
+
* The alternative — leaving whatever was there — is the one way this store can
|
|
130
|
+
* lie: paste an eternal admin key over a slot a device login used, and the
|
|
131
|
+
* stale 90-day expiry would have us announce that a perfectly good credential
|
|
132
|
+
* had died. Key and metadata therefore only ever move together, through
|
|
133
|
+
* {@link writeCredentialWithMeta}.
|
|
95
134
|
*/
|
|
96
135
|
export function writeCredential(provider, key, file = credentialsPath()) {
|
|
136
|
+
writeCredentialWithMeta(provider, key, {}, file);
|
|
137
|
+
}
|
|
138
|
+
/** Persist `key` for `provider` together with what is known about it. */
|
|
139
|
+
export function writeCredentialWithMeta(provider, key, meta, file = credentialsPath()) {
|
|
97
140
|
writeInto(file, (store) => {
|
|
98
141
|
store.keys[provider] = key;
|
|
142
|
+
const entry = {};
|
|
143
|
+
if (meta.expiresAt)
|
|
144
|
+
entry.expiresAt = meta.expiresAt;
|
|
145
|
+
if (meta.keyId)
|
|
146
|
+
entry.keyId = meta.keyId;
|
|
147
|
+
if (meta.source)
|
|
148
|
+
entry.source = meta.source;
|
|
149
|
+
if (Object.keys(entry).length === 0) {
|
|
150
|
+
// Nothing known. Drop any prior entry rather than keeping a fact about
|
|
151
|
+
// a key that no longer exists in this slot.
|
|
152
|
+
if (store.keyMeta) {
|
|
153
|
+
delete store.keyMeta[provider];
|
|
154
|
+
if (Object.keys(store.keyMeta).length === 0)
|
|
155
|
+
delete store.keyMeta;
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
store.keyMeta ??= {};
|
|
160
|
+
store.keyMeta[provider] = entry;
|
|
99
161
|
}, "provider");
|
|
100
162
|
}
|
|
163
|
+
/** The lifetime of the credential currently stored for `provider`. */
|
|
164
|
+
export function credentialLifetime(provider, now = Date.now(), file = credentialsPath()) {
|
|
165
|
+
return classifyCredentialLifetime(readCredentialMeta(provider, file)?.expiresAt, now);
|
|
166
|
+
}
|
|
101
167
|
/**
|
|
102
168
|
* Merge `mutate` into the store and persist it owner-only. The one write path
|
|
103
169
|
* shared by every credential namespace, and the single place the owner-only
|
package/dist/config/schema.js
CHANGED
|
@@ -2,7 +2,24 @@ import { z } from "zod";
|
|
|
2
2
|
import { LOG_LEVELS } from "../utils/logger.js";
|
|
3
3
|
import { MODEL_TIERS } from "../brand/voice.js";
|
|
4
4
|
import { TASK_CLASSES } from "../routing/types.js";
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* The backends a session can run on. ONE, deliberately.
|
|
7
|
+
*
|
|
8
|
+
* This used to be `["cruxy", "openai", "custom"]`. Neither extra value could
|
|
9
|
+
* ever produce a working session: `createProvider` implements `cruxy` and
|
|
10
|
+
* throws {@link NotImplementedError} for anything else, so `provider: "openai"`
|
|
11
|
+
* passed config validation and then failed at session start — a promise the
|
|
12
|
+
* schema made and the SDK refused. `OpenAICompatibleProvider`, the only code
|
|
13
|
+
* that could have honoured it, was removed in #260; "reserved for the future"
|
|
14
|
+
* is not a claim this enum can support when the implementation it named is
|
|
15
|
+
* gone.
|
|
16
|
+
*
|
|
17
|
+
* Rejecting them HERE is the point: a bad provider is now a config-load error
|
|
18
|
+
* that names the valid values, instead of a NotImplementedError thrown after
|
|
19
|
+
* onboarding has already run. Adding a backend means adding the value back
|
|
20
|
+
* together with the provider that serves it — never ahead of it.
|
|
21
|
+
*/
|
|
22
|
+
export const ProviderSchema = z.enum(["cruxy"]);
|
|
6
23
|
export const ModelConfigSchema = z
|
|
7
24
|
.object({
|
|
8
25
|
provider: ProviderSchema.default("cruxy"),
|
|
@@ -220,6 +237,73 @@ export const CheckpointConfigSchema = z
|
|
|
220
237
|
retention: z.number().int().positive().default(10),
|
|
221
238
|
})
|
|
222
239
|
.strict();
|
|
240
|
+
/**
|
|
241
|
+
* The floor under `sessions.retention`.
|
|
242
|
+
*
|
|
243
|
+
* The `--resume` picker (`PICKER_LIMIT`) and the TUI sidebar
|
|
244
|
+
* (`SIDEBAR_SESSIONS`) each offer ten rows. A retention below that would let
|
|
245
|
+
* both surfaces list sessions a later prune has already decided are
|
|
246
|
+
* expendable — offering a row and then deleting it is worse than never showing
|
|
247
|
+
* it. `session/retention-floor.test.ts` pins this against the two constants
|
|
248
|
+
* themselves, so raising either surface without raising the floor fails.
|
|
249
|
+
*
|
|
250
|
+
* Rejected rather than silently raised: a config that says 5 and behaves as 10
|
|
251
|
+
* is a config that lies, and `loadConfig` already treats a bad value as a hard
|
|
252
|
+
* error everywhere else.
|
|
253
|
+
*/
|
|
254
|
+
export const SESSION_RETENTION_FLOOR = 10;
|
|
255
|
+
/**
|
|
256
|
+
* Session persistence and retention (#257).
|
|
257
|
+
*
|
|
258
|
+
* The stores either side of this one have always bounded themselves —
|
|
259
|
+
* checkpoints at 10, run history at 50 — and sessions did not. Every session
|
|
260
|
+
* file ever written was still on disk, and nothing could turn recording off.
|
|
261
|
+
*
|
|
262
|
+
* WHY AGE IS THE PRIMARY BOUND AND COUNT IS THE BACKSTOP. "Old sessions I will
|
|
263
|
+
* never resume" is how people actually think about this, and once a session
|
|
264
|
+
* that recorded nothing stops being written at all (the lazy-meta change that
|
|
265
|
+
* had to land first), every remaining file is a real conversation for which age
|
|
266
|
+
* is the honest signal. The count cap stays because age alone does not protect
|
|
267
|
+
* a directory where someone runs forty sessions a day.
|
|
268
|
+
*
|
|
269
|
+
* WHY NOT BYTES. `statSync` hands us the size for free, so cost is not the
|
|
270
|
+
* objection — meaning is. Pruning on bytes makes whether YOUR session survives
|
|
271
|
+
* depend on how chatty an unrelated one was, which is not a rule anyone can
|
|
272
|
+
* hold in their head. `cruxy sessions` reports bytes, because seeing where the
|
|
273
|
+
* disk went is exactly what a report is for; nothing prunes on them.
|
|
274
|
+
*
|
|
275
|
+
* PER PROJECT, matching `projectDir` and how listing already works. A global
|
|
276
|
+
* cap would need the cross-project scan #172 item 2 declined.
|
|
277
|
+
*/
|
|
278
|
+
export const SessionsConfigSchema = z
|
|
279
|
+
.object({
|
|
280
|
+
/**
|
|
281
|
+
* Master switch. When false nothing is recorded: no file, no `--resume`,
|
|
282
|
+
* no sidebar. The session runs entirely in memory.
|
|
283
|
+
*/
|
|
284
|
+
enabled: z.boolean().default(true),
|
|
285
|
+
/**
|
|
286
|
+
* How many sessions to keep per project; older ones are pruned oldest-first.
|
|
287
|
+
* Never below {@link SESSION_RETENTION_FLOOR} — see the note there.
|
|
288
|
+
*/
|
|
289
|
+
retention: z
|
|
290
|
+
.number()
|
|
291
|
+
.int()
|
|
292
|
+
.min(SESSION_RETENTION_FLOOR, {
|
|
293
|
+
message: `sessions.retention must be at least ${SESSION_RETENTION_FLOOR} — ` +
|
|
294
|
+
`the --resume picker and the TUI sidebar both offer that many rows, and ` +
|
|
295
|
+
`a lower retention would list sessions that are already marked for deletion`,
|
|
296
|
+
})
|
|
297
|
+
.default(50),
|
|
298
|
+
/**
|
|
299
|
+
* Prune sessions untouched for this many days. Measured on the file's
|
|
300
|
+
* MTIME, never on `meta.startedAt`: a conversation begun forty days ago and
|
|
301
|
+
* resumed this morning is live, and an age check on when it BEGAN would
|
|
302
|
+
* delete it out from under the user.
|
|
303
|
+
*/
|
|
304
|
+
maxAgeDays: z.number().int().positive().default(30),
|
|
305
|
+
})
|
|
306
|
+
.strict();
|
|
223
307
|
/**
|
|
224
308
|
* Test-execution loop (C.13): how the agent runs the project's test suite and
|
|
225
309
|
* iterates on failures. The command is detected from package.json when unset;
|
|
@@ -517,7 +601,11 @@ export const UsageConfigSchema = z
|
|
|
517
601
|
* arguments over the network (https + cert-validated + pinned to a public IP; http
|
|
518
602
|
* only for a loopback dev server) and treats its responses as untrusted data. A
|
|
519
603
|
* url's trust also binds its resolved IP set at trust time, so a later IP-set
|
|
520
|
-
* change re-gates
|
|
604
|
+
* change re-gates. NEITHER transport's fingerprint covers the server's CODE —
|
|
605
|
+
* both hash the invocation (command/args/env, url, credential ref, header names),
|
|
606
|
+
* so a trusted server that later ships different code is still trusted. Trust
|
|
607
|
+
* means "I accept running this", not "this is still what I trusted"; see
|
|
608
|
+
* `mcp/types.ts`.
|
|
521
609
|
*/
|
|
522
610
|
export const McpServerSchema = z
|
|
523
611
|
.object({
|
|
@@ -577,6 +665,40 @@ function isHttps(url) {
|
|
|
577
665
|
* model names scrubbed, and results are NEVER persisted. The tool list a server
|
|
578
666
|
* advertises is bounded (count + per-tool description/schema size) so a hostile
|
|
579
667
|
* server can't blow the context budget.
|
|
668
|
+
*
|
|
669
|
+
* THE THREE BOUNDS HAVE CEILINGS OF THEIR OWN (#237). Each was `positive()` with
|
|
670
|
+
* no maximum, which made the protection exactly as good as the number the user
|
|
671
|
+
* typed: `maxSchemaBytes: 100000000` validated cleanly, and a cap whose docstring
|
|
672
|
+
* says a hostile server "can't flood context" then does not do that. The failure
|
|
673
|
+
* moved downstream to a provider request-size rejection or a blown context
|
|
674
|
+
* budget — a much worse place to find out than config load, where the answer is
|
|
675
|
+
* one line.
|
|
676
|
+
*
|
|
677
|
+
* These ceilings are a SANITY BOUND, NOT A PROTOCOL LIMIT, and the difference
|
|
678
|
+
* matters because `MAX_SCHEMA_DEPTH` (`tools/schema-depth.ts`) bounds the same
|
|
679
|
+
* schemas and is the opposite kind of number. That one mirrors a real gateway
|
|
680
|
+
* constraint: exceed it and the
|
|
681
|
+
* whole request fails, so it is not ours to choose and must never be raised to
|
|
682
|
+
* make a config work. These three mirror nothing upstream — no external validator
|
|
683
|
+
* enforces them and no request breaks at 65 KiB. They are a statement about what
|
|
684
|
+
* configuration is worth honoring, so they are picked to be obviously generous
|
|
685
|
+
* (8x, 16x, and 8x the shipped defaults) rather than tight. Different numbers in
|
|
686
|
+
* the same spirit would be just as correct; having no number at all was not.
|
|
687
|
+
*
|
|
688
|
+
* Rejected rather than silently clamped, on `SESSION_RETENTION_FLOOR`'s rule: a
|
|
689
|
+
* config that says 100000000 and behaves as 65536 is a config that lies. The cost
|
|
690
|
+
* is that an over-ceiling value is a hard `loadConfig` error, and the schema is
|
|
691
|
+
* `.strict()`, so it fails the whole CLI rather than just MCP. That is affordable
|
|
692
|
+
* HERE and would not be everywhere: these keys are documented nowhere, are not
|
|
693
|
+
* env-settable, and `initConfig` writes the defaults — so every generated config
|
|
694
|
+
* passes, and `setValue` rejects an over-ceiling `cruxy config set` at the write
|
|
695
|
+
* with the path and the bound named. Contrast `UsageConfigSchema`, which refuses
|
|
696
|
+
* the same hard error for the opposite reason: those keys were a documented
|
|
697
|
+
* feature people really had set, so rejecting them would hand a working user a
|
|
698
|
+
* CLI that will not start.
|
|
699
|
+
*
|
|
700
|
+
* The two timeouts above are deliberately left unbounded: a long timeout costs
|
|
701
|
+
* patience rather than memory, and a user may have a legitimately slow server.
|
|
580
702
|
*/
|
|
581
703
|
export const McpConfigSchema = z
|
|
582
704
|
.object({
|
|
@@ -592,15 +714,24 @@ export const McpConfigSchema = z
|
|
|
592
714
|
/** Fail a single `tools/call` if the server does not respond within this many
|
|
593
715
|
* ms — the connection is kept, only the one call errors. */
|
|
594
716
|
requestTimeout: z.number().int().positive().default(30000),
|
|
595
|
-
/**
|
|
596
|
-
*
|
|
597
|
-
|
|
717
|
+
/**
|
|
718
|
+
* Max tools accepted from ONE server; extras are dropped with a visible note
|
|
719
|
+
* (a hostile server can't advertise thousands of tools to flood context).
|
|
720
|
+
*
|
|
721
|
+
* PER SERVER, which is not the number that decides the context budget. Ten
|
|
722
|
+
* trusted servers at the default advertise up to 320 tools between them, and
|
|
723
|
+
* nothing caps that sum — this bound stops one server flooding the list, not
|
|
724
|
+
* a large `servers` map adding up. Configuring many servers is a deliberate
|
|
725
|
+
* act with a visible cost, so it is bounded by the user rather than here.
|
|
726
|
+
*/
|
|
727
|
+
maxToolsPerServer: z.number().int().positive().max(256).default(32),
|
|
598
728
|
/** Max characters kept from a single tool's description; the rest is truncated
|
|
599
|
-
* with a visible marker. */
|
|
600
|
-
maxDescriptionChars: z.number().int().positive().default(1024),
|
|
729
|
+
* with a visible marker. A description past the 16 KiB ceiling is not one. */
|
|
730
|
+
maxDescriptionChars: z.number().int().positive().max(16_384).default(1024),
|
|
601
731
|
/** Max bytes kept from a single tool's advertised JSON input schema; an
|
|
602
|
-
* over-cap schema is replaced with a permissive one and a visible note.
|
|
603
|
-
|
|
732
|
+
* over-cap schema is replaced with a permissive one and a visible note. One
|
|
733
|
+
* tool's schema at the 64 KiB ceiling is already ~16k tokens of context. */
|
|
734
|
+
maxSchemaBytes: z.number().int().positive().max(65_536).default(8192),
|
|
604
735
|
})
|
|
605
736
|
.strict();
|
|
606
737
|
/**
|
|
@@ -660,6 +791,7 @@ export const CruxyConfigSchema = z
|
|
|
660
791
|
index: IndexConfigSchema.default({}),
|
|
661
792
|
lsp: LspConfigSchema.default({}),
|
|
662
793
|
checkpoint: CheckpointConfigSchema.default({}),
|
|
794
|
+
sessions: SessionsConfigSchema.default({}),
|
|
663
795
|
subagent: SubagentConfigSchema.default({}),
|
|
664
796
|
jobs: JobsConfigSchema.default({}),
|
|
665
797
|
test: TestConfigSchema.default({}),
|
package/dist/constants.js
CHANGED
|
@@ -26,8 +26,18 @@ export const CONFIG_FILE_NAME = "config.json";
|
|
|
26
26
|
export const CREDENTIALS_FILE_NAME = "credentials.json";
|
|
27
27
|
/** Onboarding state + completion marker under the global dir (U.6). */
|
|
28
28
|
export const ONBOARDING_FILE_NAME = "onboarding.json";
|
|
29
|
-
/**
|
|
30
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Where a user creates a Cruxy gateway key (printed during onboarding).
|
|
31
|
+
*
|
|
32
|
+
* `cruxy.ai`, not `app.cruxy.in` — the app moved and the old host now only
|
|
33
|
+
* `308`s here. Printed verbatim into the terminal, where a redirect buys
|
|
34
|
+
* nothing: a URL a user copies by hand or reads aloud should be the one that
|
|
35
|
+
* serves the page, and this one outlived the redirect that was covering for it.
|
|
36
|
+
*
|
|
37
|
+
* Deliberately NOT `api.cruxy.in`. The gateway did not move, and nothing in
|
|
38
|
+
* this package that names it should be changed alongside this.
|
|
39
|
+
*/
|
|
40
|
+
export const CREATE_KEY_URL = "https://cruxy.ai";
|
|
31
41
|
/** Project-level config filenames, checked in order. */
|
|
32
42
|
export const PROJECT_CONFIG_FILENAMES = [
|
|
33
43
|
"cruxy.config.json",
|
package/dist/errors/boundary.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CommanderError } from "commander";
|
|
2
|
-
import { classifyProviderError, internal, usageError } from "./constructors.js";
|
|
2
|
+
import { classifyProviderError, internal, usageError, } from "./constructors.js";
|
|
3
3
|
import { shouldUseColor, terminalFormatter } from "./format.js";
|
|
4
4
|
import { CruxyError } from "./types.js";
|
|
5
5
|
/**
|
|
@@ -16,13 +16,13 @@ import { CruxyError } from "./types.js";
|
|
|
16
16
|
* - a known provider/transport error → its mapped code;
|
|
17
17
|
* - anything else → CRUXY_E_INTERNAL, preserving the original as `underlying`.
|
|
18
18
|
*/
|
|
19
|
-
export function fromUnknown(err) {
|
|
19
|
+
export function fromUnknown(err, ctx = {}) {
|
|
20
20
|
if (err instanceof CruxyError)
|
|
21
21
|
return err;
|
|
22
22
|
if (err instanceof CommanderError) {
|
|
23
23
|
return usageError(stripErrorPrefix(err.message));
|
|
24
24
|
}
|
|
25
|
-
return classifyProviderError(err) ?? internal(err);
|
|
25
|
+
return classifyProviderError(err, ctx) ?? internal(err);
|
|
26
26
|
}
|
|
27
27
|
/** A Commander "error" that's actually success (`--help`, `--version`). */
|
|
28
28
|
export function isCommanderSuccess(err) {
|
|
@@ -34,7 +34,7 @@ export function isCommanderSuccess(err) {
|
|
|
34
34
|
* touching the real process.
|
|
35
35
|
*/
|
|
36
36
|
export function handleFatal(err, opts = {}) {
|
|
37
|
-
const cruxy = fromUnknown(err);
|
|
37
|
+
const cruxy = fromUnknown(err, opts.context ?? {});
|
|
38
38
|
const verbose = opts.verbose ?? isVerbose();
|
|
39
39
|
const color = opts.color ?? shouldUseColor();
|
|
40
40
|
const formatter = opts.formatter ?? terminalFormatter;
|