llm-cli-gateway 1.6.1 → 1.8.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.
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Phase 4 slice β — Mistral Vibe `meta.json` parser.
3
+ *
4
+ * Vibe writes per-session telemetry to
5
+ *
6
+ * ~/.vibe/logs/session/session_<YYYYMMDD>_<HHMMSS>_<first8hex>/meta.json
7
+ *
8
+ * where `<first8hex>` is the first 8 lowercase hex characters of the full
9
+ * session UUID. Inside the file:
10
+ *
11
+ * {
12
+ * "session_id": "<full-uuid>",
13
+ * "stats": {
14
+ * "session_prompt_tokens": <number> → inputTokens
15
+ * "session_completion_tokens": <number> → outputTokens
16
+ * "session_cost": <number> → costUsd
17
+ * }
18
+ * }
19
+ *
20
+ * The gateway's mistral session-id surface accepts the full UUID (so does
21
+ * `vibe --resume <uuid>`). To find the right directory we glob for
22
+ * `session_*_<first8>` and disambiguate by reading each candidate's
23
+ * `session_id` field. If callers happen to pass the directory basename
24
+ * itself we still honour that — useful for tests and for forward-compat if
25
+ * Vibe ever changes its dir naming scheme.
26
+ *
27
+ * Cache-token surfaces are not exposed by Vibe today, so `cacheReadTokens`
28
+ * and `cacheCreationTokens` are intentionally absent.
29
+ *
30
+ * Best-effort by design: any failure (missing file, bad JSON, missing
31
+ * fields, gateway-generated `gw-*` sessionId, unresolvable UUID, path
32
+ * outside the session log root) returns `{}` so the flight-recorder row
33
+ * simply lacks usage data.
34
+ */
35
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "fs";
36
+ import { join, resolve, sep } from "path";
37
+ import { GATEWAY_SESSION_PREFIX } from "./request-helpers.js";
38
+ function asPositiveNumber(value) {
39
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
40
+ return undefined;
41
+ }
42
+ return value;
43
+ }
44
+ /**
45
+ * Read a file only if its realpath lives under `realBase`. Returns undefined
46
+ * on any error, missing file, or out-of-tree symlink target. This is the one
47
+ * place that calls `readFileSync` for meta.json content — the rest of the
48
+ * module routes through it so the security boundary is uniform.
49
+ */
50
+ function readInBase(realBase, candidate) {
51
+ if (!existsSync(candidate))
52
+ return undefined;
53
+ let realCandidate;
54
+ try {
55
+ realCandidate = realpathSync(candidate);
56
+ }
57
+ catch {
58
+ return undefined;
59
+ }
60
+ const realBaseWithSep = realBase.endsWith(sep) ? realBase : realBase + sep;
61
+ if (!realCandidate.startsWith(realBaseWithSep))
62
+ return undefined;
63
+ try {
64
+ return readFileSync(realCandidate, "utf-8");
65
+ }
66
+ catch {
67
+ return undefined;
68
+ }
69
+ }
70
+ // UUID v4-ish (Vibe's own session UUIDs are not strictly v4, so we
71
+ // validate against the broader 8-4-4-4-12 lowercase-hex shape) OR
72
+ // Vibe's session_<digits>_<digits>_<first8> directory basename.
73
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
74
+ const DIRNAME_RE = /^session_\d{8}_\d{6}_[0-9a-f]{8}$/;
75
+ /**
76
+ * Resolve the session-log directory basename for a given gateway sessionId.
77
+ * Returns undefined when no candidate can be found or the input is
78
+ * unsuitable. Pure with respect to side-effects on the caller — only reads
79
+ * the filesystem.
80
+ *
81
+ * Security invariants enforced here:
82
+ * - Inputs are charset-gated (UUID or DIRNAME) before any filesystem read.
83
+ * - For UUID input, the chosen candidate's meta.json MUST advertise the
84
+ * same `session_id` — single-candidate is NOT trusted, because two
85
+ * UUIDs sharing the first 8 hex chars would otherwise cross-attribute
86
+ * usage (and leak telemetry to the caller of the other session).
87
+ */
88
+ function resolveVibeSessionDirname(baseDir, realBase, sessionId) {
89
+ // 1. Caller already supplied the directory name verbatim.
90
+ if (DIRNAME_RE.test(sessionId) && existsSync(join(baseDir, sessionId, "meta.json"))) {
91
+ return sessionId;
92
+ }
93
+ // 2. Treat the input as a full session UUID.
94
+ if (!UUID_RE.test(sessionId))
95
+ return undefined;
96
+ const short = sessionId.slice(0, 8).toLowerCase();
97
+ let entries;
98
+ try {
99
+ entries = readdirSync(baseDir);
100
+ }
101
+ catch {
102
+ return undefined;
103
+ }
104
+ // Filter to candidates matching `session_*_<short>`. Sort newest-first
105
+ // by mtime; we still require an exact session_id match below.
106
+ const candidates = entries
107
+ .filter(name => DIRNAME_RE.test(name) && name.endsWith(`_${short}`))
108
+ .map(name => {
109
+ let mtimeMs = 0;
110
+ try {
111
+ mtimeMs = statSync(join(baseDir, name)).mtimeMs;
112
+ }
113
+ catch {
114
+ /* ignore */
115
+ }
116
+ return { name, mtimeMs };
117
+ })
118
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
119
+ for (const { name } of candidates) {
120
+ const text = readInBase(realBase, join(baseDir, name, "meta.json"));
121
+ if (text === undefined)
122
+ continue;
123
+ try {
124
+ const parsed = JSON.parse(text);
125
+ if (typeof parsed.session_id === "string" && parsed.session_id === sessionId) {
126
+ return name;
127
+ }
128
+ }
129
+ catch {
130
+ /* ignore and continue */
131
+ }
132
+ }
133
+ return undefined;
134
+ }
135
+ export function parseVibeMetaJson(home, sessionId) {
136
+ if (!sessionId)
137
+ return {};
138
+ if (sessionId.startsWith(GATEWAY_SESSION_PREFIX)) {
139
+ // gw-* IDs are gateway internal — Vibe never wrote a meta.json under that name.
140
+ return {};
141
+ }
142
+ const baseDir = resolve(join(home, ".vibe", "logs", "session"));
143
+ let realBase;
144
+ try {
145
+ realBase = realpathSync(baseDir);
146
+ }
147
+ catch {
148
+ return {};
149
+ }
150
+ const dirname = resolveVibeSessionDirname(baseDir, realBase, sessionId);
151
+ if (!dirname)
152
+ return {};
153
+ // `readInBase` is the security boundary: it realpath-resolves the file
154
+ // and rejects anything whose target lives outside `realBase`. Re-routing
155
+ // the final read through it (instead of a bespoke readFileSync) keeps
156
+ // the in-tree-only invariant in one place.
157
+ const text = readInBase(realBase, join(baseDir, dirname, "meta.json"));
158
+ if (text === undefined)
159
+ return {};
160
+ let raw;
161
+ try {
162
+ raw = JSON.parse(text);
163
+ }
164
+ catch {
165
+ return {};
166
+ }
167
+ const stats = raw?.stats;
168
+ if (!stats || typeof stats !== "object")
169
+ return {};
170
+ return {
171
+ inputTokens: asPositiveNumber(stats.session_prompt_tokens),
172
+ outputTokens: asPositiveNumber(stats.session_completion_tokens),
173
+ costUsd: asPositiveNumber(stats.session_cost),
174
+ };
175
+ }
@@ -107,6 +107,13 @@ export interface PrepareMistralRequestInput {
107
107
  * emit a `logger.warn` when this is non-empty.
108
108
  */
109
109
  disallowedTools?: string[];
110
+ /**
111
+ * Phase 4 slice γ: emit `--trust` so non-interactive runs in fresh
112
+ * workspaces skip Vibe's interactive trust prompt for this invocation
113
+ * only (not persisted to `trusted_folders.toml`). Default undefined →
114
+ * Vibe's prompt behaviour is preserved for existing callers.
115
+ */
116
+ trust?: boolean;
110
117
  }
111
118
  export interface PrepareMistralRequestResult {
112
119
  args: string[];
@@ -204,9 +211,11 @@ export declare function resolveCodexSandboxFlags(input: CodexSandboxFlagsInput):
204
211
  * Flags that `codex exec resume` rejects (the original session's policy is
205
212
  * inherited). Callers must drop these when building resume argv.
206
213
  *
207
- * U26 expands this list with `--add-dir`, `-C`, `--output-schema`, and
208
- * `--search`, all of which `codex exec resume --help` rejects at the audit
209
- * date.
214
+ * Verified against `codex exec resume --help` (codex-cli 0.133.0):
215
+ * `--full-auto`, `--sandbox`, `--ask-for-approval`, `--add-dir`, `-C`, and
216
+ * `--search` are rejected. `--output-schema` and `-c key=value` ARE accepted
217
+ * on resume and therefore are NOT in this filter (Phase 4 slice α restored
218
+ * the previously-silent drop of those two).
210
219
  */
211
220
  export declare const CODEX_RESUME_FILTERED_FLAGS: ReadonlySet<string>;
212
221
  /**
@@ -398,8 +407,8 @@ export declare const CODEX_HIGH_IMPACT_PARAMS_SCHEMA: z.ZodObject<{
398
407
  ignoreRules: z.ZodOptional<z.ZodBoolean>;
399
408
  }, "strip", z.ZodTypeAny, {
400
409
  search?: boolean | undefined;
401
- profile?: string | undefined;
402
410
  outputSchema?: string | Record<string, unknown> | undefined;
411
+ profile?: string | undefined;
403
412
  configOverrides?: Record<string, string> | undefined;
404
413
  ephemeral?: boolean | undefined;
405
414
  images?: string[] | undefined;
@@ -407,8 +416,8 @@ export declare const CODEX_HIGH_IMPACT_PARAMS_SCHEMA: z.ZodObject<{
407
416
  ignoreRules?: boolean | undefined;
408
417
  }, {
409
418
  search?: boolean | undefined;
410
- profile?: string | undefined;
411
419
  outputSchema?: string | Record<string, unknown> | undefined;
420
+ profile?: string | undefined;
412
421
  configOverrides?: Record<string, string> | undefined;
413
422
  ephemeral?: boolean | undefined;
414
423
  images?: string[] | undefined;
@@ -176,6 +176,9 @@ export function prepareMistralRequest(input) {
176
176
  args.push("--enabled-tools", tool);
177
177
  }
178
178
  }
179
+ if (input.trust) {
180
+ args.push("--trust");
181
+ }
179
182
  const ignoredDisallowedTools = Boolean(input.disallowedTools && input.disallowedTools.length > 0);
180
183
  return { args, env, ignoredDisallowedTools };
181
184
  }
@@ -279,9 +282,11 @@ export function resolveCodexSandboxFlags(input) {
279
282
  * Flags that `codex exec resume` rejects (the original session's policy is
280
283
  * inherited). Callers must drop these when building resume argv.
281
284
  *
282
- * U26 expands this list with `--add-dir`, `-C`, `--output-schema`, and
283
- * `--search`, all of which `codex exec resume --help` rejects at the audit
284
- * date.
285
+ * Verified against `codex exec resume --help` (codex-cli 0.133.0):
286
+ * `--full-auto`, `--sandbox`, `--ask-for-approval`, `--add-dir`, `-C`, and
287
+ * `--search` are rejected. `--output-schema` and `-c key=value` ARE accepted
288
+ * on resume and therefore are NOT in this filter (Phase 4 slice α restored
289
+ * the previously-silent drop of those two).
285
290
  */
286
291
  export const CODEX_RESUME_FILTERED_FLAGS = new Set([
287
292
  "--full-auto",
@@ -289,7 +294,6 @@ export const CODEX_RESUME_FILTERED_FLAGS = new Set([
289
294
  "--ask-for-approval",
290
295
  "--add-dir",
291
296
  "-C",
292
- "--output-schema",
293
297
  "--search",
294
298
  ]);
295
299
  /**
@@ -301,7 +305,6 @@ const CODEX_RESUME_FILTERED_FLAGS_WITH_VALUE = new Set([
301
305
  "--ask-for-approval",
302
306
  "--add-dir",
303
307
  "-C",
304
- "--output-schema",
305
308
  ]);
306
309
  /**
307
310
  * Strip resume-incompatible flag/value pairs from a Codex argv segment.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-cli-gateway",
3
- "version": "1.6.1",
3
+ "version": "1.8.0",
4
4
  "mcpName": "io.github.verivus-oss/llm-cli-gateway",
5
5
  "description": "MCP server providing unified access to Claude Code, Codex, Gemini, Grok, and Mistral Vibe CLIs with session management, retry logic, async job orchestration, durable job results, and cross-LLM validation.",
6
6
  "license": "MIT",