mtech-ai-local-runner 0.1.15 → 0.1.17

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,37 @@ 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.17: owned startup, pipe and transport observations
14
+
15
+ An owned-process collector marker distinguishes available diagnostics from a
16
+ missing collector. Fixed observations separate spawn, stdin write/end requests,
17
+ OS pipe finish, stdout/stderr activity/EOF, selected JSON event type names, exit,
18
+ and MCP TCP connection from authenticated HTTP discovery. No event payload is
19
+ copied into diagnostics. Pipe finish does **not** prove the CLI consumed stdin;
20
+ TCP activity does **not** prove the connection belongs to Codex or authorize tools.
21
+ Provider event type observations are not attestations of policy or model use.
22
+ The legacy `Reading prompt from stdin...` marker is not emitted for forced
23
+ `exec -` input in the inspected release source; its absence cannot locate a wait.
24
+ Failure snapshots are captured before owned cleanup. Existing preparation limits,
25
+ actual employee task limits, credentials, permissions and tool gates are unchanged.
26
+ This is local diagnostic coverage, not proof of the previous account failure's
27
+ cause or a successful real-account run. No automatic stack sampler is enabled.
28
+
29
+ ## 0.1.16: bounded startup and MCP failure observations
30
+
31
+ Failed Codex preparation now preserves fixed native error markers and separates
32
+ local MCP request arrival, rejected credentials/Origin/Host, and authenticated
33
+ initialize receipt. Raw stderr, tokens, request bodies, paths and URLs are not
34
+ included. Observations do not authorize tools, attest native policy, or prove a
35
+ root cause. A missing successful initialize response does not prove that the
36
+ client sent no HTTP request. Existing trusted failure stages/codes remain intact,
37
+ including when new observations are added to an already classified gate error.
38
+
39
+ This patch fixes diagnostic evidence loss. It does not establish successful
40
+ real-account MCP connectivity, change permissions/deadlines/task limits, update
41
+ the daemon, or authorize another actual model attempt. Publication and verified
42
+ foreground installation are separate from a subsequently approved execution.
43
+
13
44
  ## 0.1.15: startup output before MCP readiness
14
45
 
15
46
  The process host drains stdout from spawn, before the adapter starts consuming
