mtech-ai-local-runner 0.1.52 → 0.1.54

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
@@ -1,3 +1,24 @@
1
+ ## 0.1.53: separate candidate change, file and payload limits
2
+
3
+ ## 0.1.54: read only the source lines you need
4
+
5
+ Use `local_search` to locate code, then `local_read({path, start_line, end_line})`. Lines are 1-based and inclusive; supply both bounds, at most 400 lines. The response includes `content`, `start_line`, `end_line` and `total_lines`. Invalid/missing bounds, ranges over 400 lines and ranges past EOF are rejected with `DENIED_READ_RANGE`, `DENIED_READ_RANGE_LIMIT` and `DENIED_READ_RANGE_BOUNDS`. Empty files have 0 lines; a final newline adds no extra line. Original LF/CRLF line endings are preserved. Without bounds the existing `{content}` whole-file result is unchanged. Existing file-size and path protections still apply; no shell permission is added.
6
+
7
+
8
+ New `gateway_publish_v1` scopes may explicitly opt into `candidate_limits.version: 2` with
9
+ `max_changed_files`, `max_change_bytes`, `max_file_bytes` and `max_payload_bytes`.
10
+ Change bytes are the exact minimum byte insertions plus deletions from the frozen base;
11
+ file bytes are each resulting decoded file; payload bytes are compact UTF-8 JSON for
12
+ `{files, validation_report?}`, including base64, paths, hashes and the report. HTTP/MCP
13
+ framing and provider multipart overhead remain separate infrastructure limits.
14
+
15
+ The Runner and Gateway compute these independently. Unknown measurement or unsupported
16
+ modes fail closed. The Gateway bounds frozen-base response streaming and rechecks
17
+ scope/authority before staging. Legacy unversioned `max_diff_bytes` keeps its existing
18
+ meaning. Existing candidates/contracts/Jobs/Loops are not upgraded or published by this
19
+ release. Match new package runtime receipts on both sides and approve a new v2 scope
20
+ before use; a publication-only recovery protocol for retained candidates is separate.
21
+
1
22
  ## 0.1.52: publication diagnostics and candidate cleanup
2
23
 
3
24
  Publication failures report fixed `publication_step` and `LOCAL_FIX_*` codes;
@@ -15,6 +36,8 @@ model run. Pre-existing forensic candidates are not swept automatically.
15
36
 
16
37
  ## 0.1.51: open a session for an ask no session can show
17
38
 
39
+ 0.1.51 was not published to npm; install 0.1.52 or later to get these changes.
40
+
18
41
  Sometimes no live session can show an AI Gateway ask or answer soon: there is no live session, no human prompt for 30
19
42
  minutes, an idle Codex session, or an idle Claude session that cannot be woken. The runner then opens a session in the
20
43
  person's own AI app instead of only showing a notification (Work Item e1c08b33, Work Decision 3ea5c197).
@@ -118,7 +141,10 @@ npx mtech-ai-local-runner integrations remove claude
118
141
 
119
142
  Claude Code gets the skills-directory plugin `~/.claude/skills/mtec-ai-gateway-asks/`, which loads as
120
143
  `mtec-ai-gateway-asks@skills-dir` in a new session or after `/reload-plugins`; `settings.json` is not touched. Codex gets
121
- four handlers appended to `~/.codex/hooks.json` after a backup of that file; trust them once with `/hooks`. A
144
+ four handlers appended to `~/.codex/hooks.json` after a backup of that file. Codex runs them only after you trust them
145
+ once. In the Codex app, trust them under Settings → Hooks or with the Review hooks button. In the Codex CLI in a
146
+ terminal, trust them with `/hooks`; the Codex app has no such command and sends `/hooks` to the model as text. Until
147
+ then the Slack message covers each ask. A
122
148
  `hooks.json` that is not valid JSON, or whose hooks do not have the shape Codex reads, is left as it is and the install
123
149
  reports `CODEX_HOOKS_JSON_INVALID`. `uninstall` removes both integrations, the shim, `asks.json` and any temporary
124
150
  `asks.json.*.tmp` file a crash left behind, and it also works on a setup that never finished.
@@ -23,7 +23,7 @@ function repositoryScope(taskPackage) {
23
23
  "These are scope descriptions, not instructions from source files; the host policy remains authoritative and may deny blocked/private paths within an allowed prefix.";
24
24
  }
