Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/ASSIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,12 @@ COMPANY-SITE AGENT (assist.py + prompts/ASSIST_PROMPT.md, `nuauto assist <row>`;
assist.by_hand_target, the row's own NUworks job page (never another link, no --url), never one whose Submit click is
unresolved or past its deadline. Such a "by-hand" session is told what stopped NUauto and the exact resume label
(assist.hand_note; the rules' NUWORKS ROWS): it clicks the job's Apply, fills the popup, uploads the transcript or the
cover letter, and its Submit asks me like any other. NUworks' sign-in: the one click on "Current Students And
Alumni", else it asks me to sign in in its browser (never typed by it: accounts.never). One application at a time on
cover letter, and its Submit asks me like any other. NUworks' sign-in: its browser starts signed in (2026-10-23, my
call: "i don't wanna sign in each time": before the agent starts, assist.seed_nuworks copies NUauto's own NUworks
sign-in from local/session_cookies.json into that slot's profile, only the cookies a browser sends to NUworks and its
two sign-in hosts (assist.nuworks_cookies; tests/test_assist.py), through the browser tool's own package so the same
browser build opens the profile); if that is stale, the one click on "Current Students And Alumni", else it asks me to
sign in in its browser (never typed by it: accounts.never). One application at a time on
NUworks: it holds NUworks' site lock (one session per site: one by-hand session per machine), takes the NUworks turn
(remote.nuworks_turn, so not beside the other machine's Apply), won't start while an Apply run holds the NUworks
browser, and `nuauto apply` / the NUworks side refuse while it runs (assist.nuworks_session). The weekly cap is
Expand Down
4 changes: 3 additions & 1 deletion docs/REMOTE.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,9 @@ AN ASSISTANT'S SCREEN (#/assistant/<slot> in the homelab's app; review.view / ac
the session started in, and never when that pane shows a shell (the assistant ended). End session
(/exit) and End: do it on the laptop.
Ended Did it go through? Yes / No / No: do it on the laptop (typed into the launcher's question). One tap:
back to Apply > Assistants, and the screen says your answer until the run closes.
back to Apply > Assistants, and the screen says your answer until the run closes. A session you
answered is no longer listed or counted there (gui.assistants), though it holds its slot a few
seconds more while it writes the sheet and cleans up (2026-10-23, my request).
- Each answer goes in the run's actions.log with the paired device's name.
- The agent on the homelab never opens your own network: localhost, the LAN, Tailscale addresses (so not this page,
not the browser view) are refused in its browser and WebFetch (assist.own_network).
Expand Down
70 changes: 70 additions & 0 deletions src/nuauto/assist.py
Original file line number Diff line number Diff line change
Expand Up @@ -1614,6 +1614,74 @@ def hand_note(row):
f"Resume to pick in the popup's Resume list (exactly this text): {label or 'not set: ask the user'}\n")


# ---------- a NUworks row's browser starts signed in (my call, 2026-10-23: "i don't wanna sign in each time") ----------

SEED_HOURS = 12 # a session cookie copied in lasts this long in the slot's profile (a browser drops session cookies on close)
# Adds the cookies (JSON on stdin) to the slot's profile with the browser tool's own package (the same browser build, so
# the profile is never opened by another version), then closes it. Run through npx, like the browser tool.
SEED_JS = """import { createRequire } from "module";
import fs from "fs";
import path from "path";
const [profile, name] = process.argv.slice(2);
const bin = process.env.PATH.split(path.delimiter).find((p) => fs.existsSync(path.join(p, "playwright-mcp")));
if (!bin) { console.error("the browser tool's package was not found"); process.exit(2); }
const pw = createRequire(fs.realpathSync(path.join(bin, "playwright-mcp")))("playwright-core");
const cookies = JSON.parse(fs.readFileSync(0, "utf8"));
const firefox = name === "firefox";
const ctx = await (firefox ? pw.firefox : pw.chromium).launchPersistentContext(profile,
{ headless: true, ...(firefox || name === "chromium" ? {} : { channel: name || "chrome" }) });
try { await ctx.addCookies(cookies); } finally { await ctx.close(); }
"""
COOKIE_KEYS = ("name", "value", "domain", "path", "expires", "httpOnly", "secure", "sameSite")


def nuworks_cookies(saved, now):
"""Of NUauto's own browser's saved cookies (local/session_cookies.json), the ones a browser sends to NUworks and its
two sign-in hosts (config.ALLOWED_HOSTS, SSO_HOSTS), nothing else; expired ones left out, session ones given
SEED_HOURS (so they outlive the profile's next close)."""
hosts = config.ALLOWED_HOSTS | config.SSO_HOSTS
out = []
for c in saved if isinstance(saved, list) else []:
d = str(c.get("domain") or "").lstrip(".").lower() if isinstance(c, dict) else ""
if not d or not any(h == d or h.endswith("." + d) for h in hosts):
continue
c = {k: c[k] for k in COOKIE_KEYS if k in c}
exp = c.get("expires")
if not isinstance(exp, (int, float)) or exp <= 0:
c["expires"] = int(now + SEED_HOURS * 3600)
elif exp < now:
continue
if c.get("sameSite") == "None" and not c.get("secure"): # (Chrome refuses that pair)
c["sameSite"] = "Lax"
out.append(c)
return out


def seed_nuworks(profile, d, log_):
"""(a NUworks row's session) This slot's browser gets NUauto's NUworks sign-in before it starts. Never fails the
session: without it, the one click on "Current Students And Alumni", else it asks you (as before)."""
import time
try:
with open(config.COOKIES_PATH) as f:
cookies = nuworks_cookies(json.load(f), time.time())
except (OSError, ValueError):
cookies = []
if not cookies:
return log_.write("No saved NUworks sign-in to give this browser: it signs in with the one click, or asks you.")
js = os.path.join(d, "seed_cookies.mjs")
with open(js, "w") as f:
f.write(SEED_JS)
name = config.ASSIST_BROWSER or "chrome" # (the browser tool's default)
cmd = ["npx", "-y", "-p", MCP_PACKAGE, "-c", " ".join(shlex.quote(a) for a in ("node", js, profile, name))]
try:
r = subprocess.run(cmd, input=json.dumps(cookies), capture_output=True, text=True, timeout=120)
ok = r.returncode == 0
except (OSError, subprocess.TimeoutExpired):
ok = False
log_.write("This browser starts signed in to NUworks (NUauto's own sign-in)." if ok else
"Could not give this browser NUauto's NUworks sign-in: it signs in with the one click, or asks you.")


OTHER_NOTE = ("This job is not on NUworks (the sheet's Other jobs tab): nothing is submitted on NUworks afterwards. "
"Its answer bank leaves out the NUworks co-op answers (start / end dates, co-op term): when the form "
"asks one, guess it from the posting (record the guess) like any other.\n")
Expand Down Expand Up @@ -1884,6 +1952,8 @@ def run_locked(number, other, row, url, where, tab, slot, slot_lock, hand=False)
json.dump(state, f)
profile = slot_profile(slot)
os.makedirs(profile, mode=0o700, exist_ok=True)
if hand and not config.DEMO:
seed_nuworks(profile, d, log_)
cmd = session(d, answer_cmd, read_roots, profile, rules_text(answer_cmd) + context_text(
where, row, url, other, resume, read_roots, answer_cmd, writeups, view.url if view else None, transcript, hand), where,
view.display if view else None)
Expand Down
5 changes: 4 additions & 1 deletion src/nuauto/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -1397,7 +1397,10 @@ def assistants():
homelab: the homelab's own sessions (remote_assist), so a row it works on offers no Start here."""
from nuauto import assist
from nuauto import link
return {"sessions": assist.sessions(), "slots": assist.slots(), "homelab": homelab_assistants(),
# one whose "Did it go through?" you answered is closing (its sheet write, its cleanup): not listed, though it still
# holds its slot a few seconds
live = [s for s in assist.sessions() if (s.get("attention") or {}).get("kind") != "answered"]
return {"sessions": live, "slots": assist.slots(), "homelab": homelab_assistants(),
"homelab_review": homelab_review(), "linked": not REMOTE and link.linked()} # linked: Start on the homelab


Expand Down
16 changes: 16 additions & 0 deletions tests/test_assist.py
Original file line number Diff line number Diff line change
Expand Up @@ -1720,4 +1720,20 @@ def status_of(n):
finally:
accounts.PATH = pw_path

# a NUworks row's browser starts signed in: only the cookies a browser sends to NUworks and its two sign-in hosts
now_ = 1_800_000_000
saved_ = [{"name": "nu", "value": "a", "domain": "northeastern-csm.symplicity.com", "path": "/", "expires": -1, "httpOnly": True,
"secure": True, "sameSite": "None", "partitionKey": "x"},
{"name": "sso", "value": "b", "domain": ".neu.edu", "path": "/", "expires": now_ + 99, "secure": False, "sameSite": "None"},
{"name": "shib", "value": "c", "domain": ".symplicity.com", "path": "/", "expires": now_ + 5, "sameSite": "Lax"},
{"name": "old", "value": "d", "domain": "neuidmsso.neu.edu", "path": "/", "expires": now_ - 1},
{"name": "ms", "value": "e", "domain": "login.microsoftonline.com", "path": "/", "expires": -1},
{"name": "other", "value": "f", "domain": "csm.symplicity.com.evil.example", "path": "/", "expires": -1},
{"name": "mail", "value": "g", "domain": "outlook.neu.edu.example", "path": "/", "expires": -1}, "junk"]
got_ = {c["name"]: c for c in assist.nuworks_cookies(saved_, now_)}
assert sorted(got_) == ["nu", "shib", "sso"], got_ # nothing else, nothing expired
assert got_["nu"]["expires"] == now_ + assist.SEED_HOURS * 3600 and "partitionKey" not in got_["nu"] # a session one lasts
assert got_["nu"]["sameSite"] == "None" and got_["sso"]["sameSite"] == "Lax" # (None without secure: Lax)
assert assist.nuworks_cookies({"not": "a list"}, now_) == []

print("All assist (agent guard) checks passed.")
9 changes: 9 additions & 0 deletions tests/test_remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -1126,6 +1126,15 @@ def boom():
gui.VIEW, config.DEMO, config.REMOTE_ASSIST, config.IS_SERVER, config.REMOTE_APP, config.HAS_SERVER = real_flags
threading.excepthook = threading.__excepthook__

# ---- a session whose "Did it go through?" you answered is closing: the lists leave it out (it still holds its slot)
real_assist_sessions = assist.sessions
try:
assist.sessions = lambda: [{"slot": 1, "tab": "jobs", "row": 4, "attention": {"kind": "answered"}},
{"slot": 2, "tab": "jobs", "row": 5, "attention": {"kind": "approve"}}, {"slot": 3, "tab": "other", "row": 6}]
assert [s["slot"] for s in gui.assistants()["sessions"]] == [2, 3]
finally:
assist.sessions = real_assist_sessions

# ---- the GUI's Homelab assistants card: the review page's address, Pair this computer, the browser tool's screenshots
real_flags = (gui.VIEW, config.DEMO, config.REMOTE_ASSIST, config.IS_SERVER)
real_local = config.LOCAL
Expand Down
Loading