From 34dc4f2f58c39fecdfe77d69f99c488bf29c2da8 Mon Sep 17 00:00:00 2001 From: Rui Fu Date: Sun, 4 Oct 2026 00:39:31 +0800 Subject: [PATCH] fix: scope cloud agent SQL calls to configured database --- .env.cloud.example | 7 ++++++ cli/lib.sh | 6 +++++ labs/cloud/00-set-up.md | 6 +++++ labs/cloud/02-streaming-sql.md | 2 ++ labs/cloud/03-live-context.md | 4 ++++ labs/cloud/04-act-with-approval.md | 3 +++ python/common.py | 8 +++++++ python/tests/test_agent_defs.py | 38 ++++++++++++++++++++++++++++-- typescript/src/common.ts | 8 +++++++ typescript/test/agent-defs.test.ts | 19 ++++++++++++++- 10 files changed, 98 insertions(+), 3 deletions(-) diff --git a/.env.cloud.example b/.env.cloud.example index b715036..e2c2463 100644 --- a/.env.cloud.example +++ b/.env.cloud.example @@ -32,6 +32,13 @@ SCHEMA_REGISTRY_URL= # https://mcp.streamnative.cloud/mcp/x//sqlworkspace.compute.streamnative.io/ SN_MCP_URL= +# The SQL catalog/database used in Labs 2-4, not the SQL workspace name. +# Set this to the catalog NAME whose sourceRef names your Kafka cluster. +# Agent construction scopes all SQL instructions to this database. Leaving it +# empty keeps legacy automatic discovery; set it when a workspace has multiple databases. +# This is prompt guidance, not an MCP authorization boundary. +SN_SQL_DATABASE= + # MCP authentication: oauth (default) or static_bearer for API-key MCP servers. # All three paths call ork for the first OAuth login, then reuse the # credential stored in the vault. Needs ork v0.6.0 or newer. diff --git a/cli/lib.sh b/cli/lib.sh index f6e054f..ecb0cd6 100644 --- a/cli/lib.sh +++ b/cli/lib.sh @@ -92,6 +92,12 @@ agent_definition() { # agent_definition elif type == "object" then with_entries(.value |= fill) else . end; {name: $name, model: $model, system: .system, mcp_servers: (.mcp_servers | fill), tools: (.tools | fill)} + | if ($ENV.TUTORIAL_STACK // "cloud") == "cloud" and (.mcp_servers | length) > 0 and (($ENV.SN_SQL_DATABASE // "") | length) > 0 then + .system = ("Target SQL database: " + ($ENV.SN_SQL_DATABASE | tojson) + + ". Use this exact database for every SQL tool call, including reads, table descriptions, and writes. " + + "Do not discover or select another database; this overrides database discovery instructions below. " + + "If it is unavailable or required tables are missing, report the error and stop; never fall back to another database.\n\n" + .system) + else . end ' "$file" } diff --git a/labs/cloud/00-set-up.md b/labs/cloud/00-set-up.md index 4350353..6caa20e 100644 --- a/labs/cloud/00-set-up.md +++ b/labs/cloud/00-set-up.md @@ -108,6 +108,12 @@ NAME DISPLAY INSTANCE c-abc1234 ana-kafka ana ``` +From the SQL catalog list, choose the row whose `KAFKA_CLUSTER` and +`SQL_WORKSPACE` match your resources. Set `SN_SQL_DATABASE` in `.env` to that +row's `NAME`: this is the database you use in Lab 2 and the agent targets in +Labs 3 and 4. Do not use the SQL workspace name. Explicit selection matters +when your SQL workspace imports multiple catalogs. + Now ask for each address, and write it into `.env`: | `.env` line | Command | Write it as | diff --git a/labs/cloud/02-streaming-sql.md b/labs/cloud/02-streaming-sql.md index c9027e9..11a20bd 100644 --- a/labs/cloud/02-streaming-sql.md +++ b/labs/cloud/02-streaming-sql.md @@ -12,6 +12,8 @@ Lab 3, and a table it can write to in Lab 4. - Open your SQL workspace. In the StreamNative Cloud console, open **SQL Workspace**, select your SQL workspace, and pick the database named after your SQL catalog (Lab 0, step 2). Use a new query tab for each step. + This must match `SN_SQL_DATABASE` in `.env`, so you and the agent use the same + database. SQL Workspace does not read `.env`; select the database yourself. - If the console cannot open the database yet, use `psql` from the repository root instead. Look up your SQL workspace's address, then connect as `root` with your API key as the password: diff --git a/labs/cloud/03-live-context.md b/labs/cloud/03-live-context.md index c8bcb33..0138ba1 100644 --- a/labs/cloud/03-live-context.md +++ b/labs/cloud/03-live-context.md @@ -10,6 +10,10 @@ materialized view, and its answer changes when the stream does. - You finished [Lab 2](02-streaming-sql.md): `login_failures` exists in your SQL workspace's database. +- Set `SN_SQL_DATABASE` in `.env` to that database's SQL catalog name (Lab 0, + step 2). The agent is instructed to use it for every SQL call, with no + fallback to another database. This is prompt guidance, not MCP permission + isolation; an empty value retains legacy automatic discovery. - One terminal is in your path's folder, a second one is at the repository root. - `ork` v0.6.0 or newer is installed. All three paths use it for the first MCP login. diff --git a/labs/cloud/04-act-with-approval.md b/labs/cloud/04-act-with-approval.md index b81db9f..1e8b971 100644 --- a/labs/cloud/04-act-with-approval.md +++ b/labs/cloud/04-act-with-approval.md @@ -11,6 +11,9 @@ yes, and has not flagged another because you said no. - You finished [Lab 3](03-live-context.md): the agent reads `login_failures`, and the browser login for the MCP server is done. - `flagged_accounts` exists in your SQL workspace's database (Lab 2, step 3). +- `SN_SQL_DATABASE` in `.env` names that same database. Check the proposed + insert's `database` before approving: the configured target is prompt + guidance, not an MCP authorization boundary. - Your SQL workspace's MCP access is read-write; the organizers set this up. Read-only access offers the agent no tool that writes: see [Troubleshooting](troubleshooting.md). diff --git a/python/common.py b/python/common.py index 0141799..bfc66d6 100644 --- a/python/common.py +++ b/python/common.py @@ -133,6 +133,14 @@ def agent_params(layer: dict[str, Any], config: Config) -> dict[str, Any]: "mcp_servers": _fill(layer["mcp_servers"], config), "tools": _fill(layer["tools"], config), } + if config.stack == "cloud" and layer["mcp_servers"] and config.values.get("SN_SQL_DATABASE"): + database = json.dumps(config["SN_SQL_DATABASE"], ensure_ascii=False) + params["system"] = ( + f"Target SQL database: {database}. Use this exact database for every SQL tool call, including reads, table descriptions, and writes. " + "Do not discover or select another database; this overrides database discovery instructions below. " + "If it is unavailable or required tables are missing, report the error and stop; never fall back to another database.\n\n" + + params["system"] + ) # Same recipe in every language (and `jq -cS` in the CLI): compact JSON, sorted keys. canonical = json.dumps(params, sort_keys=True, separators=(",", ":"), ensure_ascii=False) fingerprint = hashlib.sha256(canonical.encode()).hexdigest()[:16] diff --git a/python/tests/test_agent_defs.py b/python/tests/test_agent_defs.py index 0187020..25ad096 100644 --- a/python/tests/test_agent_defs.py +++ b/python/tests/test_agent_defs.py @@ -1,18 +1,21 @@ """agent_params: turn agent/.json into the arguments for agents.create/update.""" import inspect +import json +import os +import subprocess import pytest from orca.resources.agents.agents import Agents -from common import Config, ConfigError, agent_params, load_layer +from common import REPO_ROOT, Config, ConfigError, agent_params, load_layer from policy import effective_policy MCP_URL = "https://mcp.example.com/mcp/x/o-test/sqlworkspace/ws-1" def config(**overrides: str) -> Config: - values = {"ORCA_MODEL": "claude-sonnet-4-6", "SN_MCP_URL": MCP_URL, **overrides} + values = {"ORCA_MODEL": "claude-sonnet-4-6", "SN_MCP_URL": MCP_URL, "RW_MCP_URL": "http://localhost:8080/mcp", **overrides} return Config(values=values, participant="jane") @@ -61,6 +64,37 @@ def test_different_layers_carry_different_definition_fingerprints(): assert fingerprint("l3-live-context") == fingerprint("l3-live-context") +@pytest.mark.parametrize("layer", ["l3-live-context", "l4-act"]) +def test_configured_database_scopes_cloud_agent_and_changes_fingerprint(layer): + params = agent_params(load_layer(layer), config(SN_SQL_DATABASE='catalog-\"rfu')) + assert params["system"].startswith('Target SQL database: "catalog-\\\"rfu".') + assert "never fall back to another database" in params["system"] + assert params["metadata"]["definition_sha"] != agent_params(load_layer(layer), config())["metadata"]["definition_sha"] + assert params["metadata"]["definition_sha"] != agent_params(load_layer(layer), config(SN_SQL_DATABASE="other"))["metadata"]["definition_sha"] + + +@pytest.mark.parametrize("layer, stack", [("l1-hello", "cloud"), ("l3-live-context", "local"), ("l4-act", "local")]) +def test_database_setting_does_not_change_hello_or_local_agents(layer, stack): + definition = load_layer(layer, stack) + assert agent_params(definition, config(TUTORIAL_STACK=stack, SN_SQL_DATABASE="catalog-rfu")) == agent_params(definition, config(TUTORIAL_STACK=stack)) + + +@pytest.mark.parametrize("layer", ["l3-live-context", "l4-act"]) +def test_cli_database_definition_matches_python_without_loading_dotenv(layer): + # Extract only the pure definition builder; never source lib.sh/env.sh or .env. + source = (REPO_ROOT / "cli/lib.sh").read_text() + builder = source[source.index("agent_definition() {"):source.index("\n# Same recipe as the other languages")] + script = """layer_file() { printf '%s/agent/cloud/%s.json' "$REPO" "$1"; } +""" + builder + '\nagent_definition "$LAYER"' + env = {"PATH": os.environ["PATH"], "REPO": str(REPO_ROOT), "LAYER": layer, + "HELLO_PARTICIPANT": "jane", "ORCA_MODEL": "claude-sonnet-4-6", + "SN_MCP_URL": MCP_URL, "SN_SQL_DATABASE": 'catalog-\"rfu'} + result = subprocess.run(["bash", "-c", script], env=env, capture_output=True, text=True, check=True) + expected = agent_params(load_layer(layer), config(SN_SQL_DATABASE=env["SN_SQL_DATABASE"])) + expected.pop("metadata") + assert json.loads(result.stdout) == expected + + @pytest.mark.parametrize( "layer, tool, expected", [ diff --git a/typescript/src/common.ts b/typescript/src/common.ts index 43836e9..ebf733e 100644 --- a/typescript/src/common.ts +++ b/typescript/src/common.ts @@ -180,6 +180,14 @@ export function agentParams(layer: Layer, config: Config): AgentParams { mcp_servers: fill(layer.mcp_servers, config), tools: fill(layer.tools, config), }; + if (config.stack === 'cloud' && layer.mcp_servers.length > 0 && config.has('SN_SQL_DATABASE') && config.get('SN_SQL_DATABASE')) { + const database = JSON.stringify(config.get('SN_SQL_DATABASE')); + params.system = + `Target SQL database: ${database}. Use this exact database for every SQL tool call, including reads, table descriptions, and writes. ` + + 'Do not discover or select another database; this overrides database discovery instructions below. ' + + 'If it is unavailable or required tables are missing, report the error and stop; never fall back to another database.\n\n' + + params.system; + } // Same recipe in every language (and `jq -cS` in the CLI): compact JSON, sorted keys. const fingerprint = createHash('sha256').update(canonicalJson(params), 'utf8').digest('hex').slice(0, 16); return { ...params, metadata: { tutorial: 'dss2026-hello-world', layer: layer.layer, definition_sha: fingerprint } }; diff --git a/typescript/test/agent-defs.test.ts b/typescript/test/agent-defs.test.ts index 8868e05..73d3148 100644 --- a/typescript/test/agent-defs.test.ts +++ b/typescript/test/agent-defs.test.ts @@ -9,7 +9,7 @@ import { effectivePolicy } from './policy.js'; const MCP_URL = 'https://mcp.example.com/mcp/x/o-test/sqlworkspace/ws-1'; function config(overrides: Record = {}): Config { - return new Config({ ORCA_MODEL: 'claude-sonnet-4-6', SN_MCP_URL: MCP_URL, ...overrides }, 'jane'); + return new Config({ ORCA_MODEL: 'claude-sonnet-4-6', SN_MCP_URL: MCP_URL, RW_MCP_URL: 'http://localhost:8080/mcp', ...overrides }, 'jane'); } describe('agentParams', () => { @@ -64,6 +64,23 @@ describe('agentParams', () => { expect(agentParams(loadLayer(layer), config()).metadata.definition_sha).toBe(expected); }); + it.each(['l3-live-context', 'l4-act'])('%s uses the configured database and fingerprints it', (layer) => { + const params = agentParams(loadLayer(layer), config({ SN_SQL_DATABASE: 'catalog-"rfu' })); + expect(params.system).toContain(`Target SQL database: ${JSON.stringify('catalog-"rfu')}.`); + expect(params.system).toContain('never fall back to another database'); + expect(params.metadata.definition_sha).not.toBe(agentParams(loadLayer(layer), config()).metadata.definition_sha); + expect(params.metadata.definition_sha).not.toBe(agentParams(loadLayer(layer), config({ SN_SQL_DATABASE: 'other' })).metadata.definition_sha); + }); + + it('does not change L1 or Local agents', () => { + expect(agentParams(loadLayer('l1-hello'), config({ SN_SQL_DATABASE: 'catalog-rfu' }))) + .toEqual(agentParams(loadLayer('l1-hello'), config())); + for (const layer of ['l3-live-context', 'l4-act']) { + expect(agentParams(loadLayer(layer, 'local'), config({ TUTORIAL_STACK: 'local', SN_SQL_DATABASE: 'catalog-rfu' }))) + .toEqual(agentParams(loadLayer(layer, 'local'), config({ TUTORIAL_STACK: 'local' }))); + } + }); + it.each([ ['l3-live-context', 'sql_workspace_list_databases', 'always_allow'], ['l3-live-context', 'sql_workspace_query', 'always_allow'],