mtech-ai-local-runner 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,6 +10,31 @@ The package stores safe runner configuration locally and keeps OAuth refresh cre
10
10
 
11
11
  Public npm installation requires no npm account or publishing OTP. Setup requires Gateway OAuth login, and local Claude Code must already be authenticated (`claude auth status`).
12
12
 
13
+ ## 0.1.16: bounded startup and MCP failure observations
14
+
15
+ Failed Codex preparation now preserves fixed native error markers and separates
16
+ local MCP request arrival, rejected credentials/Origin/Host, and authenticated
17
+ initialize receipt. Raw stderr, tokens, request bodies, paths and URLs are not
18
+ included. Observations do not authorize tools, attest native policy, or prove a
19
+ root cause. A missing successful initialize response does not prove that the
20
+ client sent no HTTP request. Existing trusted failure stages/codes remain intact,
21
+ including when new observations are added to an already classified gate error.
22
+
23
+ This patch fixes diagnostic evidence loss. It does not establish successful
24
+ real-account MCP connectivity, change permissions/deadlines/task limits, update
25
+ the daemon, or authorize another actual model attempt. Publication and verified
26
+ foreground installation are separate from a subsequently approved execution.
27
+
28
+ ## 0.1.15: startup output before MCP readiness
29
+
30
+ The process host drains stdout from spawn, before the adapter starts consuming
31
+ events after authenticated MCP discovery. A bounded queue prevents the child
32
+ from blocking on ordinary startup output; overflow stops only its owned process
33
+ group. This changes no tool permissions, preparation deadlines or task limits.
34
+ Fake-process tests reproduce the defect, but do not prove the cause of a previous
35
+ real-account failure or successful account/MCP acceptance. Publication and a
36
+ foreground installation do not update the daemon or authorize a model run.
37
+
13
38
  ## 0.1.14: foreground administrator authentication wait
14
39
 
15
40
  The manual Codex pilot waits for administrator authentication before starting
@@ -1,3 +1,5 @@
1
+ import { localEndpointObservations } from '../../safety/local-tool-server.js';
2
+ import { processStartupObservations } from '../../process/node-process-group-host.js';
1
3
  import { executionError } from '../../safety/execution-failure.js';
2
4
  import { UNMANAGED_EXEC_MODE } from './unmanaged-scope.js';
3
5
  import { fingerprint as taskFingerprint } from '../../daemon/execution-coordinator.js';
