mtech-ai-local-runner 0.1.86 → 0.1.88
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.
|
@@ -20,6 +20,75 @@ function record(value) {
|
|
|
20
20
|
? value
|
|
21
21
|
: undefined;
|
|
22
22
|
}
|
|
23
|
+
/** References only: never project approval payloads, lease tokens or arbitrary authored instructions. */
|
|
24
|
+
function continuationContext(taskPackage) {
|
|
25
|
+
let projectionTruncated = false;
|
|
26
|
+
const contract = record(taskPackage.contract) ?? {};
|
|
27
|
+
const workflow = record(contract.workflow_contract) ?? {};
|
|
28
|
+
const policy = record(contract.policy) ?? {};
|
|
29
|
+
const scalar = (value) => (typeof value === "string" && value.length <= 512)
|
|
30
|
+
|| (typeof value === "number" && Number.isFinite(value)) || typeof value === "boolean";
|
|
31
|
+
const pick = (value, fields) => {
|
|
32
|
+
const source = record(value) ?? {};
|
|
33
|
+
return Object.fromEntries(fields.filter(key => scalar(source[key])).map(key => [key, source[key]]));
|
|
34
|
+
};
|
|
35
|
+
const items = (value) => {
|
|
36
|
+
if (!Array.isArray(value)) {
|
|
37
|
+
if (value !== undefined)
|
|
38
|
+
projectionTruncated = true;
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
if (value.length > 64)
|
|
42
|
+
projectionTruncated = true;
|
|
43
|
+
return value.slice(0, 64);
|
|
44
|
+
};
|
|
45
|
+
const strings = (value) => {
|
|
46
|
+
const source = items(value);
|
|
47
|
+
const result = source?.filter((item) => typeof item === "string" && item.length <= 512);
|
|
48
|
+
if (source && result?.length !== source.length)
|
|
49
|
+
projectionTruncated = true;
|
|
50
|
+
return result;
|
|
51
|
+
};
|
|
52
|
+
const execution = record(taskPackage.execution);
|
|
53
|
+
const coverage = record(taskPackage.coverage);
|
|
54
|
+
const limitKeys = ["max_turns", "max_runtime_seconds", "max_estimated_cost_usd", "max_correction_rounds",
|
|
55
|
+
"max_iterations", "max_model_input_tokens", "max_model_output_tokens", "max_total_tokens"];
|
|
56
|
+
const context = {
|
|
57
|
+
...pick(contract.identity, ["work_item_id", "execution_actor_id", "environment"]),
|
|
58
|
+
contract: pick(contract, ["id", "content_fingerprint", "status"]),
|
|
59
|
+
delegation: pick(workflow, ["delegation_contract_id", "delegation_fingerprint", "delegation_revision", "acknowledgement_required"]),
|
|
60
|
+
selected_execution_units: strings(workflow.selected_execution_units),
|
|
61
|
+
allowed_execution_units: strings(workflow.allowed_execution_units),
|
|
62
|
+
blocked_execution_units: strings(workflow.blocked_execution_units),
|
|
63
|
+
policy: {
|
|
64
|
+
...pick(policy, ["environment", "policy_key", ...limitKeys]),
|
|
65
|
+
limits: pick(policy.limits, limitKeys),
|
|
66
|
+
allowed_local_actions: strings(policy.allowed_local_actions),
|
|
67
|
+
approval_required_actions: strings(policy.approval_required_actions),
|
|
68
|
+
prohibited_actions: strings(policy.prohibited_actions),
|
|
69
|
+
},
|
|
70
|
+
agent_limits: pick(record(taskPackage.agent_execution)?.limits, limitKeys),
|
|
71
|
+
iteration: pick(taskPackage.iteration, ["id", "number", "lease_expires_at"]),
|
|
72
|
+
decision_references: (items(taskPackage.decisions) ?? []).map(decision => pick(decision, ["id", "decision_key", "revision_number", "content_fingerprint"])),
|
|
73
|
+
...(execution ? { execution: pick(execution, ["mode", "runtime_adapter", "runtime_transport", "limits_mode"]) } : {}),
|
|
74
|
+
coverage: { ...pick(coverage, ["serialized_bytes", "max_bytes"]),
|
|
75
|
+
included_sections: strings(coverage?.included_sections),
|
|
76
|
+
omitted_sections: strings(coverage?.omitted_sections),
|
|
77
|
+
truncated_sections: strings(coverage?.truncated_sections) },
|
|
78
|
+
...(projectionTruncated ? { reference_projection_truncated: true } : {}),
|
|
79
|
+
};
|
|
80
|
+
return "## Continuation references (snapshot, not new authority)\n" + JSON.stringify(context) + "\n"
|
|
81
|
+
+ (projectionTruncated ? "Reference projection was truncated; absence is not permission. Stop affected work until the host resolves missing context. " : "")
|
|
82
|
+
+ (execution?.limits_mode === "unbounded" ? "The host's effective limits mode remains authoritative; historical numeric caps do not override an explicitly admitted unbounded employee session. " : "")
|
|
83
|
+
+ "Reuse valid same-scope authorization for bounded implementation, tests and corrections; do not ask again solely because an AI, session or skill changed. "
|
|
84
|
+
+ "The host revalidates current authority; these decision references and contract snapshots are not approvals or proof of current validity. "
|
|
85
|
+
+ "Missing or omitted decision context does not prove that there are no decisions. Do not fetch Gateway context or manage leases yourself; the Runner owns that work. "
|
|
86
|
+
+ "Stop on changed, expired, revoked, consumed or actor-mismatched authority and server/app permission denial. "
|
|
87
|
+
+ "Stop blocked execution units and units that depend on them; continue only independently allowed work inside the assigned scope. "
|
|
88
|
+
+ "A new paid execution or correction attempt requires existing host admission, current contract, remaining attempts/budget and a valid lease; ordinary local retesting grants none of these. "
|
|
89
|
+
+ "Do not replay a completed side effect. Never answer a human-only question or approve on another actor's behalf. "
|
|
90
|
+
+ "Required review, completion and publication gates remain separate; report only observed work and evidence, not delivery or operational success inferred from a local result.";
|
|
91
|
+
}
|
|
23
92
|
export function compilePrompt(taskPackage, options) {
|
|
24
93
|
const contract = record(taskPackage.contract);
|
|
25
94
|
if (!contract) {
|
|
@@ -50,6 +119,7 @@ export function compilePrompt(taskPackage, options) {
|
|
|
50
119
|
const requiredTests = requiredArray(brief.required_tests ?? [], "required_tests");
|
|
51
120
|
const sections = [
|
|
52
121
|
`# Bounded implementation brief: ${options.workItemLabel}`,
|
|
122
|
+
continuationContext(taskPackage),
|
|
53
123
|
`## Objective\n${objective}`,
|
|
54
124
|
`## Required changes\n${requiredChanges.map((item) => `- ${item}`).join("\n")}`,
|
|
55
125
|
`## Invariants\n${invariants.map((item) => `- ${item}`).join("\n")}`,
|
|
@@ -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');
|
|
@@ -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();
|
|
@@ -26,11 +26,48 @@ function scopePath(root, scope) {
|
|
|
26
26
|
throw new Error('DENIED_UNSUPPORTED_SCOPE');
|
|
27
27
|
return path.resolve(root, directory);
|
|
28
28
|
}
|
|
29
|
+
// A blocked pattern as an anchored matcher on the root-relative path: a leading "**/" is any leading folders, a
|
|
30
|
+
// trailing "/**" everything below, "*" and "?" stay within one segment. Brackets and braces stay unsupported and
|
|
31
|
+
// refuse every access.
|
|
32
|
+
function blockedGlob(scope) {
|
|
33
|
+
if (/[\[\]{}]/.test(scope))
|
|
34
|
+
throw new Error('DENIED_UNSUPPORTED_SCOPE');
|
|
35
|
+
let pattern = '';
|
|
36
|
+
for (let i = 0; i < scope.length; i++) {
|
|
37
|
+
const rest = scope.slice(i);
|
|
38
|
+
if (rest.startsWith('**/')) {
|
|
39
|
+
pattern += '(?:.*/)?';
|
|
40
|
+
i += 2;
|
|
41
|
+
}
|
|
42
|
+
else if (rest === '/**') {
|
|
43
|
+
pattern += '(?:/.*)?';
|
|
44
|
+
i += 2;
|
|
45
|
+
}
|
|
46
|
+
else if (rest.startsWith('**')) {
|
|
47
|
+
pattern += '.*';
|
|
48
|
+
i += 1;
|
|
49
|
+
}
|
|
50
|
+
else if (scope[i] === '*')
|
|
51
|
+
pattern += '[^/]*';
|
|
52
|
+
else if (scope[i] === '?')
|
|
53
|
+
pattern += '[^/]';
|
|
54
|
+
else
|
|
55
|
+
pattern += scope[i].replace(/[.+^$()|\\]/g, '\\$&');
|
|
56
|
+
}
|
|
57
|
+
return new RegExp(`^${pattern}$`);
|
|
58
|
+
}
|
|
29
59
|
function exactBlockedScopeContains(root, scope, file) {
|
|
30
60
|
// The compiled repository policy includes this private-file family. Handle
|
|
31
61
|
// it only as a denial, never as a wildcard grant or an ignored block.
|
|
32
62
|
if (scope === '.env.*')
|
|
33
63
|
return file.startsWith(path.join(root, '.env.'));
|
|
64
|
+
// Registered blocks also use patterns such as **/*.keystore or **/.env*. Read only as a denial: a pattern can
|
|
65
|
+
// refuse more paths, never grant one, and allowed scopes keep the plain directory form of scopePath.
|
|
66
|
+
if (/[?*\[\]{}]/.test(scope.endsWith('/**') ? scope.slice(0, -3) : scope)) {
|
|
67
|
+
if (!contains(root, file))
|
|
68
|
+
return false;
|
|
69
|
+
return blockedGlob(scope).test(path.relative(root, file).split(path.sep).join('/'));
|
|
70
|
+
}
|
|
34
71
|
return contains(scopePath(root, scope), file);
|
|
35
72
|
}
|
|
36
73
|
// APFS and HFS+ resolve names regardless of case and Unicode normalization, so app/secrets reaches a blocked
|