mtech-ai-local-runner 0.1.87 → 0.1.89

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.
@@ -60,7 +60,8 @@ export function changedBytes(before, after) {
60
60
  return reject('CHANGE_MEASUREMENT_REJECTED');
61
61
  }
62
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) {
63
+ /** withModes: the frozen scope lists file modes (design 2026-11-02), so each file carries the mode it keeps; delta_modes checks it. */
64
+ export function measureSplitDelta(diff, limits, report, withModes = false) {
64
65
  const delta = JSON.parse(diff);
65
66
  if (delta?.format !== 'local-candidate-binary-diff-v1' || !Array.isArray(delta.changes) || !delta.changes.length || delta.changes.length > limits.max_changed_files)
66
67
  reject('FILE_COUNT_REJECTED');
@@ -78,7 +79,7 @@ export function measureSplitDelta(diff, limits, report) {
78
79
  changes += changedBytes(before, after);
79
80
  if (changes > limits.max_change_bytes)
80
81
  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
+ 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'), ...(withModes ? { mode: c.status === 'deleted' ? null : c.after_mode === 0o100755 ? '100755' : '100644' } : {}) };
82
83
  });
83
84
  if (!changes)
84
85
  reject('CHANGE_MEASUREMENT_REJECTED');
@@ -198,7 +198,8 @@ export class GitSnapshotHost {
198
198
  if (before.length > o.maxFileBytes)
199
199
  throw denied();
200
200
  }
201
- total += (before?.length ?? 0) + (current?.bytes.length ?? 0);
201
+ // The cap bounds the tree, as matches() and batchObjects() do: each path counts once, at its larger version.
202
+ total += Math.max(before?.length ?? 0, current?.bytes.length ?? 0);
202
203
  if (total > o.maxTotalBytes)
203
204
  throw denied();
204
205
  if (current)
@@ -13,6 +13,8 @@ import { FilePublicationJournal } from './journal.js';
13
13
  import { GitSnapshotHost, SNAPSHOT_TREE_MAX_BYTES } from './git-snapshot.js';
14
14
  import { PublicationOutcomeUnknownError } from './publisher.js';
15
15
  import { isLocalFixAdapter } from './local-fix-adapters.js';
16
+ /** Design 2026-11-02: a frozen scope listing exactly these lets an unchanged 100755 regular file keep its mode. */
17
+ export const FILE_MODES = ['100644', '100755'];
16
18
  const denied = () => Error('LOCAL_FIX_SERVER_CI_REJECTED');
17
19
  /** Work Decision b3d7eb22: a failed Gateway publication call is repeated for at most this long; the coordinator heartbeat keeps the lease. */
18
20
  export const PUBLICATION_RETRY_WINDOW_MS = 900_000;
@@ -83,6 +85,10 @@ export function createServerCiLocalFixHost(options) {
83
85
  if (!lifecycle.isCurrent() || !isLocalFixAdapter(pkg.runtime_adapter) || !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))
84
86
  throw denied();
85
87
  const limits = candidateLimits(rawLimits, mode === 'gateway_publish_v1');
88
+ // Without file_modes (an older Gateway or contract) only 100644 is published and no file carries a mode; any other list is refused.
89
+ const fileModes = scope.file_modes !== undefined;
90
+ if (fileModes && protocolHash(scope.file_modes) !== protocolHash(FILE_MODES))
91
+ throw denied();
86
92
  const fence = { jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, launchGrant: job.launchGrant, bindingId: input.bindingId };
87
93
  const binding = bindingProof((await options.gateway.localPublicationContext(fence)).context);
88
94
  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)