@@ -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 lifecycle/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 {
@@ -5,6 +8,9 @@ export class NodeProcessGroupHost {
5
8
  if (process.platform !== "darwin")
6
9
  throw new Error("Codex process isolation requires macOS");
7
10
  const child = spawn(command, args, { ...options, shell: false, detached: true, stdio: ["pipe", "pipe", "pipe"] });
11
+ const observations = new Set(['host_observer_v2']);
12
+ child.once('spawn', () => observations.add('host_process_spawned'));
13
+ child.stdin.once('finish', () => observations.add('host_stdin_finished'));
8
14
  let failure;
9
15
  let owned = true;
10
16
  let finished = false, consumed = false, queuedBytes = 0, wake;
@@ -41,8 +47,9 @@ export class NodeProcessGroupHost {
41
47
  if (signal === "SIGKILL")
42
48
  owned = false;
43
49
  };
44
- child.on("error", () => { failure ??= new Error("Provider process could not start"); terminated(null, null); notify(); });
50
+ child.on("error", () => { observations.add('host_process_spawn_failed'); failure ??= new Error("Provider process could not start"); terminated(null, null); notify(); });
45
51
  child.stdin.on("error", () => {
52
+ observations.add('host_stdin_failed');
46
53
  failure ??= new Error("Provider process input closed");
47
54
  try {
48
55
  kill();
@@ -53,9 +60,46 @@ export class NodeProcessGroupHost {
53
60
  terminated(null, null);
54
61
  notify();
55
62
  });
56
- // Provider stderr can contain account details. Consume without publishing it.
57
- child.stderr.resume();
63
+ // Keep fixed observations and fixed marker prefixes only: no raw stderr,
64
+ // account text, paths or URLs survive a data callback, even for split chunks.
65
+ const markers = [
66
+ // Legacy wording appears only with implicit piped stdin, NOT forced `exec -`.
67
+ // Its absence cannot locate startup; pipe finish does not prove native consumption.
68
+ { name: 'native_stdin_read', text: 'Reading prompt from stdin...', prefix: '' },
69
+ { name: 'native_model_catalog_failed', text: 'codex_models_manager::manager: failed to refresh available models:', prefix: '' },
70
+ { name: 'native_cloud_policy_failed', text: 'Failed to fetch cloud config bundle', prefix: '' },
71
+ { name: 'native_cloud_policy_failed', text: 'Failed to load cloud config bundle', prefix: '' },
72
+ { name: 'native_cloud_policy_timeout', text: 'Timed out waiting for cloud config bundle after ', prefix: '' },
73
+ { name: 'native_cloud_policy_timeout', text: 'timed out waiting for cloud config bundle after ', prefix: '' },
74
+ { name: 'native_required_mcp_failed', text: 'required MCP servers failed to initialize: ai_local:', prefix: '' },
75
+ { name: 'native_config_failed', text: 'Error loading config:', prefix: '' },
76
+ { name: 'native_auth_refresh_unsupported', text: 'chatgpt auth token refresh is not supported in exec mode', prefix: '' },
77
+ ];
78
+ child.stderr.on('data', (chunk) => {
79
+ observations.add('host_stderr_data');
80
+ const data = chunk.toString('utf8');
81
+ for (const marker of markers) {
82
+ if (observations.has(marker.name))
83
+ continue;
84
+ const text = marker.prefix + data;
85
+ marker.prefix = '';
86
+ if (text.includes(marker.text)) {
87
+ observations.add(marker.name);
88
+ continue;
89
+ }
90
+ for (let n = Math.min(text.length, marker.text.length - 1); n > 0; n--) {
91
+ const fixed = marker.text.slice(0, n);
92
+ if (text.endsWith(fixed)) {
93
+ marker.prefix = fixed;
94
+ break;
95
+ }
96
+ }
97
+ }
98
+ });
99
+ child.stderr.once('end', () => { observations.add('host_stderr_ended'); for (const marker of markers)
100
+ marker.prefix = ''; });
58
101
  child.once("exit", (code, signal) => {
102
+ observations.add('host_process_exited');
59
103
  try {
60
104
  kill();
61
105
  }
@@ -74,12 +118,25 @@ export class NodeProcessGroupHost {
74
118
  throw new Error("Provider event exceeded the allowed size");
75
119
  if (queuedBytes + line.length + 1 > MAX_QUEUED_BYTES || lines.length >= MAX_QUEUED_LINES)
76
120
  throw new Error("Provider event buffer exceeded the allowed size");
121
+ observations.add('host_stdout_line');
122
+ // Diagnostic type names only; no event payload, IDs, text or provider error.
123
+ try {
124
+ const event = JSON.parse(line.toString('utf8'));
125
+ const types = {
126
+ 'thread.started': 'host_event_thread_started', 'turn.started': 'host_event_turn_started',
127
+ 'turn.completed': 'host_event_turn_completed', 'turn.failed': 'host_event_turn_failed', 'error': 'host_event_error',
128
+ };
129
+ if (event && typeof event === 'object' && 'type' in event && typeof event.type === 'string' && Object.hasOwn(types, event.type))
130
+ observations.add(types[event.type]);
131
+ }
132
+ catch { /* Malformed or unrelated output has no diagnostic authority. */ }
77
133
  lines.push({ text: line.toString('utf8').replace(/\r$/, ''), bytes: line.length + 1 });
78
134
  queuedBytes += line.length + 1;
79
135
  notify();
80
136
  };
81
137
  try {
82
138
  for await (const chunk of child.stdout) {
139
+ observations.add('host_stdout_data');
83
140
  pending = Buffer.concat([pending, Buffer.from(chunk)]);
84
141
  let end;
85
142
  while ((end = pending.indexOf(10)) !== -1) {
@@ -91,6 +148,7 @@ export class NodeProcessGroupHost {
91
148
  }
92
149
  if (pending.length)
93
150
  enqueue(pending);
151
+ observations.add('host_stdout_ended');
94
152
  }
95
153
  catch (error) {
96
154
  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');
@@ -106,8 +164,8 @@ export class NodeProcessGroupHost {
106
164
  notify();
107
165
  }
108
166
  })();
109
- return {
110
- stdin: { write: (text) => { child.stdin.write(text); }, end: () => { child.stdin.end(); } },
167
+ const handle = {
168
+ stdin: { write: (text) => { observations.add('host_stdin_write_requested'); child.stdin.write(text); }, end: () => { observations.add('host_stdin_end_requested'); child.stdin.end(); } },
111
169
  stdout: { async *[Symbol.asyncIterator]() {
112
170
  if (consumed)
113
171
  throw new Error('Provider event stream already consumed');
@@ -141,5 +199,7 @@ export class NodeProcessGroupHost {
141
199
  else
142
200
  exitCallbacks.push(callback); },
143
201
  };
202
+ startup.set(handle, observations);
203
+ return handle;
144
204
  }
145
205
  }
@@ -1,10 +1,21 @@
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([
4
+ 'host_observer_v2', 'host_process_spawned', 'host_process_spawn_failed', 'host_process_exited',
5
+ 'host_stdin_write_requested', 'host_stdin_end_requested', 'host_stdin_finished', 'host_stdin_failed',
6
+ 'host_stdout_data', 'host_stdout_line', 'host_stdout_ended', 'host_stderr_data', 'host_stderr_ended',
7
+ 'host_event_thread_started', 'host_event_turn_started', 'host_event_turn_completed', 'host_event_turn_failed', 'host_event_error',
8
+ 'local_mcp_tcp_connected',
9
+ 'native_stdin_read', 'native_model_catalog_failed', 'native_cloud_policy_failed', 'native_cloud_policy_timeout',
10
+ 'native_required_mcp_failed', 'native_config_failed', 'native_auth_refresh_unsupported',
11
+ 'local_mcp_request_received', 'local_mcp_auth_rejected', 'local_mcp_origin_or_host_rejected', 'local_mcp_initialize_received',
12
+ ]);
2
13
  const issued = new WeakMap();
3
14
  export function executionFailure(error, stage, fallback = 'EXECUTION_FAILED') {
4
15
  if (error && typeof error === 'object') {
5
16
  const trusted = issued.get(error);
6
17
  if (trusted)
7
- return { ...trusted };
18
+ return { ...trusted, ...(trusted.observations ? { observations: [...trusted.observations] } : {}) };
8
19
  try {
9
20
  if (error instanceof GatewayToolError && (error.code === 'GATEWAY_TOOL_REJECTED' || error.code === 'GATEWAY_TRANSPORT_ERROR' || error.code === 'WORK_LOOP_CANCELLED')) {
10
21
  return { stage, code: error.code };
@@ -24,12 +35,17 @@ export function executionFailure(error, stage, fallback = 'EXECUTION_FAILED') {
24
35
  }
25
36
  return { stage, code: fallback };
26
37
  }
27
- export function executionError(error, stage, fallback, detail) {
38
+ export function executionError(error, stage, fallback, detail, observations) {
28
39
  const diagnostic = executionFailure(error, stage, fallback);
29
40
  // Only trusted callers supply this fixed vocabulary. Serialized provider
30
41
  // errors, messages and stderr never become diagnostic authority.
31
42
  if (!issued.has(error) && ['mcp_initialize_missing', 'mcp_initialized_missing', 'mcp_inventory_missing', 'mcp_discovery_complete'].includes(detail))
32
43
  diagnostic.detail = detail;
44
+ if (Array.isArray(observations)) {
45
+ const fixed = STARTUP_OBSERVATIONS.filter(value => diagnostic.observations?.includes(value) || observations.includes(value));
46
+ if (fixed.length)
47
+ diagnostic.observations = fixed;
48
+ }
33
49
  const safe = new Error(fallback);
34
50
  issued.set(safe, Object.freeze(diagnostic));
35
51
  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' } });
@@ -178,6 +189,8 @@ export class LocalToolServer {
178
189
  reply(400, { error: 'Invalid request' });
179
190
  }
180
191
  });
192
+ // Transport activity is not authenticated MCP discovery or tool authority.
193
+ this.server.on('connection', () => this.observations.add('local_mcp_tcp_connected'));
181
194
  this.server.requestTimeout = 15000;
182
195
  this.server.headersTimeout = 10000;
183
196
  this.starting = new Promise((resolve, reject) => { this.server.once('error', reject); this.server.listen(0, '127.0.0.1', () => resolve()); });
@@ -190,10 +203,13 @@ export class LocalToolServer {
190
203
  if (!address || typeof address === 'string')
191
204
  throw new Error('Failed to bind local tool server');
192
205
  this.url = `http://127.0.0.1:${address.port}/mcp`;
206
+ endpointObservations.set(this.url, { credentialHash: createHash('sha256').update(this.token).digest('hex'), values: this.observations });
193
207
  discoveryScopes.set(this.discoveryScope, { endpoint: this.url, credentialHash: createHash('sha256').update(this.token).digest('hex') });
194
208
  return { url: this.url, token: this.token };
195
209
  }
196
210
  async close() {
211
+ if (this.url)
212
+ endpointObservations.delete(this.url);
197
213
  this.revoke();
198
214
  const server = this.server;
199
215
  this.server = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {