@tiens.nguyen/gu-cli 1.0.835 → 1.0.837

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.
@@ -10,6 +10,9 @@
10
10
  // host.tabs() → [{id, title, url, current}] host.switchTo(tabId) → boolean
11
11
  // host.info(tabId) → {url, title, status} | null host.activate(tabId)
12
12
  // host.close(tabId)
13
+ // host.runsJs — true only where running gu's own JavaScript text is allowed (gu's own window).
14
+ // The Chrome Web Store extension leaves it unset: text sent at run time and executed is
15
+ // "remote code", which a Manifest V3 store extension may not run (#377, GitHub #158).
13
16
  // No chrome.* and no Node API in here: it runs in an extension service worker and in Node.
14
17
 
15
18
  const consoleBuf = new Map(); // tabId → [{level, text}] since the last "console" read
@@ -305,6 +308,11 @@ export async function runAction(cmd, host) {
305
308
  await sleep(Math.max(0, Math.min(Number(cmd.seconds) || 1, 30)) * 1000);
306
309
  return after(host, id);
307
310
  case "js": {
311
+ if (!host.runsJs) {
312
+ throw new Error("js is not available through the gu Chrome extension (Chrome Web Store rules: " +
313
+ "it runs only the code it ships with). Use read, console, click and type instead — or gu's own " +
314
+ "Chrome window, which has it.");
315
+ }
308
316
  const value = await evaluate(host, id, String(cmd.text || ""));
309
317
  return after(host, id, { value: value === undefined ? "undefined" : JSON.stringify(value)?.slice(0, 8000) });
310
318
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "gu browser",
4
- "version": "1.0.1",
4
+ "version": "1.0.4",
5
5
  "description": "Lets the gu coding agent open, see and operate web pages in its own tabs (tab group \"gu\") with a real mouse and keyboard.",
6
6
  "minimum_chrome_version": "116",
7
7
  "icons": {
@@ -17,9 +17,6 @@
17
17
  "alarms",
18
18
  "storage"
19
19
  ],
20
- "host_permissions": [
21
- "<all_urls>"
22
- ],
23
20
  "background": {
24
21
  "service_worker": "background.js",
25
22
  "type": "module"
@@ -7933,6 +7933,123 @@ def _ws_shell_command_risk(scan, denyset, wd="", allowset=None, roots=None):
7933
7933
  return (None, None, None)
7934
7934
 
7935
7935
 
7936
+ # ---- POWERSHELL LINES (task #374, GitHub #155) ---------------------------------------------------
7937
+ # The same policy as a bash line, read with gu_powershell: every simple command — inside $( ),
7938
+ # @( ), ( ) and { } too, since a ForEach-Object block runs — through the allow/deny lists,
7939
+ # elevation, deletes and writes outside the project (cmdlets' -Path/-Destination as well as
7940
+ # redirects), plus PowerShell's own ways to run text as code: Invoke-Expression, piping into an
7941
+ # interpreter (unless what is piped is a string the line itself wrote — PowerShell's heredoc),
7942
+ # [ScriptBlock]::Create and -EncodedCommand.
7943
+
7944
+ def _ps_mod():
7945
+ import gu_powershell as _gp
7946
+ return _gp
7947
+
7948
+
7949
+ def _ps_background_error(scan):
7950
+ """A line ending a command with `&` (a PowerShell job, gone when the shell exits), or None."""
7951
+ gp = _ps_mod()
7952
+ top, _ = gp.ps_all_commands(scan)
7953
+ if any(c["background"] for c in top):
7954
+ return ("Error: that command did NOT run. Do not end it with `&` — in PowerShell that "
7955
+ "starts a job that dies with the shell. run_command already leaves a server or app "
7956
+ "RUNNING when it starts listening or opens a window; for a long job pass "
7957
+ "background=True. Send the same command without the `&`.")
7958
+ return None
7959
+
7960
+
7961
+ def _ps_priv_reason(scan):
7962
+ """Elevation in any command of a PowerShell line (runas, -Verb RunAs, sudo, ACL/service/
7963
+ registry rewrites) — judged with the Windows rules whatever OS reads it."""
7964
+ gp = _ps_mod()
7965
+ top, inner = gp.ps_all_commands(scan)
7966
+ for c in top + inner:
7967
+ r = _ws_priv_reason(" ".join(c["argv"]), windows=True)
7968
+ if r:
7969
+ return r
7970
+ return None
7971
+
7972
+
7973
+ def _ps_native_paths(paths, wd):
7974
+ """PowerShell paths as the OS reads them: $env:X / $HOME / ~ filled in. A path that only
7975
+ exists at run time ($_, $x) becomes an impossible one, so it is never judged "inside"."""
7976
+ gp = _ps_mod()
7977
+ out = []
7978
+ for p in paths:
7979
+ q = gp.ps_expand_path(p, os.environ, os.path.expanduser("~"), wd or os.getcwd())
7980
+ out.append(q if q is not None else os.path.join(os.sep, "gu-unknown-at-run-time", p))
7981
+ return out
7982
+
7983
+
7984
+ def _ws_ps_command_risk(scan, denyset, wd="", allowset=None, roots=None):
7985
+ """_ws_shell_command_risk for a PowerShell line. Same contract: (refuse, hard, ask)."""
7986
+ gp = _ps_mod()
7987
+ top, inner = gp.ps_all_commands(scan)
7988
+ every = top + inner
7989
+ effects = [(c, gp.ps_effects(c)) for c in every]
7990
+
7991
+ def names(c, e):
7992
+ raw = re.split(r"[\\/]", c["argv"][0])[-1].lower() if c["argv"] else ""
7993
+ return {n for n in (raw, e["program"], c["argv"][0] if c["argv"] else "") if n}
7994
+
7995
+ if allowset:
7996
+ low = {a.lower() for a in allowset}
7997
+ for c, e in effects:
7998
+ if e["program"] and not (names(c, e) & (set(allowset) | low)):
7999
+ return (f"Error: '{c['argv'][0]}' is not in your run allowlist. Add it in web "
8000
+ "Settings → Agent, or clear the allowlist to allow all commands.", None, None)
8001
+ priv = _ps_priv_reason(scan)
8002
+ if priv:
8003
+ return (None, priv, priv)
8004
+ if denyset:
8005
+ low = {d.lower() for d in denyset}
8006
+ for c, e in effects:
8007
+ if e["program"] and names(c, e) & (set(denyset) | low):
8008
+ msg = f"'{c['argv'][0]}' is on your blocked-commands list"
8009
+ return (None, msg, msg)
8010
+ # Deletes and writes outside the project, following Set-Location the way PowerShell will.
8011
+ cur = wd
8012
+ for c, e in effects:
8013
+ is_top = any(c is t for t in top)
8014
+ if is_top and e["program"] in ("set-location", "push-location") and c["sep"] != "|":
8015
+ _, pos, _ = gp.ps_bound(c["argv"])
8016
+ if pos:
8017
+ p = _ps_native_paths(pos[:1], cur)[0]
8018
+ cur = os.path.normpath(os.path.join(cur or os.getcwd(), p))
8019
+ continue
8020
+ here = cur if is_top else wd
8021
+ why = None
8022
+ if e["deletes"]:
8023
+ why = _ws_delete_outside_reason(["rm"] + _ps_native_paths(e["deletes"], here), here, roots)
8024
+ if not why and e["writes"]:
8025
+ why = _ws_write_outside_reason([(">", t) for t in _ps_native_paths(e["writes"], here)],
8026
+ here, roots)
8027
+ if why:
8028
+ return (None, why, why)
8029
+ # Text run as code.
8030
+ for k, c in enumerate(top):
8031
+ e = gp.ps_effects(c)
8032
+ if c["sep"] == "|" and e["program"] in gp.PS_INTERPRETERS \
8033
+ and all(x.startswith("-") for x in c["argv"][1:]):
8034
+ # Upstream of this stage, back to the pipeline's start: only a literal string = the
8035
+ # code is in the line itself (a heredoc). Anything else feeds it unseen text.
8036
+ j = k
8037
+ while j > 0 and top[j]["sep"] == "|":
8038
+ j -= 1
8039
+ if not all(gp.ps_is_literal(x) for x in top[j:k]):
8040
+ msg = f"it pipes text into {c['argv'][0]}, which runs that text as code"
8041
+ return (None, msg, msg)
8042
+ for c, e in effects:
8043
+ if e["runs_text"]:
8044
+ return (None, None, f"it runs text as code ({e['runs_text']})")
8045
+ if e["dynamic"]:
8046
+ return (None, None, f"the program to run is only known at run time ({c['argv'][0][:40]})")
8047
+ for c, e in effects:
8048
+ if e["destructive"] or _WS_DESTRUCTIVE_RE.search(" ".join(c["argv"])):
8049
+ return (None, None, "a destructive command (deletes files / wipes disk / powers off)")
8050
+ return (None, None, None)
8051
+
8052
+
7936
8053
  def _with_attribution_text(command, scan, settings):
7937
8054
  """_with_attribution for a SHELL line: the co-author trailer is inserted into the text right
7938
8055
  after each top-level `git … commit` word. → (command, note). A gh pr create body written as a
@@ -7940,7 +8057,7 @@ def _with_attribution_text(command, scan, settings):
7940
8057
  import shlex as _shlex
7941
8058
  name = str(settings.get("name") or "").strip()
7942
8059
  email = str(settings.get("email") or "").strip()
7943
- cmds = _shell_commands(scan["tokens"])
8060
+ cmds = scan["commands"] if "commands" in scan else _shell_commands(scan["tokens"])
7944
8061
  notes = []
7945
8062
  if settings.get("commit") and name and email and email.lower() not in command.lower():
7946
8063
  at = []
@@ -7990,30 +8107,79 @@ def _git_bash_path(env=None, which=None, isfile=None):
7990
8107
  return ""
7991
8108
 
7992
8109
 
8110
+ _RUN_SHELL_KIND = None
8111
+
8112
+
8113
+ def _pwsh_path(env=None, which=None, isfile=None):
8114
+ """PowerShell for run_command, or "". PURE given its lookups (tested off Windows).
8115
+
8116
+ GU_PWSH_PATH first; then PowerShell 7 (`pwsh`), which has && and || of its own; then Windows
8117
+ PowerShell 5.1, which every Windows 10/11 has — gu supplies && and || there itself."""
8118
+ import ntpath
8119
+ import shutil as _sh
8120
+ env = os.environ if env is None else env
8121
+ which = which or _sh.which
8122
+ isfile = isfile or os.path.isfile
8123
+ if env.get("GU_PWSH_PATH") and isfile(env["GU_PWSH_PATH"]):
8124
+ return env["GU_PWSH_PATH"]
8125
+ for name in ("pwsh", "powershell"):
8126
+ p = which(name) or ""
8127
+ if p and "\\windowsapps\\" not in p.lower(): # never a Store alias stub
8128
+ return p
8129
+ root = env.get("SystemRoot") or env.get("SYSTEMROOT") or env.get("WINDIR") or ""
8130
+ if root:
8131
+ p = ntpath.join(root, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
8132
+ if isfile(p):
8133
+ return p
8134
+ return ""
8135
+
8136
+
8137
+ def _run_shell_kind():
8138
+ """"bash" | "powershell" | "" — how run_command runs a line (see _run_shell_bin)."""
8139
+ _run_shell_bin()
8140
+ return _RUN_SHELL_KIND or ""
8141
+
8142
+
7993
8143
  def _run_shell_bin():
7994
- """The shell for run_command's shell lines: bash (else sh), Git Bash on Windows — or "" for
7995
- the argv-only path (Windows without Git for Windows, or GU_RUN_NO_SHELL=1, the way back if a
7996
- shell line ever misbehaves for a user)."""
7997
- global _RUN_SHELL_BIN
8144
+ """The shell for run_command (task #359; PowerShell #374): bash (else sh) on macOS/Linux;
8145
+ on Windows PowerShell, the way Claude Code does it there — Git Bash instead with
8146
+ GU_WINDOWS_SHELL=bash. "" = the argv-only path (GU_RUN_NO_SHELL=1, the way back if a shell
8147
+ line ever misbehaves for a user, or no shell found). GU_RUN_SHELL=powershell|bash picks one
8148
+ on any OS (how the PowerShell path is tested on a Mac)."""
8149
+ global _RUN_SHELL_BIN, _RUN_SHELL_KIND
7998
8150
  if os.environ.get("GU_RUN_NO_SHELL") == "1":
7999
8151
  return ""
8000
8152
  if _RUN_SHELL_BIN is None:
8001
- if os.name == "nt":
8002
- _RUN_SHELL_BIN = _git_bash_path()
8003
- _log("run_command shell: " + ("Git Bash" if _RUN_SHELL_BIN else
8004
- "none (Git for Windows not found) — commands run without a shell"))
8153
+ import shutil as _sh
8154
+ want = (os.environ.get("GU_RUN_SHELL") or "").strip().lower()
8155
+ if not want:
8156
+ want = ("bash" if (os.environ.get("GU_WINDOWS_SHELL") or "").strip().lower() == "bash"
8157
+ else "powershell") if os.name == "nt" else "bash"
8158
+ if want == "powershell":
8159
+ _RUN_SHELL_BIN, _RUN_SHELL_KIND = _pwsh_path(), "powershell"
8160
+ if not _RUN_SHELL_BIN and os.name == "nt":
8161
+ _RUN_SHELL_BIN, _RUN_SHELL_KIND = _git_bash_path(), "bash"
8162
+ elif os.name == "nt":
8163
+ _RUN_SHELL_BIN, _RUN_SHELL_KIND = _git_bash_path(), "bash"
8164
+ if not _RUN_SHELL_BIN:
8165
+ _RUN_SHELL_BIN, _RUN_SHELL_KIND = _pwsh_path(), "powershell"
8005
8166
  else:
8006
- import shutil as _sh
8007
- _RUN_SHELL_BIN = next((p for p in (_sh.which("bash"), "/bin/bash", "/bin/sh")
8008
- if p and os.path.exists(p)), "")
8167
+ _RUN_SHELL_BIN, _RUN_SHELL_KIND = next(
8168
+ (p for p in (_sh.which("bash"), "/bin/bash", "/bin/sh") if p and os.path.exists(p)), ""), "bash"
8169
+ if not _RUN_SHELL_BIN:
8170
+ _RUN_SHELL_KIND = ""
8171
+ _log("run_command shell: " + ({"powershell": "PowerShell", "bash": "bash"}.get(_RUN_SHELL_KIND, "none")
8172
+ + (f" ({_RUN_SHELL_BIN})" if _RUN_SHELL_BIN else " — commands run without a shell")))
8009
8173
  return _RUN_SHELL_BIN
8010
8174
 
8011
8175
 
8012
- def _run_command_prompt_line(shell, windows=None):
8176
+ def _run_command_prompt_line(shell, windows=None, kind=None):
8013
8177
  """The system prompt's run_command rule. With a shell it carries the git and GitHub recipes,
8014
8178
  the way Claude Code's Bash tool description does: told how to batch, the model does (#359).
8015
- On Windows the shell is Git Bash, and the line says what differs there."""
8179
+ On Windows the shell is PowerShell (#374) — or Git Bash, and the line says what differs there."""
8016
8180
  windows = (os.name == "nt") if windows is None else windows
8181
+ if shell and kind == "powershell":
8182
+ return _PS_PROMPT_LINE + _BG_LINE
8017
8183
  if not shell:
8018
8184
  return (
8019
8185
  " run_command CHAINS: put dependent commands in ONE call with && — e.g. "
@@ -8052,6 +8218,37 @@ def _run_command_prompt_line(shell, windows=None):
8052
8218
  "RUNNING — stop_server closes it. " + _BG_LINE)
8053
8219
 
8054
8220
 
8221
+ # The run_command rule when the shell is PowerShell (#374) — Claude Code's PowerShell tool, in gu's
8222
+ # words: the same recipes as the bash line, written the PowerShell way.
8223
+ _PS_PROMPT_LINE = (
8224
+ " run_command IS POWERSHELL (Windows), and it KEEPS its folder and $env: variables from one "
8225
+ "call to the next in this turn (`cd sub`, then `npm test` runs in sub; an explicit workdir "
8226
+ "wins). Write PowerShell, not bash: pipes `|`, `;`, `&&`, `||`, redirects (`> out.txt`, "
8227
+ "`2>&1`), `$env:NAME = 'x'` (not export), cmdlets (Get-ChildItem, Select-String, "
8228
+ "Get-Content -Tail 40, Remove-Item -Recurse), `$LASTEXITCODE`. Put a whole sequence in ONE call "
8229
+ "(e.g. `cd app && npm test 2>&1 | Select-Object -Last 40`); a multi-line command goes in a "
8230
+ "triple-quoted string. A program path with spaces needs the call operator: "
8231
+ "`& 'C:\\Program Files\\x\\x.exe' args`. Python is `py -3` or `python`, not python3.\n"
8232
+ " MULTI-LINE TEXT is a here-string: `@'` ends its line, the text follows, and `'@` STARTS "
8233
+ "the closing line; nothing inside needs escaping.\n"
8234
+ " GIT in one call each: look (`git status -sb; git diff --stat; git log --oneline -5`), "
8235
+ "commit (`git add a.py b.py; if ($?) { git commit -m \"msg\" }` or with a here-string: "
8236
+ "`@'` newline message newline `'@ | git commit -F -`).\n"
8237
+ " GITHUB: use `gh`. A multi-line body goes as a here-string on stdin — `@'` … `'@ | gh issue "
8238
+ "comment 12 --body-file -` (same for gh issue create / gh pr create) — never write a body file "
8239
+ "first, and do not run `gh auth status` first.\n"
8240
+ " CHECKS: a Python/Node script goes as a here-string piped in — `@'` … `'@ | py -3 -` (node: "
8241
+ "`| node --input-type=module -`). To check one function, load its module and call it with real "
8242
+ "input instead of running the app. Order: syntax check → the ONE test → the suite once. Test "
8243
+ "output arrives with a TEST SUMMARY of each failure first, so never pipe it into "
8244
+ "Select-Object/Select-String (a pipe hides the exit code).\n"
8245
+ " Read and search FILES with read_file_lines / grep_repo, not Get-Content/Select-String. "
8246
+ "Elevation (Start-Process -Verb RunAs, runas, sudo) is blocked; Invoke-Expression, piping "
8247
+ "into an interpreter, and writing or deleting outside the project ask the user first; never "
8248
+ "end a line with `&` (a background job). An interactive prompt (Read-Host) fails. A server "
8249
+ "or GUI app is left RUNNING — stop_server closes it. ")
8250
+
8251
+
8055
8252
  # THE BROWSER (task #363, GitHub #144): a real Chrome the agent drives through the gu extension.
8056
8253
  # Offered only where commands may run (the terminal's own folders, never the web app's scratch one)
8057
8254
  # and node can start the bridge. GU_BROWSER=0 turns it off.
@@ -8098,7 +8295,8 @@ _HEREDOC_HINT = ("\n\nSHELL QUOTING broke this line. For text with quotes or lin
8098
8295
  _PIPE_FAIL_RE = re.compile(
8099
8296
  r"^.*(?:No module named \S+|ModuleNotFoundError: .*|: command not found|: not found$"
8100
8297
  r"|^Traceback \(most recent call last\):|^fatal: .*|^error: .*|^npm ERR! .*"
8101
- r"|^E: Unable to locate package .*|: No such file or directory$)",
8298
+ r"|^E: Unable to locate package .*|: No such file or directory$"
8299
+ r"|is not recognized as (?:the |a )?name of a cmdlet.*)",
8102
8300
  re.M)
8103
8301
 
8104
8302
 
@@ -8110,12 +8308,12 @@ def _pipe_hidden_failure(scan, out: str) -> str:
8110
8308
  return m.group(0).strip()[:200] if m else ""
8111
8309
 
8112
8310
 
8113
- def _pipe_hidden_failure_note(line: str) -> str:
8311
+ def _pipe_hidden_failure_note(line: str, ps: bool = False) -> str:
8114
8312
  return ("\n\nWARNING — A FAILURE WAS HIDDEN BY A PIPE: the output shows "
8115
8313
  f"'{line}', but a pipeline reports only its LAST command's exit code, so this "
8116
8314
  "line counted as success and every part after `&&` RAN ANYWAY. Check what those "
8117
- "parts did before relying on them. To chain on a check, run it without a pipe, or "
8118
- "start the line with `set -o pipefail;`.")
8315
+ "parts did before relying on them. To chain on a check, run it without a pipe"
8316
+ + ("." if ps else ", or start the line with `set -o pipefail;`."))
8119
8317
 
8120
8318
 
8121
8319
  # THE PROJECT'S OWN TEST COMMANDS (#360 review). The model guessed `python3 -m pytest` in a repo
@@ -8218,7 +8416,18 @@ _SHELL_QUOTE_ERR_RE = re.compile(
8218
8416
  r"unexpected EOF while looking for matching|syntax error near unexpected token|"
8219
8417
  r"syntax error: unexpected end of file|unterminated quoted string")
8220
8418
 
8419
+ # PowerShell's words for a line it could not parse (#374), and the form that has nothing to quote.
8420
+ _PS_QUOTE_ERR_RE = re.compile(
8421
+ r"string is missing the terminator|Missing closing|Unexpected token|missing the closing|"
8422
+ r"ParserError|Missing (?:argument|expression)")
8423
+ _PS_HEREDOC_HINT = ("\n\nQUOTING broke this line. For text with quotes or line breaks (a commit "
8424
+ "message, an issue body) use a here-string inside a triple-quoted string: "
8425
+ "run_command(\"\"\"@'\n<message>\n'@ | git commit -F -\"\"\") — `'@` must start "
8426
+ "its line, and nothing in the message needs escaping.")
8427
+
8221
8428
  _RUN_DOC_ARGV = ("No other shell syntax (no pipes/redirects/;). sudo/su are blocked.")
8429
+ _RUN_DOC_PS = ("It runs in PowerShell, so pipes, ;, &&, ||, redirects, cmdlets and here-strings "
8430
+ "work too (e.g. 'npm test 2>&1 | Select-Object -Last 40'). Elevation is blocked.")
8222
8431
  _RUN_DOC_SHELL = ("It runs in bash, so pipes, redirects, ;, ||, $( ), globs and heredocs work too "
8223
8432
  "(e.g. 'npm test 2>&1 | tail -40'). sudo/su are blocked.")
8224
8433
 
@@ -9458,7 +9667,8 @@ def _shells_finished_note(shells: list, now=None) -> str:
9458
9667
  s["announced"] = True
9459
9668
  note = (f"[background shell {s.get('id')} finished: {_shell_status_text(st)} after "
9460
9669
  f"{_fmt_runtime(st['runtime'])} — '{_clip(str(s.get('command')), 120)}'. "
9461
- f"shell_output('{s.get('id')}') has its output.]")
9670
+ f"shell_output('{s.get('id')}') has its output. If it failed, find out why and fix it; "
9671
+ f"if it was a step of this task, carry on with the next one.]")
9462
9672
  try:
9463
9673
  with open(s.get("log") or "", encoding="utf-8", errors="replace") as fh:
9464
9674
  dg = _test_digest(fh.read(), str(s.get("workdir") or ""))
@@ -9526,13 +9736,69 @@ def _run_state_path() -> str:
9526
9736
  return _RUN_STATE["path"]
9527
9737
 
9528
9738
 
9739
+ _CD_FIRST_RE = re.compile(
9740
+ r"""^\s*(?:cd|pushd|set-location|sl|chdir)\s+(?:-(?:literalpath|path)\s+)?(['"]?)([^'";&|\s]+)\1(?=\s*(?:$|&&|;|\|\||\n))""",
9741
+ re.IGNORECASE)
9742
+
9743
+
9744
+ def _cd_from_root(command: str, kept: str, root: str) -> str:
9745
+ """The folder a line should start in: `kept` (where the last shell line ended), unless the line
9746
+ begins by entering a RELATIVE folder that does not exist there but does at `root` — then
9747
+ `root`. PURE but for isdir. Anything else (absolute, ~, $VAR, `..`, already there) → `kept`."""
9748
+ if not kept or not root or os.path.realpath(kept) == os.path.realpath(root):
9749
+ return kept
9750
+ m = _CD_FIRST_RE.match(command or "")
9751
+ if not m:
9752
+ return kept
9753
+ target = m.group(2)
9754
+ if os.path.isabs(target) or target.startswith(("~", "$", ".")) or re.match(r"^[A-Za-z]:", target):
9755
+ return kept
9756
+ if os.path.isdir(os.path.join(kept, target)) or not os.path.isdir(os.path.join(root, target)):
9757
+ return kept
9758
+ return root
9759
+
9760
+
9761
+ def _patch_dotted_imports() -> bool:
9762
+ """`import urllib.request` in the agent's sandbox binds the name `urllib` (#378, GitHub #162).
9763
+
9764
+ smolagents 1.26's evaluate_import stores `import a.b` under the literal key "a.b" and never binds
9765
+ `a`, so `urllib.request.urlopen(…)` — urllib.request is an allowed import here — fails with
9766
+ "module 'urllib.error' has no attribute 'request'". Measured: three identical failing steps in
9767
+ one deploy turn before the model gave up and used curl. Python binds the TOP package for a
9768
+ dotted import; this does the same, and only when that top package is itself an allowed import
9769
+ (so `import os.path` stays refused). Idempotent. False when smolagents is not there."""
9770
+ try:
9771
+ import ast as _ast
9772
+ from importlib import import_module as _imp
9773
+ from smolagents import local_python_executor as _lpe
9774
+ except Exception: # noqa: BLE001
9775
+ return False
9776
+ if getattr(_lpe.evaluate_import, "_gu_dotted", False):
9777
+ return True
9778
+ orig = _lpe.evaluate_import
9779
+
9780
+ def evaluate_import(expression, state, authorized_imports):
9781
+ r = orig(expression, state, authorized_imports)
9782
+ if isinstance(expression, _ast.Import):
9783
+ for alias in expression.names:
9784
+ if not alias.asname and "." in alias.name:
9785
+ top = alias.name.split(".")[0]
9786
+ if _lpe.check_import_authorized(top, authorized_imports):
9787
+ state[top] = _lpe.get_safe_module(_imp(top), authorized_imports)
9788
+ return r
9789
+
9790
+ evaluate_import._gu_dotted = True
9791
+ _lpe.evaluate_import = evaluate_import
9792
+ return True
9793
+
9794
+
9529
9795
  def _run_state_cwd() -> str:
9530
9796
  """The folder the last shell line ended in, this turn — or ""."""
9531
9797
  p = _RUN_STATE["path"]
9532
9798
  if not p:
9533
9799
  return ""
9534
9800
  try:
9535
- with open(p + ".cwd", encoding="utf-8") as fh:
9801
+ with open(p + ".cwd", encoding="utf-8-sig") as fh: # PowerShell 5.1 writes a BOM
9536
9802
  return fh.read().strip()
9537
9803
  except OSError:
9538
9804
  return ""
@@ -9572,7 +9838,9 @@ def _ws_scrubbed_env() -> dict:
9572
9838
  keep += ("SYSTEMROOT", "SystemDrive", "WINDIR", "COMSPEC", "PATHEXT", "USERPROFILE",
9573
9839
  "HOMEDRIVE", "HOMEPATH", "USERNAME", "APPDATA", "LOCALAPPDATA", "TEMP", "TMP",
9574
9840
  "ProgramFiles", "ProgramFiles(x86)", "ProgramW6432", "ProgramData",
9575
- "CommonProgramFiles", "NUMBER_OF_PROCESSORS", "PROCESSOR_ARCHITECTURE")
9841
+ "CommonProgramFiles", "NUMBER_OF_PROCESSORS", "PROCESSOR_ARCHITECTURE",
9842
+ # PowerShell finds its own modules (Microsoft.PowerShell.*) through it (#374).
9843
+ "PSModulePath")
9576
9844
  env = {k: os.environ[k] for k in keep if k in os.environ}
9577
9845
  # CI=1 makes watch-mode test runners (CRA's `npm test`, jest --watch) run ONCE and
9578
9846
  # exit instead of sitting in interactive watch mode until the timeout kills them.
@@ -9857,6 +10125,7 @@ def run_agent_chat(cfg):
9857
10125
  f"then: gu stop && gu start"})
9858
10126
  _log(f"smolagents import FAILED on {_where}: {e}")
9859
10127
  return
10128
+ _patch_dotted_imports()
9860
10129
 
9861
10130
  messages = cfg.get("messages") or []
9862
10131
  _turn_image_refs = _take_image_markers(messages)
@@ -10948,7 +11217,7 @@ def run_agent_chat(cfg):
10948
11217
  "and reads do not pile up here and are not re-sent on every later step. Then read only "
10949
11218
  "the exact ranges you will edit. Use grep_repo/read_file_lines directly when you already "
10950
11219
  "know the file or the exact string.\n"
10951
- + _run_command_prompt_line(bool(_run_shell_bin()))
11220
+ + _run_command_prompt_line(bool(_run_shell_bin()), kind=_run_shell_kind())
10952
11221
  + (_BROWSER_LINE if _browser_on() else "") +
10953
11222
  " DEPLOY: prefer deploy_web(local_dir, host, user, remote_path, domain) over "
10954
11223
  "hand-written scp/ssh/rsync (it rsyncs over your SSH key and finishes nginx when it "
@@ -14977,6 +15246,12 @@ def run_agent_chat(cfg):
14977
15246
  wd = _rag_read_allowed(_kept)
14978
15247
  except Exception: # noqa: BLE001 — left the allowed folders: start over
14979
15248
  wd = _wd_before
15249
+ # `cd sub` AGAIN FROM INSIDE sub (#378, GitHub #161), measured: after
15250
+ # `cd frontend && npx tsc` the shell stayed in frontend, the next line was
15251
+ # `cd frontend && node test…` → "no such folder: frontend", then two text-only
15252
+ # steps before the tests ran — 4 steps for one test run. A line that starts by
15253
+ # entering a folder that is not here but IS at the project root means the root.
15254
+ wd = _cd_from_root(command, wd, _wd_before)
14980
15255
  w = _ws_for_path(wd)
14981
15256
  if w is None or not w.get("allowRun"):
14982
15257
  # Contextual nudge (task #102): the #1 reason the model shells out here is
@@ -14993,20 +15268,25 @@ def run_agent_chat(cfg):
14993
15268
  # A REAL SHELL WHEN THE LINE NEEDS ONE (task #359). Scanned BEFORE shlex.split,
14994
15269
  # which raises on a heredoc body with an apostrophe in it ("don't").
14995
15270
  _shell_bin = _run_shell_bin()
14996
- _scan = _shell_scan(command) if _shell_bin else None
15271
+ # PowerShell on Windows (#374): every line goes through it — a cmdlet is no program.
15272
+ _ps = bool(_shell_bin) and _run_shell_kind() == "powershell"
15273
+ _scan = (_ps_mod().ps_scan(command) if _ps else _shell_scan(command)) if _shell_bin else None
14997
15274
  if _scan and _scan["error"]:
14998
15275
  msg = (f"Error: that command did NOT run — {_scan['error']}. Nothing was "
14999
15276
  "executed; fix the quoting and send it again."
15000
- + (_HEREDOC_HINT if "quote" in str(_scan["error"]) else ""))
15277
+ + ((_PS_HEREDOC_HINT if _ps else _HEREDOC_HINT)
15278
+ if re.search(r"quote|here-string", str(_scan["error"])) else ""))
15001
15279
  _last_obs["text"] = msg
15002
15280
  return msg
15003
15281
  # Once this turn has shell state (a cd, an activated venv), every line goes through
15004
15282
  # the shell so it starts from that state — not only lines with shell syntax.
15005
- _words = [c["argv"][0] for c in _shell_commands(_scan["tokens"]) if c["argv"]] if _scan else []
15283
+ _words = [c["argv"][0] for c in _shell_commands(_scan["tokens"]) if c["argv"]] \
15284
+ if (_scan and not _ps) else []
15006
15285
  _builtin = sorted({w for w in _words if w in _SHELL_STATE_BUILTINS})
15007
- _use_shell = bool(_scan) and (_shell_needed(_scan) or bool(_builtin) or bool(
15286
+ _use_shell = bool(_scan) and (_ps or _shell_needed(_scan) or bool(_builtin) or bool(
15008
15287
  _RUN_STATE["path"] and os.path.exists(_RUN_STATE["path"])))
15009
- _bg_err = _shell_background_error(_scan) if _use_shell else None
15288
+ _bg_err = ((_ps_background_error(_scan) if _ps else _shell_background_error(_scan))
15289
+ if _use_shell else None)
15010
15290
  if _bg_err:
15011
15291
  _last_obs["text"] = _bg_err
15012
15292
  return _bg_err
@@ -15020,7 +15300,7 @@ def run_agent_chat(cfg):
15020
15300
  # the first failure stops the rest. `cd DIR` sets the folder for what follows.
15021
15301
  # A chain runs each part as a program; `export`/`source` in it would run nothing,
15022
15302
  # so with a shell such a line goes to bash whole. (`cd` the chain does itself.)
15023
- _chain = None if (_use_shell and [b for b in _builtin if b != "cd"]) \
15303
+ _chain = None if (_ps or (_use_shell and [b for b in _builtin if b != "cd"])) \
15024
15304
  else _and_chain_parts(command)
15025
15305
  if _chain:
15026
15306
  _chain_start = _t.time()
@@ -15084,7 +15364,13 @@ def run_agent_chat(cfg):
15084
15364
  # `/usr/bin/<x>` can't slip past a bare-name rule.
15085
15365
  _runner = argv[0]
15086
15366
  _rbase = os.path.basename(_runner)
15087
- if _use_shell:
15367
+ if _use_shell and _ps:
15368
+ _refuse, _hard_reason, _ask_reason = _ws_ps_command_risk(
15369
+ _scan, _WS_RUN_DENYLIST, wd, _WS_RUN_ALLOWLIST)
15370
+ if _refuse:
15371
+ _last_obs["text"] = _refuse
15372
+ return _refuse
15373
+ elif _use_shell:
15088
15374
  # Every simple command in the line, including those inside $( ), gets the
15089
15375
  # checks an argv command gets — allowlist, privilege, denylist, deletes —
15090
15376
  # plus the shell-only ones (redirect outside the project, `| sh`).
@@ -15122,7 +15408,8 @@ def run_agent_chat(cfg):
15122
15408
  # states it. "this needs root and sudo needs a password we cannot type" is a
15123
15409
  # different question from "sudo is passwordless here", and the user can only
15124
15410
  # answer the one they are shown.
15125
- _priv = _shell_priv_reason(_scan) if _use_shell else _ws_priv_reason(command)
15411
+ _priv = ((_ps_priv_reason(_scan) if _ps else _shell_priv_reason(_scan))
15412
+ if _use_shell else _ws_priv_reason(command))
15126
15413
  _facts = _ws_access_facts() if _priv else None
15127
15414
  if _priv and _facts and not _facts["can_elevate"]:
15128
15415
  # REFUSE BEFORE SPAWNING (requirement 5). With no tty, `sudo x` dies with
@@ -15186,7 +15473,24 @@ def run_agent_chat(cfg):
15186
15473
  exit_path = log_path[:-4] + ".exit"
15187
15474
  # gu as a co-author on the commits it makes (task #289) — after every check
15188
15475
  # above, which judged the command as the model wrote it.
15189
- if _use_shell:
15476
+ if _use_shell and _ps:
15477
+ # PowerShell (#374): the line, sequenced (&& / || for 5.1, the exit status
15478
+ # kept), in a file the wrapper dot-sources — no quoting crosses a command
15479
+ # line — and the wrapper keeps the exit code, folder and $env: changes.
15480
+ _gp = _ps_mod()
15481
+ _cmd_text, _attrib_note = _with_attribution_text(
15482
+ command, dict(_scan, commands=_gp.ps_commands(_scan["tokens"])),
15483
+ _attribution_settings(wd))
15484
+ _ps_cmd = log_path[:-4] + ".cmd.ps1"
15485
+ _ps_wrap = log_path[:-4] + ".ps1"
15486
+ with open(_ps_cmd, "w", encoding="utf-8") as _fh:
15487
+ # Windows PowerShell 5.1 (powershell.exe) needs the 2>&1 care; pwsh 7 does not.
15488
+ _fh.write(_gp.ps_sequence(_cmd_text, legacy=os.path.basename(
15489
+ _shell_bin).lower().startswith("powershell")))
15490
+ with open(_ps_wrap, "w", encoding="utf-8-sig") as _fh: # 5.1 reads a BOM-less file as ANSI
15491
+ _fh.write(_gp.ps_wrapper(_ps_cmd, exit_path, _run_state_path()))
15492
+ argv = _gp.ps_argv(_shell_bin, _ps_wrap)
15493
+ elif _use_shell:
15190
15494
  _cmd_text, _attrib_note = _with_attribution_text(
15191
15495
  command, _scan, _attribution_settings(wd))
15192
15496
  # The exit code is written to a file so it outlives this turn's process, and
@@ -15287,11 +15591,11 @@ def run_agent_chat(cfg):
15287
15591
  # triple-quoted string has no quoting to get wrong.
15288
15592
  _hidden = _pipe_hidden_failure(_scan, out) if (rc == 0 and _use_shell) else ""
15289
15593
  if _hidden:
15290
- result += _pipe_hidden_failure_note(_hidden)
15594
+ result += _pipe_hidden_failure_note(_hidden, ps=_ps)
15291
15595
  _emit({"type": "step", "text":
15292
15596
  f"Command failed behind a pipe → {_trim_words(_hidden)}"})
15293
- if rc != 0 and _use_shell and _SHELL_QUOTE_ERR_RE.search(out or ""):
15294
- result += _HEREDOC_HINT
15597
+ if rc != 0 and _use_shell and (_PS_QUOTE_ERR_RE if _ps else _SHELL_QUOTE_ERR_RE).search(out or ""):
15598
+ result += _PS_HEREDOC_HINT if _ps else _HEREDOC_HINT
15295
15599
  # Say where the shell now is, so a next `cd sub` is not done twice (#362).
15296
15600
  _now = _run_state_cwd() if _use_shell else ""
15297
15601
  if _now and os.path.realpath(_now) != os.path.realpath(wd):
@@ -15386,7 +15690,8 @@ def run_agent_chat(cfg):
15386
15690
  # bash here (Git Bash on Windows), argv when there is none (#359). Set before tool() reads
15387
15691
  # the docstring.
15388
15692
  if _run_shell_bin():
15389
- run_command.__doc__ = run_command.__doc__.replace(_RUN_DOC_ARGV, _RUN_DOC_SHELL)
15693
+ run_command.__doc__ = run_command.__doc__.replace(
15694
+ _RUN_DOC_ARGV, _RUN_DOC_PS if _run_shell_kind() == "powershell" else _RUN_DOC_SHELL)
15390
15695
  run_command = tool(run_command)
15391
15696
 
15392
15697
  @tool
@@ -15697,7 +16002,7 @@ def run_agent_chat(cfg):
15697
16002
  """Use a REAL Chrome browser like a person does — open a page, see it, click with the mouse, type with the keyboard — to test a UI flow, check a page renders, fill a form or click through an app. The first call asks the user's permission. It uses the user's Chrome through the gu extension when that is installed, otherwise gu's own Chrome window (a separate profile: not logged in to the user's accounts). Loop: open → read (or screenshot) → click / type → read again to check what happened. Only gu's own tabs, and tabs the user shared, are used. If the answer starts BROWSER NOT AVAILABLE, stop and tell the user (the answer says what to do).
15698
16003
 
15699
16004
  Args:
15700
- action: one of: open (url) · read (page text + visible interactive elements numbered [n] with x,y) · screenshot (an image of the page; pixels = click x,y) · click / double_click / right_click / hover (ref, or x and y) · type (text into the focused field; with ref it clicks that field first) · key (text: "Enter", "Tab", "Escape", "ctrl+a", "shift+Tab"… several separated by spaces) · scroll (direction, amount) · back · reload · wait (seconds) · js (text = a JavaScript expression run in the page; returns its value) · console (the page's console messages since the last call) · tabs · switch (tab) · close
16005
+ action: one of: open (url) · read (page text + visible interactive elements numbered [n] with x,y) · screenshot (an image of the page; pixels = click x,y) · click / double_click / right_click / hover (ref, or x and y) · type (text into the focused field; with ref it clicks that field first) · key (text: "Enter", "Tab", "Escape", "ctrl+a", "shift+Tab"… several separated by spaces) · scroll (direction, amount) · back · reload · wait (seconds) · js (text = a JavaScript expression run in the page; returns its value — gu's own Chrome window only: through the gu Chrome extension it is refused, so prefer read / console) · console (the page's console messages since the last call) · tabs · switch (tab) · close
15701
16006
  url: for open — a full http(s):// or file:// url (localhost:3000 is taken as http://localhost:3000)
15702
16007
  ref: an element's [n] from the last read, for click/hover/type
15703
16008
  x: page x coordinate for click/hover (from read's @x,y or the screenshot), when no ref
@@ -15848,6 +16153,16 @@ def run_agent_chat(cfg):
15848
16153
  "Finish with final_answer(<summary>): the answer in a few sentences, then the "
15849
16154
  "relevant files as `path:start-end — what is there`, one per line. At most "
15850
16155
  "about 250 words; never paste whole files.")
16156
+ # ITS OWN CONTEXT, ITS OWN "ALREADY SHOWN" (#378, GitHub #160). The sub-agent runs the
16157
+ # same tool functions, so its reads went into the turn's re-read guard — and the main
16158
+ # agent was then refused lines it had never been shown ("already in this
16159
+ # conversation"): 3 refused steps in one measured turn, then a whole-file read
16160
+ # (50k chars carried by every later step) to get round it. The guards are swapped
16161
+ # for empty ones while the sub-agent runs, and the main agent's put back after.
16162
+ _guards = (_reads, _read_ranges, _seen_calls)
16163
+ _saved = tuple(dict(g) for g in _guards)
16164
+ for g in _guards:
16165
+ g.clear()
15851
16166
  try:
15852
16167
  with contextlib.redirect_stdout(sys.stderr):
15853
16168
  out = sub.run(sub_task)
@@ -15855,6 +16170,10 @@ def run_agent_chat(cfg):
15855
16170
  msg = f"Error: explore failed: {type(e).__name__}: {_clip(str(e), 200)}"
15856
16171
  _last_obs["text"] = msg
15857
16172
  return msg
16173
+ finally:
16174
+ for g, old in zip(_guards, _saved):
16175
+ g.clear()
16176
+ g.update(old)
15858
16177
  n = sum(1 for s in getattr(sub.memory, "steps", []) if type(s).__name__ == "ActionStep")
15859
16178
  _emit({"type": "step", "text": f"Explored → {n} step(s), in its own context"})
15860
16179
  _log(f"explore: {n} sub-step(s), {len(str(out))} chars back")