okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

Aly Raffauffeat(api)!: migrate daemon and clients to protocol 361c43ca

main
7.0 KiB175 linesraw
1"""Every secret lives in creds.json, and only the daemon reads or writes it.
2
3A boot moves secrets still kept in auth.json, mcp-credentials.json or a
4config.json profile into creds.json; clients then change them through the
5daemon, which never answers with a secret. Both are only visible through a
6real daemon: the move happens at boot, and the keys prove themselves in the
7requests the provider receives.
8"""
9
10import json
11import stat
12import unittest
13
14from harness import Albedo, Provider, exclusive, text
15
16
17class CredentialsTest(unittest.TestCase):
18    def setUp(self):
19        self.provider = Provider(lambda _request: text("ok"))
20        self.addCleanup(self.provider.close)
21
22    def app_for(self, prepare=None):
23        app = Albedo(self.provider, prepare=prepare)
24        app.__enter__()
25        self.addCleanup(app.__exit__, None, None, None)
26        return app
27
28    def authorization(self, app):
29        """The Authorization header of a turn on the active profile."""
30        start = len(self.provider.requests)
31        session = app.session()
32        app.prompt(session, "which key").close()
33        app.idle(session)
34        return self.provider.requests[start:][-1]["authorization"]
35
36    def summary(self, app):
37        with app.api("/settings") as response:
38            return response.read().decode()
39
40    # exclusive: restarts the daemon to migrate global credential files
41    @exclusive
42    def test_a_boot_moves_every_secret_into_creds_json(self):
43        app = self.app_for()
44        # Written while the daemon runs, so the restart below is what moves them.
45        for name, content in {
46            "auth.json": {"anthropic": [{"type": "oauth", "access": "old-access"}]},
47            "mcp-credentials.json": {"servers": {"docs": {"bearerToken": "old-token"}}},
48        }.items():
49            (app.home / name).write_text(json.dumps(content))
50            (app.home / name).chmod(0o600)
51        config = json.loads((app.home / "config.json").read_text())
52        config["providers"][app.profile]["apiKey"] = "fixture-key"
53        (app.home / "config.json").write_text(json.dumps(config))
54        app.restart()
55        creds = json.loads((app.home / "creds.json").read_text())
56        self.assertEqual(creds["accounts"]["anthropic"][0]["access"], "old-access")
57        self.assertEqual(creds["mcp"]["docs"], {"bearerToken": "old-token"})
58        self.assertEqual(creds["providers"][app.profile], {"apiKey": "fixture-key"})
59        self.assertEqual(stat.S_IMODE((app.home / "creds.json").stat().st_mode), 0o600)
60        config = json.loads((app.home / "config.json").read_text())
61        self.assertNotIn("apiKey", config["providers"][app.profile])
62        self.assertFalse((app.home / "auth.json").exists())
63        self.assertFalse((app.home / "mcp-credentials.json").exists())
64        backups = {path.name.split("-before-creds-")[0] for path in self.backups(app)}
65        self.assertEqual(backups, {"auth.json", "mcp-credentials.json", "config.json"})
66        with app.api("/server") as response:
67            notices = json.load(response)["notices"]
68        migrated = " ".join(
69            notice["message"] for notice in notices if notice["kind"] == "migration"
70        )
71        for name in ("auth.json", "mcp-credentials.json", "config.json"):
72            self.assertIn(name, migrated)
73        self.assertNotIn("old-access", migrated)
74        self.assertNotIn("old-token", migrated)
75        # Not even their owner reads them without a chmod first.
76        self.assertEqual(
77            {stat.S_IMODE(path.stat().st_mode) for path in self.backups(app)}, {0}
78        )
79        # The moved key still reaches the provider.
80        self.assertEqual(self.authorization(app), "Bearer fixture-key")
81
82    @exclusive
83    def test_clients_change_secrets_without_reading_them(self):
84        # No legacy config key: clients store and rotate only this fixture's
85        # credential, without depending on startup migration of config.json.
86        app = Albedo(
87            self.provider,
88            providers={
89                f"fixture-{self.provider.route}": {
90                    "baseUrl": self.provider.url,
91                    "model": "fixture-model",
92                    "protocol": "chat_completions",
93                }
94            },
95        )
96        app.__enter__()
97        self.addCleanup(app.__exit__, None, None, None)
98        server = app.profile
99
100        def patch(group, body):
101            route = f"/settings?group={group}"
102            with app.api(route) as response:
103                json.load(response)
104                revision = response.headers["ETag"]
105            with app.api(
106                route,
107                body,
108                method="PATCH",
109                headers={
110                    "If-Match": revision,
111                    "Content-Type": "application/merge-patch+json",
112                },
113            ) as response:
114                return json.load(response)["resource"]["value"]
115
116        patch("providers", {"profiles": {app.profile: {"api_key": "fixture-key"}}})
117        self.assertEqual(self.authorization(app), "Bearer fixture-key")
118        patch("providers", {"profiles": {app.profile: {"api_key": "rotated-key"}}})
119        self.assertEqual(self.authorization(app), "Bearer rotated-key")
120        value = patch(
121            "mcp",
122            {
123                "definitions": {
124                    server: {
125                        "enabled": False,
126                        "transport": "http",
127                        "url": "http://127.0.0.1:1/mcp",
128                        "secrets": {
129                            "bearer_token": "secret-token",
130                            "headers": {"X-Team": "secret-team"},
131                        },
132                    }
133                }
134            },
135        )
136        self.assertEqual(
137            value["definitions"][server]["secret_presence"],
138            {"bearer_token": True, "headers": ["X-Team"], "environment": []},
139        )
140        value = patch(
141            "mcp",
142            {
143                "definitions": {
144                    server: {
145                        "secrets": {
146                            "bearer_token": None,
147                            "environment": {"TOKEN": "secret-env"},
148                        }
149                    }
150                }
151            },
152        )
153        self.assertEqual(
154            value["definitions"][server]["secret_presence"],
155            {"bearer_token": False, "headers": ["X-Team"], "environment": ["TOKEN"]},
156        )
157        summary = self.summary(app)
158        self.assertFalse(
159            any(
160                secret in summary
161                for secret in (
162                    "rotated-key",
163                    "secret-token",
164                    "secret-team",
165                    "secret-env",
166                    "fixture-key",
167                )
168            )
169        )
170        patch("mcp", {"definitions": {server: None}})
171        with (app.home / "creds.json").open() as source:
172            self.assertNotIn(server, json.load(source).get("mcp", {}))
173
174    def backups(self, app):
175        return list((app.home / "backups").glob("*-before-creds-*"))