From 4129f7ad350bf2dbf1936b43de4818e9e9d335d5 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:28:27 +0900 Subject: [PATCH] Fix: Refuse a Kaggle notebook owner that is an email address Notebook deletes answered 403 because the account's username file held the owner's email address, so kernels delete named a notebook that does not exist. Login now refuses an owner containing @, whitespace or a slash, open_channel refuses a stored one with the fix command, and the 403 delete warning names the owner rule instead of blaming the token's permissions. --- docs/SPEC.md | 4 ++-- letify/config/login.py | 4 ++++ letify/providers/kaggle.py | 41 ++++++++++++++++++++++++++++++++++---- tests/test_kaggle.py | 33 +++++++++++++++++++++++++++++- 4 files changed, 75 insertions(+), 7 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index a45a054..7072f2a 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -290,7 +290,7 @@ Not opening a link is a decision of this project, not a rule of Kaggle's. Two so - `TPU_V3_8` is a TPU instance, carried by `Instance.tpu` rather than `Instance.gpu`, and requests `TPU_V3_8` as the accelerator in `CommitAndRun`. With `host="remote"`, letify ships the whole function to the runtime. Using the TPU is the user's code's responsibility, with `torch-xla` or JAX in the user's own lock file; letify neither installs nor checks those TPU libraries. letify requests and reports the TPU, without making the user's code TPU-capable. Kaggle refuses `host="local"` for TPU instances as it does for every other Kaggle instance. - When no account inventory is configured, the default inventory takes each discovered instance's device count, so the two-card `T4` shape can be reserved. An explicit account inventory keeps its configured counts and indices. - **Two credentials, both required.** The cookie starts and ends the interactive session and mints its Jupyter proxy URL. The API token authenticates every official Kaggle CLI invocation through `KAGGLE_API_TOKEN` in the child environment, never process arguments, for notebook deletion and weekly quota. There is no cookie fallback. Login stores the cookie in `~/.letify/accounts//cookie` and the verbatim token, including `KGAT_`, in `access_token` beside it, using owner-only files (0600) and an owner-only directory (0700). The CLI accepts no working file-based token form, so keeping the token out of the environment cannot be honoured. Its child process environment is the narrowest available scope and exposes less than the argument list. An account without `access_token` is refused with `ConfigError` naming the login command when it reaches a CLI-only path. -- **Token input and notebook owner.** `letify login kaggle ` accepts `--key` or hidden input asking for the API token exactly as kaggle.com shows it, including `KGAT_`. It requires that prefix and a nonempty body with no whitespace; it does not strip or reformat the token. Length is not fixed because the measured 32-character body establishes a working token, not a universal length contract. A malformed token is refused with a message naming what to paste, before any cookie verification or file write. The token is not verified online at login. A renewal asks for each credential rather than reusing what is on disk, and a blank answer keeps the stored one. The two credentials expire on different clocks: the cookie is a thirty day web session and the token lasts until it is rotated, so renewing one must not demand the other. `letify login kaggle --replace` therefore prompts for the token and then the cookie, each saying that blank keeps what is stored, and writes only what was answered. A blank answer with nothing stored is refused, naming what to paste. Silently reusing a stored credential would make a renewal a no-op with no way to replace a credential that stopped working. Login retains `--username` or a username prompt and stores it in `username` beside the credentials with mode 0600. This is only the owner for `kernels delete -y /`, not an authentication credential. Explicit input avoids another authenticated lookup and does not mistake a cookie display name for an owner. The owner is not read from an authenticated call. Reading an existing `access_token` file strips leading and trailing whitespace, including a trailing newline, before CLI calls or reuse by login when `--key` is omitted. Explicit `--key` and prompted token input still reject whitespace rather than reformatting it, so adding an owner with `--username` does not require pasting the token again. The owner is a public Kaggle username, not a secret, and its file uses the same owner-only permissions as the account directory. Login refuses a missing or blank owner before cookie verification or any file write. Nothing is written until both credentials, the owner and cookie verification pass. Before any session starts or notebook is created, `open_channel` requires a readable, nonempty `username` file. An incomplete account is refused with `ConfigError` naming the login command, so a run cannot reach teardown without a notebook owner. +- **Token input and notebook owner.** `letify login kaggle ` accepts `--key` or hidden input asking for the API token exactly as kaggle.com shows it, including `KGAT_`. It requires that prefix and a nonempty body with no whitespace; it does not strip or reformat the token. Length is not fixed because the measured 32-character body establishes a working token, not a universal length contract. A malformed token is refused with a message naming what to paste, before any cookie verification or file write. The token is not verified online at login. A renewal asks for each credential rather than reusing what is on disk, and a blank answer keeps the stored one. The two credentials expire on different clocks: the cookie is a thirty day web session and the token lasts until it is rotated, so renewing one must not demand the other. `letify login kaggle --replace` therefore prompts for the token and then the cookie, each saying that blank keeps what is stored, and writes only what was answered. A blank answer with nothing stored is refused, naming what to paste. Silently reusing a stored credential would make a renewal a no-op with no way to replace a credential that stopped working. Login retains `--username` or a username prompt and stores it in `username` beside the credentials with mode 0600. This is only the owner for `kernels delete -y /`, not an authentication credential. Explicit input avoids another authenticated lookup and does not mistake a cookie display name for an owner. The owner is not read from an authenticated call. Reading an existing `access_token` file strips leading and trailing whitespace, including a trailing newline, before CLI calls or reuse by login when `--key` is omitted. Explicit `--key` and prompted token input still reject whitespace rather than reformatting it, so adding an owner with `--username` does not require pasting the token again. The owner is a public Kaggle username, not a secret, and its file uses the same owner-only permissions as the account directory. Login refuses a missing or blank owner before cookie verification or any file write. Login also refuses an owner that is not a Kaggle username: one containing `@`, whitespace or `/`. An email address is the common mistake, and `kernels delete -y /` names a notebook that does not exist, which Kaggle answers with 403. The refusal says ` looks like an email address; enter the Kaggle username shown in your profile URL kaggle.com/`, and no fuller charset rule is imposed. Nothing is written until both credentials, the owner and cookie verification pass. Before any session starts or notebook is created, `open_channel` requires a readable, nonempty `username` file. An incomplete account is refused with `ConfigError` naming the login command, so a run cannot reach teardown without a notebook owner. A stored owner containing `@`, whitespace or `/` is refused the same way before any notebook is created, with the command that fixes it: `letify login kaggle --replace --username `, where blank answers keep the stored cookie and token. - **The cookie carries an ``exp`` claim, but that claim is not proof the cookie still works.** The `CLIENT-TOKEN` cookie is an `alg:none` JWT whose `exp` claim was thirty days after the login that minted it, in every case observed. But a live account has been seen with over nine days left on that claim while every internal call, including the plain `users.UsersService/GetCurrentUser` read, answered as a refused, anonymous session: Kaggle invalidates a cookie server side, for reasons this project has not pinned down, without the claim changing to say so. So `exp` is useful for exactly one direction: a cookie past its `exp` is certainly dead. It says nothing about a cookie before its `exp`, which may be live or may already be refused. - **Two checks, for two costs.** An offline check reads only the claim, costs no network call, and is what `letify providers` shows as the account's `exp claim says N days left (not checked live; see letify usage)`, or `EXPIRED` once `exp` has passed. An online check additionally calls `GetCurrentUser`, the same call the login proved the cookie with, and is made once per session start (`open_channel`) and once per `letify usage` read, each result trusted for sixty seconds so starting several runtimes together costs one round trip rather than one per runtime. A run whose cookie fails either check is refused with `ConfigError`: missing or past `exp` says exactly that; refused online says "the Kaggle cookie has not expired, but Kaggle no longer accepts it", which is the one fact that check establishes, never a guess at why. A run is also refused when fewer than one hour is left on the `exp` claim, since starting a session the claim says the cookie cannot outlive risks it dying mid run with no way to tell that apart from any other lost runtime; this offline guard and the online check both have to pass. - **A refused session can answer HTTP 400 with an empty body.** When the `XSRF-TOKEN` cookie in the stored cookie is no longer the one Kaggle issued for the session, `GetCurrentUser` answers HTTP 400, 401 or 403 instead of an empty account. The online check treats those three statuses as Kaggle refusing the cookie and reports `the Kaggle cookie was refused (HTTP )` with the instruction to log in again. Any other HTTP status, such as 500, and any transport failure is not an answer about the cookie: the check reports `the Kaggle cookie could not be checked: HTTP ` or the exception class name, and the run fails as an infrastructure failure rather than as a bad cookie. @@ -361,7 +361,7 @@ it leaves it calling a runtime that is gone. So the line names the way out, `sen process that started it, which letify handles by closing its sessions`, and then what `--orphans` is for. A bare `stop` otherwise reads as ending everything. The command lists what it will delete and asks before deleting, `-y` answers yes, and `--alias` limits it to one account. Each ref is deleted with the same CLI call `stop` uses and dropped from the record only when the delete succeeds, so a failure leaves the ref to be tried again rather than losing track of it. The exit status is 1 when any delete failed. -**Deleting the notebook, through the CLI only.** `stop` deletes the notebook with the official CLI, `kaggle kernels delete -y /`, authenticated by the account's API token in `KAGGLE_API_TOKEN`; deletion of a notebook created for a cookie-authenticated interactive session is confirmed on the live account. The cookie is never asked, not even as a fallback, because deleting a notebook is not part of running or ending the interactive session. The call never fails the runtime, because the accelerator quota is already released by cancelling the run before it is tried. When it cannot run at all, for want of a token, of uv, or of the slug Kaggle did not return, or when the command itself fails, `stop` prints one warning line naming the notebook by its slug or id, never its cookie, so the user can delete it by hand on kaggle.com. When the command ran and failed, the warning also carries the first nonblank line of the CLI's standard error, which holds the HTTP status and Kaggle's reason, for example `403 Client Error: Forbidden for url: .../DeleteKernel`, and when that line contains `403` it adds that the account's API token may lack the permission to delete notebooks, so the token is re-created on kaggle.com with that permission. The same text is kept for a token that lists notebooks but is refused `kernels.get`, which is the failure measured on account `kaggle_60` on 2026-11-08, where `kaggle kernels delete`, `status` and `pull` all answered 403 while `kernels list --mine` succeeded with the same token. The chain that starts the session, all on `https://www.kaggle.com/api/i/`, authenticated by the cookie: +**Deleting the notebook, through the CLI only.** `stop` deletes the notebook with the official CLI, `kaggle kernels delete -y /`, authenticated by the account's API token in `KAGGLE_API_TOKEN`; deletion of a notebook created for a cookie-authenticated interactive session is confirmed on the live account. The cookie is never asked, not even as a fallback, because deleting a notebook is not part of running or ending the interactive session. The call never fails the runtime, because the accelerator quota is already released by cancelling the run before it is tried. When it cannot run at all, for want of a token, of uv, or of the slug Kaggle did not return, or when the command itself fails, `stop` prints one warning line naming the notebook by its slug or id, never its cookie, so the user can delete it by hand on kaggle.com. When the command ran and failed, the warning also carries the first nonblank line of the CLI's standard error, which holds the HTTP status and Kaggle's reason, for example `403 Client Error: Forbidden for url: .../DeleteKernel`, and when that line contains `403` it adds that Kaggle refused the notebook `/`, that the owner must be the Kaggle username and not an email, and the fix command `letify login kaggle --replace --username `. The cause measured on 2026-11-08 on account `kaggle_60` was the stored owner being the owner's email address: `kaggle kernels delete`, `status` and `pull` answered 403 while `kernels list --mine` succeeded with the same token, and deleting the same three notebooks with the Kaggle username succeeded with that token. The chain that starts the session, all on `https://www.kaggle.com/api/i/`, authenticated by the cookie: 1. `GetOrCreateKernelSession {kernelId}` reads the notebook's draft sequence. 2. `CommitAndRun` commits a trivial notebook body and starts the interactive session, returning the run id. This is what the editor's Run does. `CreateKernelSession` on an empty notebook wedges it, so `CommitAndRun` is the call that reliably starts a session letify controls. Empty compute is a CPU session; an accelerator name (`NVIDIA_TESLA_T4`) asks for that card. diff --git a/letify/config/login.py b/letify/config/login.py index 08af5e7..4ab8923 100644 --- a/letify/config/login.py +++ b/letify/config/login.py @@ -840,6 +840,7 @@ def read_kaggle_token( username = (given_username or "").strip() if not username and answers.interactive: username = read_line(KAGGLE_OWNER_PROMPT).strip() + from ..providers import kaggle as kg from ..providers.kaggle import read_api_token stored = read_api_token(answers.alias) @@ -856,6 +857,9 @@ def read_kaggle_token( f"{answers.alias} needs the Kaggle API token and notebook owner: pass " f"--username and --key from kaggle.com, or drop --no-input." ) + refusal = kg.owner_refusal(username) + if refusal is not None: + raise LoginError(f"{answers.alias}: {refusal}") if not key.startswith("KGAT_") or len(key) <= len("KGAT_") or any( character.isspace() for character in key ): diff --git a/letify/providers/kaggle.py b/letify/providers/kaggle.py index 2d70c73..cfd05e5 100644 --- a/letify/providers/kaggle.py +++ b/letify/providers/kaggle.py @@ -590,6 +590,30 @@ def read_notebook_owner(alias: str) -> str | None: return owner or None +def owner_refusal(owner: str) -> str | None: + """Say why ``owner`` cannot be a Kaggle username, or return None when it can. + + Spec "Kaggle" token input and notebook owner: only ``@``, whitespace and ``/`` are + refused, because the spec states no fuller charset rule. + """ + if "@" in owner: + return ( + f"{owner} looks like an email address; enter the Kaggle username shown in " + f"your profile URL kaggle.com/" + ) + if "/" in owner or any(character.isspace() for character in owner): + return ( + f"{owner} is not a Kaggle username; enter the username shown in your " + f"profile URL kaggle.com/, with no whitespace or slash" + ) + return None + + +def owner_fix_command(alias: str) -> str: + """The command that replaces a wrong stored owner and keeps the credentials.""" + return f"letify login kaggle {alias} --replace --username " + + def notebook_record(alias: str) -> Path: """Where the refs of the notebooks letify created on this account are kept.""" return account_directory(alias) / "notebooks" @@ -865,7 +889,8 @@ def delete_notebook_best_effort(alias: str, kernel_id: int, slug: str | None) -> Never raises. A notebook the CLI could not delete, for want of a token, of uv, of a slug, or because the command itself failed, is reported with a warning naming it by slug or id, never by its cookie, so the user can delete it by hand on kaggle.com. A - command that ran and failed adds what the CLI said, and a 403 adds the likely cause. + command that ran and failed adds what the CLI said, and a 403 adds that the owner must be + the Kaggle username, not an email, with the command that fixes it. """ reason = delete_notebook_failure(alias, slug) if reason is None: @@ -876,9 +901,10 @@ def delete_notebook_best_effort(alias: str, kernel_id: int, slug: str | None) -> name = f"slug {slug}" if slug else f"id {kernel_id}" detail = f": {reason}" if reason else "" if "403" in reason: + owner = read_notebook_owner(alias) or "" detail += ( - ". The account's API token may lack the permission to delete notebooks; " - "create a new token on kaggle.com with that permission" + f". Kaggle refused the notebook {owner}/{slug or kernel_id}; the owner must be " + f"the Kaggle username, not an email. Fix it with: {owner_fix_command(alias)}" ) print( f"letify: {alias}: could not delete the Kaggle notebook ({name}){detail}; delete it " @@ -1720,11 +1746,18 @@ def open_channel(self, runtime: Runtime) -> Channel: The notebook is created fresh for this runtime alone, never reused from an earlier one: spec "Kaggle session token chain". """ - if read_notebook_owner(self.alias) is None: + owner = read_notebook_owner(self.alias) + if owner is None: raise ConfigError( f"{self.alias} has no Kaggle notebook owner. Log in again with: " f"letify login kaggle {self.alias} --username " ) + refusal = owner_refusal(owner) + if refusal is not None: + raise ConfigError( + f"{self.alias}: the stored notebook owner is wrong: {refusal}. " + f"Fix it with: {owner_fix_command(self.alias)}" + ) cookie = self._require_live_cookie() self._leave_a_gap() instance = getattr(runtime, "instance", None) diff --git a/tests/test_kaggle.py b/tests/test_kaggle.py index 12a7c64..9b51b11 100644 --- a/tests/test_kaggle.py +++ b/tests/test_kaggle.py @@ -141,6 +141,34 @@ def test_login_refuses_a_missing_owner_before_declaring_the_account( assert accept_cookie == [] +@pytest.mark.parametrize("owner", ["me@gmail.com", "a b", "me/slug"]) +def test_login_refuses_an_owner_that_is_not_a_kaggle_username( + isolated_home, accept_cookie, capsys, owner +) -> None: + """Spec "Kaggle" token input and notebook owner: an email address is not an owner.""" + assert main(["login", "kaggle", "kaggle_a", "--cookie", make_cookie(), + "--key", API_TOKEN, "--username", owner, "--no-input"]) == 1 + err = capsys.readouterr().err + assert "kaggle.com/" in err + assert not account("kaggle_a").exists() + assert not (Path.home() / ".letify" / "config.toml").exists() + assert accept_cookie == [] + + +def test_a_session_with_a_stored_email_owner_is_refused_before_creating_a_notebook( + fake_kaggle +) -> None: + """Spec "Kaggle" token input and notebook owner: a stored email owner names no notebook.""" + from letify.errors import ConfigError + + (account("kaggle_a") / "username").write_text("me@gmail.com", encoding="utf-8") + pattern = r"letify login kaggle kaggle_a --replace --username " + with pytest.raises(ConfigError, match=pattern): + session_channel(fake_kaggle) + assert fake_kaggle.cloud_calls == [] + assert fake_kaggle.kernels == set() + + def test_an_expired_cookie_is_refused_with_nothing_written( isolated_home, accept_cookie, capsys ) -> None: @@ -964,7 +992,10 @@ def test_a_failed_cli_delete_warning_carries_the_http_status_and_reason( err = capsys.readouterr().err assert "403 Client Error: Forbidden" in err assert "DeleteKernel" in err - assert "API token" in err + assert "irack000/" in err + assert "Kaggle username, not an email" in err + assert "letify login kaggle kaggle_a --replace --username " in err + assert "lack" not in err assert "delete it by hand" in err assert fake_kaggle.token not in err