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
64 changes: 64 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,69 @@
# Changelog

## Unreleased (v2.2.0 — Phase 1 + Phase 1.2.1 UX polish in progress)

### Added — Phase 1.2.1 UX polish (chat-pipe / `tdpilot_API.tox`)

Three real friction points the live-debug session exposed:

1. **`TDPILOT_API_INSECURE` was a process env var** — set in Textport, gone on TD restart. Users got 401 after every restart with no clear remedy.
2. **Pasting a new `Apikey` value required two follow-up pulses** (`Saveapikey` then `Reloadconfig`) — non-obvious; lots of "key is set but the agent doesn't see it" confusion.
3. **After every `.tox` rebuild the chat panel's token rotated** — every open browser tab 401'd until the user knew to navigate to `http://127.0.0.1:9987/` for a fresh one.

This release addresses all three:

**1. `Authmode` COMP param replaces the env var as source of truth.**

- New `Authmode` Menu param under the API page on `tdpilot_API`. Values: `open` (default) / `token`.
- `tdpilot_api_web_callbacks._insecure_mode` reads the COMP param first; env-var becomes a fallback for back-compat. Each `/send` request re-reads the param, so flipping it takes effect immediately (no Reloadconfig needed).
- Persists in the `.toe` → survives every TD restart. **Drag in + paste key + done forever.**
- **Default is `open`**: the chat-pipe webserver doesn't require `X-TDPilot-Token` on `/send`. The **origin allowlist still enforces single-machine isolation** — a malicious cross-origin browser tab can't drive the chat-pipe even in open mode. Suitable for TouchDesigner's "single-user dev / live performance on a personal box" usage profile.
- Users who run on a shared / LAN-exposed machine flip `Authmode = token` once in the param panel — the v2.1.3 token security model kicks back in.

**2. Auto-save + auto-reload on `Apikey` value change.**

- `tdpilot_api_parexec` now listens to `valuechange` events (added via `_wire_parexec` in `build_tdpilot_api_tox.py`). Filter is narrow: only `Apikey` and `Authmode` route to the extension; every other value change is a no-op.
- New `Extension.OnApikeyValueChange(par)` delegates to `OnSaveApiKeyPulse` (which already writes to `~/.tdpilot-api/api_key` + calls `runtime.reload_config()`). User pastes a key → it works. Zero pulses.
- New `Extension.OnAuthmodeValueChange(par, prev)` updates the status line so the user sees their toggle land ("auth mode: OPEN (no token required)" / "auth mode: TOKEN (X-TDPilot-Token required)").
- Recursion-safe: the empty-string short-circuit in `OnApikeyValueChange` prevents the value-change-fires-again loop after `OnSaveApiKeyPulse` clears `Apikey.val`.

**3. Stale-token recovery banner in the chat panel.**

- New `appendReconnectBanner()` JS helper in `tdpilot_api_chat.html`. When `fetch('/send')` returns 401 (the canonical "token rotated, panel is stale" signal), the panel renders a yellow message with a real `<button>` that calls `window.location.reload()`.
- Reload re-fetches `GET /`, which already bakes the current token into the served HTML — bookmark-friendly URL `http://127.0.0.1:9987/` always serves a working panel.
- Non-401 errors (port closed, TD not running, etc.) still flow through the generic `appendMessage('error', ...)` path.

#### Files

- `td_component/build_tdpilot_api_tox.py` — adds the `Authmode` param under a new `Authhdr` header on the API page; `_wire_parexec` enables `valuechange=1`.
- `td_component/tdpilot_api_web_callbacks.py` — `_insecure_mode` reads `Authmode` COMP param first; env var becomes fallback.
- `td_component/tdpilot_api_parexec.py` — `onValueChange` filter routes `Apikey` / `Authmode` to extension methods.
- `td_component/tdpilot_api_extension.py` — new `OnApikeyValueChange` / `OnAuthmodeValueChange` methods.
- `td_component/tdpilot_api_chat.html` — new `appendReconnectBanner()` JS helper; `/send` catch branches on `401` substring.

#### Tests (17 new in `tests/test_v221_authmode_and_autoreload.py`)

- **`TestInsecureModeFromAuthmode` (6)** — Authmode=open/token/whitespace+case/unknown-falls-through; COMP param beats env var.
- **`TestBackwardsCompatibility` (2)** — old .tox (no Authmode param) still works via env var; `_comp()` returning None doesn't raise.
- **`TestAuthGateEndToEnd` (3)** — Authmode=open lets tokenless `/send` through; Authmode=token blocks; open mode still rejects cross-origin (origin allowlist preserved).
- **`TestParexecValueChangeRouting` (5)** — Apikey/Authmode route correctly; other params are no-op; missing extension or raising handler doesn't crash the cook thread.
- **`TestApikeyEmptyShortCircuit` (1)** — verifies the empty-string guard prevents recursion through `OnSaveApiKeyPulse`'s `Apikey.val = ""` wipe.

