From 1d1928675ef32d683edcb7f9ba144343509a3406 Mon Sep 17 00:00:00 2001 From: aship1619-byte Date: Sat, 3 Oct 2026 16:41:13 +0530 Subject: [PATCH] End users of your app: connect, saveKey, disconnect and tenantId - exec passes --tenant (tenantId / tenant_id) and --tenant-label - connect() returns a one-time link; saveKey() / save_key() stores an end user's API key from stdin; disconnect() - Swytchcode clients can be bound to one end user, so every tool an agent picks runs for them - README section and tests --- README.md | 18 ++++ swytchcode_runtime/__init__.py | 4 + swytchcode_runtime/cli.py | 7 +- swytchcode_runtime/client.py | 35 +++++++- swytchcode_runtime/exec.py | 22 +++++ swytchcode_runtime/tenants.py | 96 +++++++++++++++++++++ tests/test_tenants.py | 153 +++++++++++++++++++++++++++++++++ 7 files changed, 331 insertions(+), 4 deletions(-) create mode 100644 swytchcode_runtime/tenants.py create mode 100644 tests/test_tenants.py diff --git a/README.md b/README.md index 2e8dfd6..40be219 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ output = exec("api.report.export", {"id": "123"}, raw=True) - **raw** - If `True`, use `--raw` and return stdout as a string. - **dry_run** - If `True`, pass `--dry-run` to the CLI; request details (method, url, headers, body) are output instead of calling the server. - **allow_raw** - If `True`, pass `--allow-raw` to the CLI; required for executing raw methods (kernel has this disabled by default). +- **tenant_id** - Run the call for one end user of your app, with their own connected account (passes `--tenant`). Never falls back to your account. See [Multi-tenant apps](#multi-tenant-apps). +- **tenant_label** - How approvers see that end user, e.g. `"Alice Smith (alice@acme.com)"` (passes `--tenant-label`). Needs `tenant_id`. This runtime invokes `swytchcode exec [canonical_id]` with the flags above. For full exec behavior, see the Swytchcode kernel documentation. @@ -103,6 +105,22 @@ except SwytchcodeError as e: print(e.details.get("suggested_action")) ``` +## Multi-tenant apps + +When your app's own users each connect their accounts, pass your id for the logged-in user (from your server's session, never from the browser) on every call made for them: + +```python +from swytchcode_runtime import connect, save_key, disconnect, exec, Swytchcode + +url = connect("gmail", user.id)["url"] # one-time link (OAuth), open it from the user's click +save_key("stripe", user.id, key) # API-key providers: stored on your server only +exec("gmail.user.profile.get", {"params": {"userId": "me"}}, tenant_id=user.id) +client = Swytchcode(provider, tenant_id=user.id) # agents: every tool runs for this user +disconnect("gmail", user.id) +``` + +An end user who has not connected raises `SwytchcodeError` with `details["category"] == "tenant_not_connected"`. Guide: https://docs.swytchcode.com/guides/multi-tenant/ + ## What this library is - A thin process wrapper over the Swytchcode shell. diff --git a/swytchcode_runtime/__init__.py b/swytchcode_runtime/__init__.py index 0992528..1a0889f 100644 --- a/swytchcode_runtime/__init__.py +++ b/swytchcode_runtime/__init__.py @@ -4,13 +4,17 @@ from .errors import SwytchcodeError, is_swytchcode_error from .exec import exec_ as exec from .prompts import TOOL_USE_INSTRUCTIONS +from .tenants import connect, disconnect, save_key __all__ = [ "TOOL_USE_INSTRUCTIONS", "Swytchcode", "SwytchcodeError", + "connect", + "disconnect", "exec", "is_swytchcode_error", + "save_key", ] diff --git a/swytchcode_runtime/cli.py b/swytchcode_runtime/cli.py index 942f3ba..4b1e60f 100644 --- a/swytchcode_runtime/cli.py +++ b/swytchcode_runtime/cli.py @@ -17,9 +17,11 @@ def run_cli( cwd: str | None = None, env: dict | None = None, timeout: float | None = 60, + input: str | None = None, + json_output: bool = True, ) -> Any: cmd = [_resolve_bin(), *args] - if "--json" not in args: + if json_output and "--json" not in args: cmd.append("--json") run_env = os.environ.copy() @@ -29,6 +31,7 @@ def run_cli( try: r = subprocess.run( cmd, + input=input.encode("utf-8") if input is not None else None, capture_output=True, cwd=cwd or os.getcwd(), env=run_env, @@ -51,7 +54,7 @@ def run_cli( ) out = r.stdout.decode("utf-8", "replace").strip() - if not out: + if not out or not json_output: return None try: diff --git a/swytchcode_runtime/client.py b/swytchcode_runtime/client.py index 6b72024..c7124eb 100644 --- a/swytchcode_runtime/client.py +++ b/swytchcode_runtime/client.py @@ -9,6 +9,7 @@ from . import discover as _discover from . import manage as _manage from . import schema as _schema +from .errors import SwytchcodeError from .exec import exec_ as _exec from .providers.base import Provider, Tool @@ -145,7 +146,14 @@ def execute(self, canonical_id: str, args: dict, **options) -> Any: final_args["body"] = _strip_empty(final_args["body"]) if isinstance(final_args.get("params"), (dict, list)): final_args["params"] = _strip_empty(final_args["params"]) - # Forward exec options (dry_run, raw, allow_raw, cwd, env) to the CLI. + # A client bound to one end user runs every tool for that user unless + # the call names another one itself. + if self._c.tenant_id is not None and options.get("tenant_id") is None: + options["tenant_id"] = self._c.tenant_id + if options.get("tenant_label") is None: + options["tenant_label"] = self._c.tenant_label + # Forward exec options (dry_run, raw, allow_raw, cwd, env, tenant_id, + # tenant_label) to the CLI. return _exec(canonical_id, final_args, **options) def _tool(self, cid: str) -> Tool: @@ -200,8 +208,31 @@ def _ids(self, toolkits, tools, search) -> list[str]: class Swytchcode: - def __init__(self, provider: Provider | None = None): + def __init__( + self, + provider: Provider | None = None, + *, + tenant_id: str | None = None, + tenant_label: str | None = None, + ): + """ + tenant_id binds the client to one end user of your app: every tool it + runs, including those an AI agent picks, uses that user's connected + accounts and never yours. Create one client per request, from your + server's session. tenant_label is how approvers see that end user, for + example "Alice Smith (alice@acme.com)". + """ self.provider = provider + if tenant_id is not None: + tenant_id = tenant_id.strip() + # An empty id would quietly run the agent's tools on your own account. + if not tenant_id: + raise SwytchcodeError("tenant_id must be a non-empty string when set") + self.tenant_id = tenant_id + tenant_label = (tenant_label or "").strip() or None + if tenant_label and not tenant_id: + raise SwytchcodeError("tenant_label needs tenant_id") + self.tenant_label = tenant_label self.tools = _Tools(self) def handle_tool_calls( diff --git a/swytchcode_runtime/exec.py b/swytchcode_runtime/exec.py index 6688b6a..9c22b4e 100644 --- a/swytchcode_runtime/exec.py +++ b/swytchcode_runtime/exec.py @@ -79,6 +79,8 @@ def exec_( dry_run: bool = False, allow_raw: bool = False, timeout: float | None = None, + tenant_id: str | None = None, + tenant_label: str | None = None, ) -> Any: """ Run swytchcode exec with optional JSON args on stdin. @@ -101,6 +103,14 @@ def exec_( dry_run: If True, pass --dry-run; request details are output instead of calling the server. allow_raw: If True, pass --allow-raw; required for executing raw methods (kernel default is disabled). timeout: Optional timeout in seconds for the subprocess. + tenant_id: Run the call for one end user (tenant) of your app, with their own + connected account (passes --tenant). Use your own id for the logged-in user, + taken from your server's session. Never falls back to your own account: if + this user has not connected the provider, it fails with category + "tenant_not_connected". + tenant_label: How approvers see this end user when a policy asks for human + approval, for example "Alice Smith (alice@acme.com)" (passes + --tenant-label). Needs tenant_id. Returns: Parsed result (any) or raw string when raw is True. @@ -111,6 +121,14 @@ def exec_( canonical_id = canonical_id.strip() if not canonical_id: raise SwytchcodeError("canonical_id must be a non-empty string") + # An empty tenant id must never quietly run the call on the developer's own account. + if tenant_id is not None: + tenant_id = tenant_id.strip() + if not tenant_id: + raise SwytchcodeError("tenant_id must be a non-empty string when set") + tenant_label = (tenant_label or "").strip() + if tenant_label and not tenant_id: + raise SwytchcodeError("tenant_label needs tenant_id") flag = "--raw" if raw else "--json" bin_path = _resolve_bin() @@ -119,6 +137,10 @@ def exec_( cmd.append("--dry-run") if allow_raw: cmd.append("--allow-raw") + if tenant_id: + cmd.extend(["--tenant", tenant_id]) + if tenant_label: + cmd.extend(["--tenant-label", tenant_label]) run_env = os.environ.copy() if env: diff --git a/swytchcode_runtime/tenants.py b/swytchcode_runtime/tenants.py new file mode 100644 index 0000000..ec9b874 --- /dev/null +++ b/swytchcode_runtime/tenants.py @@ -0,0 +1,96 @@ +"""Connect, save and remove accounts for the end users (tenants) of your app.""" + +from __future__ import annotations + +from .cli import run_cli +from .errors import SwytchcodeError + + +def _tenant_args(provider: str, tenant_id: str) -> list[str]: + provider = (provider or "").strip() + tenant_id = (tenant_id or "").strip() + if not provider: + raise SwytchcodeError("provider must be a non-empty string") + # An empty tenant id would act on the developer's own account. + if not tenant_id: + raise SwytchcodeError("tenant_id must be a non-empty string") + return [provider, "--tenant", tenant_id] + + +def connect( + provider: str, + tenant_id: str, + *, + cwd: str | None = None, + env: dict[str, str] | None = None, +) -> dict: + """ + Start an OAuth connection for one end user and return the link they open. + + tenant_id is your own id for the end user, taken from your server's session, + never from the browser. The account is ready once they finish in the popup; + the first exec() with this tenant_id picks it up. The provider app used + (Swytchcode's or your own) is the one you chose with `swytchcode auth connect`. + + Returns {"url": ..., "connected_account_uuid": ...}. + """ + out = run_cli( + ["auth", "connect", *_tenant_args(provider, tenant_id), "--json"], + cwd=cwd, + env=env, + ) + if not isinstance(out, dict) or not out.get("authorization_url"): + raise SwytchcodeError( + f"{provider} does not connect with OAuth; collect the end user's key " + "in your app and save it with save_key()", + out, + ) + return { + "url": out["authorization_url"], + "connected_account_uuid": out.get("connected_account_uuid", ""), + } + + +def save_key( + provider: str, + tenant_id: str, + key: str, + *, + cwd: str | None = None, + env: dict[str, str] | None = None, +) -> None: + """ + Save an end user's API key for a provider that uses keys. The key is stored + encrypted on this machine only and never sent to Swytchcode; the end user is + recorded (without the key) toward your plan's end-user limit, so this needs a + login or API key, and at the limit it raises and nothing is saved. + """ + key = (key or "").strip() + if not key: + raise SwytchcodeError("key must be a non-empty string") + out = run_cli( + ["auth", "connect", *_tenant_args(provider, tenant_id), "--json"], + cwd=cwd, + env=env, + input=key + "\n", + ) + if not isinstance(out, dict) or out.get("stored") != "local": + raise SwytchcodeError( + f"{provider} does not use an API key; use connect() instead", out + ) + + +def disconnect( + provider: str, + tenant_id: str, + *, + cwd: str | None = None, + env: dict[str, str] | None = None, +) -> None: + """Remove one end user's account for a provider, on this machine and on Swytchcode.""" + run_cli( + ["auth", "disconnect", *_tenant_args(provider, tenant_id)], + cwd=cwd, + env=env, + json_output=False, + ) diff --git a/tests/test_tenants.py b/tests/test_tenants.py new file mode 100644 index 0000000..4f59986 --- /dev/null +++ b/tests/test_tenants.py @@ -0,0 +1,153 @@ +import json +import subprocess +import unittest +from unittest import mock + +from swytchcode_runtime import SwytchcodeError, connect, disconnect, exec, save_key + + +def _done(stdout="", returncode=0, stderr=""): + return subprocess.CompletedProcess( + args=[], returncode=returncode, stdout=stdout.encode(), stderr=stderr.encode() + ) + + +@mock.patch("swytchcode_runtime.exec._resolve_bin", return_value="swytchcode") +@mock.patch("swytchcode_runtime.cli._resolve_bin", return_value="swytchcode") +class TestTenants(unittest.TestCase): + def test_exec_passes_tenant(self, *_): + with mock.patch("subprocess.run", return_value=_done('{"ok": true}')) as run: + exec("gmail.send", {"body": {}}, tenant_id=" alice ") + self.assertEqual(run.call_args.args[0][-2:], ["--tenant", "alice"]) + + def test_exec_without_tenant_passes_no_flag(self, *_): + with mock.patch("subprocess.run", return_value=_done("{}")) as run: + exec("gmail.send", {"body": {}}) + self.assertNotIn("--tenant", run.call_args.args[0]) + + def test_exec_passes_tenant_label(self, *_): + with mock.patch("subprocess.run", return_value=_done('{"ok": true}')) as run: + exec("gmail.send", {}, tenant_id="alice", tenant_label=" Alice Smith ") + self.assertEqual(run.call_args.args[0][-4:], ["--tenant", "alice", "--tenant-label", "Alice Smith"]) + + def test_exec_refuses_label_without_tenant(self, *_): + with mock.patch("subprocess.run") as run: + with self.assertRaisesRegex(SwytchcodeError, "tenant_label needs tenant_id"): + exec("gmail.send", {}, tenant_label="Alice") + run.assert_not_called() + + def test_exec_rejects_empty_tenant(self, *_): + with mock.patch("subprocess.run") as run: + with self.assertRaises(SwytchcodeError): + exec("gmail.send", {}, tenant_id=" ") + run.assert_not_called() + + def test_exec_reports_tenant_not_connected(self, *_): + err = json.dumps( + { + "error": "alice has not connected google", + "category": "tenant_not_connected", + "retryable": False, + } + ) + with mock.patch("subprocess.run", return_value=_done("", 3, err)): + with self.assertRaises(SwytchcodeError) as ctx: + exec("gmail.send", {}, tenant_id="alice") + self.assertEqual(ctx.exception.details["category"], "tenant_not_connected") + + def test_connect_returns_link(self, *_): + out = json.dumps( + { + "authorization_url": "https://auth.example/?state=s", + "connected_account_uuid": "ca-1", + "provider_slug": "google", + "tenant_id": "alice", + } + ) + with mock.patch("subprocess.run", return_value=_done(out)) as run: + res = connect("google", "alice") + self.assertEqual( + res, {"url": "https://auth.example/?state=s", "connected_account_uuid": "ca-1"} + ) + self.assertEqual( + run.call_args.args[0][1:], + ["auth", "connect", "google", "--tenant", "alice", "--json"], + ) + + def test_connect_on_key_provider_points_to_save_key(self, *_): + with mock.patch("subprocess.run", return_value=_done("")): + with self.assertRaisesRegex(SwytchcodeError, "save_key"): + connect("stripe", "alice") + + def test_save_key_uses_stdin(self, *_): + out = json.dumps({"provider_slug": "stripe", "tenant_id": "alice", "auth_type": "api_key", "stored": "local"}) + with mock.patch("subprocess.run", return_value=_done(out)) as run: + save_key("stripe", "alice", "sk_test_123") + self.assertNotIn("sk_test_123", run.call_args.args[0]) + self.assertEqual(run.call_args.kwargs["input"], b"sk_test_123\n") + + def test_disconnect_has_no_json_flag(self, *_): + with mock.patch("subprocess.run", return_value=_done("Disconnected")) as run: + disconnect("google", "alice") + self.assertEqual( + run.call_args.args[0][1:], ["auth", "disconnect", "google", "--tenant", "alice"] + ) + + def test_helpers_refuse_empty_tenant(self, *_): + with mock.patch("subprocess.run") as run: + with self.assertRaises(SwytchcodeError): + connect("google", "") + with self.assertRaises(SwytchcodeError): + disconnect("google", " ") + run.assert_not_called() + + +if __name__ == "__main__": + unittest.main() + + +@mock.patch("swytchcode_runtime.exec._resolve_bin", return_value="swytchcode") +class TestBoundClient(unittest.TestCase): + def test_tools_execute_passes_bound_tenant(self, *_): + from swytchcode_runtime import Swytchcode + + with mock.patch("subprocess.run", return_value=_done('{"ok": true}')) as run: + Swytchcode(tenant_id="alice").tools.execute("gmail.send", {"to": "x@y.z"}) + self.assertEqual(run.call_args.args[0][-2:], ["--tenant", "alice"]) + + def test_handle_tool_calls_runs_for_bound_tenant(self, *_): + from types import SimpleNamespace + + from swytchcode_runtime import Swytchcode + + block = SimpleNamespace(type="tool_use", id="t1", name="gmail_send", input={}) + with mock.patch("subprocess.run", return_value=_done("{}")) as run: + Swytchcode(tenant_id="bob").handle_tool_calls(SimpleNamespace(content=[block])) + self.assertEqual(run.call_args.args[0][-2:], ["--tenant", "bob"]) + + def test_unbound_client_passes_no_tenant(self, *_): + from swytchcode_runtime import Swytchcode + + with mock.patch("subprocess.run", return_value=_done("{}")) as run: + Swytchcode().tools.execute("gmail.send", {}) + self.assertNotIn("--tenant", run.call_args.args[0]) + + def test_empty_tenant_refused(self, *_): + from swytchcode_runtime import Swytchcode + + with self.assertRaises(SwytchcodeError): + Swytchcode(tenant_id=" ") + + def test_bound_label_goes_with_bound_tenant_only(self, *_): + from swytchcode_runtime import Swytchcode + + client = Swytchcode(tenant_id="alice", tenant_label="Alice Smith") + with mock.patch("subprocess.run", return_value=_done("{}")) as run: + client.tools.execute("gmail.send", {}) + alice = run.call_args.args[0] + client.tools.execute("gmail.send", {}, tenant_id="bob") + bob = run.call_args.args[0] + self.assertEqual(alice[-4:], ["--tenant", "alice", "--tenant-label", "Alice Smith"]) + self.assertEqual(bob[-2:], ["--tenant", "bob"]) + with self.assertRaises(SwytchcodeError): + Swytchcode(tenant_label="Alice")