diff --git a/tests/room_network/test_launch.py b/tests/room_network/test_launch.py index d961e61..235faa0 100644 --- a/tests/room_network/test_launch.py +++ b/tests/room_network/test_launch.py @@ -6,6 +6,8 @@ import socket import sys import tempfile import unittest +import io +from contextlib import redirect_stdout from unittest.mock import patch ROOT = Path(__file__).resolve().parents[2] @@ -15,6 +17,21 @@ spec.loader.exec_module(launch) class LaunchTests(unittest.TestCase): + def test_conflicting_environment_warns_without_exposing_tokens(self): + output = io.StringIO() + with redirect_stdout(output): + selected = launch.credential({'ARCADE_OPERATOR_TOKEN':'old-private-token-123'}, 'ARCADE_OPERATOR_TOKEN', 'new-private-token-456', 'Operator token') + self.assertEqual(selected, 'old-private-token-123') + self.assertIn('overrides a DIFFERENT value', output.getvalue()) + self.assertNotIn('old-private-token-123', output.getvalue()) + self.assertNotIn('new-private-token-456', output.getvalue()) + + def test_quoted_token_is_rejected_without_exposing_it(self): + for token in ['"private-test-token-123"', "'private-test-token-123'"]: + with self.assertRaisesRegex(ValueError, 'without quotes') as caught: + launch.credential({}, 'ARCADE_OPERATOR_TOKEN', token, 'Operator token') + self.assertNotIn('private-test-token-123', str(caught.exception)) + def config(self, folder, gpio='false'): filename = Path(folder) / 'launcher.ini' filename.write_text(f'''[controller] diff --git a/tools/launch/README.md b/tools/launch/README.md index ff5cd3f..082c141 100644 --- a/tools/launch/README.md +++ b/tools/launch/README.md @@ -46,6 +46,21 @@ cabinet_tokens = {"cabinet_01":"YOUR_EXISTING_CABINET_TOKEN"} Each token must be distinct and at least 16 characters. Existing `ARCADE_OPERATOR_TOKEN`, `ARCADE_CABINET_TOKENS`, and legacy `ARCADE_CABINET_TOKEN` environment variables take precedence. Reuse credentials; the launcher does not generate or rotate them. +The launcher prints the configuration path and token source without printing the tokens. It warns when an environment operator/cabinet token overrides a different INI value, and rejects surrounding token quotes. + +### Browser says Unauthorized + +The controller was reached, but its operator token does not match what was entered. Use the **operator** token, not a cabinet token, and open the exact operator URL printed by the launcher. “Waiting for the room operator” in the game is normal until a scenario is prepared. + +If you configured `operator_token` in `launcher.ini` but the launcher reports an environment override, stop your running stack with Ctrl+C and clear the old variable **in the same PowerShell terminal you use to launch**: + +```powershell +Remove-Item Env:ARCADE_OPERATOR_TOKEN -ErrorAction SilentlyContinue +.\Start-Arcade.ps1 -Mode all -Browser +``` + +Use `-Mode controller` instead if this machine only hosts the controller. Enter the INI's operator token in the browser, without quotes. Clearing the variable affects only that PowerShell session; it does not delete an INI value or change a stored Windows environment variable. The controller must be restarted to pick up credential changes. Never paste real tokens into troubleshooting messages. + **Set `database` to your existing controller database** when upgrading. The example defaults to a new `tools/launch/state/arcade_room.sqlite3`; that would start a fresh controller history instead of using a database at the project root. To use a root-level database from the default INI location, set `database = ../../arcade_room.sqlite3`. Then validate and start: diff --git a/tools/launch/launch.py b/tools/launch/launch.py index 2a8b562..14df4e6 100644 --- a/tools/launch/launch.py +++ b/tools/launch/launch.py @@ -17,6 +17,19 @@ import webbrowser ROOT = Path(__file__).resolve().parents[2] +def credential(env, name, configured, label): + inherited = env.get(name, '') + selected = inherited or configured + source = 'environment variable '+name if inherited else 'launcher INI' + if selected: + print(f'{label} source: {source}', flush=True) + if inherited and configured and inherited != configured: + print(f'WARNING: {name} overrides a DIFFERENT value in the launcher INI. Clear that variable in the launching terminal to use the INI.', flush=True) + if selected and (selected[0] in "\"'" or selected[-1] in "\"'"): + raise ValueError(f'{label} has surrounding quote characters. In launcher.ini use the token without quotes; enter the same unquoted token in the browser.') + return selected + + def plan(config_path, mode): config_path = Path(config_path).resolve() config = configparser.ConfigParser(interpolation=None) @@ -41,7 +54,7 @@ def plan(config_path, mode): require_file(script) require_file(script.with_name('operator.html')) require_file(catalog) - operator = env.get('ARCADE_OPERATOR_TOKEN') or value('controller', 'operator_token') + operator = credential(env, 'ARCADE_OPERATOR_TOKEN', value('controller', 'operator_token'), 'Operator token') tokens = json.loads(env.get('ARCADE_CABINET_TOKENS') or value('controller', 'cabinet_tokens', '{}')) if not isinstance(tokens, dict): raise ValueError('cabinet_tokens must be a JSON object') legacy = env.get('ARCADE_CABINET_TOKEN', '') @@ -66,7 +79,7 @@ def plan(config_path, mode): if not cabinet_id or any(c.isspace() for c in cabinet_id): raise ValueError('Invalid cabinet ID') cabinet_env = dict(env) room_url = env.get('ARCADE_ROOM_URL') or value('cabinet', 'url') - token = env.get('ARCADE_CABINET_TOKEN') or value('cabinet', 'token') + token = credential(env, 'ARCADE_CABINET_TOKEN', value('cabinet', 'token'), 'Cabinet token') if mode == 'all': room_url = room_url or url token = token or tokens.get(cabinet_id, '') @@ -163,6 +176,7 @@ def main(): def interrupted(*_): raise KeyboardInterrupt signal.signal(signal.SIGTERM, interrupted) try: + print('Launcher configuration: '+str(Path(args.config).resolve()), flush=True) jobs, logs = plan(args.config, args.mode) if args.check: check_ports(jobs)