mtech-ai-local-runner 0.1.13 → 0.1.15

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,24 @@ 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.15: startup output before MCP readiness
14
+
15
+ The process host drains stdout from spawn, before the adapter starts consuming
16
+ events after authenticated MCP discovery. A bounded queue prevents the child
17
+ from blocking on ordinary startup output; overflow stops only its owned process
18
+ group. This changes no tool permissions, preparation deadlines or task limits.
19
+ Fake-process tests reproduce the defect, but do not prove the cause of a previous
20
+ real-account failure or successful account/MCP acceptance. Publication and a
21
+ foreground installation do not update the daemon or authorize a model run.
22
+
23
+ ## 0.1.14: foreground administrator authentication wait
24
+
25
+ The manual Codex pilot waits for administrator authentication before starting
26
+ the 60-second profile inventory deadline. Cancelling the execution aborts its
27
+ owned pending query. Fixed error codes identify authentication and query failures
28
+ without exposing native stderr. This patch does not update a running daemon or
29
+ establish successful real-account Codex execution.
30
+
13
31
  ## 0.1.10: enforced PreToolUse hook
14
32
 
15
33
  Earlier releases configured a hook command that the shell could not run, so Claude
@@ -153,4 +171,11 @@ into a completed job. Existing reconciliation rules still apply.
153
171
 
154
172
  ### Manual Codex pilot (0.1.11)
155
173
 
174
+ The foreground inventory separates human authentication wait from the bounded
175
+ query and evidence freshness. Its query deadline starts only after the fixed
176
+ root-identity check completes, not while the administrator prompt is waiting.
177
+ Cancellation revokes the pending query and rejects late results; authentication,
178
+ query timeout, command and validation failures use distinct safe codes. The
179
+ inventory must still be fresh at Codex spawn. No automatic retry is performed.
180
+
156
181
  The reviewed manual Codex path preserves the existing CLI and ChatGPT login. It is separate from unattended daemon readiness. After the exact CLI/native reader and Gateway release are installed, an employee-authorized foreground pilot uses `mtec-ai-local-runner manual-codex --job-id=<approved Job UUID>`. It refuses a running daemon, unresolved recovery state, unknown release/account or a different Job. It does not stop services or alter personal settings. A direct administrator policy inventory occurs just before the approved execution; historical inventory is not continuous MDM monitoring. The Gateway OAuth client refuses refresh in this manual command. Actual vendor account startup may still refresh shared credentials and requires disclosure in the exact launch approval.
@@ -55,8 +55,8 @@ export async function runManualCodexJob(input, deps) {
55
55
  inner.attachCleanup(cleanup);
56
56
  return inner.prepare(ctx);
57
57
  }, async inspect(plan) { if (!inner)
58
- throw Error('MANUAL_PILOT_NOT_PREPARED'); return inner.inspect(plan); }, revoke() { inner?.revoke(); }, attachCleanup(fn) { if (cleanup)
59
- throw Error('MANUAL_PILOT_DUPLICATE_CLEANUP'); cleanup = fn; }, async close() { await inner?.close(); } };
58
+ throw Error('MANUAL_PILOT_NOT_PREPARED'); return inner.inspect(plan); }, revoke() { controller?.cancel(); inner?.revoke(); }, attachCleanup(fn) { if (cleanup)
59
+ throw Error('MANUAL_PILOT_DUPLICATE_CLEANUP'); cleanup = fn; }, async close() { controller?.cancel(); await inner?.close(); } };
60
60
  const plugin = new CodexPlugin({ probe, options: () => buildCodexExecJobOptions({ jobId: job.id, worktreePath: options.worktreePath, stateRoot: input.stateRoot, env: input.env, productionJob: () => lazy, processHost: deps?.processHost }) });
61
61
  adapter = await plugin.create(options);
62
62
  return adapter;
@@ -7,6 +7,19 @@ export class NodeProcessGroupHost {
7
7
  const child = spawn(command, args, { ...options, shell: false, detached: true, stdio: ["pipe", "pipe", "pipe"] });
8
8
  let failure;
9
9
  let owned = true;
10
+ let finished = false, consumed = false, queuedBytes = 0, wake;
11
+ const lines = [];
12
+ const MAX_QUEUED_BYTES = 1_048_576, MAX_QUEUED_LINES = 8192;
13
+ const notify = () => { const resolve = wake; wake = undefined; resolve?.(); };
14
+ let termination;
15
+ const exitCallbacks = [];
16
+ const terminated = (code, signal) => {
17
+ if (termination)
18
+ return;
19
+ termination = { code, signal };
20
+ for (const callback of exitCallbacks.splice(0))
21
+ callback(code, signal);
22
+ };
10
23
  const kill = (signal = "SIGKILL") => {
11
24
  if (!owned || !child.pid)
12
25
  return;
@@ -28,47 +41,105 @@ export class NodeProcessGroupHost {
28
41
  if (signal === "SIGKILL")
29
42
  owned = false;
30
43
  };
31
- child.on("error", () => { failure = new Error("Provider process could not start"); });
32
- child.stdin.on("error", () => { failure = new Error("Provider process input closed"); });
44
+ child.on("error", () => { failure ??= new Error("Provider process could not start"); terminated(null, null); notify(); });
45
+ child.stdin.on("error", () => {
46
+ failure ??= new Error("Provider process input closed");
47
+ try {
48
+ kill();
49
+ }
50
+ catch {
51
+ failure = new Error('Provider process group cleanup could not be confirmed');
52
+ }
53
+ terminated(null, null);
54
+ notify();
55
+ });
33
56
  // Provider stderr can contain account details. Consume without publishing it.
34
57
  child.stderr.resume();
35
- child.once("exit", () => {
58
+ child.once("exit", (code, signal) => {
36
59
  try {
37
60
  kill();
38
61
  }
39
62
  catch {
40
63
  failure = new Error("Provider process group cleanup could not be confirmed");
41
64
  }
65
+ terminated(code, signal);
42
66
  });
67
+ // Drain from spawn, not from the first adapter event read: official exec can
68
+ // emit startup events before it initializes MCP, while the adapter awaits
69
+ // that initialization before consuming events. Bound the pre-consumer queue.
70
+ void (async () => {
71
+ let pending = Buffer.alloc(0);
72
+ const enqueue = (line) => {
73
+ if (line.length > 256_000)
74
+ throw new Error("Provider event exceeded the allowed size");
75
+ if (queuedBytes + line.length + 1 > MAX_QUEUED_BYTES || lines.length >= MAX_QUEUED_LINES)
76
+ throw new Error("Provider event buffer exceeded the allowed size");
77
+ lines.push({ text: line.toString('utf8').replace(/\r$/, ''), bytes: line.length + 1 });
78
+ queuedBytes += line.length + 1;
79
+ notify();
80
+ };
81
+ try {
82
+ for await (const chunk of child.stdout) {
83
+ pending = Buffer.concat([pending, Buffer.from(chunk)]);
84
+ let end;
85
+ while ((end = pending.indexOf(10)) !== -1) {
86
+ enqueue(pending.subarray(0, end));
87
+ pending = pending.subarray(end + 1);
88
+ }
89
+ if (pending.length > 256_000)
90
+ throw new Error("Provider event exceeded the allowed size");
91
+ }
92
+ if (pending.length)
93
+ enqueue(pending);
94
+ }
95
+ catch (error) {
96
+ 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');
97
+ try {
98
+ kill();
99
+ }
100
+ catch {
101
+ failure = new Error('Provider process group cleanup could not be confirmed');
102
+ }
103
+ }
104
+ finally {
105
+ finished = true;
106
+ notify();
107
+ }
108
+ })();
43
109
  return {
44
110
  stdin: { write: (text) => { child.stdin.write(text); }, end: () => { child.stdin.end(); } },
45
111
  stdout: { async *[Symbol.asyncIterator]() {
46
- let pending = Buffer.alloc(0);
112
+ if (consumed)
113
+ throw new Error('Provider event stream already consumed');
114
+ consumed = true;
47
115
  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;
116
+ while (true) {
117
+ if (failure)
118
+ throw failure;
119
+ const line = lines.shift();
120
+ if (line) {
121
+ queuedBytes -= line.bytes;
122
+ yield line.text;
123
+ continue;
57
124
  }
58
- if (pending.length > 256_000)
59
- throw new Error("Provider event exceeded the allowed size");
125
+ if (finished)
126
+ break;
127
+ await new Promise(resolve => { wake = resolve; });
60
128
  }
61
- if (pending.length)
62
- yield pending.toString("utf8");
63
- if (failure)
64
- throw failure;
65
129
  }
66
130
  finally {
131
+ lines.length = 0;
132
+ queuedBytes = 0;
67
133
  kill();
68
134
  }
69
135
  } },
70
136
  kill: (signal) => kill((signal ?? "SIGKILL")),
71
- once: (_event, callback) => { child.once("exit", callback); },
137
+ once: (_event, callback) => { if (termination) {
138
+ const ended = termination;
139
+ queueMicrotask(() => callback(ended.code, ended.signal));
140
+ }
141
+ else
142
+ exitCallbacks.push(callback); },
72
143
  };
73
144
  }
74
145
  }
@@ -25,8 +25,10 @@ export function createManualPilotController(input, deps) {
25
25
  const claim = structuredClone(input.claim), releases = structuredClone(deps?.releases ?? CODEX_EXEC_RELEASES), readLocal = deps?.readLocal, now = deps?.now ?? Date.now;
26
26
  const account = path.resolve(input.codexHome ?? path.join(input.home, '.codex'));
27
27
  let used = false, closed = false, manual, host;
28
- const close = async () => { closed = true; manual?.revoke(); await host?.close(); };
29
- return Object.freeze({ close, async prepare() {
28
+ const queryAbort = new AbortController();
29
+ const cancel = () => { closed = true; queryAbort.abort(); manual?.revoke(); };
30
+ const close = async () => { cancel(); await host?.close(); };
31
+ return Object.freeze({ close, cancel, async prepare() {
30
32
  if (used || closed)
31
33
  throw rejected();
32
34
  used = true;
@@ -65,7 +67,7 @@ export function createManualPilotController(input, deps) {
65
67
  if (closed)
66
68
  throw rejected();
67
69
  stage = 'codex_manual_inventory';
68
- const inventory = await (deps?.collectInventory ?? createManualProfileQuery().collectOnce)();
70
+ const inventory = await (deps?.collectInventory ?? createManualProfileQuery().collectOnce)(queryAbort.signal);
69
71
  if (closed)
70
72
  throw rejected();
71
73
  stage = 'codex_manual_policy_scope';
@@ -1,30 +1,175 @@
1
1
  import { execFile } from 'node:child_process';
2
- import { promisify } from 'node:util';
3
2
  import { createHash } from 'node:crypto';
4
3
  import { emptyProfileInventoryXml } from './codex-native-observation.js';
4
+ import { executionError, executionFailure } from './execution-failure.js';
5
5
  const rejected = () => Error('CODEX_MANUAL_PROFILE_QUERY_REJECTED');
6
- /** Two fixed read-only commands, under one interactive administrator grant.
7
- * No user command, path, credential, profile write or persistent root process. */
8
- export const MANUAL_PROFILES_ARGUMENTS = Object.freeze(['-e', `set uid to do shell script "/usr/bin/id -u" with administrator privileges\nif uid is not "0" then error "Unexpected query identity"\nset inventory to do shell script "/usr/bin/profiles list -all -output stdout-xml" with administrator privileges\nreturn uid & linefeed & inventory`]);
9
- const exec = promisify(execFile);
10
- const nativeRun = async (binary, args) => exec(binary, [...args], { encoding: 'utf8', timeout: 60_000, maxBuffer: 1_048_576, env: { PATH: '/usr/bin:/bin', LANG: 'C', LC_ALL: 'C' } });
11
- /** Instantiate only in the trusted foreground controller immediately before an
12
- * approved pilot, never in the unattended daemon probe. Metadata output only. */
6
+ const AUTHENTICATED = 'MTEC_PROFILE_AUTHENTICATED_V1';
7
+ const QUERY_TIMEOUT_MS = 60_000;
8
+ const failureMarkers = Object.freeze({
9
+ MTEC_PROFILE_AUTH_CANCELLED: 'CODEX_MANUAL_AUTH_CANCELLED',
10
+ MTEC_PROFILE_AUTH_REJECTED: 'CODEX_MANUAL_AUTH_REJECTED',
11
+ MTEC_PROFILE_QUERY_TIMEOUT: 'CODEX_MANUAL_QUERY_TIMEOUT',
12
+ MTEC_PROFILE_QUERY_FAILED: 'CODEX_MANUAL_QUERY_COMMAND_FAILED',
13
+ MTEC_PROFILE_NONROOT: 'CODEX_MANUAL_QUERY_NONROOT',
14
+ });
15
+ const failure = (code) => executionError(rejected(), 'codex_manual_inventory', code);
16
+ /** No Runner deadline while the employee is authenticating. The fixed first
17
+ * command acquires the grant; only then does the bounded read-only query start.
18
+ * No user commands, credential writes or persistent privileged process. */
19
+ export const MANUAL_PROFILES_ARGUMENTS = Object.freeze(['-e', `with timeout of 2147483647 seconds
20
+ try
21
+ set uid to do shell script "/usr/bin/id -u" with administrator privileges
22
+ on error messageText number errorNumber
23
+ if errorNumber is -128 then
24
+ log "MTEC_PROFILE_AUTH_CANCELLED"
25
+ else
26
+ log "MTEC_PROFILE_AUTH_REJECTED"
27
+ end if
28
+ error "Administrator authentication failed" number 10001
29
+ end try
30
+ if uid is not "0" then
31
+ log "MTEC_PROFILE_NONROOT"
32
+ error "Unexpected query identity" number 10002
33
+ end if
34
+ log "MTEC_PROFILE_AUTHENTICATED_V1"
35
+ try
36
+ with timeout of 60 seconds
37
+ set inventory to do shell script "/usr/bin/profiles list -all -output stdout-xml" with administrator privileges
38
+ end timeout
39
+ on error messageText number errorNumber
40
+ if errorNumber is -1712 then
41
+ log "MTEC_PROFILE_QUERY_TIMEOUT"
42
+ else
43
+ log "MTEC_PROFILE_QUERY_FAILED"
44
+ end if
45
+ error "Profile inventory failed" number 10003
46
+ end try
47
+ return uid & linefeed & inventory
48
+ end timeout`]);
49
+ /** Injectable process seam, never a task-provided command. Protocol markers are
50
+ * fixed by our script; raw native stderr is discarded rather than published. */
51
+ export function createManualProfileProcessRunner(start = execFile) {
52
+ return (binary, args, options) => new Promise((resolve, reject) => {
53
+ let pending = '', marked = false, reason, settled = false;
54
+ let child;
55
+ const finishError = (error) => { if (settled)
56
+ return; settled = true; reject(error); };
57
+ try {
58
+ child = start(binary, [...args], { encoding: 'utf8', timeout: 0, maxBuffer: 1_048_576, signal: options.signal, env: { PATH: '/usr/bin:/bin', LANG: 'C', LC_ALL: 'C' } }, (error, stdout, stderr) => {
59
+ if (settled)
60
+ return;
61
+ settled = true;
62
+ if (error) {
63
+ reject(reason ? failure(reason) : executionError(error, 'codex_manual_inventory', 'CODEX_MANUAL_QUERY_COMMAND_FAILED'));
64
+ return;
65
+ }
66
+ if (reason) {
67
+ reject(failure(reason));
68
+ return;
69
+ }
70
+ if (!marked) {
71
+ reject(failure('CODEX_MANUAL_QUERY_PROTOCOL_INVALID'));
72
+ return;
73
+ }
74
+ const rest = String(stderr).split(/\r?\n/).filter(line => line !== AUTHENTICATED && line !== '').join('\n');
75
+ resolve({ stdout: String(stdout), stderr: rest });
76
+ });
77
+ child.stderr?.on('data', (chunk) => {
78
+ if (settled)
79
+ return;
80
+ pending += chunk.toString();
81
+ if (pending.length > 1_048_576) {
82
+ finishError(failure('CODEX_MANUAL_QUERY_OUTPUT_INVALID'));
83
+ child?.kill();
84
+ return;
85
+ }
86
+ let end;
87
+ while ((end = pending.indexOf('\n')) !== -1) {
88
+ const line = pending.slice(0, end).replace(/\r$/, '');
89
+ pending = pending.slice(end + 1);
90
+ if (Object.hasOwn(failureMarkers, line))
91
+ reason = failureMarkers[line];
92
+ if (line === AUTHENTICATED) {
93
+ if (marked) {
94
+ finishError(failure('CODEX_MANUAL_QUERY_PROTOCOL_INVALID'));
95
+ child?.kill();
96
+ return;
97
+ }
98
+ marked = true;
99
+ try {
100
+ options.onAuthenticated();
101
+ }
102
+ catch {
103
+ finishError(failure('CODEX_MANUAL_QUERY_PROTOCOL_INVALID'));
104
+ child?.kill();
105
+ return;
106
+ }
107
+ }
108
+ }
109
+ });
110
+ }
111
+ catch (error) {
112
+ finishError(executionError(error, 'codex_manual_inventory', 'CODEX_MANUAL_QUERY_COMMAND_FAILED'));
113
+ }
114
+ });
115
+ }
116
+ const nativeRun = createManualProfileProcessRunner();
117
+ /** Foreground-only, single-use, cancellable. Human authentication wait is not
118
+ * query age: evidence is stamped when the bounded inventory actually finishes. */
13
119
  export function createManualProfileQuery(input) {
14
120
  const run = input?.run ?? nativeRun, now = input?.now ?? Date.now;
15
121
  let used = false;
16
- return Object.freeze({ async collectOnce() {
122
+ return Object.freeze({ async collectOnce(signal) {
17
123
  if (used)
18
- throw rejected();
124
+ throw failure('CODEX_MANUAL_QUERY_REUSED');
19
125
  used = true;
126
+ const controller = new AbortController();
127
+ let timer;
128
+ let firstFailure, authenticatedAt;
129
+ const abort = (code) => { firstFailure ??= failure(code); controller.abort(); };
130
+ const externalAbort = () => abort('CODEX_MANUAL_QUERY_ABORTED');
131
+ signal?.addEventListener('abort', externalAbort, { once: true });
20
132
  try {
21
- const startedAt = now(), result = await run('/usr/bin/osascript', MANUAL_PROFILES_ARGUMENTS), observedAt = now();
22
- if (!Number.isSafeInteger(startedAt) || startedAt < 0 || !Number.isSafeInteger(observedAt) || observedAt < startedAt || observedAt - startedAt > 60_000 || result.stderr !== '' || typeof result.stdout !== 'string' || !result.stdout.startsWith('0\n') || !emptyProfileInventoryXml(result.stdout.slice(2)))
23
- throw rejected();
133
+ if (signal?.aborted) {
134
+ externalAbort();
135
+ throw firstFailure;
136
+ }
137
+ const startedAt = now();
138
+ if (!Number.isSafeInteger(startedAt) || startedAt < 0)
139
+ throw failure('CODEX_MANUAL_QUERY_PROTOCOL_INVALID');
140
+ // Reject independently of whether an injected/native runner settles on abort.
141
+ const cancelled = new Promise((_, reject) => { controller.signal.addEventListener('abort', () => reject(firstFailure), { once: true }); });
142
+ const operation = run('/usr/bin/osascript', MANUAL_PROFILES_ARGUMENTS, { signal: controller.signal, onAuthenticated() {
143
+ if (controller.signal.aborted || authenticatedAt !== undefined)
144
+ throw failure('CODEX_MANUAL_QUERY_PROTOCOL_INVALID');
145
+ authenticatedAt = now();
146
+ if (!Number.isSafeInteger(authenticatedAt) || authenticatedAt < startedAt)
147
+ throw failure('CODEX_MANUAL_QUERY_PROTOCOL_INVALID');
148
+ timer = setTimeout(() => abort('CODEX_MANUAL_QUERY_TIMEOUT'), QUERY_TIMEOUT_MS);
149
+ } });
150
+ const result = await Promise.race([operation, cancelled]), observedAt = now();
151
+ if (firstFailure)
152
+ throw firstFailure;
153
+ if (authenticatedAt === undefined || !Number.isSafeInteger(observedAt) || observedAt < authenticatedAt)
154
+ throw failure('CODEX_MANUAL_QUERY_PROTOCOL_INVALID');
155
+ if (observedAt - authenticatedAt > QUERY_TIMEOUT_MS)
156
+ throw failure('CODEX_MANUAL_QUERY_TIMEOUT');
157
+ if (typeof result.stdout !== 'string' || result.stderr !== '' || !result.stdout.includes('\n'))
158
+ throw failure('CODEX_MANUAL_QUERY_OUTPUT_INVALID');
159
+ if (!result.stdout.startsWith('0\n'))
160
+ throw failure('CODEX_MANUAL_QUERY_NONROOT');
161
+ if (!emptyProfileInventoryXml(result.stdout.slice(2)))
162
+ throw failure('CODEX_MANUAL_QUERY_OUTPUT_INVALID');
24
163
  return Object.freeze({ version: 1, exit: 0, euid: 0, scope: 'machine_and_current_user', complete: true, empty: true, observedAt, inventoryFingerprint: createHash('sha256').update(result.stdout.slice(2)).digest('hex') });
25
164
  }
26
- catch {
27
- throw rejected();
165
+ catch (error) {
166
+ const diagnostic = executionFailure(error, 'codex_manual_inventory', 'CODEX_MANUAL_QUERY_COMMAND_FAILED');
167
+ throw firstFailure ?? executionError(error, 'codex_manual_inventory', diagnostic.code);
168
+ }
169
+ finally {
170
+ if (timer)
171
+ clearTimeout(timer);
172
+ signal?.removeEventListener('abort', externalAbort);
28
173
  }
29
174
  } });
30
175
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {