mtech-ai-local-runner 0.1.19 → 0.1.20
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 +15 -0
- package/dist/src/adapters/codex/exec-launch-host.js +6 -5
- package/dist/src/daemon/execution-coordinator.js +1 -1
- package/dist/src/process/node-process-group-host.js +21 -1
- package/dist/src/process/owned-launch-observer.js +132 -0
- package/dist/src/safety/execution-failure.js +8 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,6 +10,21 @@ 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.20: distinguish owned wrapper and target startup
|
|
14
|
+
|
|
15
|
+
Add bounded, read-only process-name/state/wait hints for the exact owned
|
|
16
|
+
wrapper PID. At most seven one-second queries run during preparation and stop
|
|
17
|
+
at admission, failure, cancellation or exit. Fixed categories exclude raw names,
|
|
18
|
+
paths, process IDs, arguments and environment. Cancellation hard-stops only its
|
|
19
|
+
owned query. Local logging receives a deep diagnostic copy so it cannot mutate
|
|
20
|
+
the evidence later reported to Gateway.
|
|
21
|
+
|
|
22
|
+
These are direct-child hints, not executable identity, native function or
|
|
23
|
+
historical cause proof; forked descendants are not inspected. Preparation
|
|
24
|
+
deadlines, authenticated MCP/tool gates and unlimited admitted employee work
|
|
25
|
+
remain unchanged. Installation does not update the daemon or run Codex.
|
|
26
|
+
See [owned launch observation](https://bitbucket.org/mtechbal/api-cloud-service-ai-gateway/src/development/docs/ai-ops-gateway/codex-owned-launch-observation.md).
|
|
27
|
+
|
|
13
28
|
## 0.1.19: bounded preparation phase timing
|
|
14
29
|
|
|
15
30
|
Record fixed host milestones with monotonic elapsed milliseconds and remaining
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { performance } from 'node:perf_hooks';
|
|
2
2
|
import { PreparationClock, CODEX_PREPARATION_BUDGET_MS } from '../../safety/preparation-timing.js';
|
|
3
3
|
import { localEndpointObservations } from '../../safety/local-tool-server.js';
|
|
4
|
-
import { processStartupObservations } from '../../process/node-process-group-host.js';
|
|
4
|
+
import { processStartupObservations, freezeProcessLaunchObservation } from '../../process/node-process-group-host.js';
|
|
5
5
|
import { executionError } from '../../safety/execution-failure.js';
|
|
6
6
|
import { UNMANAGED_EXEC_MODE } from './unmanaged-scope.js';
|
|
7
7
|
import { fingerprint as taskFingerprint } from '../../daemon/execution-coordinator.js';
|
|
@@ -70,9 +70,10 @@ export class CodexExecLaunchHost {
|
|
|
70
70
|
let interruption;
|
|
71
71
|
const mcpDetail = () => stage === 'codex_mcp' ? this.deps.execTools?.discoveryDetail() : undefined;
|
|
72
72
|
const observations = () => { const invocation = requireOfficialExecPlan(this.plan); return [...(this.handle ? processStartupObservations(this.handle) : []), ...localEndpointObservations(this.plan.endpoint, invocation.env.AI_GATEWAY_JOB_MCP_TOKEN)]; };
|
|
73
|
+
const launchObservation = () => this.handle ? freezeProcessLaunchObservation(this.handle) : undefined;
|
|
73
74
|
const timing = new PreparationClock();
|
|
74
75
|
timing.mark('preparation_start');
|
|
75
|
-
const interrupt = (code) => { timing.mark('preparation_failed'); interruption ??= executionError(fail(), stage, code, mcpDetail(), observations(), timing.snapshot()); };
|
|
76
|
+
const interrupt = (code) => { timing.mark('preparation_failed'); interruption ??= executionError(fail(), stage, code, mcpDetail(), observations(), timing.snapshot(), launchObservation()); };
|
|
76
77
|
// Timer callbacks cannot interrupt synchronous native metadata checks. Keep
|
|
77
78
|
// the same total budget and enforce it at effect boundaries with a monotonic
|
|
78
79
|
// clock, rather than spawning/submitting after an already expired inspection.
|
|
@@ -174,7 +175,7 @@ export class CodexExecLaunchHost {
|
|
|
174
175
|
requireExecHandshake(observed, h, this.plan);
|
|
175
176
|
assertPreparationBudget();
|
|
176
177
|
timing.mark('preparation_authorized');
|
|
177
|
-
this.receiptValue = Object.freeze({ preparation_timing: timing.snapshot(), receipt_version: this.plan.mode === UNMANAGED_EXEC_MODE ? 'cli-exec-job-security-receipt-v3' : 'cli-exec-job-security-receipt-v2', evidence_origin: 'employee_runner', transport: EXEC_TRANSPORT, security_mode: this.plan.mode, ...(this.plan.mode === UNMANAGED_EXEC_MODE ? { policy_scope: this.inspection.snapshot.policyScope } : {}), phase: 'post_prompt_mcp_authorized', policy_evidence: 'reviewed_release_and_current_inputs', mcp_evidence: 'authenticated_server_discovery', native_policy_observed: false, first_inference_observed: false, execution_nonce: this.plan.executionNonce, invocation_fingerprint: this.plan.invocationFingerprint, ...this.plan.identity });
|
|
178
|
+
this.receiptValue = Object.freeze({ preparation_timing: timing.snapshot(), ...(launchObservation() ? { launch_process: launchObservation() } : {}), receipt_version: this.plan.mode === UNMANAGED_EXEC_MODE ? 'cli-exec-job-security-receipt-v3' : 'cli-exec-job-security-receipt-v2', evidence_origin: 'employee_runner', transport: EXEC_TRANSPORT, security_mode: this.plan.mode, ...(this.plan.mode === UNMANAGED_EXEC_MODE ? { policy_scope: this.inspection.snapshot.policyScope } : {}), phase: 'post_prompt_mcp_authorized', policy_evidence: 'reviewed_release_and_current_inputs', mcp_evidence: 'authenticated_server_discovery', native_policy_observed: false, first_inference_observed: false, execution_nonce: this.plan.executionNonce, invocation_fingerprint: this.plan.invocationFingerprint, ...this.plan.identity });
|
|
178
179
|
return h;
|
|
179
180
|
}
|
|
180
181
|
timing.mark('mcp_wait_started');
|
|
@@ -204,14 +205,14 @@ export class CodexExecLaunchHost {
|
|
|
204
205
|
requireExecHandshake(handshake, h, this.plan);
|
|
205
206
|
assertPreparationBudget();
|
|
206
207
|
timing.mark('preparation_authorized');
|
|
207
|
-
this.receiptValue = Object.freeze({ ...receipt, preparation_timing: timing.snapshot() });
|
|
208
|
+
this.receiptValue = Object.freeze({ ...receipt, preparation_timing: timing.snapshot(), ...(launchObservation() ? { launch_process: launchObservation() } : {}) });
|
|
208
209
|
return h;
|
|
209
210
|
})();
|
|
210
211
|
return await Promise.race([preparation, stopped]);
|
|
211
212
|
}
|
|
212
213
|
catch (error) {
|
|
213
214
|
timing.mark('preparation_failed');
|
|
214
|
-
const safe = executionError(interruption ?? error, stage, 'CODEX_EXEC_PREFLIGHT_REJECTED', mcpDetail(), observations(), timing.snapshot());
|
|
215
|
+
const safe = executionError(interruption ?? error, stage, 'CODEX_EXEC_PREFLIGHT_REJECTED', mcpDetail(), observations(), timing.snapshot(), launchObservation());
|
|
215
216
|
await this.cancel().catch(() => { });
|
|
216
217
|
throw safe;
|
|
217
218
|
}
|
|
@@ -256,7 +256,7 @@ export class ExecutionCoordinator {
|
|
|
256
256
|
failureDiagnostic = diagnostic;
|
|
257
257
|
// Log before reporting so an unavailable Gateway does not erase the cause.
|
|
258
258
|
try {
|
|
259
|
-
this.deps.reportExecutionFailure?.(
|
|
259
|
+
this.deps.reportExecutionFailure?.(structuredClone(diagnostic));
|
|
260
260
|
}
|
|
261
261
|
catch { /* logging must not change terminal reconciliation */ }
|
|
262
262
|
terminal = { eventType: "failed", summary: "daemon execution or event reporting failed", payload: { execution_failure: { ...diagnostic } } };
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { OwnedLaunchObserver } from './owned-launch-observer.js';
|
|
1
3
|
const startup = new WeakMap();
|
|
4
|
+
const launchObservers = new WeakMap();
|
|
5
|
+
export function freezeProcessLaunchObservation(handle) { return launchObservers.get(handle)?.freeze(); }
|
|
2
6
|
/** Fixed lifecycle/text observations from this owned host only. Never raw stderr or authority. */
|
|
3
7
|
export function processStartupObservations(handle) { return [...(startup.get(handle) ?? [])]; }
|
|
4
8
|
import { spawn } from "node:child_process";
|
|
@@ -9,7 +13,22 @@ export class NodeProcessGroupHost {
|
|
|
9
13
|
throw new Error("Codex process isolation requires macOS");
|
|
10
14
|
const child = spawn(command, args, { ...options, shell: false, detached: true, stdio: ["pipe", "pipe", "pipe"] });
|
|
11
15
|
const observations = new Set(['host_observer_v2']);
|
|
12
|
-
|
|
16
|
+
let launch;
|
|
17
|
+
child.once('spawn', () => {
|
|
18
|
+
observations.add('host_process_spawned');
|
|
19
|
+
if (command === '/usr/bin/sandbox-exec' && args[0] === '-p' && path.isAbsolute(args[2] ?? '')) {
|
|
20
|
+
try {
|
|
21
|
+
launch = new OwnedLaunchObserver({ pid: child.pid, parentPid: process.pid, wrapperName: path.basename(command), targetName: path.basename(args[2]), alive: () => owned && child.exitCode === null && child.signalCode === null, onSample: sample => {
|
|
22
|
+
observations.add(sample.status === 'observed' ? sample.image === 'wrapper_name_match' ? 'host_os_wrapper_name_seen' : sample.image === 'target_name_match' ? 'host_os_target_name_seen' : 'host_os_other_name_seen' : 'host_os_query_unavailable');
|
|
23
|
+
} });
|
|
24
|
+
launchObservers.set(handle, launch);
|
|
25
|
+
launch.start();
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
observations.add('host_os_query_unavailable');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
});
|
|
13
32
|
child.stdin.once('finish', () => observations.add('host_stdin_finished'));
|
|
14
33
|
let failure;
|
|
15
34
|
let owned = true;
|
|
@@ -27,6 +46,7 @@ export class NodeProcessGroupHost {
|
|
|
27
46
|
callback(code, signal);
|
|
28
47
|
};
|
|
29
48
|
const kill = (signal = "SIGKILL") => {
|
|
49
|
+
launch?.stop();
|
|
30
50
|
if (!owned || !child.pid)
|
|
31
51
|
return;
|
|
32
52
|
try {
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { performance } from 'node:perf_hooks';
|
|
4
|
+
const issued = new WeakMap();
|
|
5
|
+
export function trustedLaunchObservation(token) {
|
|
6
|
+
const value = token && typeof token === 'object' ? issued.get(token) : undefined;
|
|
7
|
+
return value ? structuredClone(value) : undefined;
|
|
8
|
+
}
|
|
9
|
+
/** Only one exact, directly-owned PID. No argv/environment/descendant scan,
|
|
10
|
+
* stack attachment, credentials, network, or privileged query. */
|
|
11
|
+
async function queryOwnedPid(pid, signal) {
|
|
12
|
+
return new Promise(resolve => {
|
|
13
|
+
let output = '', settled = false;
|
|
14
|
+
let timer;
|
|
15
|
+
let onAbort;
|
|
16
|
+
const finish = (value) => { if (settled)
|
|
17
|
+
return; settled = true; if (timer)
|
|
18
|
+
clearTimeout(timer); if (onAbort)
|
|
19
|
+
signal.removeEventListener('abort', onAbort); resolve(value); };
|
|
20
|
+
try {
|
|
21
|
+
const child = spawn('/bin/ps', ['-p', String(pid), '-o', 'pid=,ppid=,stat=,wchan=,comm='], { shell: false, env: { PATH: '/usr/bin:/bin', LANG: 'C', LC_ALL: 'C' }, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
22
|
+
child.stdout.on('data', (chunk) => { if (settled)
|
|
23
|
+
return; if (Buffer.byteLength(output) + chunk.length > 8192) {
|
|
24
|
+
finish({ status: 'output_invalid' });
|
|
25
|
+
try {
|
|
26
|
+
child.kill('SIGKILL');
|
|
27
|
+
}
|
|
28
|
+
catch { /* owned query already gone */ }
|
|
29
|
+
return;
|
|
30
|
+
} output += chunk.toString('utf8'); });
|
|
31
|
+
child.on('error', () => finish({ status: 'query_failed' }));
|
|
32
|
+
child.once('close', code => finish(code === 0 ? { status: 'ok', output } : { status: 'query_failed' }));
|
|
33
|
+
onAbort = () => { if (settled)
|
|
34
|
+
return; try {
|
|
35
|
+
child.kill('SIGKILL');
|
|
36
|
+
}
|
|
37
|
+
catch { /* owned query already gone */ } finish({ status: 'query_failed' }); };
|
|
38
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
39
|
+
if (signal.aborted) {
|
|
40
|
+
onAbort();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
timer = setTimeout(() => { finish({ status: 'query_timeout' }); try {
|
|
44
|
+
child.kill('SIGKILL');
|
|
45
|
+
}
|
|
46
|
+
catch { /* owned query already gone */ } }, 1000);
|
|
47
|
+
timer.unref();
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
finish({ status: 'query_failed' });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/** Names/wait symbols are observational hints, never executable identity or a
|
|
55
|
+
* native function attestation. Arbitrary process strings never leave this file. */
|
|
56
|
+
export class OwnedLaunchObserver {
|
|
57
|
+
input;
|
|
58
|
+
origin = performance.now();
|
|
59
|
+
active = false;
|
|
60
|
+
used = false;
|
|
61
|
+
pending = false;
|
|
62
|
+
discarded = false;
|
|
63
|
+
abort = new AbortController();
|
|
64
|
+
timer;
|
|
65
|
+
values = [];
|
|
66
|
+
frozen;
|
|
67
|
+
constructor(input) {
|
|
68
|
+
this.input = input;
|
|
69
|
+
if (!Number.isSafeInteger(input.pid) || input.pid < 1 || !Number.isSafeInteger(input.parentPid) || input.parentPid < 1)
|
|
70
|
+
throw Error('OWNED_LAUNCH_OBSERVER_REJECTED');
|
|
71
|
+
}
|
|
72
|
+
start() { if (this.used || this.frozen)
|
|
73
|
+
return; this.used = true; this.active = true; void this.sample(0); }
|
|
74
|
+
async sample(index) {
|
|
75
|
+
if (!this.active || !this.input.alive())
|
|
76
|
+
return;
|
|
77
|
+
this.pending = true;
|
|
78
|
+
let result;
|
|
79
|
+
try {
|
|
80
|
+
result = await (this.input.query ?? queryOwnedPid)(this.input.pid, this.abort.signal);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
result = { status: 'query_failed' };
|
|
84
|
+
}
|
|
85
|
+
this.pending = false;
|
|
86
|
+
if (!this.active || !this.input.alive())
|
|
87
|
+
return;
|
|
88
|
+
const previous = this.values.at(-1)?.elapsed_ms ?? 0, value = performance.now() - this.origin;
|
|
89
|
+
const elapsed_ms = Math.max(previous, Number.isFinite(value) ? Math.min(2147483647, Math.max(0, Math.floor(value))) : previous);
|
|
90
|
+
const sample = { elapsed_ms, status: result.status === 'ok' ? 'output_invalid' : result.status };
|
|
91
|
+
if (result.status === 'ok' && typeof result.output === 'string' && result.output.length <= 8192) {
|
|
92
|
+
const line = result.output.trim();
|
|
93
|
+
const match = /^(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+([^\r\n\0]+)$/.exec(line);
|
|
94
|
+
if (match) {
|
|
95
|
+
if (Number(match[1]) !== this.input.pid || Number(match[2]) !== this.input.parentPid)
|
|
96
|
+
sample.status = 'owner_mismatch';
|
|
97
|
+
else {
|
|
98
|
+
sample.status = 'observed';
|
|
99
|
+
const name = path.basename(match[5].trim());
|
|
100
|
+
sample.image = name === this.input.wrapperName ? 'wrapper_name_match' : name === this.input.targetName ? 'target_name_match' : 'other';
|
|
101
|
+
const states = { R: 'running', S: 'sleeping', I: 'sleeping', U: 'uninterruptible', D: 'uninterruptible', T: 'stopped', Z: 'zombie' };
|
|
102
|
+
sample.state = states[match[3][0]] ?? 'other';
|
|
103
|
+
const wait = match[4].toLowerCase();
|
|
104
|
+
sample.wait = ['-', '0', '0x0', '??'].includes(wait) ? 'not_reported' : /^(?:__)?psynch(?:_|$)/.test(wait) ? 'psynch' : /^mach_msg(?:_|$)/.test(wait) ? 'mach_message' : /^kevent/.test(wait) ? 'kevent' : /^(?:pipe_?read|piperd)$/.test(wait) ? 'pipe_read' : /^(?:wait4|wait)$/.test(wait) ? 'child_wait' : 'other';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
this.values.push(sample);
|
|
109
|
+
try {
|
|
110
|
+
this.input.onSample?.(structuredClone(sample));
|
|
111
|
+
}
|
|
112
|
+
catch { /* telemetry cannot alter execution */ }
|
|
113
|
+
const schedule = [0, 100, 500, 1500, 5000, 15000, 25000];
|
|
114
|
+
const next = schedule[index + 1];
|
|
115
|
+
if (next !== undefined && this.active) {
|
|
116
|
+
this.timer = setTimeout(() => { void this.sample(index + 1); }, Math.max(0, next - (performance.now() - this.origin)));
|
|
117
|
+
this.timer.unref();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
stop() { this.used = true; if (!this.active)
|
|
121
|
+
return; this.active = false; this.discarded ||= this.pending; if (this.timer)
|
|
122
|
+
clearTimeout(this.timer); this.abort.abort(); }
|
|
123
|
+
freeze() {
|
|
124
|
+
if (!this.frozen) {
|
|
125
|
+
this.stop();
|
|
126
|
+
this.frozen = { version: 'owned-launch-process-v1', collector: 'owned_pid_ps', samples: structuredClone(this.values), pending_query_discarded: this.discarded };
|
|
127
|
+
}
|
|
128
|
+
const token = structuredClone(this.frozen);
|
|
129
|
+
issued.set(token, structuredClone(this.frozen));
|
|
130
|
+
return token;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { trustedLaunchObservation } from '../process/owned-launch-observer.js';
|
|
1
2
|
import { trustedPreparationTiming } from './preparation-timing.js';
|
|
2
3
|
import { GatewayToolError } from '../gateway-client.js';
|
|
3
4
|
/** Observations only: provider text cannot authorize a tool or certify a cause. */
|
|
4
5
|
export const STARTUP_OBSERVATIONS = Object.freeze([
|
|
6
|
+
'host_os_wrapper_name_seen', 'host_os_target_name_seen', 'host_os_other_name_seen', 'host_os_query_unavailable',
|
|
5
7
|
'host_observer_v2', 'host_process_spawned', 'host_process_spawn_failed', 'host_process_exited',
|
|
6
8
|
'host_stdin_write_requested', 'host_stdin_end_requested', 'host_stdin_finished', 'host_stdin_failed',
|
|
7
9
|
'host_stdout_data', 'host_stdout_line', 'host_stdout_ended', 'host_stderr_data', 'host_stderr_ended',
|
|
@@ -36,7 +38,7 @@ export function executionFailure(error, stage, fallback = 'EXECUTION_FAILED') {
|
|
|
36
38
|
}
|
|
37
39
|
return { stage, code: fallback };
|
|
38
40
|
}
|
|
39
|
-
export function executionError(error, stage, fallback, detail, observations, preparationTiming) {
|
|
41
|
+
export function executionError(error, stage, fallback, detail, observations, preparationTiming, launchObservation) {
|
|
40
42
|
const diagnostic = executionFailure(error, stage, fallback);
|
|
41
43
|
// Only trusted callers supply this fixed vocabulary. Serialized provider
|
|
42
44
|
// errors, messages and stderr never become diagnostic authority.
|
|
@@ -52,6 +54,11 @@ export function executionError(error, stage, fallback, detail, observations, pre
|
|
|
52
54
|
if (timing)
|
|
53
55
|
diagnostic.preparation_timing = timing;
|
|
54
56
|
}
|
|
57
|
+
if (!diagnostic.launch_process) {
|
|
58
|
+
const observed = trustedLaunchObservation(launchObservation);
|
|
59
|
+
if (observed)
|
|
60
|
+
diagnostic.launch_process = observed;
|
|
61
|
+
}
|
|
55
62
|
const safe = new Error(fallback);
|
|
56
63
|
issued.set(safe, Object.freeze(diagnostic));
|
|
57
64
|
return safe;
|