llm-cli-gateway 1.7.0 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,78 @@
2
2
 
3
3
  All notable changes to the llm-cli-gateway project.
4
4
 
5
+ ## [1.8.0] - 2026-05-27 — Phase 4 openers (codex resume fix, mistral telemetry, headless trust flags)
6
+
7
+ Ships the first three slices of the Phase 4 provider-modernisation
8
+ backlog, one bug fix and two small features. Multi-LLM review surfaced
9
+ five additional bug classes during the cycle (path traversal, UUID→dir
10
+ resolution gap, sync usage ctx drop, retry-path flag drop, symlink
11
+ boundary bypass); all are addressed in the two follow-up fix commits.
12
+
13
+ ### Fixed — Codex `--output-schema` + `-c/--config` on `exec resume`
14
+
15
+ - `prepareCodexRequest` previously dropped `outputSchema` and
16
+ `configOverrides` on the resume branch because the U26 audit assumed
17
+ `codex exec resume` rejected both flags. Live re-verification against
18
+ `codex exec resume --help` (codex-cli 0.133.0) confirms both ARE
19
+ accepted on resume; only `--search` remains resume-incompatible. The
20
+ resume branch now threads both fields through, reusing the existing
21
+ outputSchema temp-file materialisation + cleanup contract.
22
+ `CODEX_RESUME_FILTERED_FLAGS` no longer strips `--output-schema`.
23
+
24
+ ### Added — Mistral Vibe `meta.json` usage / cost telemetry
25
+
26
+ - New `src/mistral-meta-json-parser.ts` reads
27
+ `~/.vibe/logs/session/session_<YYYYMMDD>_<HHMMSS>_<first8hex>/meta.json`
28
+ (the actual filename — an earlier TODO at `src/index.ts:750` said
29
+ `metadata.json`, which was incorrect). Maps `stats.session_prompt_tokens`,
30
+ `stats.session_completion_tokens`, and `stats.session_cost` onto the
31
+ gateway's `inputTokens`/`outputTokens`/`costUsd` flight-recorder
32
+ columns. Cache-token surfaces stay undefined — Vibe doesn't expose
33
+ them today.
34
+ - The gateway's mistral sessionId surface accepts the full UUID (to match
35
+ `vibe --resume <uuid>`), but Vibe persists telemetry under
36
+ `session_<ts>_<first8>` directories. The new resolver globs by the
37
+ leading 8-hex prefix and verifies each candidate's `session_id` field
38
+ before returning — required for every UUID input including
39
+ single-match cases, so two UUIDs sharing the leading 8 hex chars never
40
+ cross-attribute usage.
41
+ - `extractUsageAndCost` and `buildAsyncFlightRecorderHandoff` thread a
42
+ primitives-only `{ sessionId, home }` context so the AsyncJobRecord
43
+ retention stays O(constant). `buildCliResponse` passes the same ctx so
44
+ sync `mistral_request` resume calls populate structured usage in their
45
+ response (not just the flight-recorder row).
46
+
47
+ ### Added — Headless trust-prompt bypass for Gemini + Mistral
48
+
49
+ - New optional `skipTrust?: boolean` field on `gemini_request` and
50
+ `gemini_request_async`, defaulting `false`. When set, emits
51
+ `--skip-trust` so fresh workspaces don't block headless invocations on
52
+ Gemini's interactive trust prompt.
53
+ - New optional `trust?: boolean` field on `mistral_request` and
54
+ `mistral_request_async`, defaulting `false`. When set, emits `--trust`
55
+ (per-invocation only, not persisted to `trusted_folders.toml`) so
56
+ fresh workspaces don't block headless Vibe runs. Preserved on the
57
+ stale-model recovery retry path so a fresh untrusted workspace can't
58
+ deadlock on the second attempt.
59
+ - Default `false` preserves existing prompt behaviour for legacy
60
+ callers.
61
+
62
+ ### Security
63
+
64
+ - `parseVibeMetaJson` enforces a strict input charset (UUID-shape OR
65
+ `^session_\d{8}_\d{6}_[0-9a-f]{8}$` Vibe dir basename) before any
66
+ filesystem access.
67
+ - New `readInBase(realBase, candidate)` helper realpath-resolves both
68
+ ends and rejects targets whose final inode lives outside the session
69
+ log root. Both the resolver's disambiguation reads and the final
70
+ parser read route through it, so an in-tree symlink to an
71
+ out-of-tree directory (or symlinked meta.json) cannot leak file
72
+ contents outside `~/.vibe/logs/session/`.
73
+ - Test coverage: traversal inputs (`../`, absolute, control-char,
74
+ embedded `../`), single-candidate prefix-collision rejection,
75
+ symlink-to-outside-baseDir rejection.
76
+
5
77
  ## [1.7.0] - 2026-05-26 — cache-awareness slice 1.5 (async-path flight recorder + codex parser fix)
