From cfd810cd85c2d7889e0f47d0fbf5ca205dc5a8a1 Mon Sep 17 00:00:00 2001 From: Patrick Teen Date: Wed, 26 Aug 2026 08:53:13 +0000 Subject: [PATCH] feat(support): read a customer's workspace read-only from their conversation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `eesel support read --task ` runs one ordinary read command against a CUSTOMER's workspace. The server mints the credential: POST /support/mint-readonly-token exchanges a support conversation for a 15-minute, read-only token scoped to the workspace owned by that conversation's verified sender. The support side never names the target, so neither the model nor anything the customer wrote can steer it. Also `eesel support status` (live sessions and time left) and `eesel support end` (drop the cached tokens). How it hangs off the existing CLI, deliberately narrowly: - `require_creds()` returns the minted creds while a session is armed, so every existing read command targets the customer with no change to itself. - Those creds carry `ephemeral`, which `save_creds` already refuses to persist, so a support read can never become the ambient identity or overwrite the operator's own login. The token is cached in its own 0600 store (~/.config/eesel/support/), which nothing outside `support` reads. - The mint's four documented refusals get named, actionable messages: `no_verified_sender` and `sender_not_verified_owner` are expected outcomes for plenty of conversations, and have to read as answers, not as a broken CLI. Writes: the SERVER is the boundary (it rejects every mutating method on a read-only token). The three client layers here just make the refusal legible before the call: a read-noun allowlist (`chat`/`new`/`login` never run here), the parser's own `write=True` tags, and a backstop on any mutating request the first two missed. Known gap, server-side: `tasks list`/`count`/`analytics` are reads the API serves over POST, and the read-only gate is method-based, so it blocks them. `tasks show ` (GET) works. Reading a customer's activity list needs an explicit exemption for /workspace/tasks and /workspace/tasks/analytics. Server contract + design: agents/support/CLI-HANDOFF.md and agents/support/READONLY-IMPERSONATION.md on eeselapp/slack branch claude/eesel-support-agent-f45a70. Co-Authored-By: Claude Opus 5 (1M context) --- CONTEXT.md | 20 ++ README.md | 69 +++++++ eesel | 531 +++++++++++++++++++++++++++++++++++++++++++++++++- test_eesel.py | 329 +++++++++++++++++++++++++++++++ 4 files changed, 948 insertions(+), 1 deletion(-) diff --git a/CONTEXT.md b/CONTEXT.md index 37039e1..a93929f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -80,6 +80,26 @@ trimmed to those. Force the platform for one command with `--legacy` or `--platform` (mutually exclusive). `eesel schema` marks each command with a `legacy_supported` flag, so check there before calling on a legacy workspace. +## Reading a customer's workspace (support only) + +`eesel support read --task ` runs one ordinary read +command against the workspace of a support conversation's sender, using a +15-minute read-only token the server mints. You never name the customer: the +server reads the sender off the helpdesk's record of that conversation. Writes +are refused, by the server as well as by the CLI, and `eesel support status` / +`end` show and drop the cached tokens. The command is hidden from `--help` for +non-staff logins but always present in `eesel schema`. + +Four answers are expected outcomes rather than bugs, each named on a `reason:` +line with exit code 3: `no_verified_sender`, `sender_not_verified_owner`, +`caller_not_allowlisted`, `task_not_in_support_workspace`. See the README for +what to do about each. + +⚠️ `tasks list` / `count` / `analytics` do not work inside a support session: +the API serves those reads over POST and the server's read-only rule is decided +by HTTP method. `tasks show ` (a GET) does work, and `eesel tasks list` as +yourself is unaffected. + ## Exit codes `0` = success · `2` = usage error (bad flags/arguments, from the parser) · diff --git a/README.md b/README.md index 7161b6a..f690422 100644 --- a/README.md +++ b/README.md @@ -310,6 +310,75 @@ once with nothing global to race over. as a normal `eesel login` / `--dev`. - Unlink by deleting `eesel.dev.json`. +## Support read-only sessions - `eesel support` + +Read a **customer's** workspace through the ordinary read commands. You hand the +server the id of a support conversation; it works out who wrote in, checks that +person owns an eesel workspace, and hands back a 15-minute **read-only** token +for that one workspace. So the most a support session can reach is the account +of the customer already talking to you, and only to read it. + +```bash +eesel tasks list # as YOURSELF: find the conversation id +eesel support read --task agents list # everything after --task is the read command +eesel support read --task instructions +eesel support read --task files show +eesel support status # live sessions and time left +eesel support end # drop every cached token (--task drops one) +``` + +You never name the customer. The server reads the sender off the helpdesk's own +record of that conversation, so nothing the customer wrote, and nothing the +agent decides, can aim the token at a different account. Every mint is logged +server-side with who asked, which workspace was resolved, and the token's +lifetime. + +### When it says no + +The first two are ordinary answers about the conversation, not faults to +report: plenty of people who write into support are not the owner of an eesel +workspace, or are not resolvable at all. + +| what you see | what it means | what to do | +| --- | --- | --- | +| `no_verified_sender` | the helpdesk record has no sender we can trust for that conversation. Resolution works for Intercom conversations, inbound email, and Gorgias tickets; a Zendesk or Freshdesk conversation always lands here today | nothing to retry, and no way to name the customer by hand. Ask them in the conversation for the account email and use the dashboard | +| `sender_not_verified_owner` | the sender is real but isn't the email-verified **owner** of an eesel workspace. A teammate writing in instead of the owner lands here | if you need the account read, ask the owner to write in themselves | +| `caller_not_allowlisted` | your workspace isn't on the server's list of support workspaces | ask whoever runs the support workspace to add it. In prod the list is in code and needs a deploy | +| `task_not_in_support_workspace` | that conversation isn't in your own support inbox | wrong id, or an id from somewhere else. Re-check `eesel tasks list` | + +All four print to stderr and exit `3` (the auth class, see Exit codes below), +with the name above on its own `reason:` line so a script can branch on it. + +### Writes + +Refused, and the refusal is the server's: it rejects every request that changes +anything, whatever the CLI does. The CLI just says so before the call instead of +turning it into a bare 401, and it refuses `chat` and `new` outright here (both +would run and bill a turn in the customer's workspace). + +⚠️ `tasks list`, `tasks count` and `tasks analytics` don't work **inside** a +support session. The API serves those reads over `POST`, and the server's +read-only rule is "no request that could change anything", which is decided by +HTTP method, so it turns them away with the writes. `tasks show ` is a `GET` +and works. Note this is only in-session: `eesel tasks list` as yourself, to find +the conversation id, is unaffected. Making a customer's activity list readable +needs the server to exempt `/workspace/tasks` and `/workspace/tasks/analytics` +by name; until someone does, that one read is out of reach. + +### The token + +Cached at `~/.config/eesel/support/.json` (chmod 600, the hash is of the +env plus the task id) until it expires, so a run of many reads needs only one +round trip to mint. When it lapses the next `support read` mints a fresh one by +itself; `--fresh` forces that early. It is never written to +`credentials.json`, no command outside `eesel support` reads it, and it is +never printed - the customer's credential should not end up in your shell +history or a log. + +`eesel support` is hidden from `--help` unless your login is flagged staff, and +always present in `eesel schema`. That is cosmetic; the real gate is the +server's support-workspace allowlist above. + ## Legacy (v2) platform eesel serves two products from one backend: the new **platform** (agents) and diff --git a/eesel b/eesel index a363aeb..38340e5 100755 --- a/eesel +++ b/eesel @@ -1095,6 +1095,13 @@ def _use_creds(creds: dict) -> dict: def require_creds() -> dict: + # `eesel support read` has minted a read-only token for a customer's own + # workspace and armed it for this process — every read in the inner command + # runs against that workspace. It wins over every other credential source, + # including a linked branch env, and is never written to the creds file. + if _support_creds_override is not None: + return _support_creds_override + # A worktree linked to a branch env (or an `EESEL_BASE_URL` override) mints a # throwaway token from that env's `/dev/session` per run. This reads nothing # from ~/.config, so a linked worktree needs no `eesel login` at all — and @@ -1590,6 +1597,8 @@ def http_request(method: str, url: str, *, token: str | None = None, body: dict # Defined near main() alongside the pre-dispatch guard; a no-op unless a real # impersonation target is active. _impersonation_write_backstop(method, url) + # The same shape for a read-only support session; a no-op unless one is armed. + _support_readonly_write_backstop(method, url) try: return _http_send(method, url, token=token, body=body, timeout=timeout, headers=headers) except urllib.error.HTTPError as e: @@ -1598,6 +1607,9 @@ def http_request(method: str, url: str, *, token: str | None = None, body: dict if healed is not None: return healed body_text = e.read().decode(errors="replace") + # A no-op outside a support session; adds one line of context so a + # read-only refusal or a lapsed 15-minute token doesn't read as generic. + _support_readonly_note(e.code, body_text) fail(code_for_status(e.code), f"{method} {url} → {e.code}: {body_text}") except (TimeoutError, socket.timeout): fail(EXIT_SERVER, f"{method} {url} timed out after {timeout}s (server slow or hung?).") @@ -1699,6 +1711,7 @@ def http_request_allow_error(method: str, url: str, *, token: str | None = None, """ assert_url_path_safe(url) _impersonation_write_backstop(method, url) + _support_readonly_write_backstop(method, url) data = json.dumps(body).encode() if body is not None else None headers = {"Content-Type": "application/json"} if token: @@ -7594,6 +7607,7 @@ def _install_suggestions(parser: argparse.ArgumentParser) -> None: # `{id}` marks a path segment resolved at runtime. Commands with no single # stable endpoint (login/logout/whoami/link/chat/mcp call) are omitted. _COMMAND_ENDPOINTS = { + "support.read": "POST /support/mint-readonly-token", "agents.list": "GET /agents · legacy: GET /namespaces", "agents.show": "GET /agents · legacy: GET /namespaces", "agents.create": "POST /agents", @@ -8323,6 +8337,48 @@ def build_parser(staff: bool = False, platform_hint: str | None = None) -> argpa n_set.add_argument("--json", action="store_true", help="Emit the raw settings readback") sp.set_defaults(func=cmd_settings) + # Staff-only, same treatment as `impersonate`: shown in `eesel --help` only + # for global impersonators, hidden (but still runnable) for everyone else. + # The server-side support allowlist is the real gate — the mint endpoint + # refuses any workspace that isn't on it. `eesel schema` always lists it, so + # an agent driving the CLI can still discover the command. + sp = sub.add_parser( + "support", + help="Read a customer's workspace read-only, from their support conversation (staff only)" + if staff + else argparse.SUPPRESS, + description="Read a CUSTOMER's workspace through the ordinary eesel read " + "commands. The server exchanges a support conversation for a 15-minute, " + "READ-ONLY token scoped to the workspace owned by that conversation's " + "verified sender — you never name the target, and no write is possible " + "with it. Requires an allowlisted support workspace.", + ) + sup_sub = sp.add_subparsers(dest="support_cmd") + + sup_read = sup_sub.add_parser( + "read", + help="Run one eesel read command against the customer's workspace", + description="Mint (or reuse) a read-only token for the conversation's verified " + "sender, then run one ordinary read command against their workspace. " + "Everything after `--task ` is the read command, verbatim: " + "`eesel support read --task agents list`.", + ) + sup_read.add_argument("--task", required=True, metavar="TASK_ID", + help="The support conversation to derive the customer from (`eesel tasks list` as yourself)") + sup_read.add_argument("--fresh", action="store_true", + help="Mint a new token even if a live one is cached for this task") + sup_read.add_argument("command", nargs=argparse.REMAINDER, + help="The read command to run, e.g. `agents list` or `files show `") + + sup_status = sup_sub.add_parser("status", help="Show live support sessions and how long they last") + sup_status.add_argument("--task", metavar="TASK_ID", help="Only this conversation's session") + sup_status.add_argument("--json", action="store_true", help="Emit the sessions as JSON (never the token)") + + sup_end = sup_sub.add_parser("end", help="Drop cached support tokens (all, or one task's)") + sup_end.add_argument("--task", metavar="TASK_ID", help="Only drop this conversation's session") + + sp.set_defaults(func=cmd_support) + # Staff-only. Shown in `eesel --help` only for global impersonators; hidden # from everyone else (but still runnable if you know the subcommand). The # server-side allowlist is the actual gate — this just keeps the command @@ -8359,7 +8415,7 @@ def build_parser(staff: bool = False, platform_hint: str | None = None) -> argpa # unless filtered by dest here. `impersonate` stays visible for staff. _hidden_top = ["document", "tools", "instructions"] if not staff: - _hidden_top.append("impersonate") + _hidden_top.extend(("impersonate", "support")) hide_subcommands(sub, *_hidden_top) # On a legacy v2 workspace, trim `--help` to the read commands that work @@ -8835,6 +8891,479 @@ def _guard_platform_command(args, creds: dict | None) -> None: f"'{_command_label(args)}' is not available on the legacy (v2) platform (read-only).") +# ────────────────────────────────────────────────────────────────────────── +# Support read-only sessions (`eesel support …`) +# +# eesel's own support side reads a CUSTOMER's workspace — instructions, +# knowledge, activity, config — through the ordinary read commands, without +# ever holding a cross-workspace or a write credential. The server mints the +# credential it uses: `POST /support/mint-readonly-token` exchanges the id of a +# support conversation for a short-lived (15 min) READ-ONLY token scoped to the +# workspace owned by that conversation's verified sender. The support side +# never names the target — the server derives it from the conversation, so +# neither the model nor the customer's message text can steer it. +# +# So `eesel support read --task ` is: mint (or reuse a live +# cached token), then run that one command with the minted token as its bearer. +# +# The SERVER is the boundary — it refuses every mutating method on a read-only +# token. The three client-side layers here are convenience (a clear refusal +# instead of a raw 401) and are deliberately not the guarantee: +# 1. an allowlist of read nouns, so `chat`/`new`/`login` never run here, +# 2. the parser's own `write=True` tags, refused before any call is made, +# 3. a backstop on any mutating HTTP request the first two missed. +# Design of record: `agents/support/READONLY-IMPERSONATION.md` (eeselapp/slack). +# ────────────────────────────────────────────────────────────────────────── + +SUPPORT_MINT_PATH = "/support/mint-readonly-token" + + +# The mint endpoint's URL. The path is spelled out here rather than +# interpolated from SUPPORT_MINT_PATH, and the body is one bare `return`, so +# `eesel schema`'s endpoint annotation can be cross-checked against a real call +# in the source (the schema test resolves one-line f-string helpers, not module +# constants). The two spellings are pinned together by +# `test_support_mint_url_matches_the_constant`. +def _support_mint_url(base_url: str) -> str: + return f"{base_url}/support/mint-readonly-token" +# Minted tokens are cached here, 0600, one file per (env, task). A support +# engineer or agent runs many reads against one conversation and every mint +# costs the server two Auth0 round-trips, so re-minting per command would be +# both slow and rate-limit-prone. This is NOT the login credentials file: a +# support session must never become the ambient identity, so nothing outside +# `eesel support …` reads these, and `eesel support end` drops them. +SUPPORT_SESSION_DIR = CONFIG_DIR / "support" +# Treat a token as spent this long before the server's stated expiry, so a read +# can't start with two seconds left and 401 halfway through a paged fetch. +SUPPORT_TOKEN_SKEW_SECONDS = 60 + +# Top-level nouns that mean "read the customer's account". Everything else — +# `chat` and `new` (which would run and bill a turn in their workspace), +# `login`/`logout`/`link` (which would touch the operator's own credentials), +# `impersonate`, and `support` itself — is refused before anything is minted. +# Read-only by construction on the client; the server still enforces it. +_SUPPORT_READ_NOUNS = frozenset({ + "agents", "instructions", "integrations", "tasks", "files", "document", + "tools", "automations", "skills", "workspace", "billing", "settings", "mcp", +}) +# What to suggest in the refusal — the aliases above are hidden commands. +_SUPPORT_READ_NOUNS_SHOWN = ( + "agents", "instructions", "files", "integrations", "automations", + "skills", "tasks", "settings", "workspace", "billing", "mcp", +) + +# Set by `eesel support read` once a token is minted and the inner command is +# about to run; read by the HTTP backstop and the 401 explainer. None means "no +# support session", which is every other invocation of the CLI. +_support_session: dict | None = None +# The creds the inner command runs with. `require_creds()` returns this while a +# support session is armed, so every existing read command targets the +# customer's workspace with no change to the command itself. +_support_creds_override: dict | None = None + +# The mint's own POST is the one mutating request a support session may make — +# and only to re-mint an expired token mid-session. +_SUPPORT_ALLOWED_WRITE_PATHS = frozenset({SUPPORT_MINT_PATH}) + + +def _support_session_path(api_url: str, task_id: str) -> Path: + """One cache file per (env, task). Hashed so a task id never becomes a + filename, and so the same task against two envs can't collide.""" + key = hashlib.sha256(f"{api_url}\n{task_id}".encode()).hexdigest()[:32] + return SUPPORT_SESSION_DIR / f"{key}.json" + + +def _support_session_seconds_left(sess: dict | None) -> float: + """Seconds of usable life left on a cached session (already minus the skew). + Zero or negative means re-mint.""" + if not sess or not sess.get("token"): + return 0.0 + try: + expires_at = float(sess.get("expires_at") or 0) + except (TypeError, ValueError): + return 0.0 + return expires_at - SUPPORT_TOKEN_SKEW_SECONDS - time.time() + + +def load_support_session(api_url: str, task_id: str) -> dict | None: + """The live cached session for this (env, task), or None. A spent or + unreadable file is deleted on the way out, so a stale customer token never + lingers on disk after it stops being usable.""" + path = _support_session_path(api_url, task_id) + try: + sess = json.loads(path.read_text()) + except FileNotFoundError: + return None + except Exception: + path.unlink(missing_ok=True) + return None + if _support_session_seconds_left(sess) <= 0: + path.unlink(missing_ok=True) + return None + return sess + + +def save_support_session(sess: dict) -> None: + SUPPORT_SESSION_DIR.mkdir(parents=True, exist_ok=True, mode=0o700) + _atomic_write( + _support_session_path(sess["api_url"], sess["task_id"]), + json.dumps(sess, indent=2), + mode=0o600, + ) + + +def list_support_sessions(*, purge_expired: bool = True) -> list[dict]: + """Every cached session, newest first. Expired files are deleted as we go, + so `eesel support status` doubles as the cleanup.""" + out: list[dict] = [] + if not SUPPORT_SESSION_DIR.exists(): + return out + for path in sorted(SUPPORT_SESSION_DIR.glob("*.json")): + try: + sess = json.loads(path.read_text()) + except Exception: + if purge_expired: + path.unlink(missing_ok=True) + continue + if _support_session_seconds_left(sess) <= 0: + if purge_expired: + path.unlink(missing_ok=True) + continue + out.append(sess) + out.sort(key=lambda s: s.get("minted_at") or 0, reverse=True) + return out + + +def clear_support_sessions(*, task_id: str | None = None) -> int: + """Delete cached sessions — one task's, or all of them. Returns how many + files were removed.""" + if not SUPPORT_SESSION_DIR.exists(): + return 0 + removed = 0 + for path in SUPPORT_SESSION_DIR.glob("*.json"): + if task_id: + try: + if json.loads(path.read_text()).get("task_id") != task_id: + continue + except Exception: + continue # unreadable: only the unqualified clear removes it + path.unlink(missing_ok=True) + removed += 1 + return removed + + +# Server error → (reason slug, what it means, what to do). The last three are +# EXPECTED outcomes, not bugs: plenty of support conversations have no verified +# sender, or a sender who isn't the owner of an eesel workspace. +_SUPPORT_MINT_REASONS = ( + ( + "not authorized to mint support tokens", + "caller_not_allowlisted", + "This login's workspace isn't allowed to mint support tokens.", + "In prod the support allowlist is hardcoded server-side. On a branch env, enable it: " + 'POST {base}/dev/enable-support-lookup {"workspace_id": "", "enabled": true}.', + ), + ( + "task is not in this support workspace", + "task_not_in_support_workspace", + "That task isn't a conversation in this support workspace.", + "The server only mints for conversations in your own support inbox. Check the id with " + "`eesel tasks list` as yourself (no `support read`).", + ), + ( + "could not establish a verified sender", + "no_verified_sender", + "The server couldn't derive a verified sender for that conversation — expected, not a bug.", + "Sender resolution is Intercom-only today, and an anonymous contact has no email to resolve. " + "There is no way to name the customer by hand: the target is server-derived on purpose.", + ), + ( + "sender is not a verified owner", + "sender_not_verified_owner", + "The sender isn't the email-verified owner of an eesel workspace — expected, not a bug.", + "A teammate writing in (rather than the owner), an unverified email, or a personal address " + "that doesn't match the account all land here.", + ), + ( + "task_id is required", + "task_id_required", + "The server received no task_id.", + "", + ), +) + + +def _fail_support_mint(status: int, payload: dict, task_id: str) -> None: + """Turn a mint failure into an actionable message and a typed exit.""" + server_msg = "" + if isinstance(payload, dict): + server_msg = str(payload.get("error") or payload.get("message") or "") + reason = "mint_failed" + detail = server_msg or f"the server returned {status}" + hint = "" + for needle, slug, meaning, advice in _SUPPORT_MINT_REASONS: + if needle in server_msg: + reason, detail, hint = slug, meaning, advice + break + if status == 404 and reason == "mint_failed": + reason = "endpoint_missing" + detail = "This server has no support mint endpoint." + hint = "It ships on the support read-only branch — check the env is running that build." + err(f"Could not mint a read-only token for task {task_id}: {detail}") + if hint: + info(f" {hint}") + info(f" reason: {reason} (HTTP {status})") + sys.exit(code_for_status(status)) + + +def mint_support_readonly_token(creds: dict, task_id: str) -> dict: + """Exchange a support conversation for a read-only token scoped to the + workspace of that conversation's verified sender, and cache it. + + Authenticates as the CALLER (eesel's own support workspace). The target is + never sent — the server derives it from the conversation — so this cannot be + pointed at an arbitrary workspace, by us or by anything the customer wrote. + """ + status, payload = http_request_allow_error( + "POST", _support_mint_url(creds["api_url"]), token=creds["token"], body={"task_id": task_id} + ) + if status != 200 or not isinstance(payload, dict) or not payload.get("token"): + _fail_support_mint(status, payload if isinstance(payload, dict) else {}, task_id) + workspace_id = payload.get("workspace_id") + if not workspace_id: + fail(EXIT_SERVER, f"{SUPPORT_MINT_PATH} returned a token but no workspace_id (got keys: {sorted(payload)}).") + # Trust the server's TTL, but never longer than the 15 minutes the design + # commits to — a bad `expires_in` must not extend a customer-scoped token. + try: + ttl = min(int(payload.get("expires_in") or 900), 900) + except (TypeError, ValueError): + ttl = 900 + sess = { + "task_id": task_id, + "api_url": creds["api_url"], + "workspace_id": validate_id(str(workspace_id), "workspace id"), + "token": payload["token"], + "read_only": bool(payload.get("read_only", True)), + "minted_at": int(time.time()), + "expires_at": int(time.time()) + max(ttl, 0), + } + save_support_session(sess) + return sess + + +def _support_creds(caller: dict, sess: dict) -> dict: + """The creds every read in this session runs with: the caller's env, the + customer's workspace, the minted token. + + `ephemeral` is load-bearing — `save_creds` refuses to persist creds carrying + it, so a support read can never overwrite the operator's own login or leave + the customer's workspace/platform behind on disk. There is no refresh token, + so nothing tries to renew a customer-scoped credential either. + """ + return { + "env": caller.get("env"), + "api_url": sess["api_url"], + "dashboard_url": caller.get("dashboard_url"), + "workspace_id": sess["workspace_id"], + "agent_id": None, + "token": sess["token"], + "expires_at": sess["expires_at"], + "ephemeral": True, + "support_task_id": sess["task_id"], + } + + +def _arm_support_session(sess: dict, creds: dict) -> None: + """Point the rest of this process at the customer's workspace, read-only.""" + global _support_session, _support_creds_override, _current_creds, _REVEAL_SECRETS + _support_session = sess + _support_creds_override = creds + _current_creds = creds + # A support session reads what the customer can see; it is not the place to + # unmask their stored credentials, whatever `--secrets` and a sysadmin login + # would allow as yourself. + _REVEAL_SECRETS = False + + +def _refuse_support_write(reason: str, *advice: str) -> None: + err(f"Refused — read-only support session. {reason}") + for line in advice: + print(f" {line}", file=sys.stderr) + sys.exit(EXIT_IMPERSONATION_BLOCKED) + + +def _guard_support_readonly_command(args) -> None: + """Before dispatch: refuse anything that isn't a read of the customer's + account. Cheap and offline — nothing has been sent at this point.""" + cmd = getattr(args, "cmd", None) + if cmd not in _SUPPORT_READ_NOUNS: + _refuse_support_write( + f"`{cmd}` isn't a read of the customer's account.", + "Reads available here: " + ", ".join(_SUPPORT_READ_NOUNS_SHOWN) + ".", + "Run it as yourself (without `support read`) if that's what you meant.", + ) + if cmd == "mcp" and getattr(args, "mcp_cmd", None) == "token": + _refuse_support_write( + "`mcp token` mints a workspace token, which a read-only session can't do.", + "The minted support token is deliberately never printed — it is a customer credential.", + ) + if _is_write_command(args): + _refuse_support_write( + f"`{_command_label(args)}` would change the customer's live setup.", + "The server rejects it on this token too; this just says so before the call.", + ) + + +def _support_readonly_write_backstop(method: str, url: str) -> None: + """Last line of defence: a mutating request the two checks above missed. + + A command the parser doesn't tag as a write reaching here almost always + means a READ that is served over POST (the CLI's activity list is one). The + server's read-only gate is method-based, so it refuses those too — name the + endpoint so it can be allowlisted server-side rather than guessed at. + """ + if _support_session is None: + return + if method.upper() in ("GET", "HEAD", "OPTIONS"): + return + path = urllib.parse.urlsplit(url).path + if path in _SUPPORT_ALLOWED_WRITE_PATHS: + return + _refuse_support_write( + f"{method.upper()} {path} is not a read.", + "If this is a read the API happens to serve over POST, the server's read-only gate blocks it", + f"as well — it needs an explicit server-side exemption for {path}.", + ) + + +def _support_readonly_note(status: int, body_text: str) -> None: + """Add one line of context to a 401/403 during a support session, so a + server-side refusal doesn't read as a generic auth failure. Advisory only — + the caller still fails with the server's own message and status.""" + if _support_session is None or status not in (401, 403): + return + if "read-only" in (body_text or "").lower(): + info("This is a read-only support session — the server blocked that as a write.") + else: + info("Read-only support tokens last 15 minutes; re-run to mint a fresh one " + "(`eesel support status` shows what's left).") + + +def _support_banner(sess: dict) -> None: + left = max(int(_support_session_seconds_left(sess) + SUPPORT_TOKEN_SKEW_SECONDS), 0) + print( + _color( + f"▲ support read-only — workspace {sess['workspace_id']} " + f"(task {sess['task_id'][:8]}, {left // 60}m{left % 60:02d}s left)", + "33", + ), + file=sys.stderr, + ) + + +def cmd_support(args) -> int: + """`eesel support read|status|end` — read a customer's workspace read-only. + + `read` mints (or reuses) a 15-minute read-only token for the workspace owned + by a support conversation's verified sender, then runs one ordinary eesel + read command against it. `status` shows the live sessions and their time + left; `end` drops the cached tokens. + """ + sub = getattr(args, "support_cmd", None) + if sub == "read": + return _cmd_support_read(args) + if sub == "status": + return _cmd_support_status(args) + if sub == "end": + return _cmd_support_end(args) + err("Usage: eesel support read --task ") + info(" e.g. eesel support read --task 1a2b3c4d-… agents list") + info(" also: eesel support status · eesel support end [--task ]") + return EXIT_USAGE + + +def _cmd_support_read(args) -> int: + inner_argv = list(getattr(args, "command", None) or []) + # An explicit `--` separator is allowed (and needed only when the read + # command's first token could look like a flag). + if inner_argv and inner_argv[0] == "--": + inner_argv = inner_argv[1:] + if not inner_argv: + err("No read command given.") + info("Usage: eesel support read --task ") + info(" e.g. eesel support read --task agents list") + info(" eesel support read --task files list") + return EXIT_USAGE + if "--task" in inner_argv or any(t.startswith("--task=") for t in inner_argv): + err("`--task` must come before the read command.") + info(" eesel support read --task agents list") + return EXIT_USAGE + + task_id = validate_id(args.task, "task id") + + # Parse and vet the inner command BEFORE minting: refusing `chat` shouldn't + # cost a mint, and every mint is an audited exchange of a customer's identity. + parser = build_parser(staff=False, platform_hint=None) + inner = parser.parse_args(_normalize_integrations_argv(_normalize_path_scope_argv(inner_argv))) + # `main()` already lifted the global output flags out of the whole argv and + # configured them, so carry the resolved values onto the inner namespace — + # `eesel support read --task X agents list --json` behaves as you'd expect. + for flag in ("json", "secrets", "legacy", "platform"): + setattr(inner, flag, bool(getattr(inner, flag, False)) or bool(getattr(args, flag, False))) + _guard_support_readonly_command(inner) + + caller = require_creds() + sess = None if getattr(args, "fresh", False) else load_support_session(caller["api_url"], task_id) + if sess is None: + sess = mint_support_readonly_token(caller, task_id) + _support_banner(sess) + if os.environ.get("EESEL_AGENT"): + warn("EESEL_AGENT is ignored in a support session — pass `--agent` to scope to one of " + "the customer's agents.") + + _arm_support_session(sess, _support_creds(caller, sess)) + # Resolved against the CUSTOMER's workspace: a customer on the legacy v2 + # platform supports only a subset of reads, and this gives the same friendly + # refusal there as it would running as them. + _guard_platform_command(inner, _support_creds_override) + return inner.func(inner) + + +def _cmd_support_status(args) -> int: + sessions = list_support_sessions() + task_id = getattr(args, "task", None) + if task_id: + sessions = [s for s in sessions if s.get("task_id") == task_id] + rows = [ + { + "task_id": s.get("task_id"), + "workspace_id": s.get("workspace_id"), + "api_url": s.get("api_url"), + "read_only": bool(s.get("read_only", True)), + "seconds_left": max(int(_support_session_seconds_left(s) + SUPPORT_TOKEN_SKEW_SECONDS), 0), + } + for s in sessions + ] + if _OUTPUT_FORMAT == "json" or getattr(args, "json", False): + emit(rows) # the token itself is never emitted + return 0 + if not rows: + info("No live support sessions.") + return 0 + for r in rows: + left = r["seconds_left"] + print(f"{r['task_id']} {r['workspace_id']} {left // 60}m{left % 60:02d}s left {r['api_url']}") + return 0 + + +def _cmd_support_end(args) -> int: + task_id = getattr(args, "task", None) + removed = clear_support_sessions(task_id=task_id) + scope = f"task {task_id}" if task_id else "all tasks" + ok(f"Dropped {removed} cached support session{'' if removed == 1 else 's'} ({scope}).") + return 0 + + def main(argv: list[str] | None = None) -> int: # Surface staff-only commands only for global impersonators. Read from the # cached creds flag (populated at login / refreshed by `whoami`) so this diff --git a/test_eesel.py b/test_eesel.py index 177e7fa..647c84b 100644 --- a/test_eesel.py +++ b/test_eesel.py @@ -11019,3 +11019,332 @@ def test_full_session_id_shown(self, tmp_config, fake_creds, monkeypatch, capsys assert eesel.cmd_tasks(_parse("tasks", "list")) == 0 out = capsys.readouterr().out assert a in out and b in out # both full ids present and distinguishable + + +# ────────────────────────────────────────────────────────────────────────── +# Support read-only sessions (`eesel support …`) +# +# The support side reads a CUSTOMER's workspace with a server-minted, 15-minute +# read-only token derived from a support conversation. These tests pin the two +# things that make that safe on the client: the minted token is never the +# ambient identity (it lives in its own short-TTL store, and `save_creds` +# refuses it), and nothing that could change the customer's account is allowed +# to run under it. +# ────────────────────────────────────────────────────────────────────────── + +SUPPORT_TASK = "task-1a2b3c4d" +SUPPORT_MINT_OK = { + "token": "readonly-jwt-for-customer", + "workspace_id": "ws-customer-999", + "expires_in": 900, + "read_only": True, +} + + +@pytest.fixture +def support_config(tmp_config, monkeypatch): + """Redirect the support-session store into the tmp config dir and make sure + no session is armed from an earlier test (the arming is process-global).""" + monkeypatch.setattr(eesel, "SUPPORT_SESSION_DIR", tmp_config / "support") + monkeypatch.setattr(eesel, "_support_session", None) + monkeypatch.setattr(eesel, "_support_creds_override", None) + monkeypatch.delenv("EESEL_AGENT", raising=False) + return tmp_config / "support" + + +def _stub_mint(monkeypatch, status=200, payload=None): + """Route the mint POST and record what was sent.""" + calls = [] + + def fake_allow(method, url, *, token=None, body=None, timeout=60): + calls.append({"method": method, "url": url, "token": token, "body": body}) + return status, (SUPPORT_MINT_OK if payload is None else payload) + + monkeypatch.setattr(eesel, "http_request_allow_error", fake_allow) + return calls + + +class TestSupportMintUrl: + def test_support_mint_url_matches_the_constant(self): + # Two spellings of one path (see the helper's comment): the schema test + # needs the literal, the write-backstop allowlist needs the constant. + assert eesel._support_mint_url("https://x") == "https://x" + eesel.SUPPORT_MINT_PATH + + +class TestSupportMint: + def test_mint_posts_only_the_task_id_as_the_caller(self, support_config, fake_creds, monkeypatch): + calls = _stub_mint(monkeypatch) + sess = eesel.mint_support_readonly_token(fake_creds, SUPPORT_TASK) + assert calls[0]["method"] == "POST" + assert calls[0]["url"].endswith("/support/mint-readonly-token") + # Authenticated as the SUPPORT workspace; the body names the + # conversation and nothing else — the target is server-derived, so + # there is no field here that could point it at another workspace. + assert calls[0]["token"] == fake_creds["token"] + assert calls[0]["body"] == {"task_id": SUPPORT_TASK} + assert sess["token"] == SUPPORT_MINT_OK["token"] + assert sess["workspace_id"] == "ws-customer-999" + + def test_minted_token_is_cached_private_to_the_user(self, support_config, fake_creds, monkeypatch): + _stub_mint(monkeypatch) + eesel.mint_support_readonly_token(fake_creds, SUPPORT_TASK) + files = list(support_config.glob("*.json")) + assert len(files) == 1 + # A customer-scoped credential on disk is 0600, like the creds file. + assert files[0].stat().st_mode & 0o777 == 0o600 + # ...and the task id is not readable from the filename. + assert SUPPORT_TASK not in files[0].name + + def test_a_second_read_reuses_the_cached_token(self, support_config, fake_creds, monkeypatch): + calls = _stub_mint(monkeypatch) + eesel.mint_support_readonly_token(fake_creds, SUPPORT_TASK) + again = eesel.load_support_session(fake_creds["api_url"], SUPPORT_TASK) + assert again["token"] == SUPPORT_MINT_OK["token"] + assert len(calls) == 1 # every mint costs the server two Auth0 lookups + + def test_the_cache_is_scoped_to_the_env(self, support_config, fake_creds, monkeypatch): + _stub_mint(monkeypatch) + eesel.mint_support_readonly_token(fake_creds, SUPPORT_TASK) + # Same task against a different backend must not hand back this token. + assert eesel.load_support_session("https://other.preprod.eesel.xyz", SUPPORT_TASK) is None + + def test_an_expired_token_is_dropped_not_returned(self, support_config, fake_creds, monkeypatch): + _stub_mint(monkeypatch) + eesel.mint_support_readonly_token(fake_creds, SUPPORT_TASK) + path = eesel._support_session_path(fake_creds["api_url"], SUPPORT_TASK) + sess = json.loads(path.read_text()) + sess["expires_at"] = int(time.time()) - 1 + path.write_text(json.dumps(sess)) + assert eesel.load_support_session(fake_creds["api_url"], SUPPORT_TASK) is None + # A spent customer token must not linger on disk. + assert not path.exists() + + def test_a_token_inside_the_skew_window_counts_as_spent(self, support_config, fake_creds, monkeypatch): + # Half a minute of life left is not enough to start a paged read with. + sess = {"token": "t", "expires_at": time.time() + 30} + assert eesel._support_session_seconds_left(sess) < 0 + + def test_server_ttl_is_capped_at_the_committed_fifteen_minutes(self, support_config, fake_creds, monkeypatch): + _stub_mint(monkeypatch, payload={**SUPPORT_MINT_OK, "expires_in": 86400}) + sess = eesel.mint_support_readonly_token(fake_creds, SUPPORT_TASK) + assert sess["expires_at"] - sess["minted_at"] == 900 + + @pytest.mark.parametrize("server_error,slug", [ + ("not authorized to mint support tokens", "caller_not_allowlisted"), + ("task is not in this support workspace", "task_not_in_support_workspace"), + ("could not establish a verified sender for this conversation", "no_verified_sender"), + ("sender is not a verified owner of an eesel workspace", "sender_not_verified_owner"), + ]) + def test_each_mint_refusal_is_explained_by_name(self, support_config, fake_creds, monkeypatch, capsys, server_error, slug): + # "no verified sender" and "not a verified owner" are EXPECTED outcomes + # for plenty of conversations — they have to read as an answer, not as a + # broken CLI, and carry a slug an agent can branch on. + _stub_mint(monkeypatch, status=403, payload={"error": server_error}) + with pytest.raises(SystemExit) as e: + eesel.mint_support_readonly_token(fake_creds, SUPPORT_TASK) + assert e.value.code == eesel.EXIT_AUTH + assert slug in capsys.readouterr().err + + def test_a_missing_endpoint_says_so(self, support_config, fake_creds, monkeypatch, capsys): + _stub_mint(monkeypatch, status=404, payload={}) + with pytest.raises(SystemExit): + eesel.mint_support_readonly_token(fake_creds, SUPPORT_TASK) + assert "endpoint_missing" in capsys.readouterr().err + + +class TestSupportCredsAreNeverAmbient: + def test_support_creds_are_refused_by_save_creds(self, support_config, fake_creds, monkeypatch): + _stub_mint(monkeypatch) + sess = eesel.mint_support_readonly_token(fake_creds, SUPPORT_TASK) + creds = eesel._support_creds(fake_creds, sess) + assert creds["ephemeral"] is True + assert creds["workspace_id"] == "ws-customer-999" + # The operator's own login must survive a support read untouched — this + # is what stops the customer's workspace becoming the ambient identity. + eesel.save_creds(creds) + assert json.loads(eesel.CREDS_FILE.read_text())["workspace_id"] == fake_creds["workspace_id"] + + def test_support_creds_cannot_be_renewed(self, support_config, fake_creds, monkeypatch): + _stub_mint(monkeypatch) + sess = eesel.mint_support_readonly_token(fake_creds, SUPPORT_TASK) + # No refresh token: nothing tries to silently extend a customer-scoped + # credential past its 15 minutes. + assert "refresh_token" not in eesel._support_creds(fake_creds, sess) + + def test_require_creds_returns_the_armed_session(self, support_config, fake_creds, monkeypatch): + monkeypatch.setattr(eesel, "_support_creds_override", {"token": "readonly", "api_url": "x"}) + assert eesel.require_creds()["token"] == "readonly" + + def test_arming_a_session_never_reveals_customer_secrets(self, support_config, fake_creds, monkeypatch): + monkeypatch.setattr(eesel, "_REVEAL_SECRETS", True) # e.g. a sysadmin ran --secrets + eesel._arm_support_session({"task_id": SUPPORT_TASK, "workspace_id": "ws-customer-999", + "token": "t", "expires_at": time.time() + 900}, + {"token": "t"}) + assert eesel._REVEAL_SECRETS is False + + +class TestSupportWriteRefusals: + """Three client-side layers, none of them the guarantee — the server refuses + every mutating method on a read-only token. These exist so a refusal reads + as a refusal instead of a raw 401.""" + + def _armed(self, monkeypatch): + monkeypatch.setattr(eesel, "_support_session", { + "task_id": SUPPORT_TASK, "workspace_id": "ws-customer-999", + "token": "t", "expires_at": time.time() + 900}) + + def test_a_write_command_is_refused_before_any_call(self, support_config, monkeypatch, capsys): + with pytest.raises(SystemExit) as e: + eesel._guard_support_readonly_command(_parse("agents", "create", "--name", "X")) + assert e.value.code == eesel.EXIT_IMPERSONATION_BLOCKED + assert "read-only support session" in capsys.readouterr().err + + @pytest.mark.parametrize("argv", [ + ("chat", "hello"), # would run and BILL a turn in their workspace + ("new",), + ("login",), # would touch the operator's own credentials + ("logout",), + ("link", "https://x.preprod.eesel.xyz"), + ("impersonate", "auth0|x"), + ("support", "status"), # no recursion + ]) + def test_only_reads_of_the_customer_are_offered(self, support_config, capsys, argv): + with pytest.raises(SystemExit) as e: + eesel._guard_support_readonly_command(_parse(*argv)) + assert e.value.code == eesel.EXIT_IMPERSONATION_BLOCKED + assert "isn't a read" in capsys.readouterr().err + + @pytest.mark.parametrize("argv", [ + ("agents", "list"), ("files", "list"), ("tasks", "list"), + ("automations", "triggers", "list"), ("skills", "list"), + ("workspace", "show"), ("billing", "show", "usage"), ("integrations", "list"), + ]) + def test_the_reads_support_actually_needs_are_allowed(self, support_config, argv): + eesel._guard_support_readonly_command(_parse(*argv)) # no exit + + def test_mcp_token_is_refused_because_it_mints(self, support_config, capsys): + with pytest.raises(SystemExit): + eesel._guard_support_readonly_command(_parse("mcp", "token")) + assert "mints a workspace token" in capsys.readouterr().err + + def test_the_backstop_refuses_a_mutating_request(self, support_config, monkeypatch, capsys): + self._armed(monkeypatch) + with pytest.raises(SystemExit) as e: + eesel._support_readonly_write_backstop("DELETE", "https://api/agents/a1") + assert e.value.code == eesel.EXIT_IMPERSONATION_BLOCKED + err = capsys.readouterr().err + # Names the endpoint, because a READ served over POST needs a + # server-side exemption and guessing which one is the slow way. + assert "/agents/a1" in err + + def test_the_backstop_allows_reads_and_the_re_mint(self, support_config, monkeypatch): + self._armed(monkeypatch) + eesel._support_readonly_write_backstop("GET", "https://api/agents") + eesel._support_readonly_write_backstop("POST", "https://api" + eesel.SUPPORT_MINT_PATH) + + def test_the_backstop_is_inert_with_no_session(self, support_config): + eesel._support_readonly_write_backstop("DELETE", "https://api/agents/a1") + + def test_a_server_read_only_refusal_is_explained(self, support_config, monkeypatch, capsys): + self._armed(monkeypatch) + eesel._support_readonly_note(401, '{"message": "This token is read-only and cannot perform this action."}') + assert "blocked that as a write" in capsys.readouterr().err + + def test_a_plain_401_points_at_the_fifteen_minute_expiry(self, support_config, monkeypatch, capsys): + self._armed(monkeypatch) + eesel._support_readonly_note(401, '{"message": "Unauthorized"}') + assert "15 minutes" in capsys.readouterr().err + + def test_the_note_is_silent_outside_a_support_session(self, support_config, capsys): + eesel._support_readonly_note(401, "read-only") + assert capsys.readouterr().err == "" + + +class TestSupportReadEndToEnd: + def test_the_read_runs_against_the_customer_with_the_minted_token(self, support_config, fake_creds, monkeypatch, capsys): + sent = [] + + def fake_urlopen(req, timeout=None): + sent.append({"method": req.get_method(), "url": req.full_url, + "token": req.headers.get("Authorization")}) + resp = _FakeResp(SUPPORT_MINT_OK if req.full_url.endswith(eesel.SUPPORT_MINT_PATH) + else {"agents": [{"agent_id": "agent-cust-1", "name": "Their Bot", + "is_active": True}]}) + resp.status = 200 # http_request_allow_error reads it; _FakeResp is minimal + return resp + + # Patch the transport, not http_request, so the real write-backstop and + # 401 handling stay in the path under test. + monkeypatch.setattr(eesel.urllib.request, "urlopen", fake_urlopen) + assert eesel.main(["support", "read", "--task", SUPPORT_TASK, "agents", "list"]) == 0 + mint, read = sent[0], sent[1] + assert mint["url"].endswith(eesel.SUPPORT_MINT_PATH) + assert mint["token"] == f"Bearer {fake_creds['token']}" # minted AS support + assert read["method"] == "GET" + assert read["token"] == f"Bearer {SUPPORT_MINT_OK['token']}" # read AS the customer + out, err = capsys.readouterr() + assert "Their Bot" in out + assert "support read-only" in err # the session is visible + + def test_a_refused_command_never_mints(self, support_config, fake_creds, monkeypatch): + monkeypatch.setattr(eesel.urllib.request, "urlopen", + lambda *a, **k: pytest.fail("nothing should be sent")) + with pytest.raises(SystemExit) as e: + eesel.main(["support", "read", "--task", SUPPORT_TASK, "chat", "hi"]) + assert e.value.code == eesel.EXIT_IMPERSONATION_BLOCKED + + def test_task_flag_after_the_command_is_a_clear_usage_error(self, support_config, fake_creds, capsys): + assert eesel.main(["support", "read", "--task", SUPPORT_TASK, "agents", "list", + "--task", "other"]) == eesel.EXIT_USAGE + assert "must come before" in capsys.readouterr().err + + def test_no_read_command_is_a_usage_error(self, support_config, fake_creds, capsys): + assert eesel.main(["support", "read", "--task", SUPPORT_TASK]) == eesel.EXIT_USAGE + assert "No read command" in capsys.readouterr().err + + +class TestSupportStatusAndEnd: + def _seed(self, monkeypatch, fake_creds, task, workspace, seconds=900): + eesel.save_support_session({ + "task_id": task, "api_url": fake_creds["api_url"], "workspace_id": workspace, + "token": "secret-readonly-jwt", "read_only": True, + "minted_at": int(time.time()), "expires_at": int(time.time()) + seconds, + }) + + def test_status_lists_live_sessions_without_the_token(self, support_config, fake_creds, monkeypatch, capsys): + self._seed(monkeypatch, fake_creds, SUPPORT_TASK, "ws-customer-999") + assert eesel.cmd_support(_parse("support", "status", "--json")) == 0 + out = capsys.readouterr().out + assert "ws-customer-999" in out + # The token is a customer credential; `status` must never print it. + assert "secret-readonly-jwt" not in out + + def test_status_purges_what_has_expired(self, support_config, fake_creds, monkeypatch, capsys): + self._seed(monkeypatch, fake_creds, SUPPORT_TASK, "ws-customer-999", seconds=-10) + assert eesel.cmd_support(_parse("support", "status")) == 0 + assert list(support_config.glob("*.json")) == [] + + def test_end_drops_one_task(self, support_config, fake_creds, monkeypatch): + self._seed(monkeypatch, fake_creds, SUPPORT_TASK, "ws-customer-999") + self._seed(monkeypatch, fake_creds, "task-other", "ws-customer-888") + assert eesel.cmd_support(_parse("support", "end", "--task", SUPPORT_TASK)) == 0 + left = eesel.list_support_sessions() + assert [s["task_id"] for s in left] == ["task-other"] + + def test_end_drops_everything_by_default(self, support_config, fake_creds, monkeypatch): + self._seed(monkeypatch, fake_creds, SUPPORT_TASK, "ws-customer-999") + self._seed(monkeypatch, fake_creds, "task-other", "ws-customer-888") + assert eesel.cmd_support(_parse("support", "end")) == 0 + assert eesel.list_support_sessions() == [] + + +class TestSupportDiscoverability: + def test_schema_always_lists_support_so_an_agent_can_find_it(self): + schema = eesel._serialize_parser(eesel.build_parser(staff=True)) + assert "read" in schema["subcommands"]["support"]["subcommands"] + + def test_support_is_hidden_from_help_for_a_normal_login(self, capsys): + # Staff-gated in `--help` exactly like `impersonate`; the server-side + # support allowlist is the real gate. + assert "support" not in eesel.build_parser(staff=False).format_help() + assert "support" in eesel.build_parser(staff=True).format_help()