@wrenai/genbi 0.0.5 → 0.0.6

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.
Files changed (27) hide show
  1. package/README.md +1 -1
  2. package/dist-server/harness/route/codex-ask.d.ts +1 -1
  3. package/dist-server/harness/route/codex-ask.js +5 -46
  4. package/dist-server/harness/route/dispatched.d.ts +20 -0
  5. package/dist-server/harness/route/dispatched.js +122 -24
  6. package/dist-server/harness/route/host-query.d.ts +93 -0
  7. package/dist-server/harness/route/{codex-host-query.js → host-query.js} +120 -54
  8. package/dist-server/harness/route/route.js +3 -0
  9. package/dist-server/harness/route/types.d.ts +18 -1
  10. package/dist-server/harness/route/{codex-wren-proxy.js → wren-host-proxy.js} +6 -5
  11. package/dist-server/harness/setup/runner.d.ts +15 -3
  12. package/dist-server/harness/setup/runner.js +234 -72
  13. package/dist-server/harness/tools/setup-native.d.ts +7 -0
  14. package/dist-server/harness/tools/setup-native.js +7 -4
  15. package/dist-server/server/app.js +13 -4
  16. package/dist-server/server/bin.js +8 -0
  17. package/dist-server/server/fold.d.ts +9 -8
  18. package/dist-server/server/fold.js +17 -12
  19. package/dist-server/server/host-evidence-grounding.d.ts +21 -0
  20. package/dist-server/server/{codex-local-grounding.js → host-evidence-grounding.js} +15 -14
  21. package/dist-server/server/native-artifacts.d.ts +93 -4
  22. package/dist-server/server/native-artifacts.js +153 -4
  23. package/dist-server/server/turn.js +4 -1
  24. package/package.json +2 -2
  25. package/dist-server/harness/route/codex-host-query.d.ts +0 -52
  26. package/dist-server/server/codex-local-grounding.d.ts +0 -18
  27. /package/dist-server/harness/route/{codex-wren-proxy.d.ts → wren-host-proxy.d.ts} +0 -0