@@ -65,7 +67,8 @@ export class CodexExecLaunchHost {
65
67
  let stage = 'codex_context';
66
68
  let interruption;
67
69
  const mcpDetail = () => stage === 'codex_mcp' ? this.deps.execTools?.discoveryDetail() : undefined;
68
- const interrupt = (code) => { interruption ??= executionError(fail(), stage, code, mcpDetail()); };
70
+ const observations = () => { const invocation = requireOfficialExecPlan(this.plan); return [...(this.handle ? processStartupObservations(this.handle) : []), ...localEndpointObservations(this.plan.endpoint, invocation.env.AI_GATEWAY_JOB_MCP_TOKEN)]; };
71
+ const interrupt = (code) => { interruption ??= executionError(fail(), stage, code, mcpDetail(), observations()); };
69
72
  let timer, external;
70
73
  try {
71
74
  this.assertContext(ctx, jobId);
@@ -158,7 +161,7 @@ export class CodexExecLaunchHost {
158
161
  return await Promise.race([preparation, stopped]);
159
162
  }
160
163
  catch (error) {
161
- const safe = executionError(interruption ?? error, stage, 'CODEX_EXEC_PREFLIGHT_REJECTED', mcpDetail());
164
+ const safe = executionError(interruption ?? error, stage, 'CODEX_EXEC_PREFLIGHT_REJECTED', mcpDetail(), observations());
162
165
  await this.cancel().catch(() => { });
163
166
  throw safe;
164
167
  }
@@ -1,3 +1,6 @@
1
+ const startup = new WeakMap();
2
+ /** Fixed text observations from this owned host only. Never raw stderr or authority. */
3
+ export function processStartupObservations(handle) { return [...(startup.get(handle) ?? [])]; }
1
4
  import { spawn } from "node:child_process";
2
5
  /** Each invocation owns one new process group; no name-based process sweeping. */
3
6
  export class NodeProcessGroupHost {
@@ -7,6 +10,19 @@ export class NodeProcessGroupHost {
7
10
  const child = spawn(command, args, { ...options, shell: false, detached: true, stdio: ["pipe", "pipe", "pipe"] });
8
11
  let failure;
9
12
  let owned = true;
13
+ let finished = false, consumed = false, queuedBytes = 0, wake;
14
+ const lines = [];
15
+ const MAX_QUEUED_BYTES = 1_048_576, MAX_QUEUED_LINES = 8192;
16
+ const notify = () => { const resolve = wake; wake = undefined; resolve?.(); };
17
+ let termination;
18
+ const exitCallbacks = [];
19
+ const terminated = (code, signal) => {
20
+ if (termination)
21
+ return;
22
+ termination = { code, signal };
23
+ for (const callback of exitCallbacks.splice(0))
24
+ callback(code, signal);
25
+ };
10
26
  const kill = (signal = "SIGKILL") => {
11
27
  if (!owned || !child.pid)
12
28
  return;
@@ -28,47 +44,141 @@ export class NodeProcessGroupHost {
28
44
  if (signal === "SIGKILL")
29
45
  owned = false;
30
46
  };
31
- child.on("error", () => { failure = new Error("Provider process could not start"); });
32
- child.stdin.on("error", () => { failure = new Error("Provider process input closed"); });
33
- // Provider stderr can contain account details. Consume without publishing it.
34
- child.stderr.resume();
35
- child.once("exit", () => {
47
+ child.on("error", () => { failure ??= new Error("Provider process could not start"); terminated(null, null); notify(); });
48
+ child.stdin.on("error", () => {
49
+ failure ??= new Error("Provider process input closed");
50
+ try {
51
+ kill();
52
+ }
53
+ catch {
54
+ failure = new Error('Provider process group cleanup could not be confirmed');
55
+ }
56
+ terminated(null, null);
57
+ notify();
58
+ });
59
+ // Keep fixed observations and fixed marker prefixes only: no raw stderr,
60
+ // account text, paths or URLs survive a data callback, even for split chunks.
61
+ const observations = new Set();
62
+ const markers = [
63
+ { name: 'native_stdin_read', text: 'Reading prompt from stdin...', prefix: '' },
64
+ { name: 'native_model_catalog_failed', text: 'codex_models_manager::manager: failed to refresh available models:', prefix: '' },
65
+ { name: 'native_cloud_policy_failed', text: 'Failed to fetch cloud config bundle', prefix: '' },
66
+ { name: 'native_cloud_policy_failed', text: 'Failed to load cloud config bundle', prefix: '' },
67
+ { name: 'native_cloud_policy_timeout', text: 'Timed out waiting for cloud config bundle after ', prefix: '' },
68
+ { name: 'native_cloud_policy_timeout', text: 'timed out waiting for cloud config bundle after ', prefix: '' },
69
+ { name: 'native_required_mcp_failed', text: 'required MCP servers failed to initialize: ai_local:', prefix: '' },
70
+ { name: 'native_config_failed', text: 'Error loading config:', prefix: '' },
71
+ { name: 'native_auth_refresh_unsupported', text: 'chatgpt auth token refresh is not supported in exec mode', prefix: '' },
72
+ ];
73
+ child.stderr.on('data', (chunk) => {
74
+ const data = chunk.toString('utf8');
75
+ for (const marker of markers) {
76
+ if (observations.has(marker.name))
77
+ continue;
78
+ const text = marker.prefix + data;
79
+ marker.prefix = '';
80
+ if (text.includes(marker.text)) {
81
+ observations.add(marker.name);
82
+ continue;
83
+ }
84
+ for (let n = Math.min(text.length, marker.text.length - 1); n > 0; n--) {
85
+ const fixed = marker.text.slice(0, n);
86
+ if (text.endsWith(fixed)) {
87
+ marker.prefix = fixed;
88
+ break;
89
+ }
90
+ }
91
+ }
92
+ });
93
+ child.stderr.once('end', () => { for (const marker of markers)
94
+ marker.prefix = ''; });
95
+ child.once("exit", (code, signal) => {
36
96
  try {
37
97
  kill();
38
98
  }
39
99
  catch {
40
100
  failure = new Error("Provider process group cleanup could not be confirmed");
41
101
  }
102
+ terminated(code, signal);
42
103
  });
43
- return {
104
+ // Drain from spawn, not from the first adapter event read: official exec can
105
+ // emit startup events before it initializes MCP, while the adapter awaits
106
+ // that initialization before consuming events. Bound the pre-consumer queue.
107
+ void (async () => {
108
+ let pending = Buffer.alloc(0);
109
+ const enqueue = (line) => {
110
+ if (line.length > 256_000)
111
+ throw new Error("Provider event exceeded the allowed size");
112
+ if (queuedBytes + line.length + 1 > MAX_QUEUED_BYTES || lines.length >= MAX_QUEUED_LINES)
113
+ throw new Error("Provider event buffer exceeded the allowed size");
114
+ lines.push({ text: line.toString('utf8').replace(/\r$/, ''), bytes: line.length + 1 });
115
+ queuedBytes += line.length + 1;
116
+ notify();
117
+ };
118
+ try {
119
+ for await (const chunk of child.stdout) {
120
+ pending = Buffer.concat([pending, Buffer.from(chunk)]);
121
+ let end;
122
+ while ((end = pending.indexOf(10)) !== -1) {
123
+ enqueue(pending.subarray(0, end));
124
+ pending = pending.subarray(end + 1);
125
+ }
126
+ if (pending.length > 256_000)
127
+ throw new Error("Provider event exceeded the allowed size");
128
+ }
129
+ if (pending.length)
130
+ enqueue(pending);
131
+ }
132
+ catch (error) {
133
+ failure ??= error instanceof Error && ['Provider event exceeded the allowed size', 'Provider event buffer exceeded the allowed size'].includes(error.message) ? error : new Error('Provider event stream failed');
134
+ try {
135
+ kill();
136
+ }
137
+ catch {
138
+ failure = new Error('Provider process group cleanup could not be confirmed');
139
+ }
140
+ }
141
+ finally {
142
+ finished = true;
143
+ notify();
144
+ }
145
+ })();
146
+ const handle = {
44
147
  stdin: { write: (text) => { child.stdin.write(text); }, end: () => { child.stdin.end(); } },
45
148
  stdout: { async *[Symbol.asyncIterator]() {
46
- let pending = Buffer.alloc(0);
149
+ if (consumed)
150
+ throw new Error('Provider event stream already consumed');
151
+ consumed = true;
47
152
  try {
48
- for await (const chunk of child.stdout) {
49
- pending = Buffer.concat([pending, Buffer.from(chunk)]);
50
- let end;
51
- while ((end = pending.indexOf(10)) !== -1) {
52
- if (end > 256_000)
53
- throw new Error("Provider event exceeded the allowed size");
54
- const line = pending.subarray(0, end).toString("utf8").replace(/\r$/, "");
55
- pending = pending.subarray(end + 1);
56
- yield line;
153
+ while (true) {
154
+ if (failure)
155
+ throw failure;
156
+ const line = lines.shift();
157
+ if (line) {
158
+ queuedBytes -= line.bytes;
159
+ yield line.text;
160
+ continue;
57
161
  }
58
- if (pending.length > 256_000)
59
- throw new Error("Provider event exceeded the allowed size");
162
+ if (finished)
163
+ break;
164
+ await new Promise(resolve => { wake = resolve; });
60
165
  }
61
- if (pending.length)
62
- yield pending.toString("utf8");
63
- if (failure)
64
- throw failure;
65
166
  }
66
167
  finally {
168
+ lines.length = 0;
169
+ queuedBytes = 0;
67
170
  kill();
68
171
  }
69
172
  } },
70
173
  kill: (signal) => kill((signal ?? "SIGKILL")),
71
- once: (_event, callback) => { child.once("exit", callback); },
174
+ once: (_event, callback) => { if (termination) {
175
+ const ended = termination;
176
+ queueMicrotask(() => callback(ended.code, ended.signal));
177
+ }
178
+ else
179
+ exitCallbacks.push(callback); },
72
180
  };
181
+ startup.set(handle, observations);
182
+ return handle;
73
183
  }
74
184
  }
@@ -1,10 +1,12 @@
1
1
  import { GatewayToolError } from '../gateway-client.js';
2
+ /** Observations only: provider text cannot authorize a tool or certify a cause. */
3
+ export const STARTUP_OBSERVATIONS = Object.freeze(['native_stdin_read', 'native_model_catalog_failed', 'native_cloud_policy_failed', 'native_cloud_policy_timeout', 'native_required_mcp_failed', 'native_config_failed', 'native_auth_refresh_unsupported', 'local_mcp_request_received', 'local_mcp_auth_rejected', 'local_mcp_origin_or_host_rejected', 'local_mcp_initialize_received']);
2
4
  const issued = new WeakMap();
3
5
  export function executionFailure(error, stage, fallback = 'EXECUTION_FAILED') {
4
6
  if (error && typeof error === 'object') {
5
7
  const trusted = issued.get(error);
6
8
  if (trusted)
7
- return { ...trusted };
9
+ return { ...trusted, ...(trusted.observations ? { observations: [...trusted.observations] } : {}) };
8
10
  try {
9
11
  if (error instanceof GatewayToolError && (error.code === 'GATEWAY_TOOL_REJECTED' || error.code === 'GATEWAY_TRANSPORT_ERROR' || error.code === 'WORK_LOOP_CANCELLED')) {
10
12
  return { stage, code: error.code };
@@ -24,12 +26,17 @@ export function executionFailure(error, stage, fallback = 'EXECUTION_FAILED') {
24
26
  }
25
27
  return { stage, code: fallback };
26
28
  }
27
- export function executionError(error, stage, fallback, detail) {
29
+ export function executionError(error, stage, fallback, detail, observations) {
28
30
  const diagnostic = executionFailure(error, stage, fallback);
29
31
  // Only trusted callers supply this fixed vocabulary. Serialized provider
30
32
  // errors, messages and stderr never become diagnostic authority.
31
33
  if (!issued.has(error) && ['mcp_initialize_missing', 'mcp_initialized_missing', 'mcp_inventory_missing', 'mcp_discovery_complete'].includes(detail))
32
34
  diagnostic.detail = detail;
35
+ if (Array.isArray(observations)) {
36
+ const fixed = STARTUP_OBSERVATIONS.filter(value => diagnostic.observations?.includes(value) || observations.includes(value));
37
+ if (fixed.length)
38
+ diagnostic.observations = fixed;
39
+ }
33
40
  const safe = new Error(fallback);
34
41
  issued.set(safe, Object.freeze(diagnostic));
35
42
  return safe;
@@ -8,6 +8,12 @@ export function localDiscoveryScopeMatches(scope, endpoint, token) {
8
8
  const data = discoveryScopes.get(scope);
9
9
  return !!data && data.endpoint === endpoint && data.credentialHash === createHash('sha256').update(token).digest('hex');
10
10
  }
11
+ // Bounded diagnostic metadata; possession grants neither discovery nor tool authority.
12
+ const endpointObservations = new Map();
13
+ export function localEndpointObservations(url, token) {
14
+ const record = endpointObservations.get(url);
15
+ return record?.credentialHash === createHash('sha256').update(token).digest('hex') ? [...record.values] : [];
16
+ }
11
17
  const MAX_BODY = 1024 * 1024;
12
18
  const schemas = {
13
19
  local_read: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'], additionalProperties: false },
@@ -22,6 +28,7 @@ export function localToolInventory() {
22
28
  /** Job-only JSON MCP endpoint; intentionally has no Gateway transport or discovery. */
23
29
  export class LocalToolServer {
24
30
  options;
31
+ observations = new Set();
25
32
  server = null;
26
33
  revoked = false;
27
34
  starting = null;
@@ -57,6 +64,7 @@ export class LocalToolServer {
57
64
  throw new Error('Local tool server already started');
58
65
  this.token = randomBytes(32).toString('hex');
59
66
  this.server = createServer(async (req, res) => {
67
+ this.observations.add('local_mcp_request_received');
60
68
  const reply = (status, body) => {
61
69
  if (res.writableEnded)
62
70
  return;
@@ -72,11 +80,13 @@ export class LocalToolServer {
72
80
  return;
73
81
  }
74
82
  if (req.headers.origin !== undefined || req.headers.host !== new URL(this.url).host) {
83
+ this.observations.add('local_mcp_origin_or_host_rejected');
75
84
  reply(403, { error: 'Forbidden' });
76
85
  return;
77
86
  }
78
87
  const token = this.token, expected = Buffer.from(token ? `Bearer ${token}` : ''), supplied = Buffer.from(req.headers.authorization ?? '');
79
88
  if (!token || expected.length !== supplied.length || !timingSafeEqual(expected, supplied)) {
89
+ this.observations.add('local_mcp_auth_rejected');
80
90
  reply(401, { error: 'Unauthorized' });
81
91
  return;
82
92
  }
@@ -128,6 +138,7 @@ export class LocalToolServer {
128
138
  }
129
139
  const send = (result) => reply(200, { jsonrpc: '2.0', id: rpc.id, result });
130
140
  if (rpc.method === 'initialize') {
141
+ this.observations.add('local_mcp_initialize_received');
131
142
  res.once('finish', () => { if (!this.revoked)
132
143
  this.observe('initialize_response'); });
133
144
  send({ protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'runner-local-tools', version: '1' } });
@@ -190,10 +201,13 @@ export class LocalToolServer {
190
201
  if (!address || typeof address === 'string')
191
202
  throw new Error('Failed to bind local tool server');
192
203
  this.url = `http://127.0.0.1:${address.port}/mcp`;
204
+ endpointObservations.set(this.url, { credentialHash: createHash('sha256').update(this.token).digest('hex'), values: this.observations });
193
205
  discoveryScopes.set(this.discoveryScope, { endpoint: this.url, credentialHash: createHash('sha256').update(this.token).digest('hex') });
194
206
  return { url: this.url, token: this.token };
195
207
  }
196
208
  async close() {
209
+ if (this.url)
210
+ endpointObservations.delete(this.url);
197
211
  this.revoke();
198
212
  const server = this.server;
199
213
  this.server = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {