@tiens.nguyen/gonext-local-worker 1.0.225 → 1.0.227

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.
@@ -1842,6 +1842,12 @@ 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 ?? [],
1845
1851
  // Max web_search + fetch_url calls the agent may make per task (user-configurable
1846
1852
  // in web Settings → Agent). Default 10; past it the retrieval tools refuse.
1847
1853
  researchBudget: payload?.researchBudget ?? 10,
package/gonext-repl.mjs CHANGED
@@ -699,6 +699,21 @@ async function runAgentTurn(history) {
699
699
  let thinkWord = pickWord(); // playful word shown UNDER the in-progress line (flavor)
700
700
  let almostDone = false; // set from the worker's "…almost completed thinking…" heartbeat
701
701
  let runningCmd = null; // the command currently executing (from a "Running → …" event)
702
+ // The model's CURRENT Thought (task #64): while it streams its reasoning inside the
703
+ // hidden ~~~ fence, show a one-clause summary on the blinking line so a 300s CPU-bound
704
+ // step reads as "◑ Adding the nav links to app.component.html… (52s)" instead of a
705
+ // random word. `fenceThought` accumulates the raw in-fence text for the current step.
706
+ let liveThought = "";
707
+ let fenceThought = "";
708
+ // First non-empty Thought clause: drop the "Thought:" prefix, stop at the <code> block
709
+ // (we never show code), take the first line, and cap length so it never wraps.
710
+ const extractThought = (buf) => {
711
+ let t = String(buf || "");
712
+ const ci = t.search(/<code[\s>]/i);
713
+ if (ci >= 0) t = t.slice(0, ci);
714
+ t = t.replace(/^\s*Thought:\s*/i, "").split(/\n/)[0].trim();
715
+ return t;
716
+ };
702
717
  const thinkSecs = () => Math.max(1, Math.round((Date.now() - thinkingSince) / 1000));
703
718
 
704
719
  // The status is TWO in-place lines — the in-progress action, then the playful word
@@ -724,9 +739,12 @@ async function runAgentTurn(history) {
724
739
  const glyph = SPINNER[secs % SPINNER.length]; // rotates → reads as "in progress"
725
740
  // Line 1 = WHAT is happening now: the running command, else the phase (Thinking /
726
741
  // Almost done). Line 2 = the playful word (flavor). Truncate so neither wraps.
742
+ // Priority: a live command > the model's current Thought clause (task #64, so a
743
+ // long CPU-bound step reads as what it's actually reasoning about, not "Thinking")
744
+ // > the "almost done" heartbeat > the bare fallback.
727
745
  const primary = runningCmd
728
746
  ? `Running ${runningCmd}`
729
- : almostDone ? "Almost done" : "Thinking";
747
+ : liveThought || (almostDone ? "Almost done" : "Thinking");
730
748
  const max = Math.max(24, (process.stdout.columns || 80) - 14);
731
749
  const fit = (s) => (s.length > max ? s.slice(0, max - 1) + "…" : s);
732
750
  clearStatus();
@@ -744,6 +762,10 @@ async function runAgentTurn(history) {
744
762
  clearStatus();
745
763
  thinking = false;
746
764
  runningCmd = null;
765
+ // A concrete action landed → the Thought that led to it is spent; clear it so the
766
+ // next step's blinking line starts from "Thinking", not last step's stale clause.
767
+ liveThought = "";
768
+ fenceThought = "";
747
769
  lastContentAt = Date.now();
748
770
  };
749
771
 
@@ -782,11 +804,26 @@ async function runAgentTurn(history) {
782
804
  almostDone = /almost/i.test(line);
783
805
  continue;
784
806
  }
785
- if (isFenceLine(line)) { inStreamFence = !inStreamFence; continue; }
786
- // Inside a ~~~ fence = raw model Thought/code stream suppressed entirely. Do NOT
787
- // bump lastContentAt: while the (now hidden) tokens flow, the blinking bullet keeps
788
- // ticking so the user sees the step is still being worked on.
789
- if (inStreamFence) continue;
807
+ // A fence opening starts a fresh step's raw stream → reset the harvested Thought
808
+ // so the blinking line doesn't carry last step's clause into this one.
809
+ if (isFenceLine(line)) {
810
+ inStreamFence = !inStreamFence;
811
+ if (inStreamFence) fenceThought = "";
812
+ continue;
813
+ }
814
+ // Inside a ~~~ fence = raw model Thought/code stream → suppressed from scrollback. Do
815
+ // NOT bump lastContentAt: while the (now hidden) tokens flow, the blinking bullet keeps
816
+ // ticking so the user sees the step is still being worked on. But HARVEST the first
817
+ // Thought clause for the blinking line (task #64) — up to the <code> block, which is
818
+ // where the prose ends and the tool call begins (we never surface code here).
819
+ if (inStreamFence) {
820
+ if (!/<code[\s>]/i.test(fenceThought)) {
821
+ fenceThought += line + "\n";
822
+ const th = extractThought(fenceThought);
823
+ if (th) liveThought = th;
824
+ }
825
+ continue;
826
+ }
790
827
  if (isToolStepSummary(line)) { lastContentAt = Date.now(); continue; } // swallow
791
828
  if (isRoutingLine(line)) {
792
829
  // Swallow the router's play-by-play entirely — the blinking-bullet ticker (which
@@ -914,6 +951,14 @@ async function runAgentTurn(history) {
914
951
  answerShownLive = true;
915
952
  lastWasBlank = false;
916
953
  }
954
+ // While in the hidden fence, the model's FIRST Thought line often sits in `carry` with
955
+ // no newline yet (a long reasoning sentence streams token-by-token) — recompute the
956
+ // live clause from the partial too, so the blinking line updates mid-sentence instead
957
+ // of only once the line finally breaks (task #64).
958
+ if (inStreamFence && !/<code[\s>]/i.test(fenceThought)) {
959
+ const th = extractThought(fenceThought + carry);
960
+ if (th) liveThought = th;
961
+ }
917
962
  // A non-empty remainder is a partial CONTENT line (heartbeats always arrive whole with
918
963
  // a newline), so tokens are actively flowing — keep the ticker asleep.
919
964
  if (carry.trim() !== "") lastContentAt = Date.now();
@@ -2209,17 +2209,23 @@ 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
- _WS_RUN_ALLOWED = {
2213
- "npm", "npx", "yarn", "pnpm", "node", "mvn", "./mvnw", "gradle", "./gradlew",
2214
- "pytest", "python", "python3", "go", "cargo", "make", "dotnet", "swift",
2215
- "rspec", "bundle", "php", "composer", "tsc", "jest", "vitest",
2216
- # Deployment (task #57): transfer files + run remote commands. Auth uses the user's
2217
- # own SSH key/agent (SSH_AUTH_SOCK passes through _ws_scrubbed_env); the model is
2218
- # told to use non-interactive KEY auth (BatchMode) and NEVER hardcode passwords (a
2219
- # stdin password prompt EOFErrors here). No `sftp`: it's interactive by default
2220
- # (would hang timeout kill); scp/rsync cover the same ground batch-safely.
2221
- "ssh", "scp", "rsync",
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
2223
2229
 
2224
2230
  # ---- background servers (behavior-detected, command-agnostic) ----
2225
2231
  # run_command classifies a command by OBSERVED BEHAVIOR, never by its text: if the
@@ -2597,6 +2603,18 @@ def run_agent_chat(cfg):
2597
2603
  except Exception: # noqa: BLE001
2598
2604
  continue
2599
2605
  _WS_AVAILABLE = bool(_WS_ROOTS)
2606
+ # run_command policy overrides from Settings (both optional, default = allow all but
2607
+ # sudo). Accept either a list or a comma/space-separated string; basenames so a user
2608
+ # can write "git" and match "/usr/bin/git".
2609
+ global _WS_RUN_ALLOWLIST, _WS_RUN_DENYLIST
2610
+
2611
+ def _split_cmds(v):
2612
+ import re as _re
2613
+ items = v if isinstance(v, (list, tuple)) else _re.split(r"[,\s]+", str(v or ""))
2614
+ return {str(c).strip() for c in items if str(c).strip()}
2615
+
2616
+ _WS_RUN_ALLOWLIST = _split_cmds(cfg.get("runAllowlist"))
2617
+ _WS_RUN_DENYLIST = _split_cmds(cfg.get("runDenylist"))
2600
2618
  # Active terminal workspace: the folder the `gonext` REPL was launched from (sent as
2601
2619
  # payload.activeWorkspace = the terminal's cwd). download_file / unzip_file put their
2602
2620
  # output HERE instead of the shared ~/.gonext/rag-work cache, so files land where the
@@ -2960,9 +2978,10 @@ def run_agent_chat(cfg):
2960
2978
  "left RUNNING (its window opens on the user's Mac); stop_server closes it. An "
2961
2979
  "interactive input() CLI can't be driven here (EOFError) — make it non-interactive "
2962
2980
  "or give the user the command.\n"
2963
- f" ALLOWED runners (first word only): {', '.join(sorted(_WS_RUN_ALLOWED))}. "
2964
- "Anything else is rejected do NOT retry a blocked command a different way. "
2965
- "For Python packages use `python3 -m pip install <pkg>` (bare `pip` is NOT allowed).\n"
2981
+ " Most commands run directly build/test/package tools, git, docker, kubectl, "
2982
+ "terraform, aws/gcloud/az, and so on. Two limits: `sudo`/`su` are blocked (no root); "
2983
+ "and there is NO shell, so pipes `|`, redirects `>`, `&&`, and backticks do NOT work "
2984
+ "— run one command per call, or use python3 for anything that needs composition.\n"
2966
2985
  " - deploy_web(local_dir, host, user, remote_path, domain='') — DEPLOY a built "
2967
2986
  "static site in ONE call: build first (run_command('npm run build')), then "
2968
2987
  "deploy_web('my-app/build', host, user, remote_path, domain). It rsyncs over your "
@@ -4568,7 +4587,7 @@ def run_agent_chat(cfg):
4568
4587
  """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
4588
 
4570
4589
  Args:
4571
- command: the command line (allowlisted runners only; no shells or sudo).
4590
+ command: the command line. Most commands run; no shell (so no pipes/redirects/&&), and sudo/su are blocked.
4572
4591
  workdir: directory to run in. Empty = first registered workspace.
4573
4592
  timeout_seconds: give up after this many seconds if the command neither exits nor starts a server (max 600).
4574
4593
  """
@@ -4610,9 +4629,24 @@ def run_agent_chat(cfg):
4610
4629
  msg = "Error: empty command."
4611
4630
  _last_obs["text"] = msg
4612
4631
  return msg
4613
- if argv[0] not in _WS_RUN_ALLOWED:
4614
- msg = (f"Error: '{argv[0]}' is not an allowed runner. Allowed: "
4615
- + ", ".join(sorted(_WS_RUN_ALLOWED)))
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.
4634
+ _runner = argv[0]
4635
+ _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
4647
+ if _WS_RUN_ALLOWLIST and _runner not in _WS_RUN_ALLOWLIST and _rbase not in _WS_RUN_ALLOWLIST:
4648
+ msg = (f"Error: '{_runner}' is not in your run allowlist. Add it in web "
4649
+ "Settings → Agent, or clear the allowlist to allow all commands.")
4616
4650
  _last_obs["text"] = msg
4617
4651
  return msg
4618
4652
  _emit({"type": "step", "text": f"Running → {command[:70]}"})
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.225",
3
+ "version": "1.0.227",
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",