@tiens.nguyen/gu-cli 1.0.820 → 1.0.822

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.
@@ -7346,6 +7346,477 @@ def _shell_syntax_error(command, argv):
7346
7346
  "later read of it finds no such file. You already RECEIVE the command's output, "
7347
7347
  "so just run it; use create_file if you genuinely need the text on disk.")
7348
7348
  return None
7349
+
7350
+
7351
+ # ---- a REAL SHELL for run_command, checked per command (task #359, GitHub #139) ----------------
7352
+ # The no-shell rule above cost more than it protected. Over 21 days the model wrote a pipe ~207
7353
+ # times, `||` 60, `;` 30 — each refused and retried as another full model step — and 51 turns wrote
7354
+ # a body file only because `gh … --body` could not take multi-line text. It was never a sandbox
7355
+ # either: `python3 -c "import os; os.system('…')"` always ran anything. So on macOS/Linux a command
7356
+ # that NEEDS a shell now runs through `bash -c`, the way Claude Code's Bash tool does, and the
7357
+ # safety moves to where it can actually work: the line is scanned into its simple commands —
7358
+ # including those inside $( ), backticks and heredoc-fed commands — and EACH one goes through the
7359
+ # same checks an argv command gets (privilege, deny/allow lists, delete outside the project), plus
7360
+ # the two things only a shell can do: write outside the project with a redirect, and pipe text into
7361
+ # an interpreter. A command with no shell syntax still runs as argv, exactly as before.
7362
+ # Windows keeps the argv path (no bash to rely on there yet).
7363
+ _SHELL_OPS = ("&&", "||", ";;", "|&", "<<<", "<<-", "<<", "&>>", "&>", ">>", ">&", "<&", "<>",
7364
+ ">|", ";", "|", "&", "(", ")", ">", "<")
7365
+ _SHELL_SEPARATORS = {"&&", "||", ";", ";;", "|", "|&", "&", "\n", "(", ")"}
7366
+ _SHELL_WRITE_REDIRECTS = {">", ">>", ">|", "&>", "&>>", "<>"}
7367
+ # Runners that execute TEXT as code. Piping into one with no script argument (`curl … | sh`) is the
7368
+ # shape a prompt injection takes, so it is blocked when nobody can be asked.
7369
+ _SHELL_INTERPRETERS = {"sh", "bash", "zsh", "dash", "ksh", "fish", "python", "python3", "node",
7370
+ "perl", "ruby", "php", "pwsh", "powershell"}
7371
+ _SHELL_EVAL_RUNNERS = {"eval", "source", "."}
7372
+ _SHELL_HARMLESS_TARGETS = {"/dev/null", "/dev/stdout", "/dev/stderr", "/dev/tty"}
7373
+
7374
+
7375
+ def _shell_scan(command):
7376
+ """Tokenise a shell line the way bash reads it. PURE. → dict:
7377
+
7378
+ tokens — ("word", unquoted text, start, end, quoted) / ("op", text, start, end, False);
7379
+ start/end index the ORIGINAL string, so a word can be edited in place
7380
+ substs — [{"text", "tokens"}] for every $( ) and backtick body (scanned the same way)
7381
+ heredocs — how many heredocs were read (their bodies are DATA and appear in no token)
7382
+ expands — True when an unquoted $, glob or leading ~ needs the shell to expand it
7383
+ error — why the line cannot be run (unbalanced quote, heredoc never closed), or None
7384
+ """
7385
+ out = {"tokens": [], "substs": [], "heredocs": 0, "expands": False, "error": None}
7386
+ try:
7387
+ _shell_scan_into(command or "", 0, out, out["tokens"], nested=False)
7388
+ except ValueError as e:
7389
+ out["error"] = str(e)
7390
+ return out
7391
+
7392
+
7393
+ def _shell_scan_into(s, i, out, toks, nested):
7394
+ """Scan from `i` into `toks`. nested=True stops at the `)` closing a $( and returns past it."""
7395
+ n = len(s)
7396
+ st = {"word": None, "start": 0, "quoted": False, "delim_op": None}
7397
+ pending = [] # heredocs whose body starts after the next newline: (delimiter, op)
7398
+ depth = 0
7399
+
7400
+ def start_word(at):
7401
+ if st["word"] is None:
7402
+ st["word"], st["start"] = [], at
7403
+
7404
+ def end_word(at):
7405
+ if st["word"] is None:
7406
+ return
7407
+ text = "".join(st["word"])
7408
+ if st["delim_op"]:
7409
+ pending.append((text, st["delim_op"]))
7410
+ st["delim_op"] = None
7411
+ out["heredocs"] += 1
7412
+ toks.append(("word", text, st["start"], at, st["quoted"]))
7413
+ st["word"], st["quoted"] = None, False
7414
+
7415
+ def subst(j): # j = index just past "$("
7416
+ inner = []
7417
+ k = _shell_scan_into(s, j, out, inner, nested=True)
7418
+ out["substs"].append({"text": s[j:k - 1], "tokens": inner})
7419
+ out["expands"] = True
7420
+ return k
7421
+
7422
+ def backtick(j): # j = index of the opening `
7423
+ k = j + 1
7424
+ while k < n and s[k] != "`":
7425
+ k += 2 if s[k] == "\\" else 1
7426
+ if k >= n:
7427
+ raise ValueError("unbalanced backtick")
7428
+ inner = []
7429
+ _shell_scan_into(s[j + 1:k], 0, out, inner, nested=False)
7430
+ out["substs"].append({"text": s[j + 1:k], "tokens": inner})
7431
+ out["expands"] = True
7432
+ return k + 1
7433
+
7434
+ def closing(j, opener, closer): # j = index of opener's first char → index past its closer
7435
+ k, d = j + len(opener), 1
7436
+ while k < n and d:
7437
+ if s.startswith(opener, k):
7438
+ d, k = d + 1, k + len(opener)
7439
+ elif s.startswith(closer, k):
7440
+ d, k = d - 1, k + len(closer)
7441
+ else:
7442
+ k += 1
7443
+ if d:
7444
+ raise ValueError(f"unbalanced {opener}")
7445
+ return k
7446
+
7447
+ def dollar(j): # a `$` at j, outside single quotes → index past the expansion, or None
7448
+ if s.startswith("$((", j):
7449
+ out["expands"] = True
7450
+ return _past(j + 1, "(", ")")
7451
+ if s.startswith("$(", j):
7452
+ return subst(j + 2)
7453
+ if s.startswith("${", j):
7454
+ out["expands"] = True
7455
+ return closing(j + 1, "{", "}")
7456
+ if j + 1 < n and (s[j + 1].isalnum() or s[j + 1] in "_@*#?$!-"):
7457
+ out["expands"] = True
7458
+ return None
7459
+
7460
+ def _past(j, o, c): # j = index of the first o of a balanced o…c run
7461
+ k, d = j, 0
7462
+ while k < n:
7463
+ if s[k] == o:
7464
+ d += 1
7465
+ elif s[k] == c:
7466
+ d -= 1
7467
+ if d == 0:
7468
+ return k + 1
7469
+ k += 1
7470
+ raise ValueError("unbalanced $((")
7471
+
7472
+ while i < n:
7473
+ c = s[i]
7474
+ w = st["word"]
7475
+ if c in " \t":
7476
+ end_word(i)
7477
+ i += 1
7478
+ continue
7479
+ if c == "\n":
7480
+ end_word(i)
7481
+ toks.append(("op", "\n", i, i + 1, False))
7482
+ i += 1
7483
+ if pending:
7484
+ i = _shell_heredoc_bodies(s, i, pending)
7485
+ pending.clear()
7486
+ continue
7487
+ if c == "#" and w is None:
7488
+ j = s.find("\n", i)
7489
+ i = n if j < 0 else j
7490
+ continue
7491
+ if c == "\\":
7492
+ if i + 1 < n and s[i + 1] == "\n":
7493
+ i += 2
7494
+ continue
7495
+ start_word(i)
7496
+ if i + 1 < n:
7497
+ st["word"].append(s[i + 1])
7498
+ st["quoted"] = True
7499
+ i += 2
7500
+ continue
7501
+ if c == "'":
7502
+ start_word(i)
7503
+ j = s.find("'", i + 1)
7504
+ if j < 0:
7505
+ raise ValueError("unbalanced single quote")
7506
+ st["word"].extend(s[i + 1:j])
7507
+ st["quoted"] = True
7508
+ i = j + 1
7509
+ continue
7510
+ if c == '"':
7511
+ start_word(i)
7512
+ st["quoted"] = True
7513
+ i += 1
7514
+ while True:
7515
+ if i >= n:
7516
+ raise ValueError("unbalanced double quote")
7517
+ d = s[i]
7518
+ if d == '"':
7519
+ i += 1
7520
+ break
7521
+ if d == "\\" and i + 1 < n and s[i + 1] in '"\\$`\n':
7522
+ if s[i + 1] != "\n":
7523
+ st["word"].append(s[i + 1])
7524
+ i += 2
7525
+ continue
7526
+ if d == "$":
7527
+ k = dollar(i)
7528
+ if k is not None:
7529
+ st["word"].extend(s[i:k])
7530
+ i = k
7531
+ continue
7532
+ if d == "`":
7533
+ k = backtick(i)
7534
+ st["word"].extend(s[i:k])
7535
+ i = k
7536
+ continue
7537
+ st["word"].append(d)
7538
+ i += 1
7539
+ continue
7540
+ if c == "$":
7541
+ k = dollar(i)
7542
+ if k is not None:
7543
+ start_word(i)
7544
+ st["word"].extend(s[i:k])
7545
+ i = k
7546
+ continue
7547
+ if c == "`":
7548
+ start_word(i)
7549
+ k = backtick(i)
7550
+ st["word"].extend(s[i:k])
7551
+ i = k
7552
+ continue
7553
+ if nested and c == ")" and depth == 0:
7554
+ end_word(i)
7555
+ if pending:
7556
+ raise ValueError(f"heredoc '{pending[0][0]}' has no closing {pending[0][0]} line")
7557
+ return i + 1
7558
+ op = next((o for o in _SHELL_OPS if s.startswith(o, i)), None)
7559
+ if op:
7560
+ if op[0] in "<>" and w is not None and not st["quoted"] and "".join(w).isdigit():
7561
+ st["word"] = None # `2>`: the fd number belongs to the redirect, not the argv
7562
+ end_word(i)
7563
+ depth += 1 if op == "(" else -1 if op == ")" else 0
7564
+ toks.append(("op", op, i, i + len(op), False))
7565
+ if op in ("<<", "<<-"):
7566
+ st["delim_op"] = op
7567
+ i += len(op)
7568
+ continue
7569
+ start_word(i)
7570
+ if c in "*?[" or (c == "~" and not st["word"]):
7571
+ out["expands"] = True
7572
+ st["word"].append(c)
7573
+ i += 1
7574
+ end_word(n)
7575
+ if nested:
7576
+ raise ValueError("unbalanced $(")
7577
+ if pending:
7578
+ raise ValueError(f"heredoc '{pending[0][0]}' has no closing {pending[0][0]} line")
7579
+ return n
7580
+
7581
+
7582
+ def _shell_heredoc_bodies(s, i, pending):
7583
+ """Skip the heredoc bodies that start at `i`, in order. → index past the last delimiter line."""
7584
+ n = len(s)
7585
+ for delim, op in pending:
7586
+ while True:
7587
+ if i >= n:
7588
+ raise ValueError(f"heredoc '{delim}' has no closing {delim} line")
7589
+ j = s.find("\n", i)
7590
+ line = s[i:] if j < 0 else s[i:j]
7591
+ i = n if j < 0 else j + 1
7592
+ if (line.lstrip("\t") if op == "<<-" else line) == delim:
7593
+ break
7594
+ return i
7595
+
7596
+
7597
+ def _shell_commands(tokens):
7598
+ """Tokens → the simple commands: [{"argv", "spans", "sep", "redirects"}]. Leading VAR=value
7599
+ assignments and redirects (with their target) are not part of argv; `sep` is the operator
7600
+ that came before the command ("" for the first)."""
7601
+ cmds, cur, sep, i = [], None, "", 0
7602
+ while i < len(tokens):
7603
+ kind, text, a, b, quoted = tokens[i]
7604
+ if kind == "op" and text in _SHELL_SEPARATORS:
7605
+ if cur and (cur["argv"] or cur["redirects"]):
7606
+ cmds.append(cur)
7607
+ cur, sep = None, text
7608
+ i += 1
7609
+ continue
7610
+ if cur is None:
7611
+ cur = {"argv": [], "spans": [], "sep": sep, "redirects": []}
7612
+ if kind == "op":
7613
+ nxt = tokens[i + 1] if i + 1 < len(tokens) else None
7614
+ tgt = nxt[1] if nxt and nxt[0] == "word" else ""
7615
+ cur["redirects"].append((text, tgt))
7616
+ i += 2 if tgt or (nxt and nxt[0] == "word") else 1
7617
+ continue
7618
+ if not cur["argv"] and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", text):
7619
+ i += 1
7620
+ continue
7621
+ cur["argv"].append(text)
7622
+ cur["spans"].append((a, b))
7623
+ i += 1
7624
+ if cur and (cur["argv"] or cur["redirects"]):
7625
+ cmds.append(cur)
7626
+ return cmds
7627
+
7628
+
7629
+ def _shell_needed(scan):
7630
+ """Does this line need a shell to mean what it says? False → run it as argv, as before."""
7631
+ return bool(scan["heredocs"] or scan["substs"] or scan["expands"]
7632
+ or any(t[0] == "op" for t in scan["tokens"]))
7633
+
7634
+
7635
+ def _shell_all_commands(scan):
7636
+ """(top-level commands, commands inside $( ) / backticks)."""
7637
+ top = _shell_commands(scan["tokens"])
7638
+ inner = [c for sub in scan["substs"] for c in _shell_commands(sub["tokens"])]
7639
+ return top, inner
7640
+
7641
+
7642
+ def _shell_background_error(scan):
7643
+ """A line that puts a command in the background (`npm run dev &`), or None. bash would exit at
7644
+ once and leave that process running where stop_server cannot see it, and the model would read
7645
+ "EXITED 0" as the server having started. run_command already keeps a server running."""
7646
+ if any(t[0] == "op" and t[1] == "&" for t in scan["tokens"]):
7647
+ return ("Error: that command did NOT run. Do not use `&` to background it — run_command "
7648
+ "already leaves a server or app RUNNING when it starts listening or opens a window, "
7649
+ "and reports its URL. Send the same command without the `&`.")
7650
+ return None
7651
+
7652
+
7653
+ def _shell_priv_reason(scan):
7654
+ """_ws_priv_reason over every simple command — NOT the raw line, so a heredoc body that merely
7655
+ mentions sudo (an issue comment about it) is data, while `bash -c "sudo …"` is still caught."""
7656
+ top, inner = _shell_all_commands(scan)
7657
+ for c in top + inner:
7658
+ r = _ws_priv_reason(" ".join(c["argv"]))
7659
+ if r:
7660
+ return r
7661
+ return None
7662
+
7663
+
7664
+ def _ws_write_outside_reason(redirects, wd, roots=None):
7665
+ """Why a redirect needs the user's yes: it writes a file OUTSIDE every workspace and the temp
7666
+ folder. The shell's counterpart of _ws_delete_outside_reason."""
7667
+ import tempfile
7668
+ targets = [t for op, t in redirects
7669
+ if op in _SHELL_WRITE_REDIRECTS and t and not t.startswith("&")
7670
+ and t not in _SHELL_HARMLESS_TARGETS]
7671
+ if not targets:
7672
+ return None
7673
+ ws = [os.path.realpath(r["path"]) for r in (_WS_ROOTS if roots is None else roots) if r.get("path")]
7674
+ if not ws:
7675
+ return None
7676
+ temp = [os.path.realpath(p) for p in {tempfile.gettempdir(), "/tmp", "/var/tmp"} if p]
7677
+ outside = []
7678
+ for t in targets:
7679
+ p = os.path.expanduser(t)
7680
+ p = os.path.realpath(p if os.path.isabs(p) else os.path.join(wd or os.getcwd(), p))
7681
+ if not (_ws_is_inside(p, ws) or _ws_is_inside(p, temp)):
7682
+ outside.append(t)
7683
+ if not outside:
7684
+ return None
7685
+ return "it writes a file OUTSIDE the project (" + ", ".join(outside[:3]) + ")"
7686
+
7687
+
7688
+ def _ws_shell_command_risk(scan, denyset, wd="", allowset=None, roots=None):
7689
+ """The run policy for a SHELL line, applied to each simple command in it.
7690
+
7691
+ → (refuse, hard_reason, ask_reason): `refuse` is a finished error message (allowlist — never
7692
+ asked, same as argv); hard/ask follow _ws_command_risk's contract (hard = block when nobody can
7693
+ be asked, ask = confirm when someone can)."""
7694
+ top, inner = _shell_all_commands(scan)
7695
+ every = top + inner
7696
+ if allowset:
7697
+ for c in every:
7698
+ r = c["argv"][0] if c["argv"] else ""
7699
+ if r and r not in allowset and os.path.basename(r) not in allowset:
7700
+ return (f"Error: '{r}' is not in your run allowlist. Add it in web Settings → Agent, "
7701
+ "or clear the allowlist to allow all commands.", None, None)
7702
+ priv = _shell_priv_reason(scan)
7703
+ if priv:
7704
+ return (None, priv, priv)
7705
+ for c in every:
7706
+ r = c["argv"][0] if c["argv"] else ""
7707
+ if r and denyset and (r in denyset or os.path.basename(r) in denyset):
7708
+ msg = f"'{r}' is on your blocked-commands list"
7709
+ return (None, msg, msg)
7710
+ # Deletes and redirect-writes outside the project, following `cd` the way the shell will.
7711
+ cur = wd
7712
+ for c in top:
7713
+ a = c["argv"]
7714
+ if a and a[0] == "cd" and len(a) >= 2 and c["sep"] not in ("|", "|&"):
7715
+ cur = os.path.normpath(os.path.join(cur or os.getcwd(), os.path.expanduser(a[1])))
7716
+ continue
7717
+ why = (_ws_delete_outside_reason(a, cur, roots)
7718
+ or _ws_write_outside_reason(c["redirects"], cur, roots))
7719
+ if why:
7720
+ return (None, why, why)
7721
+ for c in inner:
7722
+ why = _ws_delete_outside_reason(c["argv"], wd, roots)
7723
+ if why:
7724
+ return (None, why, why)
7725
+ for c in top:
7726
+ a = c["argv"]
7727
+ r = os.path.basename(a[0]).lower() if a else ""
7728
+ if c["sep"] in ("|", "|&") and r in _SHELL_INTERPRETERS \
7729
+ and all(x.startswith("-") for x in a[1:]):
7730
+ msg = f"it pipes text into {r}, which runs that text as code"
7731
+ return (None, msg, msg)
7732
+ for c in every:
7733
+ a = c["argv"]
7734
+ if not a:
7735
+ continue
7736
+ if a[0] in _SHELL_EVAL_RUNNERS:
7737
+ return (None, None, f"it runs text as code ({a[0]})")
7738
+ if "$" in a[0] or "`" in a[0]:
7739
+ return (None, None, f"the program to run is only known at run time ({a[0][:40]})")
7740
+ for c in every:
7741
+ if _WS_DESTRUCTIVE_RE.search(" ".join(c["argv"])):
7742
+ return (None, None, "a destructive command (deletes files / wipes disk / powers off)")
7743
+ return (None, None, None)
7744
+
7745
+
7746
+ def _with_attribution_text(command, scan, settings):
7747
+ """_with_attribution for a SHELL line: the co-author trailer is inserted into the text right
7748
+ after each top-level `git … commit` word. → (command, note). A gh pr create body written as a
7749
+ heredoc cannot be appended to safely, so it is left alone and said so in the note."""
7750
+ import shlex as _shlex
7751
+ name = str(settings.get("name") or "").strip()
7752
+ email = str(settings.get("email") or "").strip()
7753
+ cmds = _shell_commands(scan["tokens"])
7754
+ notes = []
7755
+ if settings.get("commit") and name and email and email.lower() not in command.lower():
7756
+ at = []
7757
+ for c in cmds:
7758
+ i, sub = _git_subcommand(c["argv"])
7759
+ if sub == "commit":
7760
+ at.append(c["spans"][i][1])
7761
+ if at:
7762
+ trailer = " --trailer " + _shlex.quote(f"Co-Authored-By: {name} <{email}>")
7763
+ for pos in sorted(at, reverse=True):
7764
+ command = command[:pos] + trailer + command[pos:]
7765
+ notes.append(f"co-author trailer added ({name})")
7766
+ if settings.get("pr") and any(c["argv"][:3] == ["gh", "pr", "create"] for c in cmds):
7767
+ notes.append("PR footer not added (shell form)")
7768
+ return command, "; ".join(notes)
7769
+
7770
+
7771
+ _RUN_SHELL_BIN = None
7772
+
7773
+
7774
+ def _run_shell_bin():
7775
+ """bash (else sh) for run_command's shell lines, or "" for the argv-only path: Windows, or
7776
+ GU_RUN_NO_SHELL=1 (the way back if a shell line ever misbehaves for a user)."""
7777
+ global _RUN_SHELL_BIN
7778
+ if os.name == "nt" or os.environ.get("GU_RUN_NO_SHELL") == "1":
7779
+ return ""
7780
+ if _RUN_SHELL_BIN is None:
7781
+ import shutil as _sh
7782
+ _RUN_SHELL_BIN = next((p for p in (_sh.which("bash"), "/bin/bash", "/bin/sh")
7783
+ if p and os.path.exists(p)), "")
7784
+ return _RUN_SHELL_BIN
7785
+
7786
+
7787
+ def _run_command_prompt_line(shell):
7788
+ """The system prompt's run_command rule. With a shell it carries the git and GitHub recipes,
7789
+ the way Claude Code's Bash tool description does: told how to batch, the model does (#359)."""
7790
+ if not shell:
7791
+ return (
7792
+ " run_command CHAINS: put dependent commands in ONE call with && — e.g. "
7793
+ "run_command(\"cd app && ./gradlew assembleDebug && adb install -r app/build/outputs/apk/debug/app-debug.apk\") "
7794
+ "— they run in order, `cd` sets the folder for the rest, and the first failure stops the "
7795
+ "chain. There is NO shell otherwise: pipes `|`, redirects `>`, `;`, `||` and backticks do "
7796
+ "NOT work (use python3 for anything that composes). `sudo`/`su` are blocked; an "
7797
+ "interactive input() CLI fails with EOFError. A server or GUI app is left RUNNING — "
7798
+ "stop_server closes it.\n")
7799
+ return (
7800
+ " run_command IS A SHELL (bash): pipes, redirects, `;`, `||`, `$( )`, globs and heredocs "
7801
+ "work, so put a whole sequence in ONE call — dependent steps with &&, independent ones with "
7802
+ "`;` (e.g. `cd app && npm test 2>&1 | tail -40`); a multi-line command goes in a "
7803
+ "triple-quoted string.\n"
7804
+ " GIT in one call each: look (`git status -sb; git diff --stat; git log --oneline -5`), "
7805
+ "commit (`git add a.py b.py && git commit -m \"msg\" && git log --oneline -1`).\n"
7806
+ " GITHUB: use `gh`. Multi-line text goes as a heredoc on stdin — "
7807
+ "`gh issue comment 12 --body-file - <<'EOF'` … `EOF` (same for gh issue create / gh pr "
7808
+ "create) — never write a body file first, and do not run `gh auth status` first.\n"
7809
+ " Read and search FILES with read_file_lines / grep_repo, not cat/grep/sed. `sudo`/`su` "
7810
+ "are blocked; piping into sh/python and writing or deleting outside the project ask the "
7811
+ "user first; an interactive input() CLI fails with EOFError. A server or GUI app is left "
7812
+ "RUNNING — stop_server closes it.\n")
7813
+
7814
+
7815
+ _RUN_DOC_ARGV = ("No other shell syntax (no pipes/redirects/;). sudo/su are blocked.")
7816
+ _RUN_DOC_SHELL = ("It runs in bash, so pipes, redirects, ;, ||, $( ), globs and heredocs work too "
7817
+ "(e.g. 'npm test 2>&1 | tail -40'). sudo/su are blocked.")
7818
+
7819
+
7349
7820
  # The terminal's "accept edits" toggle (task #179). True = today's behaviour, where only risky
7350
7821
  # commands are confirmed. False = confirm every command. Module-level default so the tool body
7351
7822
  # reads a real boolean even on a path where run() never set it.
@@ -9579,13 +10050,7 @@ def run_agent_chat(cfg):
9579
10050
  "and reads do not pile up here and are not re-sent on every later step. Then read only "
9580
10051
  "the exact ranges you will edit. Use grep_repo/read_file_lines directly when you already "
9581
10052
  "know the file or the exact string.\n"
9582
- " run_command CHAINS: put dependent commands in ONE call with && — e.g. "
9583
- "run_command(\"cd app && ./gradlew assembleDebug && adb install -r app/build/outputs/apk/debug/app-debug.apk\") "
9584
- "— they run in order, `cd` sets the folder for the rest, and the first failure stops the "
9585
- "chain. There is NO shell otherwise: pipes `|`, redirects `>`, `;`, `||` and backticks do "
9586
- "NOT work (use python3 for anything that composes). `sudo`/`su` are blocked; an "
9587
- "interactive input() CLI fails with EOFError. A server or GUI app is left RUNNING — "
9588
- "stop_server closes it.\n"
10053
+ + _run_command_prompt_line(bool(_run_shell_bin())) +
9589
10054
  " DEPLOY: prefer deploy_web(local_dir, host, user, remote_path, domain) over "
9590
10055
  "hand-written scp/ssh/rsync (it rsyncs over your SSH key and finishes nginx when it "
9591
10056
  "can, else writes deploy.sh and returns the exact commands). KEY auth ONLY "
@@ -13529,7 +13994,6 @@ def run_agent_chat(cfg):
13529
13994
  _last_obs["text"] = msg
13530
13995
  return msg
13531
13996
 
13532
- @tool
13533
13997
  def run_command(command: str, workdir: str = "", timeout_seconds: int = 180) -> str:
13534
13998
  """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.
13535
13999
 
@@ -13579,7 +14043,21 @@ def run_agent_chat(cfg):
13579
14043
  "gu-local-worker workspace add <path> --allow-run)")
13580
14044
  _last_obs["text"] = msg
13581
14045
  return msg
13582
- argv = shlex.split(command or "")
14046
+ # A REAL SHELL WHEN THE LINE NEEDS ONE (task #359). Scanned BEFORE shlex.split,
14047
+ # which raises on a heredoc body with an apostrophe in it ("don't").
14048
+ _shell_bin = _run_shell_bin()
14049
+ _scan = _shell_scan(command) if _shell_bin else None
14050
+ if _scan and _scan["error"]:
14051
+ msg = (f"Error: that command did NOT run — {_scan['error']}. Nothing was "
14052
+ "executed; fix the quoting and send it again.")
14053
+ _last_obs["text"] = msg
14054
+ return msg
14055
+ _use_shell = bool(_scan) and _shell_needed(_scan)
14056
+ _bg_err = _shell_background_error(_scan) if _use_shell else None
14057
+ if _bg_err:
14058
+ _last_obs["text"] = _bg_err
14059
+ return _bg_err
14060
+ argv =[_shell_bin, "-c", command] if _use_shell else shlex.split(command or "")
13583
14061
  if not argv:
13584
14062
  msg = "Error: empty command."
13585
14063
  _last_obs["text"] = msg
@@ -13637,7 +14115,7 @@ def run_agent_chat(cfg):
13637
14115
  # executes the `cd` PROGRAM — which exits 0 with no output — and the step is
13638
14116
  # reported as a success that did nothing. Refusing costs one step; the observed
13639
14117
  # alternative cost two hour-long turns and a false report to the user.
13640
- _shell_err = _shell_syntax_error(command, argv)
14118
+ _shell_err = None if _use_shell else _shell_syntax_error(command, argv)
13641
14119
  if _shell_err:
13642
14120
  _log(f"run_command refused shell syntax: {_c(_clip(command, 120))}")
13643
14121
  _last_obs["text"] = _shell_err
@@ -13646,16 +14124,26 @@ def run_agent_chat(cfg):
13646
14124
  # `/usr/bin/<x>` can't slip past a bare-name rule.
13647
14125
  _runner = argv[0]
13648
14126
  _rbase = os.path.basename(_runner)
13649
- # Opt-in allowlist lockdown: outside the set = hard refuse, never asked.
13650
- if _WS_RUN_ALLOWLIST and _runner not in _WS_RUN_ALLOWLIST and _rbase not in _WS_RUN_ALLOWLIST:
13651
- msg = (f"Error: '{_runner}' is not in your run allowlist. Add it in web "
13652
- "Settings → Agent, or clear the allowlist to allow all commands.")
13653
- _last_obs["text"] = msg
13654
- return msg
13655
- # Risk gate: privilege escalation / denylisted / destructive. When the
13656
- # client can approve interactively (terminal), PAUSE and ask the user;
13657
- # otherwise (web) keep hard-blocking the privilege/denylist cases.
13658
- _hard_reason, _ask_reason = _ws_command_risk(command, argv, _WS_RUN_DENYLIST, wd)
14127
+ if _use_shell:
14128
+ # Every simple command in the line, including those inside $( ), gets the
14129
+ # checks an argv command gets — allowlist, privilege, denylist, deletes —
14130
+ # plus the shell-only ones (redirect outside the project, `| sh`).
14131
+ _refuse, _hard_reason, _ask_reason = _ws_shell_command_risk(
14132
+ _scan, _WS_RUN_DENYLIST, wd, _WS_RUN_ALLOWLIST)
14133
+ if _refuse:
14134
+ _last_obs["text"] = _refuse
14135
+ return _refuse
14136
+ else:
14137
+ # Opt-in allowlist lockdown: outside the set = hard refuse, never asked.
14138
+ if _WS_RUN_ALLOWLIST and _runner not in _WS_RUN_ALLOWLIST and _rbase not in _WS_RUN_ALLOWLIST:
14139
+ msg = (f"Error: '{_runner}' is not in your run allowlist. Add it in web "
14140
+ "Settings → Agent, or clear the allowlist to allow all commands.")
14141
+ _last_obs["text"] = msg
14142
+ return msg
14143
+ # Risk gate: privilege escalation / denylisted / destructive. When the
14144
+ # client can approve interactively (terminal), PAUSE and ask the user;
14145
+ # otherwise (web) keep hard-blocking the privilege/denylist cases.
14146
+ _hard_reason, _ask_reason = _ws_command_risk(command, argv, _WS_RUN_DENYLIST, wd)
13659
14147
  # Shift+Tab may have switched it since the turn started (#47).
13660
14148
  global _accept_edits
13661
14149
  if _interactive_approval and _job_id:
@@ -13671,7 +14159,7 @@ def run_agent_chat(cfg):
13671
14159
  # states it. "this needs root and sudo needs a password we cannot type" is a
13672
14160
  # different question from "sudo is passwordless here", and the user can only
13673
14161
  # answer the one they are shown.
13674
- _priv = _ws_priv_reason(command)
14162
+ _priv = _shell_priv_reason(_scan) if _use_shell else _ws_priv_reason(command)
13675
14163
  _facts = _ws_access_facts() if _priv else None
13676
14164
  if _priv and _facts and not _facts["can_elevate"]:
13677
14165
  # REFUSE BEFORE SPAWNING (requirement 5). With no tty, `sudo x` dies with
@@ -13734,13 +14222,21 @@ def run_agent_chat(cfg):
13734
14222
  log_fh = open(log_path, "wb")
13735
14223
  # gu as a co-author on the commits it makes (task #289) — after every check
13736
14224
  # above, which judged the command as the model wrote it.
13737
- argv, _attrib_note = _with_attribution(argv, _attribution_settings(wd))
14225
+ if _use_shell:
14226
+ _cmd_text, _attrib_note = _with_attribution_text(
14227
+ command, _scan, _attribution_settings(wd))
14228
+ argv = [_shell_bin, "-c", _cmd_text]
14229
+ else:
14230
+ argv, _attrib_note = _with_attribution(argv, _attribution_settings(wd))
13738
14231
  if _attrib_note:
13739
14232
  _log(f"run_command: {_attrib_note}")
13740
- # Scrubbed env: repo scripts must never see worker secrets.
14233
+ # Scrubbed env: repo scripts must never see worker secrets. A shell line gets an
14234
+ # empty stdin: a heredoc feeds its own, and `--body-file -` without one must
14235
+ # end at EOF rather than wait on the worker's pipe.
13741
14236
  proc = subprocess.Popen(
13742
14237
  argv, cwd=wd, env=_ws_scrubbed_env(),
13743
14238
  stdout=log_fh, stderr=subprocess.STDOUT,
14239
+ **({"stdin": subprocess.DEVNULL} if _use_shell else {}),
13744
14240
  **_ws_spawn_kwargs(),
13745
14241
  )
13746
14242
  _INFLIGHT_CMDS.add(proc.pid) # killed with the agent until it is classified
@@ -13866,6 +14362,12 @@ def run_agent_chat(cfg):
13866
14362
  except Exception: # noqa: BLE001
13867
14363
  pass
13868
14364
 
14365
+ # The description says what the command line may contain, which depends on the machine:
14366
+ # bash here, argv only on Windows (#359). Set before tool() reads the docstring.
14367
+ if _run_shell_bin():
14368
+ run_command.__doc__ = run_command.__doc__.replace(_RUN_DOC_ARGV, _RUN_DOC_SHELL)
14369
+ run_command = tool(run_command)
14370
+
13869
14371
  @tool
13870
14372
  def stop_server(port_or_pid: str = "") -> str:
13871
14373
  """Stop a background server OR desktop app previously started by run_command (closes its window). Use when the user asks to stop/kill/close it.
package/gu-repl.mjs CHANGED
@@ -154,7 +154,7 @@ import {
154
154
  parseModelFallback,
155
155
  } from "./model-fallback.mjs";
156
156
  import { codingChoiceTags, coderDisplayName, coderWhere, whereOfActiveCoder } from "./coding-choice-tags.mjs";
157
- import { TITLE_POP, TITLE_PUSH, idleTitle, needsSummary, summariseForTitle, titleSeq, trimTitle } from "./terminal-title.mjs";
157
+ import { TITLE_POP, TITLE_PUSH, idleTitle, needsSummary, summariseForTitle, titleModel, titleSeq, trimTitle } from "./terminal-title.mjs";
158
158
  import { IMAGE_NEEDS_CLAUDE, coderTakesImagesFor, clipboardImageToFile, fitImageNotice, imageMarkersForDisplay, createImageStore, imagePasteKey, imagePathFromPaste, isClaudeCoderUrl, isImagePasteKey, storeImageFile } from "./image-paste.mjs";
159
159
  import {
160
160
  isDoubleClick,
@@ -1633,7 +1633,17 @@ function showQuestionInTitle(question) {
1633
1633
  void (async () => {
1634
1634
  const settings = await fetchWorkerSettings();
1635
1635
  if (!settings || mine !== _titleSeq) return;
1636
- const title = await summariseForTitle(question, settings, { fetchImpl: doctorFetch });
1636
+ // #358: the coder this turn runs on, with the key bound to its server — not the agent-model
1637
+ // setting, which can name a model the turns never use, called with no key at all.
1638
+ const target = titleModel(settings, { url: activeCodingUrl(), model: activeCodingModelId() });
1639
+ if (!target) return;
1640
+ const key = target.backendId ? await fetchKeyForBackend(target.backendId) : null;
1641
+ if (mine !== _titleSeq) return;
1642
+ const title = await summariseForTitle(question, settings, {
1643
+ fetchImpl: doctorFetch,
1644
+ target,
1645
+ apiKey: key?.apiKey ?? "",
1646
+ });
1637
1647
  if (title && mine === _titleSeq) writeTitle(title);
1638
1648
  })().catch(() => {});
1639
1649
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.820",
3
+ "version": "1.0.822",
4
4
  "description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -7,8 +7,8 @@
7
7
  * shortened, and keep it there until the next question.
8
8
  *
9
9
  * TWO STEPS, AND THE FIRST NEVER WAITS FOR THE SECOND. The trimmed question goes up the moment
10
- * the turn starts. Only a question too long to fit is then sent to the agent model for a
11
- * few-word summary, which replaces the trimmed one if — and only if — it arrives usable and the
10
+ * the turn starts. Only a question too long to fit is then sent to the coder the turn runs on
11
+ * (with its server's key — see titleModel) for a few-word summary, which replaces the trimmed one if — and only if — it arrives usable and the
12
12
  * user has not asked something else in the meantime. Every way that call can fail (no model
13
13
  * configured, a provider that wants a key this side does not hold, a timeout, a model that
14
14
  * answers with an essay) ends the same way: the trimmed question stays.
@@ -98,6 +98,30 @@ export function agentModelFromSettings(settings) {
98
98
  return { url, model: str(settings?.agentModelName) || "default_model" };
99
99
  }
100
100
 
101
+ /** A server URL reduced to what identifies it: no case, no trailing slash, no trailing /v1. */
102
+ function serverOf(url) {
103
+ return String(url ?? "").trim().toLowerCase().replace(/\/+$/, "").replace(/\/v1$/, "");
104
+ }
105
+
106
+ /**
107
+ * WHICH MODEL WRITES THE TITLE (#358): the coder this turn runs on, and the server row its key
108
+ * is bound to. → { url, model, backendId } or null.
109
+ *
110
+ * It used to be the account's agent-model setting, which on a real account was OpenAI o1 —
111
+ * not what the turns ran on (kimi-k3 on ollama.com), and called without a key, so every long
112
+ * question stayed trimmed. The coder is the model the user has already chosen and keyed; the
113
+ * agent-model setting is only the fallback for a session whose coder is not known yet.
114
+ */
115
+ export function titleModel(settings, coder = {}) {
116
+ const rows = Array.isArray(settings?.agentCodingBackends) ? settings.agentCodingBackends : [];
117
+ const rowFor = (url) => rows.find((r) => r?.id && serverOf(r.url) === serverOf(url))?.id ?? "";
118
+ const url = String(coder?.url ?? "").trim();
119
+ const model = String(coder?.model ?? "").trim();
120
+ if (url && model) return { url, model, backendId: rowFor(url) };
121
+ const agent = agentModelFromSettings(settings);
122
+ return agent ? { ...agent, backendId: rowFor(agent.url) } : null;
123
+ }
124
+
101
125
  export function summaryRequest(model, question) {
102
126
  const q = Array.from(cleanTitleText(question)).slice(0, SUMMARY_INPUT_MAX).join("");
103
127
  return {
@@ -140,20 +164,22 @@ export function pickSummary(body, max = TITLE_MAX) {
140
164
  }
141
165
 
142
166
  /**
143
- * Ask the agent model for a title. → the title, or "" for every reason there is not one.
167
+ * Ask a model for a title. → the title, or "" for every reason there is not one.
168
+ * `target` ({ url, model }, see titleModel) picks the model — the agent-model setting when absent —
169
+ * and `apiKey` is sent as a Bearer token when the server needs one.
144
170
  * Never throws: this runs beside a turn and must not be able to disturb it.
145
171
  */
146
172
  export async function summariseForTitle(
147
173
  question,
148
174
  settings,
149
- { fetchImpl = fetch, timeoutMs = 20_000 } = {},
175
+ { fetchImpl = fetch, timeoutMs = 20_000, target = null, apiKey = "" } = {},
150
176
  ) {
151
- const agent = agentModelFromSettings(settings);
177
+ const agent = target?.url && target?.model ? target : agentModelFromSettings(settings);
152
178
  if (!agent || !needsSummary(question)) return "";
153
179
  try {
154
180
  const res = await fetchImpl(chatEndpoint(agent.url), {
155
181
  method: "POST",
156
- headers: { "Content-Type": "application/json" },
182
+ headers: { "Content-Type": "application/json", ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}) },
157
183
  body: JSON.stringify(summaryRequest(agent.model, question)),
158
184
  signal: AbortSignal.timeout(timeoutMs),
159
185
  });