Skip to content

Commit c475733

Browse files
committed
Replace the Chat sandbox when its pasted key entries change
1 parent 41cf94d commit c475733

16 files changed

Lines changed: 116 additions & 31 deletions

‎backend/druks/agents.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,14 +82,15 @@ async def _runner(
8282
await set_run_phase("provisioning_vm")
8383
# A box that fetches gets its own identity, and the key names it. A
8484
# replay finds the box through the identity, above.
85-
identity, entries, key = None, {}, config.secrets_id
85+
identity, entries, key = None, {}, config.secrets_hash
8686
if refs:
8787
identity, entries = await SandboxIdentity.create(
8888
session,
8989
account_id=workflow.account_id,
9090
run_id=workflow_id,
9191
scoped_to=step,
9292
secret_refs=refs,
93+
secrets_hash=config.secrets_hash,
9394
)
9495
key = identity.id
9596
vm = sandbox_client.ephemeral(

‎backend/druks/chat/service.py‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,12 @@ async def get_sandbox(
128128
await session.commit()
129129
async with lock(f"chat:account:{account_id}"):
130130
identity = await SandboxIdentity.lookup(
131-
session, account_id=account_id, run_id=None, scoped_to="chat", secret_refs=refs
131+
session,
132+
account_id=account_id,
133+
run_id=None,
134+
scoped_to="chat",
135+
secret_refs=refs,
136+
secrets_hash=config.secrets_hash,
132137
)
133138
if identity:
134139
with suppress(ChatSandboxGone):
@@ -140,7 +145,12 @@ async def get_sandbox(
140145
await sandbox_client.release(host_id=previous.host_id)
141146
template = await get_template_id(CHAT_SANDBOX)
142147
identity, entries = await SandboxIdentity.create(
143-
session, account_id=account_id, run_id=None, scoped_to="chat", secret_refs=refs
148+
session,
149+
account_id=account_id,
150+
run_id=None,
151+
scoped_to="chat",
152+
secret_refs=refs,
153+
secrets_hash=config.secrets_hash,
144154
)
145155
host = await sandbox_client.provision(
146156
idempotency_key=f"chat:{account_id}:{identity.id}",

‎backend/druks/harnesses/config.py‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,17 +48,19 @@ def model_id(self) -> str:
4848
return self.model.partition("/")[2]
4949

5050
@property
51-
def secrets_id(self) -> str:
52-
"""What a box created for this config holds: a digest of the pasted key's version
51+
def secrets_hash(self) -> str:
52+
"""A digest of what a box created for this config holds: the pasted key's version
5353
and its entries without the value, or the subscriptions it fetches."""
5454
if self.secrets:
5555
# Harnesses can read one key from different variables, and a box keeps the
5656
# entries it was created with.
5757
version = f"{self.api_key.audience_name}.{self.api_key.updated_at:%Y%m%dT%H%M%S}"
5858
entries = {name: entry.get_service() for name, entry in self.secrets.items()}
59-
canonical = json.dumps([version, entries], sort_keys=True)
60-
return hashlib.sha256(canonical.encode()).hexdigest()
61-
return ".".join(ref.secret_id for ref in self.secret_refs)
59+
contents = [version, entries]
60+
else:
61+
contents = [ref.secret_id for ref in self.secret_refs]
62+
canonical = json.dumps(contents, sort_keys=True)
63+
return hashlib.sha256(canonical.encode()).hexdigest()
6264

6365
@property
6466
def charged_account_id(self) -> str | None:

‎backend/druks/sandbox/models.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ class SandboxIdentity(Base, Uuid7Pk):
8181
secret_refs: Mapped[list[SecretRef]] = relationship(
8282
back_populates="identity", cascade="all, delete-orphan", lazy="selectin"
8383
)
84+
# The secrets hash of the agent config that created the box. Drukbox binds a pasted
85+
# key's entries at creation, so another hash needs another box.
86+
secrets_hash: Mapped[str]
8487

8588
@classmethod
8689
async def create(
@@ -91,6 +94,7 @@ async def create(
9194
run_id: str | None,
9295
scoped_to: str,
9396
secret_refs: list[SecretRef],
97+
secrets_hash: str,
9498
) -> tuple["SandboxIdentity", dict[str, Issuer]]:
9599
"""The committed identity and the issuer entries for its box. Committed
96100
before the box exists: Drukbox fetches an issuer during provisioning."""
@@ -109,6 +113,7 @@ async def create(
109113
)
110114
for ref in secret_refs
111115
],
116+
secrets_hash=secrets_hash,
112117
token_hash=hashlib.sha256(bearer.encode()).digest(),
113118
created_at=now,
114119
# The box lease bounds the identity: a box never outlives it.
@@ -149,10 +154,11 @@ async def lookup(
149154
run_id: str | None,
150155
scoped_to: str,
151156
secret_refs: list[SecretRef] | None = None,
157+
secrets_hash: str | None = None,
152158
) -> "SandboxIdentity | None":
153159
"""Find a live box for this account, run, and scope.
154160
155-
Omit secret_refs to accept the box's current secrets.
161+
Omit secret_refs and secrets_hash to accept the box's current secrets.
156162
"""
157163
rows = await session.scalars(
158164
select(cls)
@@ -174,6 +180,7 @@ async def lookup(
174180
if row.is_live
175181
and (not row.run or row.run.is_active)
176182
and (wanted is None or {ref.key for ref in row.secret_refs} == wanted)
183+
and (secrets_hash is None or row.secrets_hash == secrets_hash)
177184
),
178185
None,
179186
)

‎backend/druks/workflows.py‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -854,7 +854,7 @@ def __init__(self) -> None:
854854
# The run's warm VM, provisioned lazily and reaped at segment boundaries;
855855
# its lease expiry decides when it must rotate.
856856
self._host: Host | None = None
857-
self._host_secrets_id = ""
857+
self._host_secrets_hash = ""
858858

859859
async def announce(self, topic: str, **facts: Any) -> None:
860860
"""Record a domain fact, then notify subscribers in a separate checkpoint."""
@@ -939,15 +939,15 @@ async def _lease_host(
939939
)
940940
if identity:
941941
self._host = await sandbox_client.reattach(host_id=identity.host_id)
942-
self._host_secrets_id = config.secrets_id
942+
self._host_secrets_hash = config.secrets_hash
943943
if self._host and self._host.expires_at:
944944
remaining = (self._host.expires_at - datetime.now(UTC)).total_seconds()
945945
if remaining < SANDBOX_HOST_ROTATE_BEFORE_SECONDS:
946946
# The lease can't cover another worst-case call; rotate to a fresh
947947
# host. Safe because each call rebuilds its workspace on whatever
948948
# host it lands on (state lives in git), so a bare VM is fine.
949949
await self._reap_run()
950-
if self._host and self._host_secrets_id != config.secrets_id:
950+
if self._host and self._host_secrets_hash != config.secrets_hash:
951951
# Drukbox binds entries at creation.
952952
await self._reap_run()
953953
if not self._host:
@@ -958,14 +958,15 @@ async def _lease_host(
958958
# The key names the pasted key the VM holds, so a replay finds its VM.
959959
# A box that fetches gets its own identity, and the key names that
960960
# instead. A replay finds the box through the identity, above.
961-
identity, entries, key = None, {}, config.secrets_id
961+
identity, entries, key = None, {}, config.secrets_hash
962962
if refs:
963963
identity, entries = await SandboxIdentity.create(
964964
session,
965965
account_id=self.account_id,
966966
run_id=self._workflow_id,
967967
scoped_to="workflow",
968968
secret_refs=refs,
969+
secrets_hash=config.secrets_hash,
969970
)
970971
key = identity.id
971972
self._host = await sandbox_client.provision(
@@ -974,7 +975,7 @@ async def _lease_host(
974975
template=template,
975976
identity=identity,
976977
)
977-
self._host_secrets_id = config.secrets_id
978+
self._host_secrets_hash = config.secrets_hash
978979
return self._host.id
979980

980981
async def _reap_run(self) -> None:
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Record the secrets hash of the config that created a sandbox.
2+
3+
Revision ID: 3b8e1f6c9d24
4+
Revises: 9e47b68530e2
5+
Create Date: 2026-10-03
6+
"""
7+
8+
from collections.abc import Sequence
9+
10+
import sqlalchemy as sa
11+
from alembic import op
12+
13+
revision: str = "3b8e1f6c9d24"
14+
down_revision: str | Sequence[str] | None = "9e47b68530e2"
15+
branch_labels: str | Sequence[str] | None = None
16+
depends_on: str | Sequence[str] | None = None
17+
18+
19+
def upgrade() -> None:
20+
op.add_column(
21+
"sandbox_identities",
22+
sa.Column("secrets_hash", sa.String(), nullable=False, server_default=""),
23+
)
24+
25+
26+
def downgrade() -> None:
27+
op.drop_column("sandbox_identities", "secrets_hash")

‎backend/tests/test_agent_config.py‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ async def test_a_subscription_agent_runs_as_its_actor_or_the_default_account(dru
149149
assert as_actor.charged_account_id == actor.account_id
150150
assert unattended.subscription.id == default_subscription.id
151151
assert as_actor.identity == {"email": "b@example.com"}
152-
assert (as_actor.secrets, as_actor.secrets_id) == ({}, actor.id)
152+
assert not as_actor.secrets
153153
[secret] = as_actor.secret_refs
154154
assert secret.key == ("anthropic", actor.id, "", "")
155155
assert as_actor.harness_class is ClaudeHarness
@@ -191,7 +191,6 @@ async def test_a_codex_subscription_config_carries_its_login_facts_and_its_ref(d
191191
assert config.identity == {"email": "a@example.com", "account_id": "acc-1", "plan": "pro"}
192192
[ref] = config.secret_refs
193193
assert ref.key == ("codex_subscription_token", subscription.id, "", "chatgpt.com")
194-
assert config.secrets_id == subscription.id
195194

196195

197196
async def test_a_subscription_agent_refuses_without_the_actors_own_subscription(druks_db):
@@ -216,7 +215,7 @@ async def test_a_key_agent_runs_on_the_installations_key_for_anyone(druks_db):
216215
assert (as_actor.secrets, as_actor.subscription) == ({"anthropic": _SHARED_ENTRY}, None)
217216
assert (unattended.secrets, unattended.identity) == ({"anthropic": _SHARED_ENTRY}, {})
218217
# The entries' identity is the pasted key, with no secret material.
219-
assert "sk-shared" not in as_actor.secrets_id
218+
assert "sk-shared" not in as_actor.secrets_hash
220219
# The key is nobody's, so its calls are charged to the installation.
221220
assert as_actor.charged_account_id is None
222221

@@ -245,7 +244,7 @@ def test_a_key_box_serves_another_harness_only_when_it_reads_the_same_variable(
245244
effort="high",
246245
timeout=60,
247246
fast_mode=False,
248-
).secrets_id
247+
).secrets_hash
249248
for harness in (first, second)
250249
)
251250
assert (first_id == second_id) is is_shared

‎backend/tests/test_agents.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -890,7 +890,7 @@ async def fake_provision(self, *, idempotency_key=None, **_kwargs):
890890

891891
monkeypatch.setattr("druks.sandbox.client.Client.provision", fake_provision)
892892

893-
config = SimpleNamespace(secrets={}, secret_refs=[], secrets_id="")
893+
config = SimpleNamespace(secrets={}, secret_refs=[], secrets_hash="")
894894
with pytest.raises(HarnessSandboxProvisioningError):
895895
await current_run._lease_host(db_session(), config, [])
896896
host_id = await current_run._lease_host(db_session(), config, [])
@@ -966,7 +966,7 @@ async def fake_ephemeral(self, *, idempotency_key, secrets, **_kwargs):
966966
]
967967
config = sandbox.run_agent.await_args.kwargs["config"]
968968
# The VM's key names the pasted key, never its value.
969-
assert keys == [f"wf-9:dummy:{config.secrets_id}"]
969+
assert keys == [f"wf-9:dummy:{config.secrets_hash}"]
970970
assert (config.billing, config.subscription) == ("api_key", None)
971971
[call] = await AgentCall.list_for_run(db_session(), "wf-9")
972972
assert (call.subscription_id, call.api_key.audience_name) == (None, "anthropic")
@@ -1061,6 +1061,7 @@ async def test_a_replay_resumes_the_ephemeral_box_through_its_identity(
10611061
run_id="wf-9",
10621062
scoped_to="dummy",
10631063
secret_refs=[SecretRef(name="anthropic", secret_id=subscription.id)],
1064+
secrets_hash="",
10641065
)
10651066
await identity.bind("host-crashed")
10661067
resumed: list[str] = []

‎backend/tests/test_chat.py‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from contextlib import asynccontextmanager
55
from datetime import timedelta
66
from types import SimpleNamespace
7-
from unittest.mock import AsyncMock, MagicMock
7+
from unittest.mock import AsyncMock, MagicMock, call
88
from uuid import uuid4
99

1010
import asyncssh
@@ -72,7 +72,12 @@ async def list_messages(session, conversation):
7272
@pytest.fixture
7373
async def sandbox(druks_db, conversation, monkeypatch):
7474
identity, _ = await SandboxIdentity.create(
75-
druks_db, account_id=conversation.account_id, run_id=None, scoped_to="chat", secret_refs=[]
75+
druks_db,
76+
account_id=conversation.account_id,
77+
run_id=None,
78+
scoped_to="chat",
79+
secret_refs=[],
80+
secrets_hash="",
7681
)
7782
await identity.bind("chat-sandbox")
7883
host = SimpleNamespace(
@@ -157,6 +162,7 @@ async def test_chat_identity_authenticates_until_lease_expiry_and_is_not_an_orph
157162
secret_refs=[
158163
SecretRef(name="mcp_druks_token", secret_id=token.id, host="hooks.example.com")
159164
],
165+
secrets_hash="",
160166
)
161167
await identity.bind("chat-sandbox")
162168
bearer = entries["mcp_druks_token"].headers["Authorization"].removeprefix("Bearer ")
@@ -200,7 +206,7 @@ async def attach(*, host_id):
200206
second = await Conversation.create(
201207
druks_db, account_id=conversation.account_id, body="another conversation"
202208
)
203-
config = SimpleNamespace(secret_refs=[], secrets={})
209+
config = SimpleNamespace(secret_refs=[], secrets={}, secrets_hash="key-one")
204210
first_host, _identity = await service.get_sandbox(
205211
druks_db,
206212
conversation.account_id,
@@ -217,7 +223,9 @@ async def attach(*, host_id):
217223
)
218224
login = await get_druks_account_token(druks_db, conversation.account_id, (), name="login")
219225
moved = SimpleNamespace(
220-
secret_refs=[SecretRef(name="claude_token", secret_id=login.id)], secrets={}
226+
secret_refs=[SecretRef(name="claude_token", secret_id=login.id)],
227+
secrets={},
228+
secrets_hash=login.id,
221229
)
222230
moved_host, _identity = await service.get_sandbox(
223231
druks_db,
@@ -226,10 +234,19 @@ async def attach(*, host_id):
226234
allowed_tools=Toolkit.ALL,
227235
secret_refs=[],
228236
)
237+
# A pasted key's entries are bound when the box is created, so another key needs a new box.
238+
rekeyed_host, _identity = await service.get_sandbox(
239+
druks_db,
240+
second.account_id,
241+
config=SimpleNamespace(secret_refs=moved.secret_refs, secrets={}, secrets_hash="key-two"),
242+
allowed_tools=Toolkit.ALL,
243+
secret_refs=[],
244+
)
229245

230246
assert first_host.id == second_host.id
231247
assert moved_host.id != first_host.id
232-
release.assert_awaited_once_with(host_id=first_host.id)
248+
assert rekeyed_host.id != moved_host.id
249+
assert release.await_args_list == [call(host_id=first_host.id), call(host_id=moved_host.id)]
233250

234251

235252
async def test_delivered_turn_is_sent_again_when_the_bridge_never_got_the_prompt(

‎backend/tests/test_declared_sandboxes.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ async def test_warm_lease_uses_workflow_template(monkeypatch):
240240

241241
assert (
242242
await workflow._lease_host(
243-
db_session(), SimpleNamespace(secrets={}, secret_refs=[], secrets_id=""), []
243+
db_session(), SimpleNamespace(secrets={}, secret_refs=[], secrets_hash=""), []
244244
)
245245
== "host-1"
246246
)
@@ -275,7 +275,7 @@ async def ephemeral(**kwargs):
275275
)
276276
monkeypatch.setattr(agent_module, "get_template_id", resolve)
277277

278-
config = SimpleNamespace(secrets={}, secret_refs=[], secrets_id="")
278+
config = SimpleNamespace(secrets={}, secret_refs=[], secrets_hash="")
279279
async with agent_module._runner(
280280
db_session(), workflow, None, "run-1", "summarize", config, []
281281
) as runner:

0 commit comments

Comments
 (0)