#### Net user experience

- **Brand-new user**: drag .tox in → paste DeepSeek key into the `Apikey` param → it works. Zero pulses, zero env-var dance.
- **Returning user** (saved their .toe): open TD → .toe loads → chat works. The Authmode=Open setting + the saved API key on disk both persist.
- **Dev rebuilding the .tox**: rebuild → old browser tabs get the yellow "Reconnect" banner → click → they're back. No need to know about `Openpanelnow`.
- **Security-conscious user**: flips `Authmode = token` once in the COMP param. Token-required behaviour kicks back in; persists in the .toe.

#### Migration note

The default chat-pipe webserver auth posture changes from token-required to origin-allowlist-only. Users who shared their `.toe` with a colleague to run on a separate machine and were relying on the v2.1.3 token gate must explicitly flip `Authmode = token` on the COMP for that deployment. The default change is documented under the COMP param's tooltip; the `Authhdr` section title flags "Auth (chat-pipe webserver)" so users see the surface when configuring the COMP.

The MCP-server (`tdpilot-dpsk4.tox`, port 9985) auth is **untouched** by this PR — it continues to require `TD_MCP_SHARED_SECRET`. Phase 1.2.1 is chat-pipe-only.

---

## Unreleased (v2.2.0 — Phase 1 in progress)

**v2.2.0 will be the first release of the v2.2.0→v3.0 roadmap (see
Expand Down
4 changes: 2 additions & 2 deletions td_component/.tox-api-source-hash.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"tox_source_hash": "0f6a0f482630993dfb0d78798ae04cd517b845e6ff8892b317de796cc873bb0c",
"built_at": "2026-05-11T14:51:07.216116+00:00",
"tox_source_hash": "a143d3dfc35ee7019c6df49363f0e260883f0b5da598bf8695e69accb8f4c090",
"built_at": "2026-05-11T15:39:32.607427+00:00",
"source_files": [
"td_component/tdpilot_api_agent.py",
"td_component/tdpilot_api_dispatcher.py",
Expand Down
22 changes: 21 additions & 1 deletion td_component/build_tdpilot_api_tox.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,22 @@ def _load_legacy_module():
("Autoopenpanel", "Toggle", "Auto-open chat in browser on load", True),
("Openpanelnow", "Pulse", "Open Chat in Browser", None),
("Reloadconfig", "Pulse", "Reload Config", None),
# Phase 1.2.1 (v2.2.1) — auth mode toggle.
#
# "open" (default): no X-TDPilot-Token check on /send. The
# origin allowlist still rejects cross-origin browser CSRF, so
# this is safe on a single-user dev/perform box. New users
# get drag-and-go without an auth dance.
#
# "token": original v2.1.3 behaviour — every /send POST must
# carry a matching X-TDPilot-Token header. Use this if you
# expose the chat panel over a LAN or share the machine.
#
# The webserver's auth check reads this param on every request
# (no Reloadconfig required to flip), and the param persists
# in the .toe so flipping once is forever.
("Authhdr", "Header", "Auth (chat-pipe webserver)", None),
("Authmode", "Menu", "Auth Mode", ("open", "token")),
]

_CHAT_PAGE = [
Expand Down Expand Up @@ -440,8 +456,12 @@ def _wire_parexec(parexec_dat, parent_comp):
_legacy._set_first_par(parexec_dat, ("custom",), 1)
_legacy._set_first_par(parexec_dat, ("builtin",), 0)
_legacy._set_first_par(parexec_dat, ("onpulse",), 1)
# Phase 1.2.1: turn value-change ON so onValueChange fires for
# Apikey / Authmode auto-routing. The handler in
# tdpilot_api_parexec.py filters by name — only those two trigger
# work; every other value change is a no-op.
_legacy._set_first_par(parexec_dat, ("valuechange",), 1)
for off in (
"valuechange",
"valueschanged",
"expressionchange",
"exportchange",
Expand Down
Binary file modified td_component/tdpilot_API.tox
Binary file not shown.
58 changes: 57 additions & 1 deletion td_component/tdpilot_api_chat.html
Original file line number Diff line number Diff line change
Expand Up @@ -1444,6 +1444,49 @@ <h4>quickstart</h4>
autoScroll();
}

// Phase 1.2.1 (v2.2.1) — friendly recovery banner for HTTP 401 on
// /send. The chat panel's auth token is server-injected at GET /
// time; after a .tox rebuild the server rotates that token, but
// the open browser tab still holds the old one — every /send POST
// 401s until the user reloads. Pre-1.2.1 they saw a generic
// "Could not reach TD WebServer ... HTTP 401" message and had to
// know to press Cmd-R themselves. This builds a banner with an
// inline "Reconnect" button that calls location.reload() so the
// panel re-fetches the current token. DOM-built (not via
// appendMessage) because appendMessage textContent-escapes the
// 'error' role and we need a real <button>.
function appendReconnectBanner() {
clearWelcome();
const div = document.createElement('div');
div.className = 'msg error';
const r = document.createElement('div');
r.className = 'role';
r.textContent = 'connection';
div.appendChild(r);
const body = document.createElement('div');
body.className = 'body';
body.textContent =
'Connection unauthorized (HTTP 401). The chat-pipe token rotated — most likely a .tox rebuild happened in TouchDesigner. ';
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = 'Reconnect (reload this tab)';
btn.style.cssText =
'margin-left:8px;padding:4px 12px;background:#6B47FF;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:13px;';
btn.addEventListener('click', () => {
try {
window.location.reload();
} catch (_e) {
// Some restricted contexts disallow reload; fall back to a
// hint the user can act on manually.
body.append(' (auto-reload blocked — press Cmd-R / F5)');
}
});
body.appendChild(btn);
div.appendChild(body);
$history.appendChild(div);
autoScroll();
}

// Best-effort clipboard copy. Modern browsers expose
// navigator.clipboard.writeText, but it requires a secure context;
// fall back to a synthetic textarea + execCommand for older WebKit
Expand Down Expand Up @@ -1996,7 +2039,20 @@ <h4>quickstart</h4>
// flag + safety timer so a retry isn't blocked.
clearAwaitingTurnEnd();
setAgentStatus('send failed: ' + err.message);
appendMessage('error', 'Could not reach TD WebServer at ' + SEND_URL + ': ' + err.message);
// Phase 1.2.1: split the 401 path off into a dedicated
// banner with a reload button. Other failures (port not
// listening, TD not running) still flow through the generic
// appendMessage error path. The boundary is the bare "401"
// token in err.message — set by the !r.ok branch above.
const msg = (err && err.message) || '';
if (/\b401\b/.test(msg)) {
appendReconnectBanner();
} else {
appendMessage(
'error',
'Could not reach TD WebServer at ' + SEND_URL + ': ' + msg,
);
}
})
.finally(() => {
$input.focus();
Expand Down
51 changes: 51 additions & 0 deletions td_component/tdpilot_api_extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,57 @@ def OnSaveApiKeyPulse(self) -> None:
except Exception as exc: # noqa: BLE001
self._set_status(f"save failed: {exc}")

# ------------------------------------------------------------------
# Phase 1.2.1 (v2.2.1) — value-change auto-routing
# ------------------------------------------------------------------

def OnApikeyValueChange(self, par) -> None:
"""Auto-save + auto-reload when the user pastes a value into the
Apikey COMP param. Drops the previous "type key → pulse
Saveapikey → pulse Reloadconfig" 3-step ritual to a single
paste.

Wired via ``tdpilot_api_parexec.onValueChange`` with
``valuechange=1`` enabled on the parameterexecuteDAT (build
script change in same release).

Empty values are no-op'd — this avoids a recursion loop with
``OnSaveApiKeyPulse`` which deliberately wipes ``Apikey.val``
back to ``""`` after saving the key to disk (so the key isn't
left lurking in a saved .toe). That wipe itself fires
onValueChange a second time; the empty-check short-circuits it.
"""
try:
value = str(par.val or "").strip()
except Exception: # noqa: BLE001
value = ""
if not value:
return
# OnSaveApiKeyPulse handles the rest: write to
# ~/.tdpilot-api/config.json, wipe the param, call
# self._runtime.reload_config(). We just trigger it.
self.OnSaveApiKeyPulse()

def OnAuthmodeValueChange(self, par, prev) -> None:
"""Surface the auth-mode flip in the Status field. The
webserver's auth gate reads ``Authmode`` on every request
(see ``_insecure_mode`` in tdpilot_api_web_callbacks.py), so
no rebuild / Reloadconfig is required for the flip to take
effect — this handler just gives the user immediate visual
feedback that their toggle landed.
"""
try:
new_val = str(par.val or "").strip().lower()
except Exception: # noqa: BLE001
new_val = "?"
if new_val == "open":
msg = "auth mode: OPEN (no token required)"
elif new_val == "token":
msg = "auth mode: TOKEN (X-TDPilot-Token required)"
else:
msg = f"auth mode: {new_val}"
self._set_status(msg)

def OnVerifySetupPulse(self) -> dict:
"""Phase 5.1 — run the install-doctor check registry against
the current standalone instance and return a JSON-serialisable
Expand Down
42 changes: 41 additions & 1 deletion td_component/tdpilot_api_parexec.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,47 @@ def onPulse(par):


def onValueChange(par, prev):
return
"""Phase 1.2.1 (v2.2.1) — auto-route a tiny set of value changes to
the extension so the user doesn't have to manually pulse follow-up
actions.

Currently:

* ``Apikey`` change → save to disk + Reloadconfig (so the running
Agent picks up the new key immediately). Drops the
"type key → pulse Saveapikey → pulse Reloadconfig" 3-step
ritual to a single param edit.
* ``Authmode`` change → log the new value. The webserver's auth
check reads this param on every request, so no rebuild is
needed; the log is just so the user sees the change land.

Every other value change is a no-op (cheap early-return) — we
deliberately keep this narrow to avoid spurious side effects.
"""
name = par.name
if name not in ("Apikey", "Authmode"):
return

comp = par.owner
try:
ext = comp.op("tdpilot_api_extension").module.get_extension(comp)
except Exception as exc:
debug(f"[tdpilot_API] extension fetch failed for {name} change: {exc}")
return
if ext is None:
return

try:
if name == "Apikey":
handler = getattr(ext, "OnApikeyValueChange", None)
if handler is not None:
handler(par)
elif name == "Authmode":
handler = getattr(ext, "OnAuthmodeValueChange", None)
if handler is not None:
handler(par, prev)
except Exception as exc:
debug(f"[tdpilot_API] {name} value-change handler error: {exc}")


def onValuesChanged(changes):
Expand Down
41 changes: 38 additions & 3 deletions td_component/tdpilot_api_web_callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,44 @@ def _headers(request):


def _insecure_mode() -> bool:
"""Off-switch for users who drive the chat from external tooling.
Set ``TDPILOT_API_INSECURE=1`` in the environment to disable the
token + origin checks entirely. Default is secure."""
"""Return True when the chat-pipe webserver should skip the
X-TDPilot-Token check. The origin allowlist is NEVER bypassed by
this — only the token check is.

Resolution order (first hit wins):

1. **COMP param ``Authmode``** — value ``"open"`` means insecure,
``"token"`` means require the token. Phase 1.2.1 (v2.2.1) made
this the default source of truth: it persists in the .toe so
restarts preserve user intent, and the auth check reads it on
every request so flipping the param takes effect immediately
(no Reloadconfig needed).
2. **Env var ``TDPILOT_API_INSECURE``** — backward compat for
dev workflows that pre-date the Authmode param. Set to
``"1" / "true" / "yes"`` for insecure.
3. **Default**: ``False`` (require token) — only reached on the
very rare error path where the COMP isn't resolvable AND the
env var is unset.

Default Authmode is ``"open"`` (set in the build script's
``_API_PAGE`` schema). New users get drag-and-go; users who need
multi-machine/LAN security flip Authmode to ``"token"`` in the
COMP's param panel.
"""
# 1. COMP param wins.
try:
comp = _comp()
if comp is not None and hasattr(comp.par, "Authmode"):
# ``.val`` returns the menu string for a Menu param. Same
# as ``.menuNames[par.menuIndex]`` but shorter. Avoids
# the ``.eval()`` form (which collides with a Python-eval
# security-linter false-positive in this codebase's hooks).
value = str(comp.par.Authmode.val or "").strip().lower()
if value in ("open", "token"):
return value == "open"
Comment on lines +167 to +169

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Gate open auth mode behind the same exec clamp

Authmode=open now makes _insecure_mode() return True by default, which removes the X-TDPilot-Token requirement on /send, but the runtime hardening in TDPilotAPIExt._build_runtime still clamps TD_MCP_EXEC_MODE only when the env var TDPILOT_API_INSECURE is set. In practice this commit introduces a no-token path while leaving full exec enabled, so any local process (and LAN clients if the webserver is exposed) can drive agent turns that can reach td_exec_python with full privileges. The 2.1.3 mitigation needs to key off effective auth mode, not just the legacy env var.

Useful? React with 👍 / 👎.

except Exception:
pass
# 2. Env var fallback.
return os.environ.get(_INSECURE_ENV, "").strip() in ("1", "true", "yes")


Expand Down
Loading
Loading