@melaya/runner 1.1.73 → 1.1.74
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/dist/assistantHost.py +75 -3
- package/dist/connection.js +15 -1
- package/dist/stepPlans.d.ts +37 -0
- package/dist/stepPlans.js +103 -0
- package/package.json +1 -1
package/dist/assistantHost.py
CHANGED
|
@@ -149,7 +149,9 @@ def _config_hash() -> str:
|
|
|
149
149
|
ONLY when the capability env is set, so a browser-less host hashes exactly
|
|
150
150
|
like a pre-browser server build (parity preserved). The TARGET GRANT itself
|
|
151
151
|
is NEVER hashed and never in the env at boot: it arrives per turn on the
|
|
152
|
-
turn frame and is purged at turn end (see _apply_browser_turn_grant).
|
|
152
|
+
turn frame and is purged at turn end (see _apply_browser_turn_grant).
|
|
153
|
+
Computer Control (protocol 5) follows the same rule: "computer" right after
|
|
154
|
+
"browser", only when MEL_ASSISTANT_COMPUTER_READY is set."""
|
|
153
155
|
import hashlib
|
|
154
156
|
raw_conn = os.environ.get("MEL_ASSISTANT_CONNECTORS", "") or ""
|
|
155
157
|
connectors = ",".join(sorted(c.lower() for c in raw_conn.split(",") if c.strip()))
|
|
@@ -162,6 +164,11 @@ def _config_hash() -> str:
|
|
|
162
164
|
]
|
|
163
165
|
if _browser_capable():
|
|
164
166
|
fields.append("browser")
|
|
167
|
+
# Computer Control PRESENCE (protocol 5), same rule as browser: appended
|
|
168
|
+
# only when on, right after "browser", byte-for-byte like
|
|
169
|
+
# runnerAssistantRelay.ts assistantConfigHash (COMPUTER_HASH_MARKER).
|
|
170
|
+
if _computer_ready():
|
|
171
|
+
fields.append("computer")
|
|
165
172
|
# Surgical tool pinning: PRESENCE of pinned tool names is part of the host
|
|
166
173
|
# boot config, exactly like browser capability above — a warm host that
|
|
167
174
|
# never registered a pinned tool active must reboot when a turn first
|
|
@@ -198,6 +205,14 @@ def _browser_capable() -> bool:
|
|
|
198
205
|
return (os.environ.get("MEL_ASSISTANT_BROWSER_CAPABLE", "") or "") in ("1", "true", "True")
|
|
199
206
|
|
|
200
207
|
|
|
208
|
+
def _computer_ready() -> bool:
|
|
209
|
+
"""Computer Control on this host (assistant protocol 5): the server sets
|
|
210
|
+
MEL_ASSISTANT_COMPUTER_READY when a computer is on for the chat (Devices
|
|
211
|
+
tile) or, without a selection, the user has one online. The server pins
|
|
212
|
+
every computer call to the chat's computers (/api/v1/computer/command)."""
|
|
213
|
+
return (os.environ.get("MEL_ASSISTANT_COMPUTER_READY", "") or "") in ("1", "true", "True")
|
|
214
|
+
|
|
215
|
+
|
|
201
216
|
def _render_summary_text(summary) -> str:
|
|
202
217
|
"""Render a server AssistantSummary ({facts, provenance}) to a data-only text
|
|
203
218
|
block for the compressed-summary slot. Never policy — reference only."""
|
|
@@ -918,10 +933,18 @@ def _build_agent():
|
|
|
918
933
|
# are trusted-UI-only and never part of this category (plan 0.1).
|
|
919
934
|
browser_enabled = _browser_capable()
|
|
920
935
|
|
|
936
|
+
# Computer Control (protocol 5): the computer_* toolkit (registry category
|
|
937
|
+
# "computer", shared/tools/computer.py) when the server says a computer is on
|
|
938
|
+
# for this chat. Each call posts to /api/v1/computer/command, where the
|
|
939
|
+
# server pins it to the chat's computers and applies the allowlist, app
|
|
940
|
+
# tiers, approvals and the kill switch.
|
|
941
|
+
computer_enabled = _computer_ready()
|
|
942
|
+
|
|
921
943
|
# Read-only platform tools (melaya_agent); phone control (Device Control) on
|
|
922
944
|
# mobile only. These POST to /api/v1/private/assistant-tool + /phone/command
|
|
923
945
|
# with MELAYA_API_KEY; tenant scope is enforced server-side.
|
|
924
|
-
categories = ["melaya_agent"] + (["phone"] if phone_enabled else []) + (["browser"] if browser_enabled else [])
|
|
946
|
+
categories = (["melaya_agent"] + (["phone"] if phone_enabled else []) + (["browser"] if browser_enabled else [])
|
|
947
|
+
+ (["computer"] if computer_enabled else []))
|
|
925
948
|
# Connector tool sets the user enabled for THIS chat (any service — odoo,
|
|
926
949
|
# stripe, shopify, …). When present, use a LAZY toolkit: base tools stay
|
|
927
950
|
# active+pinned, and the connectors' tools are deferred + discoverable via
|
|
@@ -977,6 +1000,12 @@ def _build_agent():
|
|
|
977
1000
|
# management + every browser action is directly callable, no search/activate.
|
|
978
1001
|
if browser_enabled:
|
|
979
1002
|
_budget = max(_budget, 64)
|
|
1003
|
+
# Same for the computer: ~38 computer_* tools must all stay pinned (no
|
|
1004
|
+
# search_tools/activate_tool per click, and an activated write would go
|
|
1005
|
+
# through the connector card on top of the server's own approval).
|
|
1006
|
+
# 64 holds melaya_agent + computer; each other device set adds ~40.
|
|
1007
|
+
if computer_enabled:
|
|
1008
|
+
_budget = max(_budget, 24 + 40 * (1 + int(phone_enabled) + int(browser_enabled)))
|
|
980
1009
|
# Surgical tool pinning: widen the budget so every pinned tool fits
|
|
981
1010
|
# alongside the base active set (melaya_agent + phone/browser if
|
|
982
1011
|
# enabled) — mirrors the phone_enabled/browser_enabled widening above,
|
|
@@ -1124,6 +1153,45 @@ def _build_agent():
|
|
|
1124
1153
|
"or route around it; tell the user if the task needs another site.\n"
|
|
1125
1154
|
if browser_enabled else ""
|
|
1126
1155
|
)
|
|
1156
|
+
# Computer Control discipline: a hand-mirror of the cloud Assistant's
|
|
1157
|
+
# computer guidance (assistantComputerTools.ts COMPUTER_PROMPT_HINT,
|
|
1158
|
+
# computerVerbs.ts COMPUTER_ROUTING / COMPUTER_LOOP / COMPUTER_LIMITS,
|
|
1159
|
+
# fastSchema.ts COMPUTER_FAST_GUIDANCE). Keep them in step.
|
|
1160
|
+
computer_rule = (
|
|
1161
|
+
"- COMPUTER CONTROL: you can operate the user's own computer (Windows, macOS or "
|
|
1162
|
+
"Linux, through the Melaya desktop app) with the computer_* tools. When the user "
|
|
1163
|
+
"asks whether you can access or use their computer, the answer is YES. Use the "
|
|
1164
|
+
"computer for desktop apps; prefer a connected service, the browser or the phone "
|
|
1165
|
+
"when they can do the job. Call computer_status first, and whenever a computer "
|
|
1166
|
+
"tool says the app is unreachable.\n"
|
|
1167
|
+
"- SEE THE SCREEN FIRST, then read, act, verify: read the window with "
|
|
1168
|
+
"computer_screen (computer_screenshot, computer_zoom or computer_ocr for a canvas, "
|
|
1169
|
+
"game, remote desktop or video that has no element tree), act on ONE element by "
|
|
1170
|
+
"its @eN ref or exact label, then check the result's observed change. A no_effect "
|
|
1171
|
+
"result means nothing changed: do not repeat the action, read again and pick "
|
|
1172
|
+
"another target. NEVER ask the user to describe, paste or screenshot their own "
|
|
1173
|
+
"screen: you have eyes, use them.\n"
|
|
1174
|
+
"- GO FAST: once you have read the window and can name the next steps, send them in "
|
|
1175
|
+
"ONE computer_fast call (click / type / select / press keys and chords / scroll / "
|
|
1176
|
+
"wait / expect / read by the names you read, collect for lists, for_each for rows) "
|
|
1177
|
+
"instead of one call per click; it re-finds every target, stops at the first "
|
|
1178
|
+
"surprise and hands the window back. computer_batch runs a known list of actions "
|
|
1179
|
+
"in one call; computer_path draws or traces strokes (canvas ink, signatures, "
|
|
1180
|
+
"sliders). Use the single computer_* tools to explore an unread window, for "
|
|
1181
|
+
"logins, secure fields and file dialogs.\n"
|
|
1182
|
+
"- APPROVALS AND LIMITS: writes follow this chat's autonomy mode, decided by the "
|
|
1183
|
+
"SYSTEM. If a result says an approval is waiting, a card is already in front of "
|
|
1184
|
+
"the user: re-issue the SAME call once, never ask in chat instead. Only apps the "
|
|
1185
|
+
"user allowed in Computer Control can be driven, each at its level (not_allowed / "
|
|
1186
|
+
"tier_denied: ask the user to allow it, never open another app or work around it). "
|
|
1187
|
+
"Never type into password, PIN, OTP or card fields (secure_field), and let the user "
|
|
1188
|
+
"answer admin / UAC prompts themselves (elevated_window, secure_desktop).\n"
|
|
1189
|
+
"- STOP: the user can stop you at any moment with the Stop button or the stop "
|
|
1190
|
+
"shortcut (Ctrl+Alt+End). After a stopped result make no more computer calls and "
|
|
1191
|
+
"report honestly what was done. Call computer_stop yourself the moment the user "
|
|
1192
|
+
"says stop or something goes wrong on screen.\n"
|
|
1193
|
+
if computer_enabled else ""
|
|
1194
|
+
)
|
|
1127
1195
|
# Surgical tool pinning: those exact tool names are already ACTIVE (not
|
|
1128
1196
|
# deferred), so the rule says to call them directly. Per-turn connectors
|
|
1129
1197
|
# (protocol 3): connector_services is empty here and the rule is rendered per
|
|
@@ -1197,12 +1265,15 @@ def _build_agent():
|
|
|
1197
1265
|
"mode the user approves each one). That is your CURRENT TOOLBOX, not a "
|
|
1198
1266
|
"description of Melaya"
|
|
1199
1267
|
+ (" — plus phone-control tools to drive the user's paired phone." if phone_enabled else ".")
|
|
1268
|
+
+ (" Computer-control tools (computer_*) drive the user's own computer through the "
|
|
1269
|
+
"Melaya desktop app." if computer_enabled else "")
|
|
1200
1270
|
+ "\nRules:\n"
|
|
1201
1271
|
"- Use tools to answer questions about the user's pipelines, spend, usage, "
|
|
1202
1272
|
"templates or evals — never invent numbers. For cost, melaya_cost_summary "
|
|
1203
1273
|
"supports dimension='pipeline' to find which pipeline cost the most.\n"
|
|
1204
1274
|
+ phone_rule
|
|
1205
1275
|
+ browser_rule
|
|
1276
|
+
+ computer_rule
|
|
1206
1277
|
+ connector_rule
|
|
1207
1278
|
+ core_rule
|
|
1208
1279
|
+ "- Be concise and concrete; format small tables or bullet lists when comparing items.\n"
|
|
@@ -1279,7 +1350,8 @@ def _build_agent():
|
|
|
1279
1350
|
# wall-clock budget is enforced separately in _run_turn.
|
|
1280
1351
|
max_iters=(int(os.environ.get("MEL_ASSISTANT_MAX_ITERS_PHONE", "300") or "300") if phone_enabled
|
|
1281
1352
|
else (int(os.environ.get("MEL_ASSISTANT_MAX_ITERS_BROWSER", "300") or "300") if browser_enabled
|
|
1282
|
-
else (
|
|
1353
|
+
else (int(os.environ.get("MEL_ASSISTANT_MAX_ITERS_COMPUTER", "300") or "300") if computer_enabled
|
|
1354
|
+
else (40 if (connector_services or _turn_connectors_mode()) else 8)))),
|
|
1283
1355
|
# EVERY assistant turn is long-horizon, not only device-driving ones.
|
|
1284
1356
|
#
|
|
1285
1357
|
# The codex cap is a latency default written for pipeline phases. An
|
package/dist/connection.js
CHANGED
|
@@ -44,6 +44,9 @@ import { newTurnFileState, readTurnFile, turnOutputDir } from "./assistantFiles.
|
|
|
44
44
|
// registered on this computer, served after the runner verifies each
|
|
45
45
|
// capability itself.
|
|
46
46
|
import { VAULT_HOST_CAPABILITY, VaultHostProcess, VaultRegistry, VaultService, attachVaultSocketHandlers, findVaultHostScript, setVaultRelayPort, vaultToolEnv, trustRunVaultCapability, } from "./vaults.js";
|
|
47
|
+
// Stored plans: the server reads ~/.melaya/plans/<name>.json through this runner
|
|
48
|
+
// for the grouped-step tools' plan: {file} source (runner:plan-read).
|
|
49
|
+
import { attachPlanSocketHandlers } from "./stepPlans.js";
|
|
47
50
|
// Obsidian app bridge: App ops through the user's running Obsidian (CLI /
|
|
48
51
|
// Local REST API); the REST key stays in ~/.melaya-runner/vaults.
|
|
49
52
|
import { ObsidianAppBridge } from "./obsidianApp.js";
|
|
@@ -106,7 +109,11 @@ const RESERVED_BROWSER_ENV = ["MEL_BROWSER_URL", "MEL_BROWSER_TOKEN"];
|
|
|
106
109
|
// to the server as `file` frames with their bytes, under the caps and path
|
|
107
110
|
// confinement of assistantFiles.ts. A server that does not send `fileDelivery`
|
|
108
111
|
// gets exactly the v3 behaviour (files stay on this computer only).
|
|
109
|
-
|
|
112
|
+
// v5: Computer Control. A start / prewarm frame may carry `computerReady`; the
|
|
113
|
+
// host then registers the computer_* toolkit (MEL_ASSISTANT_COMPUTER_READY) and
|
|
114
|
+
// hashes "computer" after "browser". A server only sends it to a v5 runner, so
|
|
115
|
+
// an older host never boots config-stale over it.
|
|
116
|
+
const ASSISTANT_PROTOCOL_VERSION = 5;
|
|
110
117
|
// Best-effort read of our own package version (mirrors cli.ts). Advertised in the
|
|
111
118
|
// hello so the server can log / gate on a package floor too.
|
|
112
119
|
let RUNNER_VERSION = "0.0.0";
|
|
@@ -305,6 +312,7 @@ export async function connect(opts) {
|
|
|
305
312
|
app: new ObsidianAppBridge({ dir: vaultRegistry.dir, log: vaultLog }),
|
|
306
313
|
});
|
|
307
314
|
attachVaultSocketHandlers(socket, vaultService, () => pickNativeFolder("Select your Obsidian vault folder"));
|
|
315
|
+
attachPlanSocketHandlers(socket);
|
|
308
316
|
if (vaultRegistry.list().length) {
|
|
309
317
|
void vaultService.restore().catch((e) => vaultLog(`restore failed: ${e?.message || e}`));
|
|
310
318
|
}
|
|
@@ -2024,6 +2032,12 @@ export async function connect(opts) {
|
|
|
2024
2032
|
// always hash "browser" while a non-browser server hash omitted it, which
|
|
2025
2033
|
// pinned every turn to a permanent runner_host_config_stale.
|
|
2026
2034
|
MEL_ASSISTANT_BROWSER_CAPABLE: payload.browserCapable ? "1" : "",
|
|
2035
|
+
// Computer Control (v5): the computer_* toolkit joins the host when a
|
|
2036
|
+
// computer is on for the chat (Devices tile) or, with no selection, the
|
|
2037
|
+
// user has one online. Part of the config hash ("computer", appended only
|
|
2038
|
+
// when set), so switching the computer on or off reboots the host. The
|
|
2039
|
+
// server pins every computer call to the chat's computers itself.
|
|
2040
|
+
MEL_ASSISTANT_COMPUTER_READY: payload.computerReady ? "1" : "",
|
|
2027
2041
|
// Connector tool sets the user enabled for this chat (comma-joined service
|
|
2028
2042
|
// ids). The host seeds a lazy toolkit from these so ANY connector's tools
|
|
2029
2043
|
// are reachable without exploding context.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stored plans on the user's own computer: the `plan: { file: "<name>.json" }`
|
|
3
|
+
* source of the grouped-step tools (computer batch / path / fast, browser and
|
|
4
|
+
* phone batch / fast). A local agent writes a big step list (a drawing, a
|
|
5
|
+
* 3000-step batch) to ~/.melaya/plans/<name>.json and references it; the
|
|
6
|
+
* server asks this runner for the file (runner:plan-read, answered on the
|
|
7
|
+
* socket.io ack) instead of the model writing every step into the call.
|
|
8
|
+
*
|
|
9
|
+
* Only ONE folder, ONE plain file name: no separators, no traversal, no hidden
|
|
10
|
+
* files, .json only, no symbolic links (the file itself, or the folder resolving
|
|
11
|
+
* elsewhere), no hard-linked files, at most 2 MB. The content is returned as
|
|
12
|
+
* text and parsed by the server; it is never executed or logged here.
|
|
13
|
+
*
|
|
14
|
+
* The desktop app runs this same runner (utilityProcess), so it answers too.
|
|
15
|
+
*/
|
|
16
|
+
export declare const PLAN_FILE_MAX_BYTES: number;
|
|
17
|
+
/** A plain file name: same rule as the server (services/plans/stepPlans.ts). */
|
|
18
|
+
export declare const PLAN_FILE_RE: RegExp;
|
|
19
|
+
/** ~/.melaya/plans */
|
|
20
|
+
export declare function plansDir(home?: string): string;
|
|
21
|
+
export type PlanReadResult = {
|
|
22
|
+
ok: true;
|
|
23
|
+
text: string;
|
|
24
|
+
bytes: number;
|
|
25
|
+
} | {
|
|
26
|
+
ok: false;
|
|
27
|
+
code: "bad_name" | "not_found" | "not_a_file" | "too_large" | "outside" | "read_failed";
|
|
28
|
+
message: string;
|
|
29
|
+
};
|
|
30
|
+
/** Read one plan file from `dir` (default ~/.melaya/plans, created when missing). */
|
|
31
|
+
export declare function readPlanFile(name: unknown, dir?: string): Promise<PlanReadResult>;
|
|
32
|
+
/** The subset of a socket.io-client Socket used here (a fake in tests). */
|
|
33
|
+
export interface PlanSocketLike {
|
|
34
|
+
on(event: string, cb: (...args: any[]) => void): unknown;
|
|
35
|
+
}
|
|
36
|
+
/** runner:plan-read {file} -> ack(PlanReadResult). Old servers never send it. */
|
|
37
|
+
export declare function attachPlanSocketHandlers(socket: PlanSocketLike, dir?: () => string): void;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stored plans on the user's own computer: the `plan: { file: "<name>.json" }`
|
|
3
|
+
* source of the grouped-step tools (computer batch / path / fast, browser and
|
|
4
|
+
* phone batch / fast). A local agent writes a big step list (a drawing, a
|
|
5
|
+
* 3000-step batch) to ~/.melaya/plans/<name>.json and references it; the
|
|
6
|
+
* server asks this runner for the file (runner:plan-read, answered on the
|
|
7
|
+
* socket.io ack) instead of the model writing every step into the call.
|
|
8
|
+
*
|
|
9
|
+
* Only ONE folder, ONE plain file name: no separators, no traversal, no hidden
|
|
10
|
+
* files, .json only, no symbolic links (the file itself, or the folder resolving
|
|
11
|
+
* elsewhere), no hard-linked files, at most 2 MB. The content is returned as
|
|
12
|
+
* text and parsed by the server; it is never executed or logged here.
|
|
13
|
+
*
|
|
14
|
+
* The desktop app runs this same runner (utilityProcess), so it answers too.
|
|
15
|
+
*/
|
|
16
|
+
import { constants as FS, promises as fsp } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
export const PLAN_FILE_MAX_BYTES = 2 * 1024 * 1024;
|
|
20
|
+
/** A plain file name: same rule as the server (services/plans/stepPlans.ts). */
|
|
21
|
+
export const PLAN_FILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.json$/;
|
|
22
|
+
/** ~/.melaya/plans */
|
|
23
|
+
export function plansDir(home = homedir()) {
|
|
24
|
+
return join(home, ".melaya", "plans");
|
|
25
|
+
}
|
|
26
|
+
const fail = (code, message) => ({ ok: false, code, message });
|
|
27
|
+
/** Read one plan file from `dir` (default ~/.melaya/plans, created when missing). */
|
|
28
|
+
export async function readPlanFile(name, dir = plansDir()) {
|
|
29
|
+
if (typeof name !== "string" || !PLAN_FILE_RE.test(name)) {
|
|
30
|
+
return fail("bad_name", "a plan file is a plain .json name inside ~/.melaya/plans/ (letters, digits, . _ -).");
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
await fsp.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
34
|
+
}
|
|
35
|
+
catch { /* reported below as not_found */ }
|
|
36
|
+
const full = join(dir, name);
|
|
37
|
+
let st;
|
|
38
|
+
try {
|
|
39
|
+
st = await fsp.lstat(full);
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
return e?.code === "ENOENT"
|
|
43
|
+
? fail("not_found", `~/.melaya/plans/${name} does not exist.`)
|
|
44
|
+
: fail("read_failed", `~/.melaya/plans/${name} could not be read.`);
|
|
45
|
+
}
|
|
46
|
+
if (st.isSymbolicLink())
|
|
47
|
+
return fail("outside", "symbolic links are not followed in ~/.melaya/plans/.");
|
|
48
|
+
if (!st.isFile())
|
|
49
|
+
return fail("not_a_file", `~/.melaya/plans/${name} is not a regular file.`);
|
|
50
|
+
if (st.nlink > 1)
|
|
51
|
+
return fail("outside", "hard-linked files are not read from ~/.melaya/plans/.");
|
|
52
|
+
if (st.size > PLAN_FILE_MAX_BYTES)
|
|
53
|
+
return fail("too_large", `~/.melaya/plans/${name} is larger than 2 MB.`);
|
|
54
|
+
// The folder itself may be a link: the file's real location must be the plans folder.
|
|
55
|
+
try {
|
|
56
|
+
const [realDir, realFile] = await Promise.all([fsp.realpath(dir), fsp.realpath(full)]);
|
|
57
|
+
if (dirname(realFile) !== realDir)
|
|
58
|
+
return fail("outside", "the plan file resolves outside ~/.melaya/plans/.");
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return fail("read_failed", `~/.melaya/plans/${name} could not be read.`);
|
|
62
|
+
}
|
|
63
|
+
let fh = null;
|
|
64
|
+
try {
|
|
65
|
+
// O_NOFOLLOW where the platform has it (not on Windows: lstat above covers it).
|
|
66
|
+
fh = await fsp.open(full, FS.O_RDONLY | (FS.O_NOFOLLOW ?? 0));
|
|
67
|
+
const st2 = await fh.stat();
|
|
68
|
+
if (!st2.isFile() || st2.size > PLAN_FILE_MAX_BYTES)
|
|
69
|
+
return fail("too_large", `~/.melaya/plans/${name} is larger than 2 MB.`);
|
|
70
|
+
const buf = Buffer.alloc(Math.min(st2.size, PLAN_FILE_MAX_BYTES) + 1);
|
|
71
|
+
let read = 0;
|
|
72
|
+
while (read < buf.length) {
|
|
73
|
+
const { bytesRead } = await fh.read(buf, read, buf.length - read, read);
|
|
74
|
+
if (!bytesRead)
|
|
75
|
+
break;
|
|
76
|
+
read += bytesRead;
|
|
77
|
+
}
|
|
78
|
+
if (read > PLAN_FILE_MAX_BYTES)
|
|
79
|
+
return fail("too_large", `~/.melaya/plans/${name} is larger than 2 MB.`);
|
|
80
|
+
return { ok: true, text: buf.subarray(0, read).toString("utf8"), bytes: read };
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
return e?.code === "ELOOP"
|
|
84
|
+
? fail("outside", "symbolic links are not followed in ~/.melaya/plans/.")
|
|
85
|
+
: fail("read_failed", `~/.melaya/plans/${name} could not be read.`);
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
await fh?.close().catch(() => undefined);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** runner:plan-read {file} -> ack(PlanReadResult). Old servers never send it. */
|
|
92
|
+
export function attachPlanSocketHandlers(socket, dir) {
|
|
93
|
+
socket.on("runner:plan-read", async (p, ack) => {
|
|
94
|
+
if (typeof ack !== "function")
|
|
95
|
+
return;
|
|
96
|
+
try {
|
|
97
|
+
ack(await readPlanFile(p?.file, dir ? dir() : plansDir()));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
ack(fail("read_failed", "the plan file could not be read."));
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|