mtech-ai-local-runner 0.1.53 → 0.1.55
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 +35 -0
- package/dist/src/adapters/codex/prompt-compiler.js +1 -1
- package/dist/src/asks/commands.js +111 -18
- package/dist/src/asks/messenger.js +9 -3
- package/dist/src/asks/shim.js +18 -11
- package/dist/src/cli.js +15 -6
- package/dist/src/daemon/runtime.js +10 -2
- package/dist/src/gateway-client.js +14 -0
- package/dist/src/guide/aggregate.js +58 -0
- package/dist/src/guide/company.js +102 -0
- package/dist/src/guide/doctor.js +47 -0
- package/dist/src/guide/files.js +237 -0
- package/dist/src/guide/hook-client.js +289 -0
- package/dist/src/guide/integrations.js +335 -0
- package/dist/src/guide/matching.js +314 -0
- package/dist/src/guide/runtime.js +173 -0
- package/dist/src/guide/templates.js +22 -0
- package/dist/src/guide/types.js +95 -0
- package/dist/src/safety/job-tool-dispatcher.js +9 -2
- package/dist/src/safety/local-read-range.js +13 -0
- package/dist/src/safety/local-tool-policy.js +5 -1
- package/dist/src/safety/local-tool-server.js +2 -2
- package/dist/src/safety/local-tool-worker.js +12 -2
- package/dist/src/transport/sync-dispatch.js +12 -0
- package/package.json +1 -1
- package/dist/src/daemon/job-poller.js +0 -42
- package/dist/src/daemon/session-open-poller.js +0 -123
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { GUIDE_SHIM_FILE, refreshShim } from "../asks/shim.js";
|
|
2
|
+
import { aggregateDay } from "./aggregate.js";
|
|
3
|
+
import { confirmPolicyCache, deleteGuideState, dropDay, eventDays, lastEventAt, markReported, pruneGuideFiles, readEventLines, readPolicyCache, utcDate, writePolicyCache } from "./files.js";
|
|
4
|
+
import { codexGuideTrust, guideHookClientScript, guideStatus } from "./integrations.js";
|
|
5
|
+
import { GUIDE_APPS, parseGuidancePolicy } from "./types.js";
|
|
6
|
+
const PASS_INTERVAL_MS = 3_600_000;
|
|
7
|
+
/** A UTC day is reported once it ended this long ago, so a hook that was still writing has finished. */
|
|
8
|
+
const REPORT_GRACE_MS = 2 * 3_600_000;
|
|
9
|
+
/** A sync that confirms the same version moves the cache's confirmation at most this often. */
|
|
10
|
+
const CONFIRM_REFRESH_MS = 3_600_000;
|
|
11
|
+
const GUIDE_OFF = "Guidance is off for this actor.";
|
|
12
|
+
function errorCode(error) {
|
|
13
|
+
const code = error?.code;
|
|
14
|
+
return typeof code === "string" && /^[A-Z0-9_]{1,64}$/.test(code) ? code : "GUIDE_FAILED";
|
|
15
|
+
}
|
|
16
|
+
export class GuideRuntime {
|
|
17
|
+
deps;
|
|
18
|
+
controller = new AbortController();
|
|
19
|
+
timer = null;
|
|
20
|
+
passing = null;
|
|
21
|
+
constructor(deps) {
|
|
22
|
+
this.deps = deps;
|
|
23
|
+
}
|
|
24
|
+
now() { return this.deps.now?.() ?? Date.now(); }
|
|
25
|
+
log(event) {
|
|
26
|
+
try {
|
|
27
|
+
this.deps.log?.(event);
|
|
28
|
+
}
|
|
29
|
+
catch { /* Logging never stops guide. */ }
|
|
30
|
+
}
|
|
31
|
+
/** Never throws. Points an installed guide shim at this release, then runs the pass now and hourly. */
|
|
32
|
+
async start() {
|
|
33
|
+
try {
|
|
34
|
+
await refreshShim({ stateRoot: this.deps.stateRoot, file: GUIDE_SHIM_FILE, feature: "guide", clientPath: guideHookClientScript() });
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
this.log({ event: "guide_shim_refresh_failed", code: errorCode(error) });
|
|
38
|
+
}
|
|
39
|
+
this.schedule(0);
|
|
40
|
+
}
|
|
41
|
+
async stop() {
|
|
42
|
+
this.controller.abort();
|
|
43
|
+
if (this.timer !== null)
|
|
44
|
+
clearTimeout(this.timer);
|
|
45
|
+
this.timer = null;
|
|
46
|
+
await this.passing?.catch(() => undefined);
|
|
47
|
+
}
|
|
48
|
+
schedule(delayMs) {
|
|
49
|
+
if (this.controller.signal.aborted)
|
|
50
|
+
return;
|
|
51
|
+
this.timer = setTimeout(() => {
|
|
52
|
+
this.passing = this.pass().finally(() => {
|
|
53
|
+
this.passing = null;
|
|
54
|
+
this.schedule(this.deps.passIntervalMs ?? PASS_INTERVAL_MS);
|
|
55
|
+
});
|
|
56
|
+
}, delayMs);
|
|
57
|
+
this.timer.unref?.();
|
|
58
|
+
}
|
|
59
|
+
/** The cached policy's version, for local_agent_runner_sync. */
|
|
60
|
+
async policyVersion() {
|
|
61
|
+
return (await readPolicyCache(this.deps.stateRoot, this.now()))?.policy.version;
|
|
62
|
+
}
|
|
63
|
+
/** Takes the guide keys of one sync result. Never throws. */
|
|
64
|
+
async handleSync(result) {
|
|
65
|
+
try {
|
|
66
|
+
if ("guidancePolicy" in result) {
|
|
67
|
+
if (result.guidancePolicy === null) {
|
|
68
|
+
if (await readPolicyCache(this.deps.stateRoot, this.now()) !== null)
|
|
69
|
+
this.log({ event: "guide_off" });
|
|
70
|
+
await deleteGuideState(this.deps.stateRoot);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (parseGuidancePolicy(result.guidancePolicy) === null) {
|
|
74
|
+
this.log({ event: "guide_policy_invalid", code: "GUIDE_POLICY_INVALID" });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
await writePolicyCache(this.deps.stateRoot, result.guidancePolicy, this.now());
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (result.guidancePolicyVersion === undefined)
|
|
81
|
+
return;
|
|
82
|
+
const cached = await readPolicyCache(this.deps.stateRoot, this.now());
|
|
83
|
+
if (cached !== null && cached.policy.version === result.guidancePolicyVersion && this.now() - cached.confirmedAt >= CONFIRM_REFRESH_MS) {
|
|
84
|
+
await confirmPolicyCache(this.deps.stateRoot, this.now());
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
this.log({ event: "guide_sync_failed", code: errorCode(error) });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** heartbeat runner.guidance, or undefined while there is neither a policy nor a guide hook on this Mac. Never throws. */
|
|
92
|
+
async advertisement() {
|
|
93
|
+
try {
|
|
94
|
+
const cached = await readPolicyCache(this.deps.stateRoot, this.now());
|
|
95
|
+
const apps = {};
|
|
96
|
+
for (const app of GUIDE_APPS) {
|
|
97
|
+
const status = await guideStatus(app, this.deps.env, this.deps.stateRoot).catch(() => null);
|
|
98
|
+
if (status === null || status.state === "not_installed")
|
|
99
|
+
continue;
|
|
100
|
+
const last = await lastEventAt(this.deps.stateRoot, app);
|
|
101
|
+
apps[app] = app === "codex" ? { state: status.state, last_event_at: last, trusted: await codexGuideTrust(this.deps.env) }
|
|
102
|
+
: { state: status.state, last_event_at: last };
|
|
103
|
+
}
|
|
104
|
+
if (cached === null && Object.keys(apps).length === 0)
|
|
105
|
+
return undefined;
|
|
106
|
+
return { version: 1, policy_version: cached?.policy.version ?? null, apps };
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** One report and prune pass. Never throws. */
|
|
113
|
+
async pass() {
|
|
114
|
+
try {
|
|
115
|
+
const now = this.now();
|
|
116
|
+
if (await readPolicyCache(this.deps.stateRoot, now) !== null)
|
|
117
|
+
await this.reportDays(now);
|
|
118
|
+
await pruneGuideFiles(this.deps.stateRoot, now);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
this.log({ event: "guide_pass_failed", code: errorCode(error) });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async reportDays(now) {
|
|
125
|
+
const cutoff = utcDate(now - REPORT_GRACE_MS);
|
|
126
|
+
const days = [...(await eventDays(this.deps.stateRoot))].sort(([left], [right]) => left.localeCompare(right));
|
|
127
|
+
for (const [date, files] of days) {
|
|
128
|
+
if (date >= cutoff || files.fresh === undefined)
|
|
129
|
+
continue;
|
|
130
|
+
// Every line of the day, reported or not, so a repeat replaces the Gateway's row with the full total.
|
|
131
|
+
const lines = await readEventLines([files.reported, files.fresh].filter((path) => path !== undefined));
|
|
132
|
+
try {
|
|
133
|
+
for (const [key, counts] of aggregateDay(lines)) {
|
|
134
|
+
const [app, projectKey] = key.split("|");
|
|
135
|
+
await this.deps.report({ date, app, projectKey, counts }, this.controller.signal);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (errorCode(error) === "GATEWAY_TOOL_REJECTED" && error instanceof Error && error.message.includes(GUIDE_OFF)) {
|
|
140
|
+
// The Gateway refuses while guide is off for this person: nothing that waits here is ever sent (spec section 8).
|
|
141
|
+
this.log({ event: "guide_report_refused", code: "GUIDE_OFF" });
|
|
142
|
+
await deleteGuideState(this.deps.stateRoot);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (errorCode(error) === "GATEWAY_TOOL_REJECTED") {
|
|
146
|
+
this.log({ event: "guide_report_refused", code: "GUIDE_DAY_DROPPED" });
|
|
147
|
+
await dropDay(this.deps.stateRoot, date);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
this.log({ event: "guide_report_failed", code: errorCode(error) });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
await markReported(this.deps.stateRoot, date);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** The daemon's side loops with guide started before them and stopped after them, like withAsks. */
|
|
158
|
+
export function withGuide(guide, loop) {
|
|
159
|
+
return {
|
|
160
|
+
async run() {
|
|
161
|
+
await guide.start();
|
|
162
|
+
try {
|
|
163
|
+
await loop.run();
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
await guide.stop();
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
stop() {
|
|
170
|
+
loop.stop();
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Every sentence a guide hook adds to a session (spec section 3.4). Nothing from the Gateway, no colleague text, path,
|
|
3
|
+
* host or command reaches a session: only these sentences and the Gateway tool names below.
|
|
4
|
+
*/
|
|
5
|
+
export const SESSION_GUIDANCE_TEXT = 'AI Gateway: if its tools are available, call workspace_getting_started (response_mode "brief") at task start. '
|
|
6
|
+
+ "Search with knowledge_search (limit 5) before opening a KnowledgeBase file you do not already know you need.";
|
|
7
|
+
export const KB_SEARCH_FIRST_TEXT = "AI Gateway: if its tools are available and you do not already know this KnowledgeBase file is the one you need, "
|
|
8
|
+
+ "search with knowledge_search (limit 5) first.";
|
|
9
|
+
export const DB_WRITE_TEXT = "AI Gateway: this looks like a direct database write, which no Gateway tool covers. Confirm it with the employee before you run it.";
|
|
10
|
+
/** The Gateway tool each CLI rule names (spec section 3.2). cli_db_write names none. */
|
|
11
|
+
export const CLI_RULE_TOOLS = Object.freeze({
|
|
12
|
+
cli_aws_logs: "aws_log_events_search",
|
|
13
|
+
cli_aws_s3_read: "s3_objects_list",
|
|
14
|
+
cli_db_read: "db_read_query",
|
|
15
|
+
cli_ssh: "server_read",
|
|
16
|
+
cli_vapor_deploy: "deployment_execute",
|
|
17
|
+
});
|
|
18
|
+
export function cliRuleText(rule) {
|
|
19
|
+
if (rule === "cli_db_write")
|
|
20
|
+
return DB_WRITE_TEXT;
|
|
21
|
+
return `AI Gateway: if its tools are available, prefer ${CLI_RULE_TOOLS[rule]}: it covers this with the Gateway's own access and an audit record.`;
|
|
22
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Guide hooks (Work Item d38d60f9, docs/superpowers/specs/2026-10-25-gateway-guide-hooks-design.md). Names shared by
|
|
3
|
+
* the guide hook client, its integrations, the daemon's guide runtime and doctor. This module imports nothing, so the
|
|
4
|
+
* hook client stays cheap to load. The Gateway sends rule modes, workspace and repository names, project keys and tool
|
|
5
|
+
* names only; every sentence a session sees comes from templates.ts.
|
|
6
|
+
*/
|
|
7
|
+
export const GUIDE_APPS = ["claude", "codex"];
|
|
8
|
+
export const GUIDE_EVENTS = ["session-start", "pre-tool"];
|
|
9
|
+
/** In the Gateway's order (AiOpsGuidancePolicy::RULES). */
|
|
10
|
+
export const RULE_IDS = ["session_guidance", "kb_search_first", "cli_aws_logs", "cli_aws_s3_read", "cli_db_read", "cli_db_write",
|
|
11
|
+
"cli_ssh", "cli_vapor_deploy"];
|
|
12
|
+
/** There is no block mode (owner decision 2026-10-25): a guide hook never sends permissionDecision. */
|
|
13
|
+
export const MODES = ["off", "observe", "warn"];
|
|
14
|
+
export const OUTCOMES = ["observed", "warned"];
|
|
15
|
+
/** In the Gateway's order (AiOpsGuidanceCounts::COUNT_KEYS). */
|
|
16
|
+
export const COUNT_KEYS = ["sessions", "sessions_with_kb_read", "sessions_with_search", "sessions_with_both", "knowledge_search_calls",
|
|
17
|
+
"gateway_tool_calls", "kb_reads_read_tool", "kb_reads_search_tool", "kb_reads_shell", "kb_read_bytes", "injected_chars", "undecidable"];
|
|
18
|
+
export const POLICY_VERSION = /^[a-f0-9]{16}$/;
|
|
19
|
+
/** What a Claude or Codex session id may look like locally. It never leaves this Mac. */
|
|
20
|
+
export const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
21
|
+
export const PROJECT_KEY = /^[a-z0-9][a-z0-9_.-]{0,190}$/;
|
|
22
|
+
const WORKSPACE = /^[a-z0-9][a-z0-9_.-]{0,62}$/;
|
|
23
|
+
const REPOSITORY = /^[a-z0-9][a-z0-9_.-]{0,99}$/;
|
|
24
|
+
const TOOL_NAME = /^[a-z][a-z0-9_]{1,63}$/;
|
|
25
|
+
/** A session in a folder that holds company clones, such as ~/Projects. */
|
|
26
|
+
export const MULTI_REPOSITORY = "multi_repository";
|
|
27
|
+
/** A company repository that the registry does not list. */
|
|
28
|
+
export const UNREGISTERED = "unregistered";
|
|
29
|
+
/** A policy the Gateway has not confirmed for this long turns every warn into observe (spec section 6). */
|
|
30
|
+
export const STALE_POLICY_MS = 7 * 24 * 60 * 60 * 1000;
|
|
31
|
+
/** The most hook input the client reads from stdin. */
|
|
32
|
+
export const MAX_HOOK_INPUT_BYTES = 64 * 1024;
|
|
33
|
+
/** <stateRoot>/guide holds everything guide keeps on the Mac. */
|
|
34
|
+
export const GUIDE_DIR = "guide";
|
|
35
|
+
function oneOf(values, value) {
|
|
36
|
+
return typeof value === "string" && values.includes(value);
|
|
37
|
+
}
|
|
38
|
+
export function isGuideApp(value) { return oneOf(GUIDE_APPS, value); }
|
|
39
|
+
export function isGuideEvent(value) { return oneOf(GUIDE_EVENTS, value); }
|
|
40
|
+
export function isRuleId(value) { return oneOf(RULE_IDS, value); }
|
|
41
|
+
export function isMode(value) { return oneOf(MODES, value); }
|
|
42
|
+
export function isOutcome(value) { return oneOf(OUTCOMES, value); }
|
|
43
|
+
function isRecord(value) {
|
|
44
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
45
|
+
}
|
|
46
|
+
/** The guidance_policy of a sync result, or null when anything in it is missing or malformed. */
|
|
47
|
+
export function parseGuidancePolicy(input) {
|
|
48
|
+
if (!isRecord(input))
|
|
49
|
+
return null;
|
|
50
|
+
const { version, issued_at: issuedAt, rules, company_workspaces: workspaces, repositories, gateway_tool_names: tools } = input;
|
|
51
|
+
if (typeof version !== "string" || !POLICY_VERSION.test(version))
|
|
52
|
+
return null;
|
|
53
|
+
if (typeof issuedAt !== "number" || !Number.isSafeInteger(issuedAt) || issuedAt < 0)
|
|
54
|
+
return null;
|
|
55
|
+
if (!isRecord(rules))
|
|
56
|
+
return null;
|
|
57
|
+
const modes = {};
|
|
58
|
+
for (const rule of RULE_IDS) {
|
|
59
|
+
const mode = rules[rule];
|
|
60
|
+
if (!isMode(mode))
|
|
61
|
+
return null;
|
|
62
|
+
modes[rule] = mode;
|
|
63
|
+
}
|
|
64
|
+
if (!Array.isArray(workspaces) || workspaces.length > 20 || !workspaces.every(item => typeof item === "string" && WORKSPACE.test(item)))
|
|
65
|
+
return null;
|
|
66
|
+
const repositoryMap = {};
|
|
67
|
+
// PHP encodes an empty map as [].
|
|
68
|
+
if (Array.isArray(repositories)) {
|
|
69
|
+
if (repositories.length > 0)
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
else if (isRecord(repositories)) {
|
|
73
|
+
const entries = Object.entries(repositories);
|
|
74
|
+
if (entries.length > 1000)
|
|
75
|
+
return null;
|
|
76
|
+
for (const [name, key] of entries) {
|
|
77
|
+
if (!REPOSITORY.test(name) || typeof key !== "string" || !PROJECT_KEY.test(key))
|
|
78
|
+
return null;
|
|
79
|
+
repositoryMap[name] = key;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (!Array.isArray(tools) || tools.length > 2000 || !tools.every(item => typeof item === "string" && TOOL_NAME.test(item)))
|
|
86
|
+
return null;
|
|
87
|
+
return { version, issuedAt, rules: modes, companyWorkspaces: [...workspaces], repositories: repositoryMap,
|
|
88
|
+
gatewayToolNames: [...tools] };
|
|
89
|
+
}
|
|
90
|
+
/** Zero counts for one day, app and project, every key present. */
|
|
91
|
+
export function emptyDayCounts() {
|
|
92
|
+
const counts = Object.fromEntries(COUNT_KEYS.map(key => [key, 0]));
|
|
93
|
+
const rules = Object.fromEntries(RULE_IDS.map(rule => [rule, { observed: 0, warned: 0 }]));
|
|
94
|
+
return { ...counts, rules };
|
|
95
|
+
}
|
|
@@ -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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
`;
|
|
@@ -65,6 +65,18 @@ export function createSyncDispatcher(deps) {
|
|
|
65
65
|
}
|
|
66
66
|
};
|
|
67
67
|
return async (result) => {
|
|
68
|
+
// Guide first: it only writes or deletes the local policy cache.
|
|
69
|
+
if (deps.guide) {
|
|
70
|
+
try {
|
|
71
|
+
await deps.guide.handleSync({
|
|
72
|
+
...("guidancePolicy" in result ? { guidancePolicy: result.guidancePolicy } : {}),
|
|
73
|
+
...(result.guidancePolicyVersion !== undefined ? { guidancePolicyVersion: result.guidancePolicyVersion } : {}),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
fail("guide", "GUIDE_DISPATCH_FAILED", error);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
68
80
|
// Asks first: they are local and quick, while each open can spend seconds on report retries.
|
|
69
81
|
if (deps.asks) {
|
|
70
82
|
try {
|
package/package.json
CHANGED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
export class JobPoller {
|
|
2
|
-
deps;
|
|
3
|
-
stopped = false;
|
|
4
|
-
failures = 0;
|
|
5
|
-
constructor(deps) {
|
|
6
|
-
this.deps = deps;
|
|
7
|
-
}
|
|
8
|
-
stop() { this.stopped = true; }
|
|
9
|
-
async tick() {
|
|
10
|
-
if (this.stopped)
|
|
11
|
-
return;
|
|
12
|
-
let stage = "heartbeat";
|
|
13
|
-
try {
|
|
14
|
-
await this.deps.heartbeat?.();
|
|
15
|
-
stage = "wait";
|
|
16
|
-
const claim = await this.deps.wait();
|
|
17
|
-
stage = "execute";
|
|
18
|
-
if (claim.status === "claimed" && claim.job)
|
|
19
|
-
await this.deps.execute(claim.job);
|
|
20
|
-
this.failures = 0;
|
|
21
|
-
}
|
|
22
|
-
catch {
|
|
23
|
-
try {
|
|
24
|
-
this.deps.reportError?.({ stage, code: stage === "execute" ? "EXECUTION_UNACKNOWLEDGED" : "GATEWAY_UNAVAILABLE" });
|
|
25
|
-
}
|
|
26
|
-
catch { /* Observational logging cannot bypass stop/backoff or discard recovery state. */ }
|
|
27
|
-
// Unknown execution/terminal outcomes must reconcile before any new claim.
|
|
28
|
-
if (stage === "execute") {
|
|
29
|
-
this.stop();
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
this.failures += 1;
|
|
33
|
-
const base = Math.min(30_000, 1000 * (2 ** Math.min(5, this.failures - 1)));
|
|
34
|
-
const jitter = Math.floor(base * 0.2 * (this.deps.random ?? Math.random)());
|
|
35
|
-
await this.deps.delay(base + jitter);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
async run() {
|
|
39
|
-
while (!this.stopped)
|
|
40
|
-
await this.tick();
|
|
41
|
-
}
|
|
42
|
-
}
|
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
const MINIMUM_CYCLE_MS = 1000;
|
|
2
|
-
/** Pauses before the second and third report attempts. The server treats a repeat report as idempotent. */
|
|
3
|
-
const REPORT_RETRY_DELAYS_MS = [1000, 2000];
|
|
4
|
-
/** The caught error's own failure code (a GatewayToolError code such as GATEWAY_HTTP_401), never its message, which may carry server text. */
|
|
5
|
-
function causeOf(error) {
|
|
6
|
-
const code = error?.code;
|
|
7
|
-
return typeof code === "string" && /^[A-Z0-9_]{1,64}$/.test(code) ? code : undefined;
|
|
8
|
-
}
|
|
9
|
-
/** Resolves after the delay or at once on abort, and never leaves a listener on a long-lived signal. */
|
|
10
|
-
export function abortableDelay(milliseconds, signal) {
|
|
11
|
-
return new Promise(resolve => {
|
|
12
|
-
if (signal.aborted) {
|
|
13
|
-
resolve();
|
|
14
|
-
return;
|
|
15
|
-
}
|
|
16
|
-
const onAbort = () => { clearTimeout(timer); resolve(); };
|
|
17
|
-
const timer = setTimeout(() => { signal.removeEventListener("abort", onAbort); resolve(); }, milliseconds);
|
|
18
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
19
|
-
});
|
|
20
|
-
}
|
|
21
|
-
export class SessionOpenPoller {
|
|
22
|
-
deps;
|
|
23
|
-
controller = new AbortController();
|
|
24
|
-
failures = 0;
|
|
25
|
-
constructor(deps) {
|
|
26
|
-
this.deps = deps;
|
|
27
|
-
}
|
|
28
|
-
stop() { this.controller.abort(); }
|
|
29
|
-
fail(stage, error, code = `SESSION_OPEN_${stage.toUpperCase()}_FAILED`) {
|
|
30
|
-
const cause = causeOf(error);
|
|
31
|
-
try {
|
|
32
|
-
this.deps.reportError?.({ stage, code, ...(cause ? { cause } : {}) });
|
|
33
|
-
}
|
|
34
|
-
catch { /* Logging cannot stop the loop. */ }
|
|
35
|
-
}
|
|
36
|
-
async pause(milliseconds, signal) {
|
|
37
|
-
await this.deps.delay(milliseconds, signal).catch(() => undefined);
|
|
38
|
-
}
|
|
39
|
-
/** Opens one claimed request and reports it. A failure here never reaches the rest of the batch. */
|
|
40
|
-
async handle(request, signal) {
|
|
41
|
-
let result;
|
|
42
|
-
try {
|
|
43
|
-
result = await this.deps.open(request);
|
|
44
|
-
}
|
|
45
|
-
catch (error) {
|
|
46
|
-
if (!signal.aborted)
|
|
47
|
-
this.fail("open", error);
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
const openId = request?.open_id;
|
|
51
|
-
if (typeof openId !== "string" || openId === "")
|
|
52
|
-
return;
|
|
53
|
-
for (let attempt = 0;; attempt += 1) {
|
|
54
|
-
try {
|
|
55
|
-
await this.deps.report(openId, result, signal);
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
catch (error) {
|
|
59
|
-
if (signal.aborted)
|
|
60
|
-
return;
|
|
61
|
-
const retryDelay = REPORT_RETRY_DELAYS_MS[attempt];
|
|
62
|
-
if (retryDelay === undefined) {
|
|
63
|
-
this.fail("report", error);
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
await this.pause(retryDelay, signal);
|
|
67
|
-
if (signal.aborted)
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
async tick() {
|
|
73
|
-
const signal = this.controller.signal;
|
|
74
|
-
if (signal.aborted)
|
|
75
|
-
return;
|
|
76
|
-
const now = this.deps.now ?? (() => performance.now());
|
|
77
|
-
const started = now();
|
|
78
|
-
let opens;
|
|
79
|
-
let retryAfterSeconds;
|
|
80
|
-
try {
|
|
81
|
-
({ opens, retryAfterSeconds } = await this.deps.wait(signal));
|
|
82
|
-
}
|
|
83
|
-
catch (error) {
|
|
84
|
-
if (signal.aborted)
|
|
85
|
-
return;
|
|
86
|
-
this.fail("wait", error);
|
|
87
|
-
this.failures += 1;
|
|
88
|
-
const base = Math.min(30_000, 1000 * (2 ** Math.min(5, this.failures - 1)));
|
|
89
|
-
const jitter = Math.floor(base * 0.2 * (this.deps.random ?? Math.random)());
|
|
90
|
-
await this.pause(base + jitter, signal);
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
this.failures = 0;
|
|
94
|
-
const claimed = Array.isArray(opens) ? opens : [];
|
|
95
|
-
for (const request of claimed) {
|
|
96
|
-
if (signal.aborted)
|
|
97
|
-
return;
|
|
98
|
-
await this.handle(request, signal);
|
|
99
|
-
}
|
|
100
|
-
if (claimed.length > 0 || signal.aborted)
|
|
101
|
-
return;
|
|
102
|
-
// A wait that returns at once with nothing (a Gateway wait of 0) must not become a hot loop, and while the Gateway's
|
|
103
|
-
// session open switch is off it asks for a longer pause (retry_after_seconds). The clock is monotonic and clamped.
|
|
104
|
-
const elapsed = Math.max(0, now() - started);
|
|
105
|
-
const remainder = elapsed < MINIMUM_CYCLE_MS ? MINIMUM_CYCLE_MS - elapsed : 0;
|
|
106
|
-
const wanted = retryAfterSeconds === undefined ? remainder : Math.max(remainder, retryAfterSeconds * 1000);
|
|
107
|
-
if (wanted > 0)
|
|
108
|
-
await this.pause(wanted, signal);
|
|
109
|
-
}
|
|
110
|
-
async run() {
|
|
111
|
-
const signal = this.controller.signal;
|
|
112
|
-
while (!signal.aborted) {
|
|
113
|
-
try {
|
|
114
|
-
await this.tick();
|
|
115
|
-
}
|
|
116
|
-
catch {
|
|
117
|
-
this.fail("wait", undefined, "SESSION_OPEN_LOOP_FAILED");
|
|
118
|
-
if (!signal.aborted)
|
|
119
|
-
await this.pause(1000, signal);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|