@tiens.nguyen/gu-cli 1.0.834 → 1.0.836
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.
- package/README.md +1 -1
- package/browser-bridge.mjs +72 -11
- package/browser-command.mjs +214 -20
- package/browser-extension/README.md +67 -27
- package/browser-extension/actions.js +319 -0
- package/browser-extension/background.js +100 -322
- package/browser-extension/icons/icon32.png +0 -0
- package/browser-extension/manifest.json +3 -1
- package/browser-extension/popup.html +4 -0
- package/browser-extension/popup.js +23 -0
- package/client-expert-api.mjs +43 -2
- package/gonext_agent_chat.py +424 -59
- package/gu-cli.mjs +45 -15
- package/gu-repl.mjs +185 -26
- package/gu_browser.py +92 -20
- package/gu_powershell.py +675 -0
- package/install-path.mjs +19 -2
- package/package.json +5 -1
- package/proc-identity.mjs +53 -0
- package/shell-details.mjs +95 -26
- package/terminal-layout.mjs +1 -0
- package/win-path.mjs +185 -0
package/gonext_agent_chat.py
CHANGED
|
@@ -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
|
|
7995
|
-
|
|
7996
|
-
|
|
7997
|
-
|
|
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
|
-
|
|
8002
|
-
|
|
8003
|
-
|
|
8004
|
-
|
|
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
|
-
|
|
8007
|
-
|
|
8008
|
-
|
|
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.
|
|
@@ -8071,9 +8268,9 @@ _BROWSER_LINE = (
|
|
|
8071
8268
|
"(text + numbered elements) or browser(\"screenshot\") → browser(\"click\", ref=n) / "
|
|
8072
8269
|
"browser(\"type\", ref=n, text=…) → read again to check the result; browser(\"console\") shows "
|
|
8073
8270
|
"page errors. A local app needs its server running first (run_command background=True). "
|
|
8074
|
-
"Never report a UI as working without looking at it.
|
|
8075
|
-
"
|
|
8076
|
-
"
|
|
8271
|
+
"Never report a UI as working without looking at it. The first browser call asks the user's "
|
|
8272
|
+
"permission (nothing to install: without the gu extension it opens gu's own Chrome window). "
|
|
8273
|
+
"If the user says no, or it answers BROWSER NOT AVAILABLE, stop and tell the user.\n")
|
|
8077
8274
|
|
|
8078
8275
|
|
|
8079
8276
|
# Background shells (task #360). Never `&` (refused) — background=True is the way to say it.
|
|
@@ -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
|
|
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
|
|
|
@@ -8725,6 +8934,8 @@ def _ws_request_approval(api_base, worker_key, job_id, command, reason, deadline
|
|
|
8725
8934
|
_emit({"type": "step", "text": "Awaiting your choice: compact the context?"})
|
|
8726
8935
|
elif command.startswith("__S3SETUP__::"):
|
|
8727
8936
|
_emit({"type": "step", "text": "Awaiting your choice: set up S3, or save on this computer?"})
|
|
8937
|
+
elif command.startswith("__BROWSER__::"):
|
|
8938
|
+
_emit({"type": "step", "text": "Awaiting your approval: use a web browser"})
|
|
8728
8939
|
elif command.startswith("__MCPTOOL__::"):
|
|
8729
8940
|
_srv, _, _rest = command[len("__MCPTOOL__::"):].partition("\t")
|
|
8730
8941
|
_emit({"type": "step", "text": f"Awaiting your approval: MCP tool {_srv}/{_rest.split(chr(9))[0]}"})
|
|
@@ -8942,6 +9153,90 @@ def _ws_pgroup_pids(pgid: int) -> list:
|
|
|
8942
9153
|
return []
|
|
8943
9154
|
|
|
8944
9155
|
|
|
9156
|
+
# A SAVED PGID IS ONLY OURS WHILE ITS LEADER IS THE PROCESS WE STARTED (task #373, GitHub #154).
|
|
9157
|
+
# shells.json / servers.json outlive their commands. After a reboot, a crash or a closed terminal
|
|
9158
|
+
# the id is free, and the OS hands it to the next program — an Electron app such as VS Code is its
|
|
9159
|
+
# own group leader — so "a group with that id exists" said "running" and a stop SIGTERMed that
|
|
9160
|
+
# app. The record's own start time is the proof: a process whose pid == the pgid must have started
|
|
9161
|
+
# when the record says (± _WS_START_SLACK). The leader gone but members left is still ours: an id
|
|
9162
|
+
# is never reused while a process group of that id exists.
|
|
9163
|
+
_WS_START_SLACK = 5.0
|
|
9164
|
+
|
|
9165
|
+
|
|
9166
|
+
def _ws_proc_start(pid: int):
|
|
9167
|
+
"""When process `pid` started (epoch seconds), None when there is no such process, or "" when
|
|
9168
|
+
it cannot be told (no ps). POSIX ps lstart: the same spelling on macOS and procps."""
|
|
9169
|
+
import subprocess
|
|
9170
|
+
import time as _t
|
|
9171
|
+
try:
|
|
9172
|
+
out = subprocess.run(["ps", "-o", "lstart=", "-p", str(int(pid))], stdout=subprocess.PIPE,
|
|
9173
|
+
stderr=subprocess.DEVNULL, text=True, timeout=5,
|
|
9174
|
+
env={**os.environ, "LC_ALL": "C", "LANG": "C"})
|
|
9175
|
+
except Exception: # noqa: BLE001
|
|
9176
|
+
return ""
|
|
9177
|
+
raw = " ".join(out.stdout.split())
|
|
9178
|
+
if not raw:
|
|
9179
|
+
return None
|
|
9180
|
+
try:
|
|
9181
|
+
return _t.mktime(_t.strptime(raw, "%a %b %d %H:%M:%S %Y"))
|
|
9182
|
+
except ValueError:
|
|
9183
|
+
return ""
|
|
9184
|
+
|
|
9185
|
+
|
|
9186
|
+
def _ws_record_started(rec: dict):
|
|
9187
|
+
"""The record's start time (epoch), from `started`, else the older `started_at` string."""
|
|
9188
|
+
import time as _t
|
|
9189
|
+
try:
|
|
9190
|
+
if rec.get("started") is not None:
|
|
9191
|
+
return float(rec["started"])
|
|
9192
|
+
except (TypeError, ValueError):
|
|
9193
|
+
pass
|
|
9194
|
+
try:
|
|
9195
|
+
return _t.mktime(_t.strptime(str(rec.get("started_at")), "%Y-%m-%d %H:%M:%S"))
|
|
9196
|
+
except (ValueError, OverflowError):
|
|
9197
|
+
return None
|
|
9198
|
+
|
|
9199
|
+
|
|
9200
|
+
def _ws_group_owner(rec: dict, proc_start=None) -> str:
|
|
9201
|
+
"""Whose is the group a shell/server record names now?
|
|
9202
|
+
"ours" — its leader is the process the record started;
|
|
9203
|
+
"leader-gone" — no process has that id; any group members left are still ours;
|
|
9204
|
+
"other" — that id now belongs to a process started at another time: NOT ours;
|
|
9205
|
+
"unknown" — nothing to compare (no start time recorded, or ps could not say).
|
|
9206
|
+
Windows has no process groups; its records are taken as they are."""
|
|
9207
|
+
if _IS_WINDOWS:
|
|
9208
|
+
return "ours"
|
|
9209
|
+
pgid = int(rec.get("pgid") or rec.get("pid") or 0)
|
|
9210
|
+
if pgid <= 0:
|
|
9211
|
+
return "unknown"
|
|
9212
|
+
leader = (proc_start or _ws_proc_start)(pgid)
|
|
9213
|
+
if leader is None:
|
|
9214
|
+
return "leader-gone"
|
|
9215
|
+
started = _ws_record_started(rec)
|
|
9216
|
+
if leader == "" or started is None:
|
|
9217
|
+
return "unknown"
|
|
9218
|
+
return "ours" if abs(float(leader) - started) <= _WS_START_SLACK else "other"
|
|
9219
|
+
|
|
9220
|
+
|
|
9221
|
+
def _ws_record_pids(rec: dict) -> list:
|
|
9222
|
+
"""Live pids of a record's group — none when the id has passed to another program."""
|
|
9223
|
+
if _ws_group_owner(rec) == "other":
|
|
9224
|
+
return []
|
|
9225
|
+
return _ws_pgroup_pids(int(rec.get("pgid") or rec.get("pid") or 0))
|
|
9226
|
+
|
|
9227
|
+
|
|
9228
|
+
def _ws_stop_record(rec: dict, force: bool = False) -> bool:
|
|
9229
|
+
"""Signal a record's group ONLY when it is provably ours. False = nothing was signalled."""
|
|
9230
|
+
owner = _ws_group_owner(rec)
|
|
9231
|
+
pgid = int(rec.get("pgid") or rec.get("pid") or 0)
|
|
9232
|
+
if owner not in ("ours", "leader-gone") or pgid <= 0:
|
|
9233
|
+
_log(f"not stopping pgid={pgid} ('{str(rec.get('command'))[:40]}'): {owner} — the id is no "
|
|
9234
|
+
"longer the process gu started")
|
|
9235
|
+
return False
|
|
9236
|
+
_ws_kill_tree(pgid, force=force)
|
|
9237
|
+
return True
|
|
9238
|
+
|
|
9239
|
+
|
|
8945
9240
|
def _ws_parse_netstat_ports(stdout: str, pids: set) -> list:
|
|
8946
9241
|
"""LISTENING ports owned by `pids`, from `netstat -ano` output. Pure, so it is testable
|
|
8947
9242
|
on a Mac against recorded Windows output — running netstat here is not."""
|
|
@@ -9054,7 +9349,7 @@ def _ws_load_servers(prune: bool = True) -> list:
|
|
|
9054
9349
|
except Exception: # noqa: BLE001
|
|
9055
9350
|
return []
|
|
9056
9351
|
if prune:
|
|
9057
|
-
servers = [s for s in servers if
|
|
9352
|
+
servers = [s for s in servers if _ws_record_pids(s)]
|
|
9058
9353
|
return servers
|
|
9059
9354
|
|
|
9060
9355
|
|
|
@@ -9080,10 +9375,8 @@ def _ws_cap_servers(max_live: int = 3) -> list:
|
|
|
9080
9375
|
if len(servers) > max_live:
|
|
9081
9376
|
for s in servers[:-max_live]: # registry is append-ordered → oldest first
|
|
9082
9377
|
try:
|
|
9083
|
-
|
|
9084
|
-
if pgid <= 0:
|
|
9378
|
+
if not _ws_stop_record(s):
|
|
9085
9379
|
continue
|
|
9086
|
-
_ws_kill_tree(pgid)
|
|
9087
9380
|
_log(f"server lifecycle: stopped old background server pid={s.get('pid')} "
|
|
9088
9381
|
f"ports={s.get('ports')} ('{str(s.get('command'))[:40]}')")
|
|
9089
9382
|
except (OSError, ValueError) as e: # already gone / no permission
|
|
@@ -9169,7 +9462,7 @@ def _shell_state(s: dict, alive=None, now=None) -> dict:
|
|
|
9169
9462
|
started = float(s.get("started") or now)
|
|
9170
9463
|
code, code_at = _shell_exit_code(s)
|
|
9171
9464
|
if alive is None:
|
|
9172
|
-
alive = code is None and bool(
|
|
9465
|
+
alive = code is None and bool(_ws_record_pids(s))
|
|
9173
9466
|
if alive and code is None and not s.get("killed"):
|
|
9174
9467
|
return {"state": "running", "code": None, "runtime": max(0.0, now - started), "ended": now}
|
|
9175
9468
|
ended = float(s.get("ended") or code_at or now)
|
|
@@ -9333,10 +9626,10 @@ def _shell_kill(s: dict, by: str = "the agent (stop_server)") -> None:
|
|
|
9333
9626
|
if _shell_state(s)["state"] != "running":
|
|
9334
9627
|
return
|
|
9335
9628
|
try:
|
|
9336
|
-
|
|
9337
|
-
|
|
9338
|
-
|
|
9339
|
-
|
|
9629
|
+
if _ws_stop_record(s):
|
|
9630
|
+
_t.sleep(1.0)
|
|
9631
|
+
if _ws_record_pids(s):
|
|
9632
|
+
_ws_stop_record(s, force=True)
|
|
9340
9633
|
except (OSError, ValueError):
|
|
9341
9634
|
pass
|
|
9342
9635
|
s["killed"] = True
|
|
@@ -9374,7 +9667,8 @@ def _shells_finished_note(shells: list, now=None) -> str:
|
|
|
9374
9667
|
s["announced"] = True
|
|
9375
9668
|
note = (f"[background shell {s.get('id')} finished: {_shell_status_text(st)} after "
|
|
9376
9669
|
f"{_fmt_runtime(st['runtime'])} — '{_clip(str(s.get('command')), 120)}'. "
|
|
9377
|
-
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.]")
|
|
9378
9672
|
try:
|
|
9379
9673
|
with open(s.get("log") or "", encoding="utf-8", errors="replace") as fh:
|
|
9380
9674
|
dg = _test_digest(fh.read(), str(s.get("workdir") or ""))
|
|
@@ -9448,7 +9742,7 @@ def _run_state_cwd() -> str:
|
|
|
9448
9742
|
if not p:
|
|
9449
9743
|
return ""
|
|
9450
9744
|
try:
|
|
9451
|
-
with open(p + ".cwd", encoding="utf-8") as fh:
|
|
9745
|
+
with open(p + ".cwd", encoding="utf-8-sig") as fh: # PowerShell 5.1 writes a BOM
|
|
9452
9746
|
return fh.read().strip()
|
|
9453
9747
|
except OSError:
|
|
9454
9748
|
return ""
|
|
@@ -9488,7 +9782,9 @@ def _ws_scrubbed_env() -> dict:
|
|
|
9488
9782
|
keep += ("SYSTEMROOT", "SystemDrive", "WINDIR", "COMSPEC", "PATHEXT", "USERPROFILE",
|
|
9489
9783
|
"HOMEDRIVE", "HOMEPATH", "USERNAME", "APPDATA", "LOCALAPPDATA", "TEMP", "TMP",
|
|
9490
9784
|
"ProgramFiles", "ProgramFiles(x86)", "ProgramW6432", "ProgramData",
|
|
9491
|
-
"CommonProgramFiles", "NUMBER_OF_PROCESSORS", "PROCESSOR_ARCHITECTURE"
|
|
9785
|
+
"CommonProgramFiles", "NUMBER_OF_PROCESSORS", "PROCESSOR_ARCHITECTURE",
|
|
9786
|
+
# PowerShell finds its own modules (Microsoft.PowerShell.*) through it (#374).
|
|
9787
|
+
"PSModulePath")
|
|
9492
9788
|
env = {k: os.environ[k] for k in keep if k in os.environ}
|
|
9493
9789
|
# CI=1 makes watch-mode test runners (CRA's `npm test`, jest --watch) run ONCE and
|
|
9494
9790
|
# exit instead of sitting in interactive watch mode until the timeout kills them.
|
|
@@ -10864,7 +11160,7 @@ def run_agent_chat(cfg):
|
|
|
10864
11160
|
"and reads do not pile up here and are not re-sent on every later step. Then read only "
|
|
10865
11161
|
"the exact ranges you will edit. Use grep_repo/read_file_lines directly when you already "
|
|
10866
11162
|
"know the file or the exact string.\n"
|
|
10867
|
-
+ _run_command_prompt_line(bool(_run_shell_bin()))
|
|
11163
|
+
+ _run_command_prompt_line(bool(_run_shell_bin()), kind=_run_shell_kind())
|
|
10868
11164
|
+ (_BROWSER_LINE if _browser_on() else "") +
|
|
10869
11165
|
" DEPLOY: prefer deploy_web(local_dir, host, user, remote_path, domain) over "
|
|
10870
11166
|
"hand-written scp/ssh/rsync (it rsyncs over your SSH key and finishes nginx when it "
|
|
@@ -14909,20 +15205,25 @@ def run_agent_chat(cfg):
|
|
|
14909
15205
|
# A REAL SHELL WHEN THE LINE NEEDS ONE (task #359). Scanned BEFORE shlex.split,
|
|
14910
15206
|
# which raises on a heredoc body with an apostrophe in it ("don't").
|
|
14911
15207
|
_shell_bin = _run_shell_bin()
|
|
14912
|
-
|
|
15208
|
+
# PowerShell on Windows (#374): every line goes through it — a cmdlet is no program.
|
|
15209
|
+
_ps = bool(_shell_bin) and _run_shell_kind() == "powershell"
|
|
15210
|
+
_scan = (_ps_mod().ps_scan(command) if _ps else _shell_scan(command)) if _shell_bin else None
|
|
14913
15211
|
if _scan and _scan["error"]:
|
|
14914
15212
|
msg = (f"Error: that command did NOT run — {_scan['error']}. Nothing was "
|
|
14915
15213
|
"executed; fix the quoting and send it again."
|
|
14916
|
-
+ (
|
|
15214
|
+
+ ((_PS_HEREDOC_HINT if _ps else _HEREDOC_HINT)
|
|
15215
|
+
if re.search(r"quote|here-string", str(_scan["error"])) else ""))
|
|
14917
15216
|
_last_obs["text"] = msg
|
|
14918
15217
|
return msg
|
|
14919
15218
|
# Once this turn has shell state (a cd, an activated venv), every line goes through
|
|
14920
15219
|
# the shell so it starts from that state — not only lines with shell syntax.
|
|
14921
|
-
_words = [c["argv"][0] for c in _shell_commands(_scan["tokens"]) if c["argv"]]
|
|
15220
|
+
_words = [c["argv"][0] for c in _shell_commands(_scan["tokens"]) if c["argv"]] \
|
|
15221
|
+
if (_scan and not _ps) else []
|
|
14922
15222
|
_builtin = sorted({w for w in _words if w in _SHELL_STATE_BUILTINS})
|
|
14923
|
-
_use_shell = bool(_scan) and (_shell_needed(_scan) or bool(_builtin) or bool(
|
|
15223
|
+
_use_shell = bool(_scan) and (_ps or _shell_needed(_scan) or bool(_builtin) or bool(
|
|
14924
15224
|
_RUN_STATE["path"] and os.path.exists(_RUN_STATE["path"])))
|
|
14925
|
-
_bg_err =
|
|
15225
|
+
_bg_err = ((_ps_background_error(_scan) if _ps else _shell_background_error(_scan))
|
|
15226
|
+
if _use_shell else None)
|
|
14926
15227
|
if _bg_err:
|
|
14927
15228
|
_last_obs["text"] = _bg_err
|
|
14928
15229
|
return _bg_err
|
|
@@ -14936,7 +15237,7 @@ def run_agent_chat(cfg):
|
|
|
14936
15237
|
# the first failure stops the rest. `cd DIR` sets the folder for what follows.
|
|
14937
15238
|
# A chain runs each part as a program; `export`/`source` in it would run nothing,
|
|
14938
15239
|
# so with a shell such a line goes to bash whole. (`cd` the chain does itself.)
|
|
14939
|
-
_chain = None if (_use_shell and [b for b in _builtin if b != "cd"]) \
|
|
15240
|
+
_chain = None if (_ps or (_use_shell and [b for b in _builtin if b != "cd"])) \
|
|
14940
15241
|
else _and_chain_parts(command)
|
|
14941
15242
|
if _chain:
|
|
14942
15243
|
_chain_start = _t.time()
|
|
@@ -15000,7 +15301,13 @@ def run_agent_chat(cfg):
|
|
|
15000
15301
|
# `/usr/bin/<x>` can't slip past a bare-name rule.
|
|
15001
15302
|
_runner = argv[0]
|
|
15002
15303
|
_rbase = os.path.basename(_runner)
|
|
15003
|
-
if _use_shell:
|
|
15304
|
+
if _use_shell and _ps:
|
|
15305
|
+
_refuse, _hard_reason, _ask_reason = _ws_ps_command_risk(
|
|
15306
|
+
_scan, _WS_RUN_DENYLIST, wd, _WS_RUN_ALLOWLIST)
|
|
15307
|
+
if _refuse:
|
|
15308
|
+
_last_obs["text"] = _refuse
|
|
15309
|
+
return _refuse
|
|
15310
|
+
elif _use_shell:
|
|
15004
15311
|
# Every simple command in the line, including those inside $( ), gets the
|
|
15005
15312
|
# checks an argv command gets — allowlist, privilege, denylist, deletes —
|
|
15006
15313
|
# plus the shell-only ones (redirect outside the project, `| sh`).
|
|
@@ -15038,7 +15345,8 @@ def run_agent_chat(cfg):
|
|
|
15038
15345
|
# states it. "this needs root and sudo needs a password we cannot type" is a
|
|
15039
15346
|
# different question from "sudo is passwordless here", and the user can only
|
|
15040
15347
|
# answer the one they are shown.
|
|
15041
|
-
_priv =
|
|
15348
|
+
_priv = ((_ps_priv_reason(_scan) if _ps else _shell_priv_reason(_scan))
|
|
15349
|
+
if _use_shell else _ws_priv_reason(command))
|
|
15042
15350
|
_facts = _ws_access_facts() if _priv else None
|
|
15043
15351
|
if _priv and _facts and not _facts["can_elevate"]:
|
|
15044
15352
|
# REFUSE BEFORE SPAWNING (requirement 5). With no tty, `sudo x` dies with
|
|
@@ -15102,7 +15410,24 @@ def run_agent_chat(cfg):
|
|
|
15102
15410
|
exit_path = log_path[:-4] + ".exit"
|
|
15103
15411
|
# gu as a co-author on the commits it makes (task #289) — after every check
|
|
15104
15412
|
# above, which judged the command as the model wrote it.
|
|
15105
|
-
if _use_shell:
|
|
15413
|
+
if _use_shell and _ps:
|
|
15414
|
+
# PowerShell (#374): the line, sequenced (&& / || for 5.1, the exit status
|
|
15415
|
+
# kept), in a file the wrapper dot-sources — no quoting crosses a command
|
|
15416
|
+
# line — and the wrapper keeps the exit code, folder and $env: changes.
|
|
15417
|
+
_gp = _ps_mod()
|
|
15418
|
+
_cmd_text, _attrib_note = _with_attribution_text(
|
|
15419
|
+
command, dict(_scan, commands=_gp.ps_commands(_scan["tokens"])),
|
|
15420
|
+
_attribution_settings(wd))
|
|
15421
|
+
_ps_cmd = log_path[:-4] + ".cmd.ps1"
|
|
15422
|
+
_ps_wrap = log_path[:-4] + ".ps1"
|
|
15423
|
+
with open(_ps_cmd, "w", encoding="utf-8") as _fh:
|
|
15424
|
+
# Windows PowerShell 5.1 (powershell.exe) needs the 2>&1 care; pwsh 7 does not.
|
|
15425
|
+
_fh.write(_gp.ps_sequence(_cmd_text, legacy=os.path.basename(
|
|
15426
|
+
_shell_bin).lower().startswith("powershell")))
|
|
15427
|
+
with open(_ps_wrap, "w", encoding="utf-8-sig") as _fh: # 5.1 reads a BOM-less file as ANSI
|
|
15428
|
+
_fh.write(_gp.ps_wrapper(_ps_cmd, exit_path, _run_state_path()))
|
|
15429
|
+
argv = _gp.ps_argv(_shell_bin, _ps_wrap)
|
|
15430
|
+
elif _use_shell:
|
|
15106
15431
|
_cmd_text, _attrib_note = _with_attribution_text(
|
|
15107
15432
|
command, _scan, _attribution_settings(wd))
|
|
15108
15433
|
# The exit code is written to a file so it outlives this turn's process, and
|
|
@@ -15203,11 +15528,11 @@ def run_agent_chat(cfg):
|
|
|
15203
15528
|
# triple-quoted string has no quoting to get wrong.
|
|
15204
15529
|
_hidden = _pipe_hidden_failure(_scan, out) if (rc == 0 and _use_shell) else ""
|
|
15205
15530
|
if _hidden:
|
|
15206
|
-
result += _pipe_hidden_failure_note(_hidden)
|
|
15531
|
+
result += _pipe_hidden_failure_note(_hidden, ps=_ps)
|
|
15207
15532
|
_emit({"type": "step", "text":
|
|
15208
15533
|
f"Command failed behind a pipe → {_trim_words(_hidden)}"})
|
|
15209
|
-
if rc != 0 and _use_shell and _SHELL_QUOTE_ERR_RE.search(out or ""):
|
|
15210
|
-
result += _HEREDOC_HINT
|
|
15534
|
+
if rc != 0 and _use_shell and (_PS_QUOTE_ERR_RE if _ps else _SHELL_QUOTE_ERR_RE).search(out or ""):
|
|
15535
|
+
result += _PS_HEREDOC_HINT if _ps else _HEREDOC_HINT
|
|
15211
15536
|
# Say where the shell now is, so a next `cd sub` is not done twice (#362).
|
|
15212
15537
|
_now = _run_state_cwd() if _use_shell else ""
|
|
15213
15538
|
if _now and os.path.realpath(_now) != os.path.realpath(wd):
|
|
@@ -15302,7 +15627,8 @@ def run_agent_chat(cfg):
|
|
|
15302
15627
|
# bash here (Git Bash on Windows), argv when there is none (#359). Set before tool() reads
|
|
15303
15628
|
# the docstring.
|
|
15304
15629
|
if _run_shell_bin():
|
|
15305
|
-
run_command.__doc__ = run_command.__doc__.replace(
|
|
15630
|
+
run_command.__doc__ = run_command.__doc__.replace(
|
|
15631
|
+
_RUN_DOC_ARGV, _RUN_DOC_PS if _run_shell_kind() == "powershell" else _RUN_DOC_SHELL)
|
|
15306
15632
|
run_command = tool(run_command)
|
|
15307
15633
|
|
|
15308
15634
|
@tool
|
|
@@ -15357,12 +15683,11 @@ def run_agent_chat(cfg):
|
|
|
15357
15683
|
return msg
|
|
15358
15684
|
else:
|
|
15359
15685
|
target = servers[-1]
|
|
15360
|
-
pgid = int(target["pgid"])
|
|
15361
15686
|
try:
|
|
15362
|
-
|
|
15363
|
-
|
|
15364
|
-
|
|
15365
|
-
|
|
15687
|
+
if _ws_stop_record(target):
|
|
15688
|
+
_t.sleep(1.5)
|
|
15689
|
+
if _ws_record_pids(target):
|
|
15690
|
+
_ws_stop_record(target, force=True)
|
|
15366
15691
|
except OSError:
|
|
15367
15692
|
pass
|
|
15368
15693
|
_ws_save_servers([s for s in servers if s is not target])
|
|
@@ -15578,12 +15903,40 @@ def run_agent_chat(cfg):
|
|
|
15578
15903
|
_last_obs["text"] = msg
|
|
15579
15904
|
return msg
|
|
15580
15905
|
|
|
15906
|
+
# ASK FIRST (#150), the way Claude Code does: the first browser call of a turn asks the user
|
|
15907
|
+
# (Allow once / Always in this folder / No), unless this folder is on the "always" list in
|
|
15908
|
+
# ~/.gonext/browser-allow.json. A "No" holds for the rest of the turn.
|
|
15909
|
+
_browser_consent = {"ok": False, "no": False, "where": ""}
|
|
15910
|
+
|
|
15911
|
+
def _browser_ask(what: str):
|
|
15912
|
+
"""→ "" when the browser may be used, else the message for the model."""
|
|
15913
|
+
import gu_browser as _gb
|
|
15914
|
+
if _browser_consent["ok"]:
|
|
15915
|
+
return ""
|
|
15916
|
+
if _browser_consent["no"]:
|
|
15917
|
+
return ("The user did not allow the browser in this turn. Do not call browser again; "
|
|
15918
|
+
"carry on without it, or ask the user.")
|
|
15919
|
+
folder = _WS_ACTIVE or _default_ws_root()
|
|
15920
|
+
if _gb.allowed_in(folder):
|
|
15921
|
+
_browser_consent["ok"] = True
|
|
15922
|
+
return ""
|
|
15923
|
+
if not (_interactive_approval and _job_id and worker_api_base and worker_api_key):
|
|
15924
|
+
return ("The browser needs the user's permission and this session cannot ask. Tell the "
|
|
15925
|
+
"user to allow it (/browser allow in the gu terminal), then carry on without it.")
|
|
15926
|
+
if _ws_request_approval(worker_api_base, worker_api_key, _job_id,
|
|
15927
|
+
f"__BROWSER__::{folder}\t{what}", "", deadline_s=180):
|
|
15928
|
+
_browser_consent["ok"] = True
|
|
15929
|
+
return ""
|
|
15930
|
+
_browser_consent["no"] = True
|
|
15931
|
+
return ("The user did not allow the browser. Do not call browser again this turn; carry on "
|
|
15932
|
+
"without it, or ask the user.")
|
|
15933
|
+
|
|
15581
15934
|
@tool
|
|
15582
15935
|
def browser(action: str, url: str = "", ref: int = 0, x: int = -1, y: int = -1,
|
|
15583
15936
|
text: str = "", clear: bool = False, submit: bool = False,
|
|
15584
|
-
direction: str = "", amount: int = 0, seconds: int = 0, tab:
|
|
15937
|
+
direction: str = "", amount: int = 0, seconds: int = 0, tab: str = "",
|
|
15585
15938
|
new_tab: bool = False) -> str:
|
|
15586
|
-
"""Use a REAL Chrome browser
|
|
15939
|
+
"""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).
|
|
15587
15940
|
|
|
15588
15941
|
Args:
|
|
15589
15942
|
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
|
|
@@ -15597,7 +15950,7 @@ def run_agent_chat(cfg):
|
|
|
15597
15950
|
direction: scroll only — down (default), up, left or right
|
|
15598
15951
|
amount: scroll only — pixels (default most of a screen)
|
|
15599
15952
|
seconds: wait only — 1 to 30
|
|
15600
|
-
tab: switch only — a tab id from tabs
|
|
15953
|
+
tab: switch only — a tab id from tabs (a number, or an id like "8F3A…" in gu's own window)
|
|
15601
15954
|
new_tab: open only — open in a new tab instead of the current gu tab
|
|
15602
15955
|
"""
|
|
15603
15956
|
import gu_browser as _gb
|
|
@@ -15627,7 +15980,7 @@ def run_agent_chat(cfg):
|
|
|
15627
15980
|
if seconds:
|
|
15628
15981
|
cmd["seconds"] = int(seconds)
|
|
15629
15982
|
if tab:
|
|
15630
|
-
cmd["tab"] = int(tab)
|
|
15983
|
+
cmd["tab"] = int(tab) if str(tab).strip().isdigit() else str(tab).strip()
|
|
15631
15984
|
shot_ok = a == "screenshot" and (
|
|
15632
15985
|
_is_anthropic_url(coding_base_url) or _coder_takes_images(coding_base_url, coding_model_id))
|
|
15633
15986
|
if a == "screenshot" and not shot_ok:
|
|
@@ -15635,11 +15988,20 @@ def run_agent_chat(cfg):
|
|
|
15635
15988
|
what = cmd.get("url") or (f"[{ref}]" if ref else f"{int(x)},{int(y)}" if "x" in cmd else
|
|
15636
15989
|
_clip(str(text), 50) if text else "")
|
|
15637
15990
|
_emit({"type": "step", "text": f"Browser {a}" + (f" → {what}" if what else "")})
|
|
15991
|
+
refused = _browser_ask(f"{a} {what}".strip())
|
|
15992
|
+
if refused:
|
|
15993
|
+
_last_obs["text"] = refused
|
|
15994
|
+
return refused
|
|
15638
15995
|
ok, res, not_connected = _gb.command(
|
|
15639
|
-
cmd, log=_log,
|
|
15640
|
-
on_wait=lambda: _emit({"type": "step", "text": "Waiting for
|
|
15996
|
+
cmd, log=_log, own=True,
|
|
15997
|
+
on_wait=lambda: _emit({"type": "step", "text": "Waiting for Chrome to connect…"}))
|
|
15998
|
+
where = res.pop("_backend", "") if ok and isinstance(res, dict) else ""
|
|
15999
|
+
if where and where != _browser_consent["where"]:
|
|
16000
|
+
_browser_consent["where"] = where
|
|
16001
|
+
_emit({"type": "step", "text": "Using gu's own Chrome window" if where == "chrome"
|
|
16002
|
+
else "Using your Chrome (gu extension)"})
|
|
15641
16003
|
if not_connected:
|
|
15642
|
-
_emit({"type": "step", "text": "⚠
|
|
16004
|
+
_emit({"type": "step", "text": "⚠ No browser gu can use — /browser shows why"})
|
|
15643
16005
|
msg = _gb.install_text(str(res))
|
|
15644
16006
|
elif not ok:
|
|
15645
16007
|
msg = f"Error: browser {a} failed: {res}"
|
|
@@ -15652,6 +16014,9 @@ def run_agent_chat(cfg):
|
|
|
15652
16014
|
+ _gb.format_result("read", res))
|
|
15653
16015
|
else:
|
|
15654
16016
|
msg = _gb.format_result(a, res)
|
|
16017
|
+
if ok and where == "chrome" and a == "open":
|
|
16018
|
+
msg = ("(gu's own Chrome window — a separate profile, not signed in to the user's "
|
|
16019
|
+
"accounts.)\n" + msg)
|
|
15655
16020
|
except Exception as ex: # noqa: BLE001
|
|
15656
16021
|
msg = f"Error: browser {a} failed: {type(ex).__name__}: {ex}"
|
|
15657
16022
|
_last_obs["text"] = msg
|