@@ -1,17 +1,20 @@
1
1
  import { randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { accessSync, existsSync, constants as fsConstants } from "node:fs";
2
3
  import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
3
4
  import { createServer } from "node:net";
4
5
  import os from "node:os";
5
6
  import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
6
8
  import { COMPONENT_LIMITS } from "../components/runner.js";
7
9
  import { GovernedWrenError } from "../components/wren-access.js";
8
10
  /**
9
- * Per-turn bounds. Count, request and per-result bytes reuse the component runner's limits;
10
- * the total caps what the host keeps in memory for one turn until its answer is grounded.
11
- * Rows default to 1000 like `run_sql`. Like `run_sql`, the host probes one row past the limit to
12
- * report truncation; the cap is 9999 so the probe stays within governed-stdio's 10000-row bound.
11
+ * Per-scope bounds (one Ask turn, or one native session). Count, request and per-result bytes
12
+ * reuse the component runner's limits; the total caps what the host keeps in memory for one scope
13
+ * until its answer is grounded. Rows default to 1000 like `run_sql`. Like `run_sql`, the host
14
+ * probes one row past the limit to report truncation; the cap is 9999 so the probe stays within
15
+ * governed-stdio's 10000-row bound.
13
16
  */
14
- export const CODEX_HOST_QUERY_LIMITS = Object.freeze({
17
+ export const HOST_QUERY_LIMITS = Object.freeze({
15
18
  queries: COMPONENT_LIMITS.calls,
16
19
  requestBytes: COMPONENT_LIMITS.requestBytes,
17
20
  resultBytes: COMPONENT_LIMITS.resultBytes,
@@ -20,58 +23,35 @@ export const CODEX_HOST_QUERY_LIMITS = Object.freeze({
20
23
  maxRows: 9_999,
21
24
  timeoutMs: COMPONENT_LIMITS.timeoutMs,
22
25
  });
23
- function requestFrame(value) {
24
- if (typeof value !== "object" || value === null || Array.isArray(value))
25
- return undefined;
26
- const record = value;
27
- if (typeof record.id !== "number" || !Number.isSafeInteger(record.id))
28
- return undefined;
29
- return { token: record.token, id: record.id, sql: record.sql, limit: record.limit };
30
- }
31
26
  /**
32
- * The host side of codex:local's `run_sql`: a per-turn unix socket in a fresh 0700 directory
33
- * that answers only requests carrying this turn's random token. Every query runs through
34
- * governed access (`wren governed-stdio`), gets a turn-unique host `query_id`, and is recorded
35
- * as an observation of this turn only.
27
+ * Every query runs through governed access (`wren governed-stdio`), gets a scope-unique host
28
+ * `query_id` (`q<N>-<tag>`), and is recorded as an observation of this scope only.
36
29
  */
37
- export async function openCodexHostQueryService(options) {
38
- if (process.platform === "win32")
39
- throw new Error("codex:local host query service requires unix sockets");
40
- const directory = await mkdtemp(path.join(os.tmpdir(), "genbi-hq-"));
41
- const token = Buffer.from(randomBytes(32).toString("base64url"), "utf8");
42
- const turnTag = randomBytes(4).toString("hex");
43
- const socketPath = path.join(directory, "s");
44
- const credentialFile = path.join(directory, "credential.json");
30
+ export function createHostQueryRecorder(options) {
31
+ const tag = randomBytes(4).toString("hex");
45
32
  const controller = new AbortController();
46
33
  const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
47
34
  const recorded = [];
48
- const sockets = new Set();
49
35
  let attempts = 0;
50
36
  let recordedBytes = 0;
51
37
  let closed = false;
52
38
  let access;
53
- const authorized = (candidate) => {
54
- if (typeof candidate !== "string")
55
- return false;
56
- const presented = Buffer.from(candidate, "utf8");
57
- return presented.length === token.length && timingSafeEqual(presented, token);
58
- };
59
39
  const execute = async (sql, limit) => {
60
40
  if (closed || signal.aborted)
61
41
  return { error: "transport" };
62
- if (typeof sql !== "string" || !sql.trim() || Buffer.byteLength(sql) > CODEX_HOST_QUERY_LIMITS.requestBytes)
42
+ if (typeof sql !== "string" || !sql.trim() || Buffer.byteLength(sql) > HOST_QUERY_LIMITS.requestBytes)
63
43
  return { error: "invalid_request" };
64
44
  if (limit !== undefined && limit !== null && (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1))
65
45
  return { error: "invalid_request" };
66
- if (attempts >= CODEX_HOST_QUERY_LIMITS.queries)
46
+ if (attempts >= HOST_QUERY_LIMITS.queries)
67
47
  return { error: "budget_exhausted" };
68
48
  attempts += 1;
69
- const rows = Math.min(CODEX_HOST_QUERY_LIMITS.maxRows, typeof limit === "number" ? limit : CODEX_HOST_QUERY_LIMITS.defaultRows);
49
+ const rows = Math.min(HOST_QUERY_LIMITS.maxRows, typeof limit === "number" ? limit : HOST_QUERY_LIMITS.defaultRows);
70
50
  let result;
71
51
  try {
72
52
  access ??= options.openAccess(signal);
73
53
  const governed = await access;
74
- result = await governed.query({ sql, limit: rows + 1 }, AbortSignal.any([signal, AbortSignal.timeout(CODEX_HOST_QUERY_LIMITS.timeoutMs)]));
54
+ result = await governed.query({ sql, limit: rows + 1 }, AbortSignal.any([signal, AbortSignal.timeout(HOST_QUERY_LIMITS.timeoutMs)]));
75
55
  }
76
56
  catch (error) {
77
57
  return { error: error instanceof GovernedWrenError ? error.errorClass : "transport" };
@@ -88,17 +68,65 @@ export async function openCodexHostQueryService(options) {
88
68
  const truncated = probed.length > rows;
89
69
  const shown = truncated ? probed.slice(0, rows) : probed;
90
70
  // The id is assigned here, once, so the observation and the model's copy carry the same id. The
91
- // tag makes ids unique per turn: an id copied from an earlier turn never names this turn's query.
92
- const output = { ...result, rows: shown, row_count: shown.length, truncated, query_id: `q${attempts}-${turnTag}` };
71
+ // tag makes ids unique per scope: an id copied from another turn or session never names this one's query.
72
+ const output = { ...result, rows: shown, row_count: shown.length, truncated, query_id: `q${attempts}-${tag}` };
93
73
  const bytes = Buffer.byteLength(JSON.stringify(output));
94
- if (bytes > CODEX_HOST_QUERY_LIMITS.resultBytes)
74
+ if (bytes > HOST_QUERY_LIMITS.resultBytes)
95
75
  return { error: "result_too_large" };
96
- if (recordedBytes + bytes > CODEX_HOST_QUERY_LIMITS.totalResultBytes)
76
+ if (recordedBytes + bytes > HOST_QUERY_LIMITS.totalResultBytes)
97
77
  return { error: "budget_exhausted" };
98
78
  recordedBytes += bytes;
99
79
  recorded.push(Object.freeze({ tool: "run_sql", input: Object.freeze({ sql, limit: rows }), output: structuredClone(output) }));
100
80
  return { result: output };
101
81
  };
82
+ let closing;
83
+ const close = () => {
84
+ closing ??= (async () => {
85
+ closed = true;
86
+ recorded.length = 0;
87
+ controller.abort();
88
+ try {
89
+ if (access)
90
+ await (await access).close();
91
+ }
92
+ catch {
93
+ // Access that never opened, or already closed, has nothing left to release.
94
+ }
95
+ })();
96
+ return closing;
97
+ };
98
+ options.signal?.addEventListener("abort", () => { void close(); }, { once: true });
99
+ return { execute, observations: () => Object.freeze([...recorded]), close };
100
+ }
101
+ function requestFrame(value) {
102
+ if (typeof value !== "object" || value === null || Array.isArray(value))
103
+ return undefined;
104
+ const record = value;
105
+ if (typeof record.id !== "number" || !Number.isSafeInteger(record.id))
106
+ return undefined;
107
+ return { token: record.token, id: record.id, sql: record.sql, limit: record.limit };
108
+ }
109
+ /**
110
+ * The host side of a subscription turn's `run_sql` (codex:local and the Claude agent-sdk Ask):
111
+ * a per-turn unix socket in a fresh 0700 directory that answers only requests carrying this
112
+ * turn's random token, in front of one {@link createHostQueryRecorder} for the turn.
113
+ */
114
+ export async function openHostQueryService(options) {
115
+ if (process.platform === "win32")
116
+ throw new Error("the host query service requires unix sockets");
117
+ const directory = await mkdtemp(path.join(os.tmpdir(), "genbi-hq-"));
118
+ const token = Buffer.from(randomBytes(32).toString("base64url"), "utf8");
119
+ const socketPath = path.join(directory, "s");
120
+ const credentialFile = path.join(directory, "credential.json");
121
+ const recorder = createHostQueryRecorder(options);
122
+ const sockets = new Set();
123
+ let closed = false;
124
+ const authorized = (candidate) => {
125
+ if (typeof candidate !== "string")
126
+ return false;
127
+ const presented = Buffer.from(candidate, "utf8");
128
+ return presented.length === token.length && timingSafeEqual(presented, token);
129
+ };
102
130
  const serve = (socket) => {
103
131
  if (closed) {
104
132
  socket.destroy();
@@ -112,7 +140,7 @@ export async function openCodexHostQueryService(options) {
112
140
  socket.setEncoding("utf8");
113
141
  socket.on("data", (chunk) => {
114
142
  buffer += chunk;
115
- if (buffer.length > 2 * CODEX_HOST_QUERY_LIMITS.requestBytes) {
143
+ if (buffer.length > 2 * HOST_QUERY_LIMITS.requestBytes) {
116
144
  socket.destroy();
117
145
  return;
118
146
  }
@@ -138,7 +166,7 @@ export async function openCodexHostQueryService(options) {
138
166
  return;
139
167
  }
140
168
  tail = tail.then(async () => {
141
- const outcome = await execute(sql, limit);
169
+ const outcome = await recorder.execute(sql, limit);
142
170
  if (socket.destroyed)
143
171
  return;
144
172
  socket.write(`${JSON.stringify("result" in outcome ? { id, result: outcome.result } : { id, error: { class: outcome.error } })}\n`);
@@ -157,6 +185,7 @@ export async function openCodexHostQueryService(options) {
157
185
  }
158
186
  catch (error) {
159
187
  server.close();
188
+ await recorder.close();
160
189
  await rm(directory, { recursive: true, force: true });
161
190
  throw error;
162
191
  }
@@ -164,17 +193,11 @@ export async function openCodexHostQueryService(options) {
164
193
  const close = () => {
165
194
  closing ??= (async () => {
166
195
  closed = true;
167
- recorded.length = 0;
168
- controller.abort();
169
196
  for (const socket of sockets)
170
197
  socket.destroy();
171
198
  await new Promise((resolve) => server.close(() => resolve()));
172
199
  try {
173
- if (access)
174
- await (await access).close();
175
- }
176
- catch {
177
- // Access that never opened, or already closed, has nothing left to release.
200
+ await recorder.close();
178
201
  }
179
202
  finally {
180
203
  await rm(directory, { recursive: true, force: true });
@@ -183,9 +206,52 @@ export async function openCodexHostQueryService(options) {
183
206
  return closing;
184
207
  };
185
208
  options.signal?.addEventListener("abort", () => { void close(); }, { once: true });
186
- return {
187
- credentialFile,
188
- observations: () => Object.freeze([...recorded]),
189
- close,
190
- };
209
+ return { directory, credentialFile, observations: recorder.observations, close };
210
+ }
211
+ /**
212
+ * The argv after the proxy command: the proxy script, this turn's credential file, then the
213
+ * `wren serve mcp` command it fronts. Shared by codex:local's `--server-arg`s and the agent-sdk
214
+ * host MCP config so the two cannot drift.
215
+ */
216
+ export function wrenHostProxyArgs(options) {
217
+ return [
218
+ ...options.proxy.prefixArgs,
219
+ "--credential-file",
220
+ options.credentialFile,
221
+ "--",
222
+ options.mcpServer.command,
223
+ ...options.mcpServer.prefixArgs,
224
+ "serve",
225
+ "mcp",
226
+ "--project",
227
+ options.userProject,
228
+ "--quiet",
229
+ ];
230
+ }
231
+ /** The built proxy next to this module, else its source (Node strips its erasable types). */
232
+ export function defaultWrenHostProxy() {
233
+ const built = fileURLToPath(new URL("./wren-host-proxy.js", import.meta.url));
234
+ const script = existsSync(built) ? built : fileURLToPath(new URL("./wren-host-proxy.ts", import.meta.url));
235
+ return { command: process.execPath, prefixArgs: [script] };
236
+ }
237
+ /** Governed access spawns one executable with fixed arguments; a prefixed command cannot be bound. */
238
+ export function singleWrenExecutable(cli) {
239
+ if (cli.prefixArgs.length > 0)
240
+ throw new Error("host queries need a standalone wren executable");
241
+ return cli.command;
242
+ }
243
+ export function resolveExecutableOnPath(name) {
244
+ for (const directory of (process.env.PATH ?? "").split(path.delimiter)) {
245
+ if (!directory)
246
+ continue;
247
+ const candidate = path.join(directory, name);
248
+ try {
249
+ accessSync(candidate, fsConstants.X_OK);
250
+ return candidate;
251
+ }
252
+ catch {
253
+ // Keep searching.
254
+ }
255
+ }
256
+ throw new Error(`could not find the "${name}" executable on PATH`);
191
257
  }
@@ -86,6 +86,9 @@ export async function route(options) {
86
86
  ...(options.agentId !== undefined ? { agentId: options.agentId } : {}),
87
87
  ...(options.chatTimeoutMs !== undefined ? { chatTimeoutMs: options.chatTimeoutMs } : {}),
88
88
  ...(options.signal !== undefined ? { signal: options.signal } : {}),
89
+ // An Ask turn: its data queries can run through a per-turn host query service, so the
90
+ // answer can be grounded on host evidence (see `withHostVerification`).
91
+ hostQueries: {},
89
92
  });
90
93
  return { backend: "agent-sdk", warnings, ...result };
91
94
  }
@@ -177,6 +177,18 @@ export interface DispatchedOptions {
177
177
  * flag) when unset, which is the pre-existing behavior.
178
178
  */
179
179
  readonly resumeSessionId?: string;
180
+ /**
181
+ * Ask turns only (`route()` sets it; the setup and enrichment runners do not): open a per-turn
182
+ * host query service and hand the dispatcher a host MCP server (`--host-mcp-config`) whose
183
+ * `run_sql` executes through it, so the answer can be grounded on what the host ran. `wren` is
184
+ * the standalone Wren executable for both governed access and `wren serve mcp`; when unset it
185
+ * is resolved on PATH, and when none is found the turn runs without host queries (and its
186
+ * answer stays unverified). Ignored under `modelsConfig`: warble refuses a host MCP for a
187
+ * hybrid-staged plan, which a models config can produce.
188
+ */
189
+ readonly hostQueries?: {
190
+ readonly wren?: string;
191
+ };
180
192
  /** Cancels the owned dispatcher process without falling back to another vendor. */
181
193
  readonly signal?: AbortSignal;
182
194
  }
@@ -191,6 +203,11 @@ export interface DispatchedResult {
191
203
  * the SDK itself never produced a session id to report.
192
204
  */
193
205
  readonly sessionId?: string | null;
206
+ /**
207
+ * Ask turns with host queries only: the queries this turn's host query service executed for
208
+ * the model's host `run_sql`, the only evidence its answer may be grounded on. Never persisted.
209
+ */
210
+ readonly hostObservations?: readonly import("./host-query.js").HostQueryObservation[];
194
211
  }
195
212
  export type DispatchedExecutor = (options: DispatchedOptions) => Promise<DispatchedResult>;
196
213
  /** Codex subscription Ask runs through warble-codex-local, never the Claude dispatched dispatcher. */
@@ -227,7 +244,7 @@ export interface CodexAskResult {
227
244
  * codex:local only: the queries this turn's host query service executed for the model's
228
245
  * `run_sql`, the only evidence its answer may be grounded on. Never persisted.
229
246
  */
230
- readonly hostObservations?: readonly import("./codex-host-query.js").HostQueryObservation[];
247
+ readonly hostObservations?: readonly import("./host-query.js").HostQueryObservation[];
231
248
  }
232
249
  export type CodexAskExecutor = (options: CodexAskOptions) => Promise<CodexAskResult>;
233
250
  /**
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * codex:local's Wren MCP server: a stdio proxy in front of `wren serve mcp`.
3
+ * The Wren MCP server of a subscription Ask turn (codex:local, and the Claude agent-sdk host MCP):
4
+ * a stdio proxy in front of `wren serve mcp`.
4
5
  *
5
- * node codex-wren-proxy.js --credential-file <abs path> -- <wren> serve mcp --project <p> --quiet
6
+ * node wren-host-proxy.js --credential-file <abs path> -- <wren> serve mcp --project <p> --quiet
6
7
  *
7
8
  * Every JSON-RPC line passes through to the child `wren serve mcp` unchanged, so the model sees
8
9
  * the same tool names and schemas, except a `tools/call` of `run_sql`: that goes to this turn's
9
- * host query service (see `codex-host-query.ts`), which executes it through governed access and
10
+ * host query service (see `host-query.ts`), which executes it through governed access and
10
11
  * records it as host evidence. The credential file (0600, owned by this user) names the turn's
11
12
  * unix socket and token; without a valid one, `run_sql` is refused rather than run elsewhere.
12
13
  *
@@ -146,7 +147,7 @@ export class HostConnection {
146
147
  function parseArgs(argv) {
147
148
  const separator = argv.indexOf("--");
148
149
  if (separator < 0 || separator === argv.length - 1) {
149
- process.stderr.write("codex wren proxy: usage: --credential-file <path> -- <command> [args...]\n");
150
+ process.stderr.write("wren host proxy: usage: --credential-file <path> -- <command> [args...]\n");
150
151
  process.exit(2);
151
152
  }
152
153
  const own = argv.slice(0, separator);
@@ -160,7 +161,7 @@ function main() {
160
161
  const host = credential ? new HostConnection(credential) : undefined;
161
162
  const child = spawn(options.command, options.args, { stdio: ["pipe", "pipe", "inherit"] });
162
163
  child.on("error", (error) => {
163
- process.stderr.write(`codex wren proxy: could not start the Wren MCP server: ${error.message}\n`);
164
+ process.stderr.write(`wren host proxy: could not start the Wren MCP server: ${error.message}\n`);
164
165
  process.exit(1);
165
166
  });
166
167
  child.on("exit", (code, signal) => process.exit(code ?? (signal ? 1 : 0)));
@@ -423,6 +423,15 @@ export interface HostContractDiagnostic {
423
423
  /** The concrete artifact/workflow conditions that must be true before another claimed success. */
424
424
  readonly expectedArtifactContract: readonly string[];
425
425
  }
426
+ /**
427
+ * Where the bound project lives, so a recorded command's optional
428
+ * `cd <dir> &&` prefix can be resolved against it. Absent binding means the
429
+ * prefix cannot be verified and is not accepted.
430
+ */
431
+ export interface ProjectBinding {
432
+ readonly root: string;
433
+ readonly projectDir: string;
434
+ }
426
435
  export type RecordedSchemaDiscovery = {
427
436
  readonly kind: "successful";
428
437
  readonly command: string;
@@ -441,7 +450,7 @@ export type RecordedSchemaDiscovery = {
441
450
  * this for both terminal acceptance and the no-introspection diagnostic,
442
451
  * rather than reimplementing the allowlist elsewhere.
443
452
  */
444
- export declare function classifyRecordedSchemaDiscovery(worklog: readonly SetupWorklogEntry[]): RecordedSchemaDiscovery;
453
+ export declare function classifyRecordedSchemaDiscovery(worklog: readonly SetupWorklogEntry[], binding?: ProjectBinding): RecordedSchemaDiscovery;
445
454
  /** Ordered, host-recorded progress; each value includes all preceding work. */
446
455
  export type ContextLifecyclePrefix = "none" | "discovery" | "validate" | "build";
447
456
  export type RecordedContextLifecycle = {
@@ -451,12 +460,15 @@ export type RecordedContextLifecycle = {
451
460
  } | {
452
461
  readonly kind: "validate_missing";
453
462
  readonly observed: string;
463
+ readonly unrecognised?: string;
454
464
  } | {
455
465
  readonly kind: "build_missing";
456
466
  readonly observed: string;
467
+ readonly unrecognised?: string;
457
468
  } | {
458
469
  readonly kind: "out_of_order";
459
470
  readonly observed: string;
471
+ readonly unrecognised?: string;
460
472
  };
461
473
  /**
462
474
  * Returns the greatest ordered prefix proved by the supplied successful tool
@@ -464,8 +476,8 @@ export type RecordedContextLifecycle = {
464
476
  * call in the same attempt is out of order, so a corrective attempt can ask
465
477
  * only for the unproven suffix.
466
478
  */
467
- export declare function recordedContextLifecyclePrefix(worklog: readonly SetupWorklogEntry[], prior?: ContextLifecyclePrefix): ContextLifecyclePrefix;
468
- export declare function classifyRecordedContextLifecycle(worklog: readonly SetupWorklogEntry[], prior?: ContextLifecyclePrefix): RecordedContextLifecycle;
479
+ export declare function recordedContextLifecyclePrefix(worklog: readonly SetupWorklogEntry[], prior?: ContextLifecyclePrefix, binding?: ProjectBinding): ContextLifecyclePrefix;
480
+ export declare function classifyRecordedContextLifecycle(worklog: readonly SetupWorklogEntry[], prior?: ContextLifecyclePrefix, binding?: ProjectBinding): RecordedContextLifecycle;
469
481
  /**
470
482
  * Parses the LAST `SETUP_STATUS: ok|needs_input|error` line out of a setup
471
483
  * turn's finalText (the terminal contract lives in the composed