@@ -131,7 +137,7 @@ export function createServerCiLocalFixHost(options) {
131
137
  let split;
132
138
  if (limits.version === 2) {
133
139
  try {
134
- split = measureSplitDelta(observed.diff, limits, report);
140
+ split = measureSplitDelta(observed.diff, limits, report, fileModes);
135
141
  }
136
142
  catch (error) {
137
143
  const code = trustedCandidateLimitCode(error);
@@ -143,7 +149,10 @@ export function createServerCiLocalFixHost(options) {
143
149
  if (!observed.changed_files.length || observed.changed_files.length > limits.max_changed_files)
144
150
  throw publicationError(phase, 'LOCAL_FIX_FILE_COUNT_REJECTED');
145
151
  // The v1 wire represents ordinary text bytes only. Refuse any change whose
146
- // executable/type metadata would be lost by the provider's regular-file write.
152
+ // executable/type metadata would be lost by the provider's regular-file write:
153
+ // a mode change, an executable added file, and every type but a regular file.
154
+ // Under file_modes an unchanged 100755 file keeps its mode; the Gateway checks
155
+ // that mode at the frozen base and reads it back before any PR.
147
156
  phase = 'delta_format';
148
157
  let delta;
149
158
  try {
@@ -155,14 +164,16 @@ export function createServerCiLocalFixHost(options) {
155
164
  if (!delta || delta.format !== 'local-candidate-binary-diff-v1' || !Array.isArray(delta.changes) || delta.changes.length !== observed.changed_files.length)
156
165
  throw denied();
157
166
  phase = 'delta_modes';
158
- const represented = [];
167
+ const represented = [], modes = new Map();
168
+ const kept = (m) => m === 0o100644 || fileModes && m === 0o100755;
159
169
  for (const value of delta.changes) {
160
170
  if (!value || typeof value !== 'object' || Array.isArray(value))
161
171
  throw denied();
162
172
  const c = value;
163
- if (typeof c.path !== 'string' || !['added', 'modified', 'deleted'].includes(String(c.status)) || c.before_mode !== (c.status === 'added' ? null : 0o100644) || c.after_mode !== (c.status === 'deleted' ? null : 0o100644))
173
+ if (typeof c.path !== 'string' || !['added', 'modified', 'deleted'].includes(String(c.status)) || (c.status === 'added' ? c.before_mode !== null || c.after_mode !== 0o100644 : c.status === 'deleted' ? !kept(c.before_mode) || c.after_mode !== null : !kept(c.before_mode) || c.after_mode !== c.before_mode))
164
174
  throw denied();
165
175
  represented.push({ path: c.path, status: c.status });
176
+ modes.set(c.path, c.status === 'deleted' ? null : c.after_mode === 0o100755 ? '100755' : '100644');
166
177
  }
167
178
  if (protocolHash(represented) !== protocolHash(observed.changed_files))
168
179
  throw denied();
@@ -173,8 +184,9 @@ export function createServerCiLocalFixHost(options) {
173
184
  phase = 'delta_scope';
174
185
  if (!safePath(f.path) || testInfrastructure(f.path) || !allowed.some(s => matches(f.path, s)))
175
186
  throw denied();
187
+ const modeField = fileModes ? { mode: modes.get(f.path) } : {};
176
188
  if (f.status === 'deleted') {
177
- files.push({ ...f, content_base64: null, content_sha256: null });
189
+ files.push({ ...f, content_base64: null, content_sha256: null, ...modeField });
178
190
  continue;
179
191
  }
180
192
  phase = 'candidate_files';
@@ -190,12 +202,14 @@ export function createServerCiLocalFixHost(options) {
190
202
  for (const s of [after, named])
191
203
  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)
192
204
  throw denied();
205
+ if (fileModes && ((before.mode & 73n) !== 0n) !== (modeField.mode === '100755'))
206
+ throw denied();
193
207
  bytes += content.length;
194
208
  if (limits.version !== 2 && bytes > limits.max_diff_bytes)
195
209
  throw publicationError(phase, 'LOCAL_FIX_DIFF_LIMIT_REJECTED');
196
210
  if (content.includes(0) || !Buffer.from(content.toString('utf8'), 'utf8').equals(content))
197
211
  throw publicationError(phase, 'LOCAL_FIX_FILE_ENCODING_REJECTED');
198
- files.push({ ...f, content_base64: content.toString('base64'), content_sha256: createHash('sha256').update(content).digest('hex') });
212
+ files.push({ ...f, content_base64: content.toString('base64'), content_sha256: createHash('sha256').update(content).digest('hex'), ...modeField });
199
213
  }
200
214
  finally {
201
215
  await fd.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.87",
3
+ "version": "0.1.89",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {