@tiens.nguyen/gu-cli 1.0.787 → 1.0.789

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.
@@ -5183,6 +5183,90 @@ def _same_embed_model(a: str, b: str) -> bool:
5183
5183
  _RUNTOOL_PREFIX = "__RUNTOOL__::"
5184
5184
 
5185
5185
 
5186
+ # ---- the command that is running, WHOLE (task #331) ------------------------------------------
5187
+ # "Running → <cmd>" carries the first 70 characters, which is right for a history line and wrong
5188
+ # for the one moment someone is watching: a heredoc or a long pipeline was cut at the source, so
5189
+ # the terminal could not show the rest whatever its layout. Sent beside that line (which stays,
5190
+ # for the web app and for older terminals) as ONE line of JSON — a command can span lines.
5191
+ _RUNCMD_PREFIX = "__RUNCMD__::"
5192
+ # How much it has printed so far: "__RUNOUT__::<lines>". From the wait loop, only when it changed.
5193
+ _RUNOUT_PREFIX = "__RUNOUT__::"
5194
+ _RUNCMD_MAX_CHARS = 4000
5195
+
5196
+ # SECRETS. The 70-character cut hid most credentials by accident; showing the whole command must
5197
+ # not undo that. Conservative and by SHAPE — a value after a name that says it is a secret, a
5198
+ # bearer header, user:password@ in a URL, sshpass -p, and the key formats gu itself hands out.
5199
+ # This is what goes to the SCREEN and the job stream; the command that runs is untouched.
5200
+ # "pass" and "token" only as whole words: --passes=3, bypass=true and --max-tokens 100 are not secrets.
5201
+ _SECRET_NAME = (r"(?:password|passwd|passphrase|pwd|(?<![a-z])pass(?![a-z])|secret|token(?![a-z])"
5202
+ r"|api[_-]?key|access[_-]?key)")
5203
+ _CMD_SECRET_RES = (
5204
+ (re.compile(r"(?i)(\b[\w.-]*" + _SECRET_NAME + r"[\w.-]*\s*[=:]\s*)(\"[^\"]*\"|'[^']*'|[^\s;&|]+)"), r"\1•••"),
5205
+ (re.compile(r"(?i)(--?[\w-]*" + _SECRET_NAME + r"[\w-]*[ =])(\"[^\"]*\"|'[^']*'|[^\s;&|]+)"), r"\1•••"),
5206
+ (re.compile(r"(?i)(\bsshpass\s+-p\s*)(\"[^\"]*\"|'[^']*'|[^\s;&|]+)"), r"\1•••"),
5207
+ (re.compile(r"(?i)(authorization:\s*(?:bearer|basic)\s+)[^\s\"']+"), r"\1•••"),
5208
+ (re.compile(r"(?i)(\b[a-z][a-z0-9+.-]*://[^\s/:@]+:)[^\s/@]+(@)"), r"\1•••\2"),
5209
+ (re.compile(r"\b(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|wk_[A-Za-z0-9_-]{16,}|gu1\.[A-Za-z0-9_.-]{20,})"), "•••"),
5210
+ )
5211
+
5212
+
5213
+ def _mask_command_secrets(command: str) -> str:
5214
+ """The command as it may be SHOWN: credentials replaced by •••, everything else as written."""
5215
+ text = str(command or "")
5216
+ for rx, sub in _CMD_SECRET_RES:
5217
+ text = rx.sub(sub, text)
5218
+ return text
5219
+
5220
+
5221
+ def _command_label(command: str, limit: int = 70) -> str:
5222
+ """A command as ONE LINE of a step label: masked, its first line only, cut to `limit`.
5223
+
5224
+ A step is a line of the stream. A heredoc's newlines made "Command finished → python3 - <<'EOF'"
5225
+ followed by every line of the script as a bullet of its own — and, on the way in, ended the
5226
+ live status row the moment the "Running →" line was read (#332). " …" says there is more.
5227
+ """
5228
+ lines = [ln.strip() for ln in _mask_command_secrets(command).replace("\r", "").split("\n") if ln.strip()]
5229
+ if not lines:
5230
+ return ""
5231
+ return lines[0][:limit] + (" …" if len(lines) > 1 else "")
5232
+
5233
+
5234
+ def _running_command_line(command: str) -> str:
5235
+ """The stream line that carries the whole command to the terminal (masked, capped)."""
5236
+ shown = _mask_command_secrets(command)
5237
+ if len(shown) > _RUNCMD_MAX_CHARS:
5238
+ shown = shown[:_RUNCMD_MAX_CHARS] + "…"
5239
+ return _RUNCMD_PREFIX + json.dumps({"cmd": shown}, ensure_ascii=False)
5240
+
5241
+
5242
+ def _count_output_lines(path: str, state: dict) -> int:
5243
+ """Lines a running command has written to its log so far. Reads only what is NEW since the
5244
+ last call (`state` keeps the offset), so a chatty ten-minute build is not re-read every tick."""
5245
+ try:
5246
+ with open(path, "rb") as fh:
5247
+ fh.seek(state.get("off", 0))
5248
+ chunk = fh.read()
5249
+ except OSError:
5250
+ return state.get("lines", 0)
5251
+ if chunk:
5252
+ state["off"] = state.get("off", 0) + len(chunk)
5253
+ state["lines"] = state.get("lines", 0) + chunk.count(b"\n")
5254
+ state["partial"] = not chunk.endswith(b"\n")
5255
+ # a last line with no newline yet is still a line someone could read
5256
+ return state.get("lines", 0) + (1 if state.get("partial") else 0)
5257
+
5258
+
5259
+ def _emit_command_progress(path: str, state: dict) -> None:
5260
+ """Tell the terminal how much the running command has printed — only when that changed."""
5261
+ try:
5262
+ n = _count_output_lines(path, state)
5263
+ if n != state.get("sent", 0):
5264
+ state["sent"] = n
5265
+ _emit({"type": "step", "text": f"{_RUNOUT_PREFIX}{n}"})
5266
+ except Exception: # noqa: BLE001 — a progress figure must never fail a command
5267
+ pass
5268
+
5269
+
5186
5270
  def _fmt_call(name: str, **kw) -> str:
5187
5271
  """A tool call rendered the way the model writes it. Skips empty/default arguments."""
5188
5272
  parts = []
@@ -6391,6 +6475,114 @@ def _and_chain_parts(command):
6391
6475
  return parts
6392
6476
 
6393
6477
 
6478
+ def _parse_heredoc(command):
6479
+ """A heredoc or here-string in `command` → what to run and what to feed it. (task #332)
6480
+
6481
+ THE BUG THIS EXISTS FOR. run_command has no shell, so
6482
+
6483
+ python3 - <<'EOF'
6484
+ print("hello")
6485
+ EOF
6486
+
6487
+ became argv ["python3", "-", "<<EOF", "print(hello)", "EOF"]: python read its script from an
6488
+ EMPTY stdin and exited 0 with no output. Nothing ran and the step looked like a success —
6489
+ the #204 failure again, in the one shape #204's check did not cover (its redirect pattern
6490
+ matches "<" and ">", not "<<"). Seen live: the model lost two steps rewriting the work as
6491
+ `python3 -c`. Heredocs are how a strong model runs a multi-line script without a file, so
6492
+ this is the common case, not an edge.
6493
+
6494
+ SUPPORTED, still with no shell: ONE program fed a body.
6495
+ PROG ARGS <<'TAG' … TAG the body becomes the program's stdin
6496
+ PROG ARGS <<-TAG … TAG same, leading tabs stripped
6497
+ PROG ARGS <<< 'text' a here-string: text + newline on stdin
6498
+ REFUSED, each with the one change that fixes it — a guess costs a model step:
6499
+ an unquoted TAG (or a double-quoted here-string) whose text has $ or ` — a shell would
6500
+ expand those and we cannot; anything after the closing TAG; no closing TAG; two heredocs.
6501
+
6502
+ Returns None when there is no heredoc (`echo "a << b"` and `python3 -c "print(1 << 3)"` are
6503
+ not: the operator only counts OUTSIDE quotes), else {"command", "stdin"} or {"error"}.
6504
+ Pure, so every shape is testable without spawning anything.
6505
+ """
6506
+ s = command or ""
6507
+ q = None
6508
+ i = 0
6509
+ at = -1
6510
+ while i < len(s):
6511
+ c = s[i]
6512
+ if q:
6513
+ if c == q:
6514
+ q = None
6515
+ elif c == "\\" and q == '"':
6516
+ i += 1
6517
+ elif c in "'\"":
6518
+ q = c
6519
+ elif c == "\\":
6520
+ i += 1
6521
+ elif s.startswith("<<", i):
6522
+ at = i
6523
+ break
6524
+ i += 1
6525
+ if at < 0:
6526
+ return None
6527
+ _why = "Error: that command did NOT run — nothing was executed. "
6528
+ before = s[:at]
6529
+ if s.startswith("<<<", at):
6530
+ # HERE-STRING: the next word is the input; anything after it stays an argument.
6531
+ rest = s[at + 3:]
6532
+ import shlex as _shlex
6533
+ try:
6534
+ words = _shlex.split(rest)
6535
+ except ValueError as e:
6536
+ return {"error": _why + f"The here-string is not closed: {e}."}
6537
+ if not words:
6538
+ return {"error": _why + "'<<<' needs the text to feed the program: PROG <<< 'text'."}
6539
+ lead = rest.lstrip()
6540
+ if not lead.startswith("'") and re.search(r"[$`]", words[0]):
6541
+ return {"error": _why + "run_command has NO shell, so $variables and `commands` in a "
6542
+ "here-string are not expanded. Put the text in SINGLE quotes to pass it "
6543
+ "exactly as written, or write the final text yourself."}
6544
+ tail = " ".join(_shlex.quote(w) for w in words[1:])
6545
+ return {"command": (before.rstrip() + (" " + tail if tail else "")).strip(),
6546
+ "stdin": words[0] + "\n"}
6547
+ m = re.match(r"<<(-?)[ \t]*(?:'([^'\n]+)'|\"([^\"\n]+)\"|\\?([A-Za-z_][\w.-]*))", s[at:])
6548
+ if not m:
6549
+ return {"error": _why + "'<<' must be followed by a tag: PROG <<'EOF', the text, then "
6550
+ "EOF on a line of its own."}
6551
+ strip_tabs = bool(m.group(1))
6552
+ tag = m.group(2) or m.group(3) or m.group(4)
6553
+ quoted = bool(m.group(2) or m.group(3)) or s[at + m.end() - len(tag) - 1] == "\\"
6554
+ after = s[at + m.end():]
6555
+ line_rest, nl, body_and_more = after.partition("\n")
6556
+ if "<<" in line_rest and _parse_heredoc(line_rest) is not None:
6557
+ return {"error": _why + "Only one heredoc per command is supported. Write the second "
6558
+ "text with create_file, or run the two commands in separate calls."}
6559
+ lines = body_and_more.split("\n") if nl else []
6560
+ end = -1
6561
+ for k, ln in enumerate(lines):
6562
+ if (ln.lstrip("\t") if strip_tabs else ln).rstrip("\r") == tag:
6563
+ end = k
6564
+ break
6565
+ if end < 0:
6566
+ return {"error": _why + f"The heredoc is never closed: put {tag} on a line of its own "
6567
+ "after the text."}
6568
+ body_lines = [(ln.lstrip("\t") if strip_tabs else ln).rstrip("\r") for ln in lines[:end]]
6569
+ trailing = "\n".join(lines[end + 1:]).strip()
6570
+ if trailing:
6571
+ return {"error": _why + f"There is more after the closing {tag}, and run_command runs "
6572
+ "ONE command per call (only && chains continue). Run what follows in a "
6573
+ "separate run_command call."}
6574
+ cmdline = (before.rstrip() + " " + line_rest.strip()).strip()
6575
+ body = "\n".join(body_lines) + ("\n" if body_lines else "")
6576
+ if not quoted and re.search(r"[$`]", body):
6577
+ return {"error": _why + "run_command has NO shell, so the $variables and `commands` in "
6578
+ f"this heredoc would not be expanded. Quote the tag — <<'{tag}' — to pass the "
6579
+ "text exactly as written (that is what a script wants), or write the final "
6580
+ "text yourself."}
6581
+ if not cmdline:
6582
+ return {"error": _why + "The heredoc has no program to feed: PROG <<'EOF' … EOF."}
6583
+ return {"command": cmdline, "stdin": body}
6584
+
6585
+
6394
6586
  def _shell_syntax_error(command, argv):
6395
6587
  """Why this command cannot do what it says, or None. (task #204)
6396
6588
 
@@ -6963,7 +7155,7 @@ def _ws_request_approval(api_base, worker_key, job_id, command, reason, deadline
6963
7155
  _srv, _, _rest = command[len("__MCPTOOL__::"):].partition("\t")
6964
7156
  _emit({"type": "step", "text": f"Awaiting your approval: MCP tool {_srv}/{_rest.split(chr(9))[0]}"})
6965
7157
  else:
6966
- _emit({"type": "step", "text": f"Awaiting your approval to run: {command[:70]}"})
7158
+ _emit({"type": "step", "text": f"Awaiting your approval to run: {_command_label(command, 70)}"})
6967
7159
  deadline = _t.time() + max(30, int(deadline_s)) # per-call; risky=180s, __MAXSTEP__ long
6968
7160
  while _t.time() < deadline:
6969
7161
  _t.sleep(1.2)
@@ -7341,6 +7533,12 @@ def _ws_scrubbed_env() -> dict:
7341
7533
  # CI=1 makes watch-mode test runners (CRA's `npm test`, jest --watch) run ONCE and
7342
7534
  # exit instead of sitting in interactive watch mode until the timeout kills them.
7343
7535
  env["CI"] = "1"
7536
+ # WINDOWS: a python child reads and writes in the ANSI code page unless told otherwise, so a
7537
+ # script fed on stdin (a heredoc, #332) with "é" or "→" in it is mis-decoded, and printing
7538
+ # one raises UnicodeEncodeError. The worker's own python is started with PYTHONUTF8=1 for
7539
+ # exactly this reason; the whitelist above dropped it for the commands it runs.
7540
+ if _IS_WINDOWS:
7541
+ env["PYTHONUTF8"] = "1"
7344
7542
  return env
7345
7543
 
7346
7544
 
@@ -12501,7 +12699,7 @@ def run_agent_chat(cfg):
12501
12699
  """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.
12502
12700
 
12503
12701
  Args:
12504
- command: the command line. Chain dependent steps with && in ONE call ("cd app && npm ci && npm test"): they run in order, cd sets the folder, the first failure stops the rest. No other shell syntax (no pipes/redirects/;). sudo/su are blocked.
12702
+ command: the command line. Chain dependent steps with && in ONE call ("cd app && npm ci && npm test"): they run in order, cd sets the folder, the first failure stops the rest. To run a multi-line script without a file, feed ONE program a heredoc, tag quoted: "python3 - <<'EOF'\n...\nEOF". No other shell syntax (no pipes/redirects/;). sudo/su are blocked.
12505
12703
  workdir: directory to run in. Empty = first registered workspace.
12506
12704
  timeout_seconds: give up after this many seconds if the command neither exits nor starts a server (max 600).
12507
12705
  """
@@ -12546,7 +12744,24 @@ def run_agent_chat(cfg):
12546
12744
  "gu-local-worker workspace add <path> --allow-run)")
12547
12745
  _last_obs["text"] = msg
12548
12746
  return msg
12549
- argv = shlex.split(command or "")
12747
+ # A HEREDOC / HERE-STRING (task #332): the text is the program's stdin, and the
12748
+ # command line is what is left. `command` stays the whole text for the risk gate,
12749
+ # the status row and the log; `_cmdline` is what is split and judged as argv.
12750
+ _here = _parse_heredoc(command)
12751
+ if _here and _here.get("error"):
12752
+ _log(f"run_command refused heredoc: {_c(_clip(command, 120))}")
12753
+ _last_obs["text"] = _here["error"]
12754
+ return _here["error"]
12755
+ _cmdline = _here["command"] if _here else (command or "")
12756
+ _stdin_text = _here["stdin"] if _here else None
12757
+ if _here and _and_chain_parts(_cmdline):
12758
+ msg = ("Error: that command did NOT run — nothing was executed. A heredoc "
12759
+ "cannot be part of an && chain here. Run the program that reads it in "
12760
+ "its own run_command call, and pass the folder as the cwd argument "
12761
+ "instead of `cd X &&`.")
12762
+ _last_obs["text"] = msg
12763
+ return msg
12764
+ argv = shlex.split(_cmdline)
12550
12765
  if not argv:
12551
12766
  msg = "Error: empty command."
12552
12767
  _last_obs["text"] = msg
@@ -12554,7 +12769,7 @@ def run_agent_chat(cfg):
12554
12769
  # AN `&&` CHAIN IS ONE STEP (task #274): each part runs through THIS function —
12555
12770
  # allowlist, risk gate, approval, server detection, all of it — in order, and
12556
12771
  # the first failure stops the rest. `cd DIR` sets the folder for what follows.
12557
- _chain = _and_chain_parts(command)
12772
+ _chain = None if _here else _and_chain_parts(command)
12558
12773
  if _chain:
12559
12774
  _chain_start = _t.time()
12560
12775
  _cur_wd = wd
@@ -12604,7 +12819,7 @@ def run_agent_chat(cfg):
12604
12819
  # executes the `cd` PROGRAM — which exits 0 with no output — and the step is
12605
12820
  # reported as a success that did nothing. Refusing costs one step; the observed
12606
12821
  # alternative cost two hour-long turns and a false report to the user.
12607
- _shell_err = _shell_syntax_error(command, argv)
12822
+ _shell_err = _shell_syntax_error(_cmdline, argv)
12608
12823
  if _shell_err:
12609
12824
  _log(f"run_command refused shell syntax: {_c(_clip(command, 120))}")
12610
12825
  _last_obs["text"] = _shell_err
@@ -12622,7 +12837,10 @@ def run_agent_chat(cfg):
12622
12837
  # Risk gate: privilege escalation / denylisted / destructive. When the
12623
12838
  # client can approve interactively (terminal), PAUSE and ask the user;
12624
12839
  # otherwise (web) keep hard-blocking the privilege/denylist cases.
12625
- _hard_reason, _ask_reason = _ws_command_risk(command, argv, _WS_RUN_DENYLIST, wd)
12840
+ # Judged on the COMMAND LINE, not on a heredoc's text: the text is data for the
12841
+ # program (a script that prints "sudo …" is not a sudo), and what a script does
12842
+ # is no more visible here than it is for `python3 -c`.
12843
+ _hard_reason, _ask_reason = _ws_command_risk(_cmdline, argv, _WS_RUN_DENYLIST, wd)
12626
12844
  # Shift+Tab may have switched it since the turn started (#47).
12627
12845
  global _accept_edits
12628
12846
  if _interactive_approval and _job_id:
@@ -12638,7 +12856,7 @@ def run_agent_chat(cfg):
12638
12856
  # states it. "this needs root and sudo needs a password we cannot type" is a
12639
12857
  # different question from "sudo is passwordless here", and the user can only
12640
12858
  # answer the one they are shown.
12641
- _priv = _ws_priv_reason(command)
12859
+ _priv = _ws_priv_reason(_cmdline)
12642
12860
  _facts = _ws_access_facts() if _priv else None
12643
12861
  if _priv and _facts and not _facts["can_elevate"]:
12644
12862
  # REFUSE BEFORE SPAWNING (requirement 5). With no tty, `sudo x` dies with
@@ -12689,7 +12907,8 @@ def run_agent_chat(cfg):
12689
12907
  _emit({"type": "step", "text": f"Server already up → {_urls} (reused)"})
12690
12908
  _last_obs["text"] = msg
12691
12909
  return msg
12692
- _emit({"type": "step", "text": f"Running → {command[:70]}"})
12910
+ _emit({"type": "step", "text": f"Running → {_command_label(command, 70)}"})
12911
+ _emit({"type": "step", "text": _running_command_line(command)}) # whole (#331)
12693
12912
  t = max(5, min(int(timeout_seconds or 180), 600))
12694
12913
  # Own process group (start_new_session) + output to a log file. This is
12695
12914
  # what lets a server OUTLIVE this job process, and what lets a timeout
@@ -12705,13 +12924,30 @@ def run_agent_chat(cfg):
12705
12924
  if _attrib_note:
12706
12925
  _log(f"run_command: {_attrib_note}")
12707
12926
  # Scrubbed env: repo scripts must never see worker secrets.
12708
- proc = subprocess.Popen(
12709
- argv, cwd=wd, env=_ws_scrubbed_env(),
12710
- stdout=log_fh, stderr=subprocess.STDOUT,
12711
- **_ws_spawn_kwargs(),
12712
- )
12927
+ # The heredoc's text as stdin, from an unnamed temp file: no pipe to keep fed (a
12928
+ # pipe needs a writer thread or it deadlocks past 64 KB), and nothing left on
12929
+ # disk — the file goes when the last handle on it closes.
12930
+ _stdin_fh = None
12931
+ if _stdin_text is not None:
12932
+ import tempfile
12933
+ _stdin_fh = tempfile.TemporaryFile()
12934
+ _stdin_fh.write(_stdin_text.encode("utf-8"))
12935
+ _stdin_fh.seek(0)
12936
+ try:
12937
+ # stdin is passed ONLY for a heredoc — every other command keeps exactly
12938
+ # the stdin it has always had.
12939
+ proc = subprocess.Popen(
12940
+ argv, cwd=wd, env=_ws_scrubbed_env(),
12941
+ stdout=log_fh, stderr=subprocess.STDOUT,
12942
+ **({"stdin": _stdin_fh} if _stdin_fh is not None else {}),
12943
+ **_ws_spawn_kwargs(),
12944
+ )
12945
+ finally:
12946
+ if _stdin_fh is not None:
12947
+ _stdin_fh.close() # the child holds its own copy
12713
12948
  _INFLIGHT_CMDS.add(proc.pid) # killed with the agent until it is classified
12714
12949
  start = _t.time()
12950
+ _out_seen: dict = {} # how much of its output the terminal has been told about
12715
12951
 
12716
12952
  def _read_log() -> str:
12717
12953
  log_fh.flush()
@@ -12744,7 +12980,7 @@ def run_agent_chat(cfg):
12744
12980
  if rc == 0 else f"FAILED (exit {rc})"
12745
12981
  )
12746
12982
  _emit({"type": "step", "text":
12747
- f"Command {'finished' if rc == 0 else 'failed'} → {command[:50]}"})
12983
+ f"Command {'finished' if rc == 0 else 'failed'} → {_command_label(command, 50)}"})
12748
12984
  # SAY "no output" RATHER THAN SHOW NOTHING (task #204). An empty result
12749
12985
  # was indistinguishable from a command that never ran — which is exactly
12750
12986
  # what `cd X && Y` used to do — and the model read the silence as a
@@ -12818,6 +13054,8 @@ def run_agent_chat(cfg):
12818
13054
  f"{t}s — killed. Output so far:\n{tail}")
12819
13055
  _last_obs["text"] = msg
12820
13056
  return msg
13057
+ log_fh.flush()
13058
+ _emit_command_progress(log_path, _out_seen)
12821
13059
  _t.sleep(2)
12822
13060
  except Exception as ex: # noqa: BLE001
12823
13061
  msg = f"Error: run_command failed: {type(ex).__name__}: {ex}"