@tiens.nguyen/gonext-local-worker 1.0.227 → 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.
@@ -1848,6 +1848,11 @@ async function runAgentChatJob(job) {
1848
1848
  // the API sends (list or comma/space string); python re-parses either shape.
1849
1849
  runAllowlist: payload?.runAllowlist ?? [],
1850
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,
1851
1856
  // Max web_search + fetch_url calls the agent may make per task (user-configurable
1852
1857
  // in web Settings → Agent). Default 10; past it the retrieval tools refuse.
1853
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),
@@ -2227,6 +2227,85 @@ _WS_RUN_DENY_ALWAYS = {"sudo", "su", "doas"} # privilege escalation — never r
2227
2227
  _WS_RUN_ALLOWLIST = set() # opt-in lockdown from cfg; empty = allow all
2228
2228
  _WS_RUN_DENYLIST = set() # extra user blocks from cfg
2229
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
2308
+
2230
2309
  # ---- background servers (behavior-detected, command-agnostic) ----
2231
2310
  # run_command classifies a command by OBSERVED BEHAVIOR, never by its text: if the
2232
2311
  # process keeps running AND starts LISTENing on a TCP port, it IS a server — npm start,
@@ -2414,6 +2493,11 @@ def run_agent_chat(cfg):
2414
2493
  # For the create_pdf tool: API base + worker key to request a presigned S3 upload.
2415
2494
  pdf_api_base = (cfg.get("apiBaseURL") or "").strip()
2416
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"))
2417
2501
  # Optional dedicated coding/reasoning model for the CodeAgent's tool-use loop.
2418
2502
  # Routing, plain replies and summarization stay on the chat model (better at
2419
2503
  # natural language); the code model only drives http_request reasoning.
@@ -4629,26 +4713,36 @@ def run_agent_chat(cfg):
4629
4713
  msg = "Error: empty command."
4630
4714
  _last_obs["text"] = msg
4631
4715
  return msg
4632
- # Allow-by-default policy (see _WS_RUN_DENY_ALWAYS). Match on the runner's
4633
- # basename too, so `/usr/bin/sudo` can't slip past a bare-name block.
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.
4634
4718
  _runner = argv[0]
4635
4719
  _rbase = os.path.basename(_runner)
4636
- if _runner in _WS_RUN_DENY_ALWAYS or _rbase in _WS_RUN_DENY_ALWAYS:
4637
- msg = (f"Error: '{_runner}' is blocked (privilege escalation is never "
4638
- "run by the agent). Run that step yourself, or tell the user the "
4639
- "exact command to run.")
4640
- _last_obs["text"] = msg
4641
- return msg
4642
- if _WS_RUN_DENYLIST and (_runner in _WS_RUN_DENYLIST or _rbase in _WS_RUN_DENYLIST):
4643
- msg = (f"Error: '{_runner}' is blocked by your run denylist "
4644
- "(change it in web Settings → Agent).")
4645
- _last_obs["text"] = msg
4646
- return msg
4720
+ # Opt-in allowlist lockdown: outside the set = hard refuse, never asked.
4647
4721
  if _WS_RUN_ALLOWLIST and _runner not in _WS_RUN_ALLOWLIST and _rbase not in _WS_RUN_ALLOWLIST:
4648
4722
  msg = (f"Error: '{_runner}' is not in your run allowlist. Add it in web "
4649
4723
  "Settings → Agent, or clear the allowlist to allow all commands.")
4650
4724
  _last_obs["text"] = msg
4651
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)
4652
4746
  _emit({"type": "step", "text": f"Running → {command[:70]}"})
4653
4747
  t = max(5, min(int(timeout_seconds or 180), 600))
4654
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.227",
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",