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
|
-
|
|
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
|
-
|
|
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)) ||
|
|
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();
|