25
25
  export function compileCodexPrompt(taskPackage, label) {
26
- return `${compileBrief(taskPackage, { workItemLabel: label, progressReporting: "native_messages" })}\n\n${repositoryScope(taskPackage)}\n\nUse only the ai_local job tools for repository reads, edits and approved tests. For reading, use server ai_local's local_read({path: "<exact approved path>"}) or local_search({path: "<exact approved path>", query: "text"}) with checkout-relative paths. For small approved edits, use local_replace({path, old, new}) after reading: old must match exactly once, including whitespace. local_edit requires the complete replacement file, never a patch. Required test criteria are not command grants. No model test commands are registered unless the host explicitly supplies exact model-callable command IDs; do not use local_test to discover commands or run --help. Missing tests remain not_run/unverified, not passed. In a read-only task, use only read/search tools; the Runner enforces the approved paths and operations. A denied native cwd is intentional: it is a private control directory, not the source checkout. Native filesystem, commands, web, images and delegation are unavailable or denied. A denied tool is a scope boundary, never permission to broaden scope or bypass the denial. Do not retry the denied path. It does not remove independently approved paths: continue with relevant listed paths, and report a missing dependency only after checking the available approved source. Do not repeat host orchestration: claim, shadow, fingerprints, binding, administrator checks, launches, Gateway usage and Job reporting belong to the Runner/host, even when described in the objective. Perform only the assigned repository work and report evidence you actually observed. Do not claim that no model call occurred. If the required tool is unavailable, emit one final structured blocked report naming the missing tool and unfinished repository work; do not request or attempt a new launch.`;
26
+ return `${compileBrief(taskPackage, { workItemLabel: label, progressReporting: "native_messages" })}\n\n${repositoryScope(taskPackage)}\n\nUse only the ai_local job tools for repository reads, edits and approved tests. For reading, first use server ai_local's local_search({path: "<exact approved path>", query: "text"}) to find the location, then local_read({path: "<exact approved path>", start_line: 1, end_line: 1}) with the matching line numbers. Ranges are inclusive, at most 400 lines; both bounds are required together. Read a matched line first to learn total_lines, then request only the needed in-file context. Omit both bounds only when the entire file is necessary. Use approved checkout-relative paths. For small approved edits, use local_replace({path, old, new}) after reading: old must match exactly once, including whitespace. local_edit requires the complete replacement file, never a patch. Required test criteria are not command grants. No model test commands are registered unless the host explicitly supplies exact model-callable command IDs; do not use local_test to discover commands or run --help. Missing tests remain not_run/unverified, not passed. In a read-only task, use only read/search tools; the Runner enforces the approved paths and operations. A denied native cwd is intentional: it is a private control directory, not the source checkout. Native filesystem, commands, web, images and delegation are unavailable or denied. A denied tool is a scope boundary, never permission to broaden scope or bypass the denial. Do not retry the denied path. It does not remove independently approved paths: continue with relevant listed paths, and report a missing dependency only after checking the available approved source. Do not repeat host orchestration: claim, shadow, fingerprints, binding, administrator checks, launches, Gateway usage and Job reporting belong to the Runner/host, even when described in the objective. Perform only the assigned repository work and report evidence you actually observed. Do not claim that no model call occurred. If the required tool is unavailable, emit one final structured blocked report naming the missing tool and unfinished repository work; do not request or attempt a new launch.`;
27
27
  }