6
78
 
7
79
  Closes the two telemetry gaps that v1.6.0 explicitly deferred: async-path
package/dist/index.d.ts CHANGED
@@ -81,6 +81,23 @@ interface GatewayServerRuntime {
81
81
  persistence: PersistenceConfig;
82
82
  cacheAwareness: CacheAwarenessConfig;
83
83
  }
84
+ export declare function extractUsageAndCost(cli: "claude" | "codex" | "gemini" | "grok" | "mistral", output: string, outputFormat?: string,
85
+ /**
86
+ * Optional context for off-stdout telemetry sources. Today only Mistral
87
+ * uses this — its meta.json lives on disk keyed by sessionId. Threading
88
+ * this in keeps the closure built by `buildAsyncFlightRecorderHandoff`
89
+ * primitives-only (no `params`/`prep` retention on AsyncJobRecord).
90
+ */
91
+ ctx?: {
92
+ sessionId?: string;
93
+ home?: string;
94
+ }): {
95
+ inputTokens?: number;
96
+ outputTokens?: number;
97
+ cacheReadTokens?: number;
98
+ cacheCreationTokens?: number;
99
+ costUsd?: number;
100
+ };
84
101
  interface CliRequestPrep {
85
102
  corrId: string;
86
103
  effectivePrompt: string;
@@ -191,6 +208,12 @@ export declare function prepareGeminiRequest(params: {
191
208
  policyFiles?: string[];
192
209
  adminPolicyFiles?: string[];
193
210
  attachments?: string[];
211
+ /**
212
+ * Phase 4 slice γ: emit `--skip-trust` so first-run workspaces don't
213
+ * block headless invocations on the interactive trust prompt. Default
214
+ * is undefined (preserves current prompt behaviour for legacy callers).
215
+ */
216
+ skipTrust?: boolean;
194
217
  }, runtime?: GatewayServerRuntime): CliRequestPrep | ExtendedToolResponse;
195
218
  export declare function prepareMistralRequest(params: {
196
219
  prompt?: string;
@@ -208,6 +231,11 @@ export declare function prepareMistralRequest(params: {
208
231
  correlationId?: string;
209
232
  optimizePrompt: boolean;
210
233
  operation: string;
234
+ /**
235
+ * Phase 4 slice γ: emit `--trust` to bypass Vibe's interactive trust
236
+ * prompt for this invocation only (not persisted). Default undefined.
237
+ */
238
+ trust?: boolean;
211
239
  }, runtime?: GatewayServerRuntime): (CliRequestPrep & {
212
240
  mistralEnv: Record<string, string>;
213
241
  }) | ExtendedToolResponse;
@@ -235,6 +263,8 @@ export interface GeminiRequestParams {
235
263
  policyFiles?: string[];
236
264
  adminPolicyFiles?: string[];
237
265
  attachments?: string[];
266
+ /** Phase 4 slice γ: emit `--skip-trust` for fresh-workspace headless runs. */
267
+ skipTrust?: boolean;
238
268
  }
239
269
  export interface HandlerDeps {
240
270
  sessionManager: ISessionManager;
@@ -297,6 +327,8 @@ export interface MistralRequestParams {
297
327
  optimizeResponse?: boolean;
298
328
  idleTimeoutMs?: number;
299
329
  forceRefresh?: boolean;
330
+ /** Phase 4 slice γ: emit `--trust` for fresh-workspace headless runs. */
331
+ trust?: boolean;
300
332
  }
301
333
  export declare function handleMistralRequest(deps: HandlerDeps, params: MistralRequestParams): Promise<ExtendedToolResponse>;
302
334
  export declare function handleMistralRequestAsync(deps: AsyncHandlerDeps, params: Omit<MistralRequestParams, "optimizeResponse">): Promise<ExtendedToolResponse>;
package/dist/index.js CHANGED
@@ -10,6 +10,8 @@ import { executeCli, killAllProcessGroups } from "./executor.js";
10
10
  import { parseStreamJson } from "./stream-json-parser.js";
11
11
  import { parseCodexJsonStream } from "./codex-json-parser.js";
12
12
  import { parseGeminiJson } from "./gemini-json-parser.js";
13
+ import { parseVibeMetaJson } from "./mistral-meta-json-parser.js";
14
+ import { homedir } from "os";
13
15
  import { createSessionManager } from "./session-manager.js";
14
16
  import { ResourceProvider } from "./resources.js";
15
17
  import { PerformanceMetrics } from "./metrics.js";
@@ -477,7 +479,14 @@ function createErrorResponse(cli, code, stderr, correlationId, error) {
477
479
  },
478
480
  };
479
481
  }
480
- function extractUsageAndCost(cli, output, outputFormat) {
482
+ export function extractUsageAndCost(cli, output, outputFormat,
483
+ /**
484
+ * Optional context for off-stdout telemetry sources. Today only Mistral
485
+ * uses this — its meta.json lives on disk keyed by sessionId. Threading
486
+ * this in keeps the closure built by `buildAsyncFlightRecorderHandoff`
487
+ * primitives-only (no `params`/`prep` retention on AsyncJobRecord).
488
+ */
489
+ ctx) {
481
490
  if (cli === "claude" && outputFormat === "stream-json") {
482
491
  const parsed = parseStreamJson(output);
483
492
  if (!parsed.usage) {
@@ -515,9 +524,14 @@ function extractUsageAndCost(cli, output, outputFormat) {
515
524
  cacheReadTokens: parsed.usage.cache_read_tokens,
516
525
  };
517
526
  }
518
- // Mistral/Vibe: does not surface usage in its stdout/stream-json output. A
519
- // future unit can read it from `~/.vibe/logs/session/<id>/metadata.json`
520
- // once we resolve the session id post-run.
527
+ // Mistral/Vibe: usage/cost live on disk in `~/.vibe/logs/session/<id>/meta.json`
528
+ // (Phase 4 slice β). Best-effort: if we don't know the sessionId (fresh
529
+ // session whose Vibe-assigned UUID we never observed) or the file is
530
+ // missing/malformed, the parser returns `{}` and the FR row simply lacks
531
+ // usage data — matching pre-slice behaviour. No stdout fallback exists.
532
+ if (cli === "mistral") {
533
+ return parseVibeMetaJson(ctx?.home ?? homedir(), ctx?.sessionId);
534
+ }
521
535
  return {};
522
536
  }
523
537
  /**
@@ -530,9 +544,13 @@ function extractUsageAndCost(cli, output, outputFormat) {
530
544
  function buildAsyncFlightRecorderHandoff(cliName, prep, sessionId, outputFormat) {
531
545
  // Extract primitives BEFORE building the closure — capturing `prep` or
532
546
  // `params` directly would pin large attachments / promptParts on the
533
- // AsyncJobRecord for JOB_TTL_MS.
547
+ // AsyncJobRecord for JOB_TTL_MS. Phase 4 slice β: `sid` and `home` are
548
+ // primitives too, threaded through so the Mistral branch of
549
+ // extractUsageAndCost can read `~/.vibe/logs/session/<id>/meta.json`.
534
550
  const cli = cliName;
535
551
  const fmt = outputFormat;
552
+ const sid = sessionId;
553
+ const home = homedir();
536
554
  return {
537
555
  flightRecorderEntry: {
538
556
  model: prep.resolvedModel || "default",
@@ -541,7 +559,7 @@ function buildAsyncFlightRecorderHandoff(cliName, prep, sessionId, outputFormat)
541
559
  stablePrefixHash: prep.stablePrefixHash ?? undefined,
542
560
  stablePrefixTokens: prep.stablePrefixTokens ?? undefined,
543
561
  },
544
- extractUsage: (stdout) => extractUsageAndCost(cli, stdout, fmt),
562
+ extractUsage: (stdout) => extractUsageAndCost(cli, stdout, fmt, { sessionId: sid, home }),
545
563
  };
546
564
  }
547
565
  function safeFlightStart(entry, runtime = resolveGatewayServerRuntime()) {
@@ -1081,11 +1099,12 @@ export function prepareCodexRequest(params, runtime = resolveGatewayServerRuntim
1081
1099
  args.push("--json");
1082
1100
  }
1083
1101
  args.push("--skip-git-repo-check");
1084
- // U26: High-impact feature flags. Some of these (`--output-schema`,
1085
- // `--search`, `-C`, `--add-dir`) are rejected by `codex exec resume`, so we
1086
- // only emit them on a NEW session. Images / ephemeral / profile /
1087
- // ignore-rules / ignore-user-config are allowed on resume per the audited
1088
- // CLI help; we emit them in both branches.
1102
+ // U26: High-impact feature flags. `--search` is rejected by
1103
+ // `codex exec resume` (resume inherits the original session's web-search
1104
+ // state), so we only emit it on a NEW session. `--output-schema`,
1105
+ // `-c key=value`, profile, ephemeral, images, and the ignore-* flags are
1106
+ // all accepted on resume per `codex exec resume --help` (codex-cli 0.133.0)
1107
+ // and are emitted in both branches.
1089
1108
  let highImpactCleanup;
1090
1109
  if (sessionPlan.mode === "new") {
1091
1110
  const high = prepareCodexHighImpactFlags({
@@ -1105,12 +1124,10 @@ export function prepareCodexRequest(params, runtime = resolveGatewayServerRuntim
1105
1124
  highImpactCleanup = high.cleanup;
1106
1125
  }
1107
1126
  else {
1108
- // On resume, emit only the resume-safe subset (profile, ephemeral,
1109
- // images, ignoreUserConfig, ignoreRules). outputSchema, search, and
1110
- // configOverrides are dropped silently to mirror existing behavior for
1111
- // sandbox/ask-for-approval on resume.
1112
1127
  const high = prepareCodexHighImpactFlags({
1128
+ outputSchema: params.outputSchema,
1113
1129
  profile: params.profile,
1130
+ configOverrides: params.configOverrides,
1114
1131
  ephemeral: params.ephemeral,
1115
1132
  images: params.images,
1116
1133
  ignoreUserConfig: params.ignoreUserConfig,
@@ -1240,6 +1257,10 @@ export function prepareGeminiRequest(params, runtime = resolveGatewayServerRunti
1240
1257
  if (params.outputFormat === "json") {
1241
1258
  args.push("-o", "json");
1242
1259
  }
1260
+ // Phase 4 slice γ: opt-in trust-prompt bypass for fresh workspaces.
1261
+ if (params.skipTrust) {
1262
+ args.push("--skip-trust");
1263
+ }
1243
1264
  return {
1244
1265
  corrId,
1245
1266
  effectivePrompt,
@@ -1411,6 +1432,7 @@ export function prepareMistralRequest(params, runtime = resolveGatewayServerRunt
1411
1432
  reasoningEffort: params.reasoningEffort,
1412
1433
  allowedTools: params.allowedTools,
1413
1434
  disallowedTools: params.disallowedTools,
1435
+ trust: params.trust,
1414
1436
  });
1415
1437
  if (prep.ignoredDisallowedTools) {
1416
1438
  runtime.logger.info(`[${corrId}] Mistral does not support disallowedTools; ignoring (caller passed ${params.disallowedTools?.length ?? 0} entries)`);
@@ -1466,7 +1488,10 @@ function buildCliResponse(cli, stdout, optimizeResponse, corrId, sessionId, prep
1466
1488
  correlationId: corrId,
1467
1489
  sessionId: sessionId || null,
1468
1490
  durationMs,
1469
- ...extractUsageAndCost(cli, stdout, outputFormat),
1491
+ // Phase 4 slice β: thread sessionId + home so the Mistral branch of
1492
+ // extractUsageAndCost can read `~/.vibe/logs/session/<dir>/meta.json`.
1493
+ // Other CLIs ignore the ctx (their usage source is stdout).
1494
+ ...extractUsageAndCost(cli, stdout, outputFormat, { sessionId, home: homedir() }),
1470
1495
  exitCode: 0,
1471
1496
  retryCount: 0,
1472
1497
  },
@@ -1564,6 +1589,7 @@ export async function handleGeminiRequest(deps, params) {
1564
1589
  policyFiles: params.policyFiles,
1565
1590
  adminPolicyFiles: params.adminPolicyFiles,
1566
1591
  attachments: params.attachments,
1592
+ skipTrust: params.skipTrust,
1567
1593
  }, runtime);
1568
1594
  if (!("args" in prep))
1569
1595
  return prep;
@@ -1692,6 +1718,7 @@ export async function handleGeminiRequestAsync(deps, params) {
1692
1718
  policyFiles: params.policyFiles,
1693
1719
  adminPolicyFiles: params.adminPolicyFiles,
1694
1720
  attachments: params.attachments,
1721
+ skipTrust: params.skipTrust,
1695
1722
  }, runtime);
1696
1723
  if (!("args" in prep))
1697
1724
  return prep;
@@ -1975,6 +2002,7 @@ export async function handleMistralRequest(deps, params) {
1975
2002
  correlationId: params.correlationId,
1976
2003
  optimizePrompt: params.optimizePrompt,
1977
2004
  operation: "mistral_request",
2005
+ trust: params.trust,
1978
2006
  }, runtime);
1979
2007
  if (!("args" in prep))
1980
2008
  return prep;
@@ -2018,6 +2046,10 @@ export async function handleMistralRequest(deps, params) {
2018
2046
  reasoningEffort: params.reasoningEffort,
2019
2047
  allowedTools: params.allowedTools,
2020
2048
  disallowedTools: params.disallowedTools,
2049
+ // Phase 4 slice γ: preserve --trust on the model-selection retry
2050
+ // so a fresh untrusted workspace doesn't block headlessly on the
2051
+ // second attempt after surviving the first.
2052
+ trust: params.trust,
2021
2053
  });
2022
2054
  const retryArgs = [...retryPrep.args, ...sessionResult.resumeArgs];
2023
2055
  // Reuse the FR handoff built above — the retry preserves corrId,
@@ -2118,6 +2150,7 @@ export async function handleMistralRequestAsync(deps, params) {
2118
2150
  correlationId: params.correlationId,
2119
2151
  optimizePrompt: params.optimizePrompt,
2120
2152
  operation: "mistral_request_async",
2153
+ trust: params.trust,
2121
2154
  }, runtime);
2122
2155
  if (!("args" in prep))
2123
2156
  return prep;
@@ -3006,7 +3039,11 @@ export function createGatewayServer(deps = {}) {
3006
3039
  policyFiles: GEMINI_HIGH_IMPACT_PARAMS_SCHEMA.shape.policyFiles.describe("Policy file paths (--policy <path>, one per file). Paths must exist."),
3007
3040
  adminPolicyFiles: GEMINI_HIGH_IMPACT_PARAMS_SCHEMA.shape.adminPolicyFiles.describe("Admin policy file paths (--admin-policy <path>, one per file). Paths must exist."),
3008
3041
  attachments: GEMINI_HIGH_IMPACT_PARAMS_SCHEMA.shape.attachments.describe("Absolute file paths prepended as @<path> tokens to the prompt"),
3009
- }, async ({ prompt, promptParts, model, sessionId, resumeLatest, createNewSession, approvalMode, approvalStrategy, approvalPolicy, mcpServers, allowedTools, includeDirs, correlationId, optimizePrompt, optimizeResponse, idleTimeoutMs, forceRefresh, outputFormat, sandbox, policyFiles, adminPolicyFiles, attachments, }) => {
3042
+ skipTrust: z
3043
+ .boolean()
3044
+ .default(false)
3045
+ .describe("Emit `--skip-trust` so Gemini trusts the workspace for this session and skips the interactive trust prompt (Phase 4 slice γ). Required for headless runs in fresh workspaces."),
3046
+ }, async ({ prompt, promptParts, model, sessionId, resumeLatest, createNewSession, approvalMode, approvalStrategy, approvalPolicy, mcpServers, allowedTools, includeDirs, correlationId, optimizePrompt, optimizeResponse, idleTimeoutMs, forceRefresh, outputFormat, sandbox, policyFiles, adminPolicyFiles, attachments, skipTrust, }) => {
3010
3047
  return handleGeminiRequest({ sessionManager, logger, runtime }, {
3011
3048
  prompt,
3012
3049
  promptParts,
@@ -3030,6 +3067,7 @@ export function createGatewayServer(deps = {}) {
3030
3067
  policyFiles,
3031
3068
  adminPolicyFiles,
3032
3069
  attachments,
3070
+ skipTrust,
3033
3071
  });
3034
3072
  });
3035
3073
  //──────────────────────────────────────────────────────────────────────────────
@@ -3200,7 +3238,11 @@ export function createGatewayServer(deps = {}) {
3200
3238
  .boolean()
3201
3239
  .default(false)
3202
3240
  .describe("Bypass dedup and force a fresh CLI run even if a recent identical request exists"),
3203
- }, async ({ prompt, promptParts, model, outputFormat, sessionId, resumeLatest, createNewSession, permissionMode, effort, reasoningEffort, approvalStrategy, approvalPolicy, mcpServers, allowedTools, disallowedTools, correlationId, optimizePrompt, optimizeResponse, idleTimeoutMs, forceRefresh, }) => {
3241
+ trust: z
3242
+ .boolean()
3243
+ .default(false)
3244
+ .describe("Emit `--trust` so Vibe trusts the cwd for this invocation only (not persisted to trusted_folders.toml) and skips the interactive trust prompt (Phase 4 slice γ)."),
3245
+ }, async ({ prompt, promptParts, model, outputFormat, sessionId, resumeLatest, createNewSession, permissionMode, effort, reasoningEffort, approvalStrategy, approvalPolicy, mcpServers, allowedTools, disallowedTools, correlationId, optimizePrompt, optimizeResponse, idleTimeoutMs, forceRefresh, trust, }) => {
3204
3246
  return handleMistralRequest({ sessionManager, logger, runtime }, {
3205
3247
  prompt,
3206
3248
  promptParts,
@@ -3222,6 +3264,7 @@ export function createGatewayServer(deps = {}) {
3222
3264
  optimizeResponse,
3223
3265
  idleTimeoutMs,
3224
3266
  forceRefresh,
3267
+ trust,
3225
3268
  });
3226
3269
  });
3227
3270
  //──────────────────────────────────────────────────────────────────────────────
@@ -3612,7 +3655,11 @@ export function createGatewayServer(deps = {}) {
3612
3655
  policyFiles: GEMINI_HIGH_IMPACT_PARAMS_SCHEMA.shape.policyFiles.describe("Policy file paths (--policy <path>, one per file). Paths must exist."),
3613
3656
  adminPolicyFiles: GEMINI_HIGH_IMPACT_PARAMS_SCHEMA.shape.adminPolicyFiles.describe("Admin policy file paths (--admin-policy <path>, one per file). Paths must exist."),
3614
3657
  attachments: GEMINI_HIGH_IMPACT_PARAMS_SCHEMA.shape.attachments.describe("Absolute file paths prepended as @<path> tokens to the prompt"),
3615
- }, async ({ prompt, promptParts, model, sessionId, resumeLatest, createNewSession, approvalMode, approvalStrategy, approvalPolicy, mcpServers, allowedTools, includeDirs, correlationId, optimizePrompt, idleTimeoutMs, forceRefresh, outputFormat, sandbox, policyFiles, adminPolicyFiles, attachments, }) => {
3658
+ skipTrust: z
3659
+ .boolean()
3660
+ .default(false)
3661
+ .describe("Emit `--skip-trust` so Gemini trusts the workspace for this session and skips the interactive trust prompt (Phase 4 slice γ). Required for headless runs in fresh workspaces."),
3662
+ }, async ({ prompt, promptParts, model, sessionId, resumeLatest, createNewSession, approvalMode, approvalStrategy, approvalPolicy, mcpServers, allowedTools, includeDirs, correlationId, optimizePrompt, idleTimeoutMs, forceRefresh, outputFormat, sandbox, policyFiles, adminPolicyFiles, attachments, skipTrust, }) => {
3616
3663
  return handleGeminiRequestAsync({ sessionManager, asyncJobManager, logger, runtime }, {
3617
3664
  prompt,
3618
3665
  promptParts,
@@ -3635,6 +3682,7 @@ export function createGatewayServer(deps = {}) {
3635
3682
  policyFiles,
3636
3683
  adminPolicyFiles,
3637
3684
  attachments,
3685
+ skipTrust,
3638
3686
  });
3639
3687
  });
3640
3688
  server.tool("grok_request_async", {
@@ -3796,7 +3844,11 @@ export function createGatewayServer(deps = {}) {
3796
3844
  .boolean()
3797
3845
  .default(false)
3798
3846
  .describe("Bypass dedup and force a fresh CLI run even if a recent identical request exists"),
3799
- }, async ({ prompt, promptParts, model, outputFormat, sessionId, resumeLatest, createNewSession, permissionMode, effort, reasoningEffort, approvalStrategy, approvalPolicy, mcpServers, allowedTools, disallowedTools, correlationId, optimizePrompt, idleTimeoutMs, forceRefresh, }) => {
3847
+ trust: z
3848
+ .boolean()
3849
+ .default(false)
3850
+ .describe("Emit `--trust` so Vibe trusts the cwd for this invocation only (not persisted to trusted_folders.toml) and skips the interactive trust prompt (Phase 4 slice γ)."),
3851
+ }, async ({ prompt, promptParts, model, outputFormat, sessionId, resumeLatest, createNewSession, permissionMode, effort, reasoningEffort, approvalStrategy, approvalPolicy, mcpServers, allowedTools, disallowedTools, correlationId, optimizePrompt, idleTimeoutMs, forceRefresh, trust, }) => {
3800
3852
  return handleMistralRequestAsync({ sessionManager, asyncJobManager, logger, runtime }, {
3801
3853
  prompt,
3802
3854
  promptParts,
@@ -3817,6 +3869,7 @@ export function createGatewayServer(deps = {}) {
3817
3869
  optimizePrompt,
3818
3870
  idleTimeoutMs,
3819
3871
  forceRefresh,
3872
+ trust,
3820
3873
  });
3821
3874
  });
3822
3875
  server.tool("llm_job_status", {
@@ -0,0 +1,6 @@
1
+ export interface VibeMetaJsonUsage {
2
+ inputTokens?: number;
3
+ outputTokens?: number;
4
+ costUsd?: number;
5
+ }
6
+ export declare function parseVibeMetaJson(home: string, sessionId: string | undefined): VibeMetaJsonUsage;
@@ -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.7.0",
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",