@tiens.nguyen/gonext-local-worker 1.0.226 → 1.0.228
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/gonext-local-worker.mjs +11 -0
- package/gonext-repl.mjs +93 -0
- package/gonext_agent_chat.py +146 -18
- package/package.json +1 -1
package/gonext-local-worker.mjs
CHANGED
|
@@ -1842,6 +1842,17 @@ async function runAgentChatJob(job) {
|
|
|
1842
1842
|
// workspaces are registered. Passing a literal 5 here would look user-set and
|
|
1843
1843
|
// block that bump.
|
|
1844
1844
|
maxSteps: payload?.maxSteps ?? 0,
|
|
1845
|
+
// run_command policy (web Settings → Agent). Both optional: empty = allow every
|
|
1846
|
+
// command except the always-blocked sudo/su. runAllowlist (non-empty) = opt-in
|
|
1847
|
+
// lockdown to exactly those runners; runDenylist = extra blocks. Accept whatever
|
|
1848
|
+
// the API sends (list or comma/space string); python re-parses either shape.
|
|
1849
|
+
runAllowlist: payload?.runAllowlist ?? [],
|
|
1850
|
+
runDenylist: payload?.runDenylist ?? [],
|
|
1851
|
+
// Interactive command-approval gate: the jobId lets python register a pending
|
|
1852
|
+
// approval + poll for the user's Yes/No via the API; interactiveApproval is the
|
|
1853
|
+
// REPL's "I can show a picker" signal (else python keeps hard-blocking, as on web).
|
|
1854
|
+
jobId,
|
|
1855
|
+
interactiveApproval: payload?.interactiveApproval === true,
|
|
1845
1856
|
// Max web_search + fetch_url calls the agent may make per task (user-configurable
|
|
1846
1857
|
// in web Settings → Agent). Default 10; past it the retrieval tools refuse.
|
|
1847
1858
|
researchBudget: payload?.researchBudget ?? 10,
|
package/gonext-repl.mjs
CHANGED
|
@@ -298,6 +298,14 @@ function drawSlashHint(prefix, sel) {
|
|
|
298
298
|
}
|
|
299
299
|
if (process.stdin.isTTY) {
|
|
300
300
|
process.stdin.on("keypress", (_ch, key) => {
|
|
301
|
+
// A command-approval picker is up → arrow keys move the highlight, Enter chooses.
|
|
302
|
+
if (approvalActive) {
|
|
303
|
+
onApprovalKey(key);
|
|
304
|
+
rl.line = "";
|
|
305
|
+
rl.cursor = 0;
|
|
306
|
+
rl.historyIndex = -1;
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
301
309
|
if (following) {
|
|
302
310
|
rl.line = "";
|
|
303
311
|
rl.cursor = 0;
|
|
@@ -604,6 +612,63 @@ let following = false;
|
|
|
604
612
|
let followAborted = false;
|
|
605
613
|
let currentJobId = null; // the running turn's jobId — so Ctrl+C can cancel it server-side
|
|
606
614
|
let cancelRequested = false; // a cancel POST has been sent for the current turn
|
|
615
|
+
// Interactive command-approval gate: while the agent is paused on a risky command, the
|
|
616
|
+
// terminal shows a Yes/No list picker (↑/↓ + Enter, Yes preselected). approvalActive
|
|
617
|
+
// routes keystrokes to the picker; approvalResolve settles the awaiting promise once.
|
|
618
|
+
let approvalActive = false;
|
|
619
|
+
let approvalSel = 0; // 0 = Yes (default highlighted), 1 = No
|
|
620
|
+
let approvalResolve = null;
|
|
621
|
+
function drawApprovalOptions(redraw) {
|
|
622
|
+
// Two option lines, redrawn in place. On redraw the cursor sits one row below "No";
|
|
623
|
+
// step up 2 rows, clear+rewrite both, landing back where we started.
|
|
624
|
+
const yes = approvalSel === 0 ? green("▸ Yes") : dim(" Yes");
|
|
625
|
+
const no = approvalSel === 1 ? green("▸ No") : dim(" No");
|
|
626
|
+
process.stdout.write((redraw ? "\x1b[2A" : "") + "\x1b[K" + yes + "\n\x1b[K" + no + "\n");
|
|
627
|
+
}
|
|
628
|
+
function finishApproval(allow) {
|
|
629
|
+
if (!approvalActive) return;
|
|
630
|
+
approvalActive = false;
|
|
631
|
+
// Settle the picker: overwrite the two option lines with a single result line.
|
|
632
|
+
process.stdout.write("\x1b[2A\x1b[J" + (allow ? green(" ▸ Yes — running it") : red(" ▸ No — skipped")) + "\n");
|
|
633
|
+
const r = approvalResolve;
|
|
634
|
+
approvalResolve = null;
|
|
635
|
+
if (r) r(!!allow);
|
|
636
|
+
}
|
|
637
|
+
function onApprovalKey(key) {
|
|
638
|
+
if (!key) return;
|
|
639
|
+
if (key.name === "up" || key.name === "down") {
|
|
640
|
+
approvalSel = key.name === "up" ? 0 : 1; // 2 options: up=Yes, down=No
|
|
641
|
+
drawApprovalOptions(true);
|
|
642
|
+
} else if (key.name === "return" || key.name === "enter") {
|
|
643
|
+
finishApproval(approvalSel === 0);
|
|
644
|
+
}
|
|
645
|
+
// Ctrl+C is delivered via readline "SIGINT" (onInterrupt), which also denies — see there.
|
|
646
|
+
}
|
|
647
|
+
// Show the picker and resolve to the user's choice. Non-TTY (piped) can't pick → deny
|
|
648
|
+
// (safe). Auto-denies after ~170s so an absent user never hangs the turn (and stays
|
|
649
|
+
// under the python side's 180s wait); Enter is instant since Yes is preselected.
|
|
650
|
+
function approvalPrompt(command) {
|
|
651
|
+
return new Promise((resolve) => {
|
|
652
|
+
if (!process.stdin.isTTY) {
|
|
653
|
+
process.stdout.write(dim(`\n(approval needed for: ${command} — no interactive terminal, skipping)\n`));
|
|
654
|
+
resolve(false);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
process.stdout.write(
|
|
658
|
+
"\n" + yellow("⚠ Allow running this command?") + "\n" + dim(" " + command) + "\n"
|
|
659
|
+
);
|
|
660
|
+
approvalSel = 0;
|
|
661
|
+
approvalResolve = resolve;
|
|
662
|
+
approvalActive = true;
|
|
663
|
+
drawApprovalOptions(false);
|
|
664
|
+
const t = setTimeout(() => finishApproval(false), 170000);
|
|
665
|
+
const orig = resolve;
|
|
666
|
+
approvalResolve = (v) => {
|
|
667
|
+
clearTimeout(t);
|
|
668
|
+
orig(v);
|
|
669
|
+
};
|
|
670
|
+
});
|
|
671
|
+
}
|
|
607
672
|
// Per-session coding-model override chosen via /model (empty = use the account default).
|
|
608
673
|
// Sent to /agent-ask as codingModelOverride; the API validates it against the whitelist.
|
|
609
674
|
let sessionCodingModel = "";
|
|
@@ -647,6 +712,9 @@ async function runAgentTurn(history) {
|
|
|
647
712
|
body: JSON.stringify({
|
|
648
713
|
messages: history,
|
|
649
714
|
cwd: resolve(process.cwd()),
|
|
715
|
+
// The terminal can show a Yes/No picker → the agent PAUSES on a risky command and
|
|
716
|
+
// asks instead of hard-blocking it (interactive command-approval gate).
|
|
717
|
+
interactiveApproval: true,
|
|
650
718
|
...(sessionCodingModel ? { codingModelOverride: sessionCodingModel } : {}),
|
|
651
719
|
}),
|
|
652
720
|
});
|
|
@@ -668,6 +736,7 @@ async function runAgentTurn(history) {
|
|
|
668
736
|
cancelRequested = false;
|
|
669
737
|
const startedAt = Date.now();
|
|
670
738
|
let shownChars = 0;
|
|
739
|
+
let lastApprovalId = null; // the last command-approval request we've already answered
|
|
671
740
|
let carry = ""; // trailing partial content line held until its newline arrives
|
|
672
741
|
let statusShown = false; // a transient status line is currently on screen
|
|
673
742
|
let warnedPending = false;
|
|
@@ -727,6 +796,7 @@ async function runAgentTurn(history) {
|
|
|
727
796
|
|
|
728
797
|
const tick = () => {
|
|
729
798
|
if (!following || followAborted || !jobRunning) return;
|
|
799
|
+
if (approvalActive) return; // a Yes/No picker owns the screen — don't draw over it
|
|
730
800
|
if (Date.now() - lastContentAt < QUIET_MS) return; // tokens still flowing
|
|
731
801
|
// A plain-reply answer streams onto a line with NO trailing newline until it's fully
|
|
732
802
|
// done — overwriting the ticker there corrupts the visible answer mid-word. Stay
|
|
@@ -1030,6 +1100,26 @@ async function runAgentTurn(history) {
|
|
|
1030
1100
|
consume(text.slice(shownChars));
|
|
1031
1101
|
shownChars = text.length;
|
|
1032
1102
|
}
|
|
1103
|
+
// Interactive command-approval gate: the agent paused on a risky command. Show the
|
|
1104
|
+
// Yes/No picker once per request id, POST the choice, then let the agent resume.
|
|
1105
|
+
if (
|
|
1106
|
+
job.pendingApproval &&
|
|
1107
|
+
job.pendingApproval.id &&
|
|
1108
|
+
job.pendingApproval.id !== lastApprovalId
|
|
1109
|
+
) {
|
|
1110
|
+
lastApprovalId = job.pendingApproval.id;
|
|
1111
|
+
clearStatus();
|
|
1112
|
+
const allow = await approvalPrompt(job.pendingApproval.command || "");
|
|
1113
|
+
try {
|
|
1114
|
+
await fetch(`${apiBase}/api/worker/jobs/${jobId}/approval`, {
|
|
1115
|
+
method: "POST",
|
|
1116
|
+
headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
|
|
1117
|
+
body: JSON.stringify({ id: lastApprovalId, allow }),
|
|
1118
|
+
});
|
|
1119
|
+
} catch {
|
|
1120
|
+
// Best-effort — if the POST fails, the python side times out and treats as deny.
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1033
1123
|
if (job.jobStatus === "cancelled") {
|
|
1034
1124
|
// User-requested stop (Ctrl+C) — a clean outcome, not an error. The worker has
|
|
1035
1125
|
// killed the Python agent. Print a quiet note and return an empty, non-persisted
|
|
@@ -1102,6 +1192,9 @@ async function main() {
|
|
|
1102
1192
|
// the OS delivers a real SIGINT to the process. Attach the same handler to both —
|
|
1103
1193
|
// only one can ever fire for a given mode, so there's no double-handling.
|
|
1104
1194
|
const onInterrupt = () => {
|
|
1195
|
+
// Ctrl+C while the approval picker is up = decline it (and fall through to also
|
|
1196
|
+
// cancel the turn) — otherwise the poll loop stays blocked awaiting a choice.
|
|
1197
|
+
if (approvalActive) finishApproval(false);
|
|
1105
1198
|
if (following) {
|
|
1106
1199
|
if (!cancelRequested && currentJobId) {
|
|
1107
1200
|
// First Ctrl+C: actually CANCEL the running turn server-side (stop the model),
|
package/gonext_agent_chat.py
CHANGED
|
@@ -2209,17 +2209,102 @@ def _ws_record_change(action: str, path: str, backup) -> None:
|
|
|
2209
2209
|
# the model on productive paths (build/test/deploy), not to contain a hostile one.
|
|
2210
2210
|
# Still excluded on purpose: bare shells, package publishes, sudo, and bare `pip`
|
|
2211
2211
|
# (the model must use `python3 -m pip`, which pip itself recommends).
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2212
|
+
# run_command execution policy — ALLOW-BY-DEFAULT.
|
|
2213
|
+
# We used to gate on a fixed allowlist (npm/python/go/…). It rejected legitimate DevOps
|
|
2214
|
+
# tooling (docker, kubectl, terraform, aws, gh, …) and, because that universe is unbounded
|
|
2215
|
+
# and grows constantly, any list was a hardcode that upset the next user. Crucially, the
|
|
2216
|
+
# gate was never a SANDBOX: `python3`/`node` are runnable and can subprocess anything, so a
|
|
2217
|
+
# restrictive allowlist bought STEERING, not safety — it just added friction. So now: any
|
|
2218
|
+
# command runs, with two guards kept:
|
|
2219
|
+
# 1. Privilege escalation is ALWAYS blocked (`sudo`/`su`/`doas`) — running the model's
|
|
2220
|
+
# commands as root is the one footgun a run-enabled workspace shouldn't hand over.
|
|
2221
|
+
# 2. Commands run via argv (shlex.split + Popen, NO shell), so pipes/redirects/`&&`
|
|
2222
|
+
# /backticks are literal args, never shell operations — the shell-injection guard.
|
|
2223
|
+
# The user can still tighten this from web Settings (passed through cfg, both default off):
|
|
2224
|
+
# • runAllowlist — if non-empty, ONLY these runners are permitted (opt-in lockdown).
|
|
2225
|
+
# • runDenylist — extra runners to block on top of the always-blocked set.
|
|
2226
|
+
_WS_RUN_DENY_ALWAYS = {"sudo", "su", "doas"} # privilege escalation — never runnable
|
|
2227
|
+
_WS_RUN_ALLOWLIST = set() # opt-in lockdown from cfg; empty = allow all
|
|
2228
|
+
_WS_RUN_DENYLIST = set() # extra user blocks from cfg
|
|
2229
|
+
|
|
2230
|
+
# Privilege escalation, scanned on the RAW command (not just argv[0]) so it also catches
|
|
2231
|
+
# `bash -c "sudo …"` / `env sudo …` — the naive shell-wrap bypass a model reaches for.
|
|
2232
|
+
_WS_PRIV_RE = re.compile(r"\b(sudo|doas|su)\b", re.IGNORECASE)
|
|
2233
|
+
# Destructive: mass delete, disk wipe, or power-off. Allowed by default (allow-by-default
|
|
2234
|
+
# policy) but worth a confirmation when we CAN ask the user (interactive terminal).
|
|
2235
|
+
_WS_DESTRUCTIVE_RE = re.compile(
|
|
2236
|
+
r"(^|[\s;&|])rm\b[^|;&\n]*\s-[a-z]*[rf]" # rm with -r / -f (any order)
|
|
2237
|
+
r"|(^|[\s;&|])(dd|shred|wipefs|mkfs(\.\w+)?)\b" # disk destroyers
|
|
2238
|
+
r"|(^|[\s;&|])(shutdown|reboot|halt|poweroff)\b", # power
|
|
2239
|
+
re.IGNORECASE,
|
|
2240
|
+
)
|
|
2241
|
+
|
|
2242
|
+
|
|
2243
|
+
def _ws_command_risk(command, argv, denyset):
|
|
2244
|
+
"""Classify a command for the run policy. Returns (hard_reason, ask_reason), either
|
|
2245
|
+
None when N/A:
|
|
2246
|
+
- hard_reason: block outright when we CAN'T ask (web / non-interactive) — privilege
|
|
2247
|
+
escalation or a user-denylisted runner.
|
|
2248
|
+
- ask_reason: prompt the user when we CAN (interactive terminal) — the above PLUS
|
|
2249
|
+
destructive filesystem/power commands (which are otherwise allowed by default)."""
|
|
2250
|
+
import os as _os
|
|
2251
|
+
cmd = command or ""
|
|
2252
|
+
runner = argv[0] if argv else ""
|
|
2253
|
+
rbase = _os.path.basename(runner)
|
|
2254
|
+
if _WS_PRIV_RE.search(cmd):
|
|
2255
|
+
r = "privilege escalation (sudo/su)"
|
|
2256
|
+
return (r, r)
|
|
2257
|
+
if denyset and (runner in denyset or rbase in denyset):
|
|
2258
|
+
r = f"'{runner}' is on your blocked-commands list"
|
|
2259
|
+
return (r, r)
|
|
2260
|
+
if _WS_DESTRUCTIVE_RE.search(cmd):
|
|
2261
|
+
return (None, "a destructive command (deletes files / wipes disk / powers off)")
|
|
2262
|
+
return (None, None)
|
|
2263
|
+
|
|
2264
|
+
|
|
2265
|
+
def _ws_request_approval(api_base, worker_key, job_id, command, reason):
|
|
2266
|
+
"""Pause and ask the user (via the terminal REPL's Yes/No picker) to allow a risky
|
|
2267
|
+
command. Registers a pending approval on the job through the API, then polls the job
|
|
2268
|
+
for the user's decision. Returns True (allow) or False (deny / timeout / cancelled).
|
|
2269
|
+
Mirrors _pdf_upload_via_api's worker-key auth — no new creds on the worker."""
|
|
2270
|
+
import urllib.request as _u
|
|
2271
|
+
import uuid as _uuid
|
|
2272
|
+
import time as _t
|
|
2273
|
+
base = (api_base or "").rstrip("/")
|
|
2274
|
+
if not base or not worker_key or not job_id:
|
|
2275
|
+
return False
|
|
2276
|
+
rid = _uuid.uuid4().hex[:12]
|
|
2277
|
+
ctx = _ssl_context()
|
|
2278
|
+
try:
|
|
2279
|
+
req = _u.Request(
|
|
2280
|
+
f"{base}/api/worker/jobs/{job_id}/approval-request",
|
|
2281
|
+
data=json.dumps({"id": rid, "command": command}).encode("utf-8"),
|
|
2282
|
+
headers={"Content-Type": "application/json", "X-Worker-Key": worker_key},
|
|
2283
|
+
method="POST",
|
|
2284
|
+
)
|
|
2285
|
+
with _u.urlopen(req, timeout=20, context=ctx) as resp:
|
|
2286
|
+
resp.read()
|
|
2287
|
+
except Exception as e: # noqa: BLE001
|
|
2288
|
+
_log(f"approval-request failed: {e} — treating as deny")
|
|
2289
|
+
return False
|
|
2290
|
+
_emit({"type": "step", "text": f"Awaiting your approval to run: {command[:70]}"})
|
|
2291
|
+
deadline = _t.time() + 180 # 3 min; absent user → deny (never auto-run something risky)
|
|
2292
|
+
while _t.time() < deadline:
|
|
2293
|
+
_t.sleep(1.2)
|
|
2294
|
+
try:
|
|
2295
|
+
g = _u.Request(f"{base}/api/worker/jobs/{job_id}",
|
|
2296
|
+
headers={"X-Worker-Key": worker_key}, method="GET")
|
|
2297
|
+
with _u.urlopen(g, timeout=15, context=ctx) as resp:
|
|
2298
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
2299
|
+
except Exception as e: # noqa: BLE001
|
|
2300
|
+
_log(f"approval poll error: {e}")
|
|
2301
|
+
continue
|
|
2302
|
+
if data.get("jobStatus") in ("cancelled", "failed", "completed"):
|
|
2303
|
+
return False # Ctrl+C or the job ended → treat as declined
|
|
2304
|
+
dec = data.get("approvalDecision") or {}
|
|
2305
|
+
if dec.get("id") == rid:
|
|
2306
|
+
return bool(dec.get("allow"))
|
|
2307
|
+
return False
|
|
2223
2308
|
|
|
2224
2309
|
# ---- background servers (behavior-detected, command-agnostic) ----
|
|
2225
2310
|
# run_command classifies a command by OBSERVED BEHAVIOR, never by its text: if the
|
|
@@ -2408,6 +2493,11 @@ def run_agent_chat(cfg):
|
|
|
2408
2493
|
# For the create_pdf tool: API base + worker key to request a presigned S3 upload.
|
|
2409
2494
|
pdf_api_base = (cfg.get("apiBaseURL") or "").strip()
|
|
2410
2495
|
pdf_worker_key = (cfg.get("workerKey") or "").strip()
|
|
2496
|
+
# Interactive command-approval gate: this job's id + whether the client (terminal
|
|
2497
|
+
# REPL) can show a Yes/No picker. When both are present, run_command PAUSES on a
|
|
2498
|
+
# risky command and asks the user via the API instead of hard-blocking it.
|
|
2499
|
+
_job_id = (cfg.get("jobId") or "").strip()
|
|
2500
|
+
_interactive_approval = bool(cfg.get("interactiveApproval"))
|
|
2411
2501
|
# Optional dedicated coding/reasoning model for the CodeAgent's tool-use loop.
|
|
2412
2502
|
# Routing, plain replies and summarization stay on the chat model (better at
|
|
2413
2503
|
# natural language); the code model only drives http_request reasoning.
|
|
@@ -2597,6 +2687,18 @@ def run_agent_chat(cfg):
|
|
|
2597
2687
|
except Exception: # noqa: BLE001
|
|
2598
2688
|
continue
|
|
2599
2689
|
_WS_AVAILABLE = bool(_WS_ROOTS)
|
|
2690
|
+
# run_command policy overrides from Settings (both optional, default = allow all but
|
|
2691
|
+
# sudo). Accept either a list or a comma/space-separated string; basenames so a user
|
|
2692
|
+
# can write "git" and match "/usr/bin/git".
|
|
2693
|
+
global _WS_RUN_ALLOWLIST, _WS_RUN_DENYLIST
|
|
2694
|
+
|
|
2695
|
+
def _split_cmds(v):
|
|
2696
|
+
import re as _re
|
|
2697
|
+
items = v if isinstance(v, (list, tuple)) else _re.split(r"[,\s]+", str(v or ""))
|
|
2698
|
+
return {str(c).strip() for c in items if str(c).strip()}
|
|
2699
|
+
|
|
2700
|
+
_WS_RUN_ALLOWLIST = _split_cmds(cfg.get("runAllowlist"))
|
|
2701
|
+
_WS_RUN_DENYLIST = _split_cmds(cfg.get("runDenylist"))
|
|
2600
2702
|
# Active terminal workspace: the folder the `gonext` REPL was launched from (sent as
|
|
2601
2703
|
# payload.activeWorkspace = the terminal's cwd). download_file / unzip_file put their
|
|
2602
2704
|
# output HERE instead of the shared ~/.gonext/rag-work cache, so files land where the
|
|
@@ -2960,9 +3062,10 @@ def run_agent_chat(cfg):
|
|
|
2960
3062
|
"left RUNNING (its window opens on the user's Mac); stop_server closes it. An "
|
|
2961
3063
|
"interactive input() CLI can't be driven here (EOFError) — make it non-interactive "
|
|
2962
3064
|
"or give the user the command.\n"
|
|
2963
|
-
|
|
2964
|
-
"
|
|
2965
|
-
"
|
|
3065
|
+
" Most commands run directly — build/test/package tools, git, docker, kubectl, "
|
|
3066
|
+
"terraform, aws/gcloud/az, and so on. Two limits: `sudo`/`su` are blocked (no root); "
|
|
3067
|
+
"and there is NO shell, so pipes `|`, redirects `>`, `&&`, and backticks do NOT work "
|
|
3068
|
+
"— run one command per call, or use python3 for anything that needs composition.\n"
|
|
2966
3069
|
" - deploy_web(local_dir, host, user, remote_path, domain='') — DEPLOY a built "
|
|
2967
3070
|
"static site in ONE call: build first (run_command('npm run build')), then "
|
|
2968
3071
|
"deploy_web('my-app/build', host, user, remote_path, domain). It rsyncs over your "
|
|
@@ -4568,7 +4671,7 @@ def run_agent_chat(cfg):
|
|
|
4568
4671
|
"""Run a command inside a workspace that has run permission (e.g. 'npm test', 'npm run build', 'npm start', 'mvn test', 'python3 app.py'). One-shot commands return their output when they exit. If the command turns out to be a SERVER (keeps running and listens on a port), it is left RUNNING and its URL is reported. If it is a DESKTOP/GUI app (opens a window — tkinter, pygame, PyQt…), it is LAUNCHED and left running: its window appears on the user's Mac; tell them it's open. Both are stoppable via stop_server. Also allows DEPLOY commands (ssh/scp/rsync) — use non-interactive KEY auth (-o BatchMode=yes); never put a password in the command (there is no stdin). Note: an interactive CLI that reads stdin (input()) cannot be driven here — it will fail with EOFError; make it non-interactive or give the user the command to run themselves.
|
|
4569
4672
|
|
|
4570
4673
|
Args:
|
|
4571
|
-
command: the command line
|
|
4674
|
+
command: the command line. Most commands run; no shell (so no pipes/redirects/&&), and sudo/su are blocked.
|
|
4572
4675
|
workdir: directory to run in. Empty = first registered workspace.
|
|
4573
4676
|
timeout_seconds: give up after this many seconds if the command neither exits nor starts a server (max 600).
|
|
4574
4677
|
"""
|
|
@@ -4610,11 +4713,36 @@ def run_agent_chat(cfg):
|
|
|
4610
4713
|
msg = "Error: empty command."
|
|
4611
4714
|
_last_obs["text"] = msg
|
|
4612
4715
|
return msg
|
|
4613
|
-
|
|
4614
|
-
|
|
4615
|
-
|
|
4716
|
+
# Allow-by-default policy. Match on the runner's basename too, so
|
|
4717
|
+
# `/usr/bin/<x>` can't slip past a bare-name rule.
|
|
4718
|
+
_runner = argv[0]
|
|
4719
|
+
_rbase = os.path.basename(_runner)
|
|
4720
|
+
# Opt-in allowlist lockdown: outside the set = hard refuse, never asked.
|
|
4721
|
+
if _WS_RUN_ALLOWLIST and _runner not in _WS_RUN_ALLOWLIST and _rbase not in _WS_RUN_ALLOWLIST:
|
|
4722
|
+
msg = (f"Error: '{_runner}' is not in your run allowlist. Add it in web "
|
|
4723
|
+
"Settings → Agent, or clear the allowlist to allow all commands.")
|
|
4616
4724
|
_last_obs["text"] = msg
|
|
4617
4725
|
return msg
|
|
4726
|
+
# Risk gate: privilege escalation / denylisted / destructive. When the
|
|
4727
|
+
# client can approve interactively (terminal), PAUSE and ask the user;
|
|
4728
|
+
# otherwise (web) keep hard-blocking the privilege/denylist cases.
|
|
4729
|
+
_hard_reason, _ask_reason = _ws_command_risk(command, argv, _WS_RUN_DENYLIST)
|
|
4730
|
+
if _ask_reason:
|
|
4731
|
+
if _interactive_approval and _job_id and pdf_api_base and pdf_worker_key:
|
|
4732
|
+
if not _ws_request_approval(pdf_api_base, pdf_worker_key,
|
|
4733
|
+
_job_id, command, _ask_reason):
|
|
4734
|
+
msg = (f"Error: the user declined to run '{command[:80]}' "
|
|
4735
|
+
f"({_ask_reason}). Do NOT retry it — choose another "
|
|
4736
|
+
"approach or ask the user what to do.")
|
|
4737
|
+
_last_obs["text"] = msg
|
|
4738
|
+
return msg
|
|
4739
|
+
# approved → fall through and run it
|
|
4740
|
+
elif _hard_reason:
|
|
4741
|
+
msg = (f"Error: '{command[:80]}' is blocked ({_hard_reason}). Run "
|
|
4742
|
+
"that step yourself, or tell the user the exact command.")
|
|
4743
|
+
_last_obs["text"] = msg
|
|
4744
|
+
return msg
|
|
4745
|
+
# else: destructive but non-interactive → allowed by default (unchanged)
|
|
4618
4746
|
_emit({"type": "step", "text": f"Running → {command[:70]}"})
|
|
4619
4747
|
t = max(5, min(int(timeout_seconds or 180), 600))
|
|
4620
4748
|
# Own process group (start_new_session) + output to a log file. This is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gonext-local-worker",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.228",
|
|
4
4
|
"description": "Polls GoNext cloud API for async local LLM jobs and runs them against Ollama/OpenAI-compatible servers on this Mac",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|