28
28
  export const CODEX_REPORT_SCHEMA = {
29
29
  type: "object", additionalProperties: false, required: ["status", "report", "evidence", "pending_work"],
@@ -5,7 +5,9 @@ import { installIntegration, integrationStatus, removeIntegration, removeShimIfU
5
5
  import { APPS, isApp } from "./types.js";
6
6
  const NEXT_STEP = {
7
7
  claude: "Start a new Claude Code session, or run /reload-plugins in an open one, to load mtec-ai-gateway-asks@skills-dir.",
8
- codex: "Open Codex and trust the four AI Gateway hooks once with /hooks. Until then Codex skips them and the Slack message covers the gap.",
8
+ // /hooks is a command of the Codex CLI in a terminal only: the Codex app sends it to the model as text.
9
+ codex: "Codex runs the four AI Gateway hooks only after you trust them once. In the Codex app, trust them under Settings → Hooks "
10
+ + "or with the Review hooks button. In the Codex CLI in a terminal, trust them with /hooks. Until then the Slack message covers each ask.",
9
11
  };
10
12
  function errorCode(error) {
11
13
  const code = error?.code;
@@ -10,7 +10,9 @@ const CLAUDE_NOT_LOADED = `Claude Code does not load ${PLUGIN_ID}. Managed setti
10
10
  + "(strictPluginOnlyCustomization, a strictKnownMarketplaces list without the skills-dir source, or blockedMarketplaces): "
11
11
  + "ask your administrator, or install the plugin from a directory marketplace your organization allows (claude plugin marketplace add <dir>). "
12
12
  + "A Claude Code release without skills-directory plugins does not load it either.";
13
- const CODEX_TRUST = "Codex runs these hooks only after you trust them once with /hooks; until then the Slack message covers each ask.";
13
+ // /hooks is a command of the Codex CLI in a terminal only: the Codex app sends it to the model as text.
14
+ const CODEX_TRUST = "Codex runs these hooks only after you trust them once. In the Codex app, trust them under Settings → Hooks "
15
+ + "or with the Review hooks button. In the Codex CLI in a terminal, trust them with /hooks. Until then the Slack message covers each ask.";
14
16
  /** Runs the Claude CLI with the environment doctor was given on top of the process's own (CLAUDE_CONFIG_DIR, for one). */
15
17
  const defaultRun = (env) => (file, args) => new Promise((resolve, reject) => {
16
18
  execFile(file, args, { encoding: "utf8", timeout: 10_000, maxBuffer: 1024 * 1024, env: { ...process.env, ...env } }, (error, stdout) => (error ? reject(error) : resolve({ stdout })));
@@ -0,0 +1,89 @@
1
+ import { createHash } from 'node:crypto';
2
+ const issued = new WeakMap();
3
+ const reject = (code) => { const error = Error('CANDIDATE_LIMIT_REJECTED'); issued.set(error, code); throw error; };
4
+ export function trustedCandidateLimitCode(error) { return error && typeof error === 'object' ? issued.get(error) : undefined; }
5
+ export function candidateLimits(value, publish) {
6
+ if (!value || typeof value !== 'object' || Array.isArray(value))
7
+ return reject('LIMIT_SCHEMA_REJECTED');
8
+ const l = value;
9
+ if (l.version !== undefined) {
10
+ const keys = ['version', 'max_changed_files', 'max_change_bytes', 'max_file_bytes', 'max_payload_bytes'];
11
+ if (!publish || l.version !== 2 || Object.keys(l).sort().join() !== keys.sort().join() || keys.slice(0).filter(k => k !== 'version').some(k => !Number.isSafeInteger(l[k]) || Number(l[k]) < 1 || Number(l[k]) > (k === 'max_changed_files' ? 50 : 1000000)))
12
+ return reject('LIMIT_SCHEMA_REJECTED');
13
+ return { ...l };
14
+ }
15
+ if ('max_change_bytes' in l || 'max_payload_bytes' in l || ['max_changed_files', 'max_diff_bytes', 'max_file_bytes'].some(k => !Number.isSafeInteger(l[k]) || Number(l[k]) < 1))
16
+ return reject('LIMIT_SCHEMA_REJECTED');
17
+ return { ...l };
18
+ }
19
+ /** Exact byte insertion/deletion distance. Fixed work exhaustion never becomes a guessed count. */
20
+ export function changedBytes(before, after) {
21
+ if (before.length > 1000000 || after.length > 1000000)
22
+ return reject('CHANGE_MEASUREMENT_REJECTED');
23
+ let start = 0, n = before.length, m = after.length, work = 0;
24
+ const tick = () => { if (++work > 2000000)
25
+ reject('CHANGE_MEASUREMENT_REJECTED'); };
26
+ while (start < n && start < m && before[start] === after[start]) {
27
+ tick();
28
+ start++;
29
+ }
30
+ while (n > start && m > start && before[n - 1] === after[m - 1]) {
31
+ tick();
32
+ n--;
33
+ m--;
34
+ }
35
+ n -= start;
36
+ m -= start;
37
+ if (!n || !m)
38
+ return n + m;
39
+ let frontier = new Map([[1, 0]]);
40
+ for (let d = 0; d <= n + m; d++) {
41
+ const next = new Map();
42
+ for (let k = -d; k <= d; k += 2) {
43
+ tick();
44
+ const left = frontier.get(k - 1) ?? -1, down = frontier.get(k + 1) ?? -1;
45
+ let x = k === -d || (k !== d && left < down) ? down : left + 1;
46
+ if (x < 0)
47
+ continue;
48
+ let y = x - k;
49
+ while (x < n && y < m && y >= 0 && before[start + x] === after[start + y]) {
50
+ tick();
51
+ x++;
52
+ y++;
53
+ }
54
+ if (x >= n && y >= m)
55
+ return d;
56
+ next.set(k, x);
57
+ }
58
+ frontier = next;
59
+ }
60
+ return reject('CHANGE_MEASUREMENT_REJECTED');
61
+ }
62
+ export function candidatePayloadBytes(files, report) { return Buffer.byteLength(JSON.stringify({ files, ...(report === undefined ? {} : { validation_report: report }) }), 'utf8'); }
63
+ export function measureSplitDelta(diff, limits, report) {
64
+ const delta = JSON.parse(diff);
65
+ if (delta?.format !== 'local-candidate-binary-diff-v1' || !Array.isArray(delta.changes) || !delta.changes.length || delta.changes.length > limits.max_changed_files)
66
+ reject('FILE_COUNT_REJECTED');
67
+ let changes = 0, maxFile = 0;
68
+ const decode = (v) => { if (typeof v !== 'string')
69
+ return reject('CHANGE_MEASUREMENT_REJECTED'); const b = Buffer.from(v, 'base64'); if (b.toString('base64') !== v || b.length > 1000000 || b.includes(0) || !Buffer.from(b.toString('utf8')).equals(b))
70
+ return reject('CHANGE_MEASUREMENT_REJECTED'); return b; };
71
+ const files = delta.changes.map((c) => {
72
+ if (!c || typeof c.path !== 'string' || !['added', 'modified', 'deleted'].includes(c.status) || c.status === 'added' && c.before !== null || c.status === 'deleted' && c.after !== null)
73
+ reject('CHANGE_MEASUREMENT_REJECTED');
74
+ const before = c.status === 'added' ? Buffer.alloc(0) : decode(c.before), after = c.status === 'deleted' ? Buffer.alloc(0) : decode(c.after);
75
+ maxFile = Math.max(maxFile, after.length);
76
+ if (maxFile > limits.max_file_bytes)
77
+ reject('FILE_LIMIT_REJECTED');
78
+ changes += changedBytes(before, after);
79
+ if (changes > limits.max_change_bytes)
80
+ reject('CHANGE_LIMIT_REJECTED');
81
+ return { path: c.path, status: c.status, content_base64: c.status === 'deleted' ? null : after.toString('base64'), content_sha256: c.status === 'deleted' ? null : createHash('sha256').update(after).digest('hex') };
82
+ });
83
+ if (!changes)
84
+ reject('CHANGE_MEASUREMENT_REJECTED');
85
+ const payload = candidatePayloadBytes(files, report);
86
+ if (payload > limits.max_payload_bytes)
87
+ reject('PAYLOAD_LIMIT_REJECTED');
88
+ return { metrics: { version: 2, change_bytes: changes, max_file_bytes: maxFile, payload_bytes: payload }, files };
89
+ }
@@ -1,6 +1,6 @@
1
1
  import { PublicationOutcomeUnknownError } from './publisher.js';
2
2
  export const PUBLICATION_STEPS = ['validation_report', 'current_fence', 'snapshot_observe', 'snapshot_materialize', 'candidate_limits', 'delta_format', 'delta_modes', 'delta_scope', 'candidate_files', 'journal', 'security_receipt', 'stage', 'advance', 'result', 'cleanup'];
3
- export const PUBLICATION_CODES = ['LOCAL_FIX_CHECK_REJECTED', 'LOCAL_FIX_REPORT_REJECTED', 'LOCAL_FIX_DIFF_LIMIT_REJECTED', 'LOCAL_FIX_FILE_COUNT_REJECTED', 'LOCAL_FIX_FILE_LIMIT_REJECTED', 'LOCAL_FIX_FILE_ENCODING_REJECTED', 'LOCAL_FIX_PUBLICATION_OUTCOME_UNKNOWN', 'LOCAL_FIX_CLEANUP_FAILED'];
3
+ export const PUBLICATION_CODES = ['LOCAL_FIX_CHANGE_LIMIT_REJECTED', 'LOCAL_FIX_PAYLOAD_LIMIT_REJECTED', 'LOCAL_FIX_CHANGE_MEASUREMENT_REJECTED', 'LOCAL_FIX_CHECK_REJECTED', 'LOCAL_FIX_REPORT_REJECTED', 'LOCAL_FIX_DIFF_LIMIT_REJECTED', 'LOCAL_FIX_FILE_COUNT_REJECTED', 'LOCAL_FIX_FILE_LIMIT_REJECTED', 'LOCAL_FIX_FILE_ENCODING_REJECTED', 'LOCAL_FIX_PUBLICATION_OUTCOME_UNKNOWN', 'LOCAL_FIX_CLEANUP_FAILED'];
4
4
  const issued = new WeakMap();
5
5
  const cleanupFailures = new WeakSet();
6
6
  /** Host vocabulary only; serialized fields/messages never carry diagnostic authority. */
@@ -55,6 +55,8 @@ export function createProductionLocalFixHost(options) {
55
55
  } };
56
56
  const frozenCurrent = async () => lifecycle.isFrozen() && await current();
57
57
  const limits = scope.candidate_limits;
58
+ if (['version', 'max_change_bytes', 'max_payload_bytes'].some(k => k in limits))
59
+ throw denied();
58
60
  const snapshots = new GitSnapshotHost({ root: input.checkoutPath, privateRoot: root, git: options.git, baseCommitSha: binding.base_commit_sha, workingBranch: binding.working_branch, maxFileBytes: SNAPSHOT_TREE_MAX_BYTES, maxTotalBytes: SNAPSHOT_TREE_MAX_BYTES });
59
61
  const isolated = createSealedIsolatedVerifier({ host: snapshots, allowedPaths: verificationPaths, blockedPaths: ['.env', '.env.*', '.git/**', '.ssh/**', '.aws/**'], commands, current: frozenCurrent });
60
62
  const verifier = new CandidateVerifier({ binding, root: input.checkoutPath, allowedPaths: allowed, blockedPaths: ['.env', '.env.*', '.git/**', '.ssh/**', '.aws/**'], maxFiles: limits.max_changed_files, maxDiffBytes: limits.max_diff_bytes, maxFileBytes: limits.max_file_bytes, commands, current: frozenCurrent, assertRetainedSeal: async () => { const seal = isolated.materialized(); if (!seal)
@@ -1,3 +1,4 @@
1
+ import { candidateLimits, measureSplitDelta, trustedCandidateLimitCode } from './candidate-limits.js';
1
2
  import { publicationError, publicationCleanupFailure, trustedPublicationFailure } from './failure.js';
2
3
  import { ServerCiPublicationError } from './server-ci-error.js';
3
4
  export { ServerCiPublicationError } from './server-ci-error.js';
@@ -67,10 +68,11 @@ export function createServerCiLocalFixHost(options) {
67
68
  const lifecycle = input.lifecycle;
68
69
  if (!lifecycle)
69
70
  throw denied();
70
- const { job } = input, pkg = job.taskPackage, scope = pkg.local_fix_scope, repo = pkg.repository, limits = scope?.candidate_limits, allowed = scope?.allowed_paths;
71
+ const { job } = input, pkg = job.taskPackage, scope = pkg.local_fix_scope, repo = pkg.repository, rawLimits = scope?.candidate_limits, allowed = scope?.allowed_paths;
71
72
  const root = await realpath(options.privateRoot), st = await lstat(root);
72
- if (!lifecycle.isCurrent() || pkg.runtime_adapter !== 'codex_cli' || !scope || scope.verification_mode !== mode || scope.ci_profile_fingerprint !== registration.ci_profile_fingerprint || protocolHash(scope.ci_profile) !== protocolHash(registration.ci_profile) || !Array.isArray(scope.required_commands) || scope.required_commands.length || !scope.registered_tests || typeof scope.registered_tests !== 'object' || Object.keys(scope.registered_tests).length || !Array.isArray(allowed) || !allowed.length || allowed.some(p => typeof p !== 'string' || !(safePath(p) || p.endsWith('/**') && safePath(p.slice(0, -3)))) || protocolHash(allowed) !== protocolHash(pkg.allowed_paths) || repo.key !== registration.project || job.gatewayTaskPackageFingerprint !== protocolHash(pkg) || Object.entries(registration.receipt).some(([k, v]) => scope[k] !== v) || !limits || ['max_changed_files', 'max_diff_bytes', 'max_file_bytes'].some(k => !Number.isSafeInteger(limits[k]) || limits[k] < 1) || root !== options.privateRoot || !st.isDirectory() || st.isSymbolicLink() || (st.mode & 0o077) !== 0 || (process.getuid && st.uid !== process.getuid()) || root === input.checkoutPath || root.startsWith(input.checkoutPath + path.sep))
73
+ if (!lifecycle.isCurrent() || pkg.runtime_adapter !== 'codex_cli' || !scope || scope.verification_mode !== mode || scope.ci_profile_fingerprint !== registration.ci_profile_fingerprint || protocolHash(scope.ci_profile) !== protocolHash(registration.ci_profile) || !Array.isArray(scope.required_commands) || scope.required_commands.length || !scope.registered_tests || typeof scope.registered_tests !== 'object' || Object.keys(scope.registered_tests).length || !Array.isArray(allowed) || !allowed.length || allowed.some(p => typeof p !== 'string' || !(safePath(p) || p.endsWith('/**') && safePath(p.slice(0, -3)))) || protocolHash(allowed) !== protocolHash(pkg.allowed_paths) || repo.key !== registration.project || job.gatewayTaskPackageFingerprint !== protocolHash(pkg) || Object.entries(registration.receipt).some(([k, v]) => scope[k] !== v) || root !== options.privateRoot || !st.isDirectory() || st.isSymbolicLink() || (st.mode & 0o077) !== 0 || (process.getuid && st.uid !== process.getuid()) || root === input.checkoutPath || root.startsWith(input.checkoutPath + path.sep))
73
74
  throw denied();
75
+ const limits = candidateLimits(rawLimits, mode === 'gateway_publish_v1');
74
76
  const fence = { jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, launchGrant: job.launchGrant, bindingId: input.bindingId };
75
77
  const binding = bindingProof((await options.gateway.localPublicationContext(fence)).context);
76
78
  if (binding.runner_id !== options.runnerId || binding.binding_id !== input.bindingId || binding.job_id !== job.id || binding.generation !== job.generation || binding.work_loop_id !== job.workLoopId || binding.repository_key !== repo.key || binding.base_commit_sha !== repo.base_commit_sha || binding.working_branch !== repo.working_branch || binding.working_branch !== `ai-work/local/${binding.attempt_id}` || binding.attempt_id !== scope.attempt_id || binding.contract_fingerprint !== job.gatewayContractFingerprint || binding.task_package_fingerprint !== job.gatewayTaskPackageFingerprint || binding.launch_fingerprint !== job.launchFingerprint)
@@ -99,7 +101,17 @@ export function createServerCiLocalFixHost(options) {
99
101
  phase = 'snapshot_observe';
100
102
  const observed = await host.observe();
101
103
  phase = 'candidate_limits';
102
- if (Buffer.byteLength(observed.diff) > limits.max_diff_bytes)
104
+ let split;
105
+ if (limits.version === 2) {
106
+ try {
107
+ split = measureSplitDelta(observed.diff, limits, report);
108
+ }
109
+ catch (error) {
110
+ const code = trustedCandidateLimitCode(error);
111
+ throw publicationError(phase, code === 'CHANGE_LIMIT_REJECTED' ? 'LOCAL_FIX_CHANGE_LIMIT_REJECTED' : code === 'PAYLOAD_LIMIT_REJECTED' ? 'LOCAL_FIX_PAYLOAD_LIMIT_REJECTED' : code === 'FILE_LIMIT_REJECTED' ? 'LOCAL_FIX_FILE_LIMIT_REJECTED' : code === 'FILE_COUNT_REJECTED' ? 'LOCAL_FIX_FILE_COUNT_REJECTED' : 'LOCAL_FIX_CHANGE_MEASUREMENT_REJECTED');
112
+ }
113
+ }
114
+ if (limits.version !== 2 && Buffer.byteLength(observed.diff) > limits.max_diff_bytes)
103
115
  throw publicationError(phase, 'LOCAL_FIX_DIFF_LIMIT_REJECTED');
104
116
  if (!observed.changed_files.length || observed.changed_files.length > limits.max_changed_files)
105
117
  throw publicationError(phase, 'LOCAL_FIX_FILE_COUNT_REJECTED');
@@ -156,7 +168,7 @@ export function createServerCiLocalFixHost(options) {
156
168
  if (!s.isFile() || s.nlink !== 1n || s.dev !== before.dev || s.ino !== before.ino || s.size !== before.size || s.mtimeNs !== before.mtimeNs || s.ctimeNs !== before.ctimeNs || s.mode !== before.mode)
157
169
  throw denied();
158
170
  bytes += content.length;
159
- if (bytes > limits.max_diff_bytes)
171
+ if (limits.version !== 2 && bytes > limits.max_diff_bytes)
160
172
  throw publicationError(phase, 'LOCAL_FIX_DIFF_LIMIT_REJECTED');
161
173
  if (content.includes(0) || !Buffer.from(content.toString('utf8'), 'utf8').equals(content))
162
174
  throw publicationError(phase, 'LOCAL_FIX_FILE_ENCODING_REJECTED');
@@ -166,6 +178,8 @@ export function createServerCiLocalFixHost(options) {
166
178
  await fd.close();
167
179
  }
168
180
  }
181
+ if (split && protocolHash(split.files) !== protocolHash(files))
182
+ throw publicationError('candidate_files');
169
183
  phase = 'journal';
170
184
  const journal = new FilePublicationJournal(root), key = protocolHash({ mode, binding }), deltaFingerprint = protocolHash({ binding, files, profile: registration.ci_profile_fingerprint, ...(report ? { validation_report: report } : {}) });
171
185
  return journal.exclusive(key, async () => {
@@ -1,3 +1,4 @@
1
+ import { validateLocalReadRange } from './local-read-range.js';
1
2
  import { localToolInventory } from './local-tool-server.js';
2
3
  const rejected = () => new Error('JOB_TOOL_DISPATCH_REJECTED');
3
4
  function record(value) { return !!value && typeof value === 'object' && !Array.isArray(value); }
@@ -35,10 +36,16 @@ export class JobToolDispatcher {
35
36
  throw rejected();
36
37
  const tool = localToolInventory().find(t => t.name === envelope.tool);
37
38
  const properties = tool?.inputSchema.properties;
38
- if (!record(properties) || !keys(envelope.args, Object.keys(properties))
39
- || Object.values(envelope.args).some(value => typeof value !== 'string')
39
+ const required = tool?.inputSchema.required;
40
+ if (!record(properties) || !Array.isArray(required) || required.some(key => typeof key !== 'string' || !Object.hasOwn(envelope.args, key))
41
+ || Object.entries(envelope.args).some(([key, value]) => {
42
+ const schema = properties[key];
43
+ return !record(schema) || (schema.type === 'integer' ? typeof value !== 'number' || !Number.isSafeInteger(value) || (typeof schema.minimum === 'number' && value < schema.minimum) : schema.type !== 'string' || typeof value !== 'string');
44
+ })
40
45
  || Buffer.byteLength(JSON.stringify(envelope.args)) > 1024 * 1024)
41
46
  throw rejected();
47
+ if (envelope.tool === 'local_read')
48
+ validateLocalReadRange(envelope.args);
42
49
  this.#lastRequest = BigInt(envelope.requestId);
43
50
  const result = await this.policy.call(envelope.tool, envelope.args);
44
51
  if (this.#revoked || !this.active())
@@ -0,0 +1,13 @@
1
+ /** Kept standalone so the isolated worker repeats the same validation. */
2
+ export function validateLocalReadRange(input) {
3
+ if (!Object.hasOwn(input, 'start_line') && !Object.hasOwn(input, 'end_line'))
4
+ return;
5
+ const start = input.start_line, end = input.end_line;
6
+ if (typeof start !== 'number' || typeof end !== 'number'
7
+ || !Number.isSafeInteger(start) || !Number.isSafeInteger(end)
8
+ || start < 1 || end < start)
9
+ throw new Error('DENIED_READ_RANGE');
10
+ if (end - start + 1 > 400)
11
+ throw new Error('DENIED_READ_RANGE_LIMIT');
12
+ return { start_line: start, end_line: end };
13
+ }
@@ -1,3 +1,4 @@
1
+ import { validateLocalReadRange } from './local-read-range.js';
1
2
  import { isPatchContent } from './local-edit-content.js';
2
3
  import path from 'node:path';
3
4
  import { randomBytes } from 'node:crypto';
@@ -44,6 +45,7 @@ export class LocalToolPolicy {
44
45
  throw new Error('DENIED_READ_ONLY');
45
46
  if (tool === 'local_test')
46
47
  return this.test(input);
48
+ const readRange = tool === 'local_read' ? validateLocalReadRange(input) : undefined;
47
49
  const file = await this.authorizedFile(input.path, tool === 'local_edit');
48
50
  if (tool === 'local_replace') {
49
51
  if (typeof input.old !== 'string' || !input.old || Buffer.byteLength(input.old) > 1024 * 1024)
@@ -60,10 +62,12 @@ export class LocalToolPolicy {
60
62
  if (tool === 'local_search' && (typeof input.query !== 'string' || input.query.length > 4096))
61
63
  throw new Error('DENIED_QUERY');
62
64
  const temp = file + '.runner-edit-' + randomBytes(16).toString('hex');
63
- const output = await runSandboxed({ executable: process.execPath, argv: ['--no-addons', '--no-global-search-paths', '--eval', LOCAL_TOOL_WORKER, JSON.stringify({ tool, path: file, temp, content: input.content, query: input.query, old: input.old, new: input.new })], readFiles: (tool === 'local_edit' || tool === 'local_replace') ? [file, temp] : [file], writeFiles: (tool === 'local_edit' || tool === 'local_replace') ? [file, temp] : [], timeoutMs: 10000, signal: this.abort.signal });
65
+ const output = await runSandboxed({ executable: process.execPath, argv: ['--no-addons', '--no-global-search-paths', '--eval', LOCAL_TOOL_WORKER, JSON.stringify({ tool, path: file, temp, content: input.content, query: input.query, old: input.old, new: input.new, ...readRange })], readFiles: (tool === 'local_edit' || tool === 'local_replace') ? [file, temp] : [file], writeFiles: (tool === 'local_edit' || tool === 'local_replace') ? [file, temp] : [], timeoutMs: 10000, signal: this.abort.signal });
64
66
  if (this.abort.signal.aborted)
65
67
  throw new Error('DENIED_REVOKED');
66
68
  const result = JSON.parse(output);
69
+ if (tool === 'local_read' && ['DENIED_READ_RANGE', 'DENIED_READ_RANGE_LIMIT', 'DENIED_READ_RANGE_BOUNDS'].includes(result.denied))
70
+ throw new Error(result.denied);
67
71
  if (tool === 'local_replace' && ['DENIED_REPLACE_NOT_FOUND', 'DENIED_REPLACE_AMBIGUOUS', 'DENIED_SIZE'].includes(result.denied))
68
72
  throw new Error(result.denied);
69
73
  return result;
@@ -21,7 +21,7 @@ export function localEndpointObservations(url, token) {
21
21
  }
22
22
  const MAX_BODY = 1024 * 1024;
23
23
  const schemas = {
24
- local_read: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'], additionalProperties: false },
24
+ local_read: { type: 'object', properties: { path: { type: 'string' }, start_line: { type: 'integer', minimum: 1, description: 'First line (1-based, inclusive). Supply with end_line; maximum 400 lines.' }, end_line: { type: 'integer', minimum: 1, description: 'Last line (inclusive). Must not exceed total_lines; supply with start_line.' } }, required: ['path'], additionalProperties: false },
25
25
  local_search: { type: 'object', properties: { path: { type: 'string' }, query: { type: 'string' } }, required: ['path', 'query'], additionalProperties: false },
26
26
  local_edit: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string', description: 'The complete new contents of the entire file. Do not send a diff, patch, hunk, or edit instructions.' } }, required: ['path', 'content'], additionalProperties: false },
27
27
  local_replace: { type: 'object', properties: { path: { type: 'string' }, old: { type: 'string', minLength: 1 }, new: { type: 'string' } }, required: ['path', 'old', 'new'], additionalProperties: false },
@@ -29,7 +29,7 @@ const schemas = {
29
29
  };
30
30
  /** Same immutable-by-copy inventory used by native admission and MCP discovery. */
31
31
  export function localToolInventory() {
32
- return LOCAL_TOOL_NAMES.map(name => ({ name, description: name === 'local_edit' ? 'Replace the entire file at path with content (or create it). Read the current file first and include all unchanged text in content. This is not a diff or patch tool; patch headers and hunks are rejected.' : name === 'local_replace' ? 'Replace exactly one occurrence of old with new in the file at path. Read the file first; old must match exactly, including whitespace, and appear once. Prefer this for small changes; use local_edit only to write a whole file.' : `Scoped ${name} operation`, inputSchema: structuredClone(schemas[name]) }));
32
+ return LOCAL_TOOL_NAMES.map(name => ({ name, description: name === 'local_read' ? 'Find locations with local_search first, then use local_read with start_line and end_line to read only the needed inclusive range (maximum 400 lines). Both range arguments are required together; invalid or beyond-EOF ranges are rejected, never widened. Returns content, start_line, end_line and total_lines for ranges; omit both for the entire file.' : name === 'local_edit' ? 'Replace the entire file at path with content (or create it). Read the current file first and include all unchanged text in content. This is not a diff or patch tool; patch headers and hunks are rejected.' : name === 'local_replace' ? 'Replace exactly one occurrence of old with new in the file at path. Read the file first; old must match exactly, including whitespace, and appear once. Prefer this for small changes; use local_edit only to write a whole file.' : `Scoped ${name} operation`, inputSchema: structuredClone(schemas[name]) }));
33
33
  }
34
34
  /** Job-only JSON MCP endpoint; intentionally has no Gateway transport or discovery. */
35
35
  export class LocalToolServer {
@@ -1,9 +1,11 @@
1
+ import { validateLocalReadRange } from './local-read-range.js';
1
2
  import { isPatchContent } from './local-edit-content.js';
2
3
  /** Standalone worker: --eval avoids ambient module/config lookup. All paths are host-supplied. */
3
4
  export const LOCAL_TOOL_WORKER = String.raw `
4
5
  const fs = require('node:fs');
5
6
  const input = JSON.parse(process.argv[1]);
6
7
  const isPatchContent = ${isPatchContent.toString()};
8
+ const validateLocalReadRange = ${validateLocalReadRange.toString()};
7
9
  if (input.tool === 'local_edit' && isPatchContent(input.content)) throw Error('DENIED_PATCH_CONTENT');
8
10
  if (input.tool === 'local_replace') {
9
11
  if (typeof input.old !== 'string' || !input.old || Buffer.byteLength(input.old) > 1048576) throw Error('DENIED_REPLACE_OLD');
@@ -11,6 +13,7 @@ if (input.tool === 'local_replace') {
11
13
  if (isPatchContent(input.new)) throw Error('DENIED_PATCH_CONTENT');
12
14
  }
13
15
  try {
16
+ const readRange = input.tool === 'local_read' ? validateLocalReadRange(input) : undefined;
14
17
  let current = '';
15
18
  for (const part of input.path.split('/').filter(Boolean)) {
16
19
  current += '/' + part;
@@ -72,12 +75,19 @@ try {
72
75
  const count = fs.readSync(fd, buffer, 0, buffer.length, 0);
73
76
  if (count > 1048576) throw Error('DENIED_SIZE');
74
77
  const content = buffer.subarray(0, count).toString('utf8');
75
- result = input.tool === 'local_search' ? {matches: content.split('\n').flatMap((line, index) => line.includes(input.query) ? [{line: index + 1, text: line}] : [])} : {content};
78
+ if (readRange) {
79
+ // Retain each original line terminator; a final newline adds no phantom line.
80
+ const lines = content.match(/[^\n]*\n|[^\n]+$/g) || [];
81
+ if (readRange.end_line > lines.length) throw Error('DENIED_READ_RANGE_BOUNDS');
82
+ result = {content: lines.slice(readRange.start_line - 1, readRange.end_line).join(''), ...readRange, total_lines: lines.length};
83
+ } else result = input.tool === 'local_search' ? {matches: content.split('\n').flatMap((line, index) => line.includes(input.query) ? [{line: index + 1, text: line}] : [])} : {content};
76
84
  }
77
85
  process.stdout.write(JSON.stringify(result));
78
86
  } finally { if (fd !== null) fs.closeSync(fd); }
79
87
  } catch (error) {
80
- if (input.tool !== 'local_replace' || !['DENIED_REPLACE_NOT_FOUND','DENIED_REPLACE_AMBIGUOUS','DENIED_SIZE'].includes(error.message)) throw error;
88
+ const rangeDenied = input.tool === 'local_read' && ['DENIED_READ_RANGE','DENIED_READ_RANGE_LIMIT','DENIED_READ_RANGE_BOUNDS'].includes(error.message);
89
+ const replaceDenied = input.tool === 'local_replace' && ['DENIED_REPLACE_NOT_FOUND','DENIED_REPLACE_AMBIGUOUS','DENIED_SIZE'].includes(error.message);
90
+ if (!rangeDenied && !replaceDenied) throw error;
81
91
  process.stdout.write(JSON.stringify({denied: error.message}));
82
92
  }
83
93
  `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.52",
3
+ "version": "0.1.54",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {