agentic-runner 3.2.0__tar.gz → 3.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/PKG-INFO +1 -1
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/__init__.py +1 -1
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/activities.py +109 -25
- agentic_runner-3.3.0/src/agentic_runner/auth_mode.py +86 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/config.py +5 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/device_login_activities.py +24 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/llm_proxy.py +101 -17
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/sealed_box.py +24 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/service.py +62 -3
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/testing/__main__.py +12 -1
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/testing/control_plane.py +174 -3
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/workers/_runtime_support.py +6 -1
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/workers/agent_runtime.py +16 -5
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/workers/claude_runtime.py +5 -2
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/workers/codex_runtime.py +61 -4
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/workers/command_policy.py +9 -1
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/workers/contract_device_login.py +15 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/.gitignore +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/LICENSE +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/README.md +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/pyproject.toml +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/attempts.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/build.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/callback.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/child_watcher.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/cli.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/credentials.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/egress.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/heartbeat_link.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/hooks.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/host_store.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/integrations/__init__.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/integrations/github/auth.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/lifecycle.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/mcp.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/message_store.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/private_state.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/py.typed +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/recipient_key_secret.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/registration.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/runtime/__init__.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/testing/__init__.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/testing/plugin.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/testing/test_conformance.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/testing/workflow.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/tiny_http.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/triage_activities.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/user_sources.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/workers/__init__.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/workers/contract_isolation.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/workers/harness_usage.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/workers/mcp_config.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/workers/settings.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/workers/skills.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/workstation.py +0 -0
|
@@ -44,6 +44,11 @@ from agentic_runner.attempts import (
|
|
|
44
44
|
fence_work_record,
|
|
45
45
|
refuse_if_prior_attempt_alive,
|
|
46
46
|
)
|
|
47
|
+
from agentic_runner.auth_mode import (
|
|
48
|
+
AuthModeRefusedError,
|
|
49
|
+
choose_auth_mode,
|
|
50
|
+
is_shared_runner,
|
|
51
|
+
)
|
|
47
52
|
from agentic_runner.callback import (
|
|
48
53
|
CALLBACK_TOKEN_ENV,
|
|
49
54
|
AnnotateRequest,
|
|
@@ -118,11 +123,12 @@ from agentic_runner.workers._runtime_support import (
|
|
|
118
123
|
)
|
|
119
124
|
from agentic_runner.workers.agent_runtime import (
|
|
120
125
|
AgentRuntime,
|
|
121
|
-
|
|
126
|
+
AuthMode,
|
|
122
127
|
DirectiveRequest,
|
|
123
128
|
DirectiveResult,
|
|
124
129
|
ResumableAgentRuntime,
|
|
125
130
|
)
|
|
131
|
+
from agentic_runner.workers.contract_device_login import login_file_counts
|
|
126
132
|
from agentic_runner.workers.contract_isolation import (
|
|
127
133
|
NO_CONTRACT,
|
|
128
134
|
ContractIsolation,
|
|
@@ -638,6 +644,9 @@ class _DirectiveAttempt:
|
|
|
638
644
|
# Console-v2 issue 28: the attached Skills, for a runtime with no skills directory to
|
|
639
645
|
# write them into. Prefixed to the Directive prompt; empty when there is none.
|
|
640
646
|
skills_preamble: str = ""
|
|
647
|
+
# Chosen before anything is spawned (local-agents 04); the runtime runs in it and
|
|
648
|
+
# the harness-usage report follows it.
|
|
649
|
+
auth_mode: AuthMode = AuthMode.API_KEY
|
|
641
650
|
|
|
642
651
|
async def run_environment_hook(self) -> None:
|
|
643
652
|
self.extra_env.update(await self.hooks.environment())
|
|
@@ -1432,6 +1441,7 @@ HARNESS_SESSION_EVIDENCE_SOURCE = "runner.harness_session"
|
|
|
1432
1441
|
DIRECTIVE_TOKEN_EVIDENCE_SOURCE = "runner.directive_token"
|
|
1433
1442
|
AGENT_RUNTIME_EVIDENCE_SOURCE = "runner.agent_runtime"
|
|
1434
1443
|
SKILLS_EVIDENCE_SOURCE = "runner.skills"
|
|
1444
|
+
HARNESS_LOGIN_RESIDUE_SOURCE = "runner.harness_login_residue"
|
|
1435
1445
|
|
|
1436
1446
|
|
|
1437
1447
|
class _EvidenceWriter(Protocol):
|
|
@@ -1578,6 +1588,10 @@ class RunnerRalphActivities:
|
|
|
1578
1588
|
# no proxy here keeps calling the backend one, which is exactly the transition
|
|
1579
1589
|
# the issue describes.
|
|
1580
1590
|
self._llm_proxy = llm_proxy
|
|
1591
|
+
# Contracts whose harness roots this process has already checked for a login
|
|
1592
|
+
# left behind (local-agents 04). Only de-duplicates an Evidence line -- no later
|
|
1593
|
+
# activity reads it -- so a restart reporting the residue again is the intent.
|
|
1594
|
+
self._residue_checked: set[str | None] = set()
|
|
1581
1595
|
# Credential References (PRD issue 48, 22 A1). None means this Runner resolves
|
|
1582
1596
|
# none: a Contract that declares a manifest then fails its Directives closed,
|
|
1583
1597
|
# which is the right answer -- a Runner with no host store cannot host that
|
|
@@ -1969,6 +1983,7 @@ class RunnerRalphActivities:
|
|
|
1969
1983
|
extra_env=attempt.env,
|
|
1970
1984
|
mcp_servers=attempt.mcp_servers,
|
|
1971
1985
|
egress_allow_list=attempt.egress_allow_list,
|
|
1986
|
+
auth_mode=attempt.auth_mode,
|
|
1972
1987
|
prompt=attempt.skills_preamble
|
|
1973
1988
|
+ _directive_prompt(
|
|
1974
1989
|
completion_criteria=runtime_state.completion_criteria,
|
|
@@ -2000,6 +2015,7 @@ class RunnerRalphActivities:
|
|
|
2000
2015
|
work_record_id=request.work_record_id,
|
|
2001
2016
|
directive_number=request.directive_number,
|
|
2002
2017
|
result=codex_result,
|
|
2018
|
+
auth_mode=attempt.auth_mode,
|
|
2003
2019
|
)
|
|
2004
2020
|
plan = _take_plan(checkout_workspace)
|
|
2005
2021
|
if codex_result.exit_code != 0:
|
|
@@ -2419,6 +2435,8 @@ class RunnerRalphActivities:
|
|
|
2419
2435
|
the bearer defensible under ADR-0011 §9.
|
|
2420
2436
|
"""
|
|
2421
2437
|
|
|
2438
|
+
auth_mode = await self._directive_auth_mode(work_record_id, state)
|
|
2439
|
+
await self._report_login_residue(work_record_id, state)
|
|
2422
2440
|
facts = AttemptFacts(
|
|
2423
2441
|
work_record_id=work_record_id,
|
|
2424
2442
|
directive_id=directive_id(
|
|
@@ -2476,8 +2494,8 @@ class RunnerRalphActivities:
|
|
|
2476
2494
|
)
|
|
2477
2495
|
)
|
|
2478
2496
|
callback_env = server.env()
|
|
2479
|
-
if self._llm_proxy is not None and
|
|
2480
|
-
# API-key mode only (issue 43): a
|
|
2497
|
+
if self._llm_proxy is not None and auth_mode == AuthMode.API_KEY:
|
|
2498
|
+
# API-key mode only (issue 43): a subscription Directive never has a slot
|
|
2481
2499
|
# to resolve, and issue 31 meters it from the harness's own output. One
|
|
2482
2500
|
# bearer serves the callback socket and the proxy alike.
|
|
2483
2501
|
proxy_attempt = await stack.enter_async_context(
|
|
@@ -2528,6 +2546,7 @@ class RunnerRalphActivities:
|
|
|
2528
2546
|
mcp_servers=mcp_servers,
|
|
2529
2547
|
egress_allow_list=egress_allow_list,
|
|
2530
2548
|
skills_preamble=delivered_preamble,
|
|
2549
|
+
auth_mode=auth_mode,
|
|
2531
2550
|
)
|
|
2532
2551
|
|
|
2533
2552
|
async def _refuse_skill_digest_mismatch(
|
|
@@ -2805,26 +2824,86 @@ class RunnerRalphActivities:
|
|
|
2805
2824
|
)
|
|
2806
2825
|
raise
|
|
2807
2826
|
|
|
2808
|
-
def
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2827
|
+
async def _directive_auth_mode(
|
|
2828
|
+
self, work_record_id: str, state: _RuntimeContextState
|
|
2829
|
+
) -> AuthMode:
|
|
2830
|
+
"""This Directive's auth mode, or a non-retryable refusal with Evidence naming the
|
|
2831
|
+
rule (local-agents 04).
|
|
2832
|
+
|
|
2833
|
+
Decided from the host party this process was registered with and from whether a
|
|
2834
|
+
key is present for the Contract -- never from the payload. A runtime that declares
|
|
2835
|
+
no modes (a test double built before the port grew the field) reads as
|
|
2836
|
+
``api_key``-only, and a kind this Runner does not serve reads the same way; that
|
|
2837
|
+
Directive fails closed in :meth:`_agent_runtime_for` regardless.
|
|
2815
2838
|
"""
|
|
2816
2839
|
|
|
2817
|
-
return self._auth_model(state) == AuthModel.API_KEY
|
|
2818
|
-
|
|
2819
|
-
def _auth_model(self, state: _RuntimeContextState) -> AuthModel:
|
|
2820
|
-
# `getattr` with a default, not `.auth_model` outright: a runtime that carries
|
|
2821
|
-
# none (a test double built before this port grew the field) is the same case as
|
|
2822
|
-
# `api_key` -- nothing to report from the harness -- never a crash on an
|
|
2823
|
-
# unrelated Directive. A kind this Runner does not serve reads the same way; the
|
|
2824
|
-
# Directive itself has already failed closed in :meth:`_agent_runtime_for`.
|
|
2825
2840
|
runtime = self._agent_runtimes.get(state.cli_kind)
|
|
2826
|
-
|
|
2827
|
-
|
|
2841
|
+
host_party = self._routing_identity.host_party if self._routing_identity else None
|
|
2842
|
+
try:
|
|
2843
|
+
return choose_auth_mode(
|
|
2844
|
+
host_party=host_party,
|
|
2845
|
+
key_present=self._api_key_present(state, runtime),
|
|
2846
|
+
runtime_modes=getattr(runtime, "auth_modes", frozenset({AuthMode.API_KEY})),
|
|
2847
|
+
)
|
|
2848
|
+
except AuthModeRefusedError as refusal:
|
|
2849
|
+
await _raise_recorded(
|
|
2850
|
+
ApplicationError(str(refusal), type=refusal.rule, non_retryable=True),
|
|
2851
|
+
self._fastapi_client,
|
|
2852
|
+
work_record_id,
|
|
2853
|
+
source=AGENT_RUNTIME_EVIDENCE_SOURCE,
|
|
2854
|
+
actor=self._actor(),
|
|
2855
|
+
payload={
|
|
2856
|
+
"event": "directive.auth_mode_refused",
|
|
2857
|
+
"rule": refusal.rule,
|
|
2858
|
+
"cli_kind": state.cli_kind,
|
|
2859
|
+
"host_party": host_party,
|
|
2860
|
+
"contract_id": state.contract_id,
|
|
2861
|
+
},
|
|
2862
|
+
)
|
|
2863
|
+
|
|
2864
|
+
def _api_key_present(self, state: _RuntimeContextState, runtime: AgentRuntime | None) -> bool:
|
|
2865
|
+
"""A delivered slot for the Contract (or its funder), or the host operator's key."""
|
|
2866
|
+
|
|
2867
|
+
if bool(getattr(runtime, "host_api_key", False)):
|
|
2868
|
+
return True
|
|
2869
|
+
contract_id = _optional_uuid(state.contract_id)
|
|
2870
|
+
return (
|
|
2871
|
+
self._llm_proxy is not None
|
|
2872
|
+
and contract_id is not None
|
|
2873
|
+
and self._llm_proxy.slots.holds(contract_id)
|
|
2874
|
+
)
|
|
2875
|
+
|
|
2876
|
+
async def _report_login_residue(self, work_record_id: str, state: _RuntimeContextState) -> None:
|
|
2877
|
+
"""On a shared Runner, say once that a Contract's harness root holds a login.
|
|
2878
|
+
|
|
2879
|
+
A count per harness, never a path or a byte of the file: the Runner does not open
|
|
2880
|
+
it, a shared Runner never runs on it, and ``wipe_contract_residue`` removes it with
|
|
2881
|
+
the Contract. Saying so is what turns a sign-in made before local-agents 03 (or by
|
|
2882
|
+
hand) into something the Organisation can see and act on.
|
|
2883
|
+
"""
|
|
2884
|
+
|
|
2885
|
+
host_party = self._routing_identity.host_party if self._routing_identity else None
|
|
2886
|
+
if (
|
|
2887
|
+
self._contract_isolation is None
|
|
2888
|
+
or not is_shared_runner(host_party)
|
|
2889
|
+
or state.contract_id in self._residue_checked
|
|
2890
|
+
):
|
|
2891
|
+
return
|
|
2892
|
+
self._residue_checked.add(state.contract_id)
|
|
2893
|
+
counts = login_file_counts(self._contract_isolation, state.contract_id)
|
|
2894
|
+
if not counts:
|
|
2895
|
+
return
|
|
2896
|
+
await self._fastapi_client.append_evidence(
|
|
2897
|
+
work_record_id,
|
|
2898
|
+
source=HARNESS_LOGIN_RESIDUE_SOURCE,
|
|
2899
|
+
actor=self._actor(),
|
|
2900
|
+
payload={
|
|
2901
|
+
"event": "harness.login_residue",
|
|
2902
|
+
"contract_id": state.contract_id,
|
|
2903
|
+
"host_party": host_party,
|
|
2904
|
+
"login_files": counts,
|
|
2905
|
+
},
|
|
2906
|
+
)
|
|
2828
2907
|
|
|
2829
2908
|
async def _agent_runtime_for(
|
|
2830
2909
|
self, work_record_id: str, state: _RuntimeContextState
|
|
@@ -4516,6 +4595,7 @@ class RunnerRalphActivities:
|
|
|
4516
4595
|
extra_env=attempt.env,
|
|
4517
4596
|
mcp_servers=attempt.mcp_servers,
|
|
4518
4597
|
egress_allow_list=attempt.egress_allow_list,
|
|
4598
|
+
auth_mode=attempt.auth_mode,
|
|
4519
4599
|
prompt=attempt.skills_preamble
|
|
4520
4600
|
+ _learning_prompt(
|
|
4521
4601
|
request,
|
|
@@ -4534,6 +4614,7 @@ class RunnerRalphActivities:
|
|
|
4534
4614
|
work_record_id=request.work_record_id,
|
|
4535
4615
|
directive_number=request.directive_number,
|
|
4536
4616
|
result=result,
|
|
4617
|
+
auth_mode=attempt.auth_mode,
|
|
4537
4618
|
)
|
|
4538
4619
|
usage = await self._directive_usage(
|
|
4539
4620
|
work_record_id=request.work_record_id,
|
|
@@ -4764,6 +4845,7 @@ class RunnerRalphActivities:
|
|
|
4764
4845
|
extra_env=attempt.env,
|
|
4765
4846
|
mcp_servers=attempt.mcp_servers,
|
|
4766
4847
|
egress_allow_list=attempt.egress_allow_list,
|
|
4848
|
+
auth_mode=attempt.auth_mode,
|
|
4767
4849
|
prompt=attempt.skills_preamble + await kind.prompt(state, prompt_notes),
|
|
4768
4850
|
base_branch=request.base_ref,
|
|
4769
4851
|
work_branch=state.work_branch,
|
|
@@ -4789,6 +4871,7 @@ class RunnerRalphActivities:
|
|
|
4789
4871
|
work_record_id=request.work_record_id,
|
|
4790
4872
|
directive_number=request.directive_number,
|
|
4791
4873
|
result=codex_result,
|
|
4874
|
+
auth_mode=attempt.auth_mode,
|
|
4792
4875
|
)
|
|
4793
4876
|
# Taken before the commit below so it never lands on the branch (PRD 59).
|
|
4794
4877
|
plan = _take_plan(state.workspace_path)
|
|
@@ -4987,14 +5070,15 @@ class RunnerRalphActivities:
|
|
|
4987
5070
|
work_record_id: str,
|
|
4988
5071
|
directive_number: int,
|
|
4989
5072
|
result: DirectiveResult,
|
|
5073
|
+
auth_mode: AuthMode,
|
|
4990
5074
|
) -> None:
|
|
4991
|
-
"""A
|
|
4992
|
-
|
|
4993
|
-
|
|
4994
|
-
|
|
5075
|
+
"""A subscription Directive bypasses the LLM proxy, so this is the only metering
|
|
5076
|
+
the Runner can produce for it (PRD issue 31, 17 A9). A no-op for an ``api_key``
|
|
5077
|
+
Directive -- the proxy already metered that call at request time -- and never the
|
|
5078
|
+
reason a Directive fails (same posture as :meth:`_directive_usage`).
|
|
4995
5079
|
"""
|
|
4996
5080
|
|
|
4997
|
-
if
|
|
5081
|
+
if auth_mode == AuthMode.API_KEY:
|
|
4998
5082
|
return
|
|
4999
5083
|
directive = directive_id(work_record_id=work_record_id, directive_number=directive_number)
|
|
5000
5084
|
if runtime_state.cli_kind == "claude_code":
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Which auth mode one Directive runs in, decided on the Runner before spawn (local-agents 04).
|
|
2
|
+
|
|
3
|
+
Subscriptions follow the person, not the host (owner, 2026-09-26): a harness runs on a
|
|
4
|
+
subscription login only on the Contract person's own Runner -- host party ``user``, which
|
|
5
|
+
routing has already tied to the Contract's person (``assert_routed`` here, the platform's
|
|
6
|
+
``_subscription_bound`` there) -- whatever channel the Runner was installed by. A shared
|
|
7
|
+
Runner (``organisation`` or ``account``) runs every Directive through the LLM proxy on an
|
|
8
|
+
API key and refuses sign-in. The platform refuses an ineligible sign-in at the door
|
|
9
|
+
(local-agents 03); this module is the backstop, so it decides from what the Runner itself
|
|
10
|
+
knows and never from the payload.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Collection
|
|
16
|
+
from typing import Final
|
|
17
|
+
|
|
18
|
+
from agentic_runner.workers.agent_runtime import AuthMode
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"SETUP_TOKEN_REFUSED_RULE",
|
|
22
|
+
"SHARED_HOST_PARTIES",
|
|
23
|
+
"SHARED_RUNNER_SIGN_IN_RULE",
|
|
24
|
+
"SHARED_RUNNER_SUBSCRIPTION_RULE",
|
|
25
|
+
"AuthModeRefusedError",
|
|
26
|
+
"choose_auth_mode",
|
|
27
|
+
"is_setup_token",
|
|
28
|
+
"is_shared_runner",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
SHARED_HOST_PARTIES: Final[frozenset[str]] = frozenset({"organisation", "account"})
|
|
32
|
+
|
|
33
|
+
# The rule names a refusal's Evidence carries, so a support read can say which line held.
|
|
34
|
+
SHARED_RUNNER_SUBSCRIPTION_RULE: Final[str] = "shared_runner_api_key_only"
|
|
35
|
+
SHARED_RUNNER_SIGN_IN_RULE: Final[str] = "shared_runner_sign_in_refused"
|
|
36
|
+
SETUP_TOKEN_REFUSED_RULE: Final[str] = "setup_token_refused"
|
|
37
|
+
|
|
38
|
+
# `claude setup-token` mints an OAuth bearer under this prefix; an Anthropic API key is
|
|
39
|
+
# `sk-ant-api…`. The sealed wire carries no mode, so the value's own shape is what the
|
|
40
|
+
# Runner can refuse on.
|
|
41
|
+
_SETUP_TOKEN_PREFIX: Final[str] = "sk-ant-oat"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AuthModeRefusedError(RuntimeError):
|
|
45
|
+
"""A Directive or sign-in this Runner will not run; ``rule`` is the stable code."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, rule: str, message: str) -> None:
|
|
48
|
+
super().__init__(message)
|
|
49
|
+
self.rule = rule
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_shared_runner(host_party: str | None) -> bool:
|
|
53
|
+
return host_party in SHARED_HOST_PARTIES
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def choose_auth_mode(
|
|
57
|
+
*,
|
|
58
|
+
host_party: str | None,
|
|
59
|
+
key_present: bool,
|
|
60
|
+
runtime_modes: Collection[AuthMode],
|
|
61
|
+
) -> AuthMode:
|
|
62
|
+
"""``api_key`` whenever a key is present; otherwise ``subscription`` on a user-hosted
|
|
63
|
+
Runner whose runtime can run one, and a refusal on a shared Runner.
|
|
64
|
+
|
|
65
|
+
The refusal is the shared Runner's "subscription case": with no key there is nothing
|
|
66
|
+
for the proxy to spend, and the only thing left that could authenticate the harness is
|
|
67
|
+
a login, which a shared Runner never uses. An unregistered process (``host_party``
|
|
68
|
+
``None``) is not user-hosted either, so it never runs a subscription; it runs on a key
|
|
69
|
+
or fails at the proxy for want of one, as it would today.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
if key_present:
|
|
73
|
+
return AuthMode.API_KEY
|
|
74
|
+
if is_shared_runner(host_party):
|
|
75
|
+
raise AuthModeRefusedError(
|
|
76
|
+
SHARED_RUNNER_SUBSCRIPTION_RULE,
|
|
77
|
+
f"this Runner is hosted by {host_party!r}, so it runs API keys only, and no API "
|
|
78
|
+
"key is present for this Contract",
|
|
79
|
+
)
|
|
80
|
+
if host_party == "user" and AuthMode.SUBSCRIPTION in runtime_modes:
|
|
81
|
+
return AuthMode.SUBSCRIPTION
|
|
82
|
+
return AuthMode.API_KEY
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def is_setup_token(value: str) -> bool:
|
|
86
|
+
return value.strip().startswith(_SETUP_TOKEN_PREFIX)
|
|
@@ -51,6 +51,11 @@ class RunnerConfig(BaseModel):
|
|
|
51
51
|
# fails its Directives with Evidence naming the reference (22 A1), instead of running
|
|
52
52
|
# without the credential it was told to use.
|
|
53
53
|
credential_store: Path | None = None
|
|
54
|
+
# Where a delivered OpenAI / Anthropic key is spent (local-agents 04b), when not at
|
|
55
|
+
# the vendor itself: a gateway the host runs in front of it. The host already holds
|
|
56
|
+
# the opened value, so pointing it elsewhere grants the host nothing it lacked.
|
|
57
|
+
openai_base_url: str | None = None
|
|
58
|
+
anthropic_base_url: str | None = None
|
|
54
59
|
log_level: str = "info"
|
|
55
60
|
|
|
56
61
|
|
|
@@ -11,7 +11,9 @@ from collections.abc import Callable
|
|
|
11
11
|
from datetime import UTC, datetime
|
|
12
12
|
|
|
13
13
|
from temporalio import activity
|
|
14
|
+
from temporalio.exceptions import ApplicationError
|
|
14
15
|
|
|
16
|
+
from agentic_runner.auth_mode import SHARED_RUNNER_SIGN_IN_RULE
|
|
15
17
|
from agentic_runner.workers.contract_device_login import ContractDeviceLogin
|
|
16
18
|
from agentic_runner.workers.contract_isolation import ContractIsolation
|
|
17
19
|
from agentic_runner_contracts.activity_io import (
|
|
@@ -31,8 +33,12 @@ class ContractDeviceLoginActivities:
|
|
|
31
33
|
*,
|
|
32
34
|
contract_isolation: ContractIsolation,
|
|
33
35
|
device_login: ContractDeviceLogin | None = None,
|
|
36
|
+
host_party: str | None = None,
|
|
34
37
|
) -> None:
|
|
35
38
|
self._contract_isolation = contract_isolation
|
|
39
|
+
# The party this process was registered as hosted by. A shared Runner never runs a
|
|
40
|
+
# subscription, so it never starts or reads one either (local-agents 04).
|
|
41
|
+
self._host_party = host_party
|
|
36
42
|
# Injectable for tests; None falls through to the real `codex` subprocess.
|
|
37
43
|
self._device_login = device_login or ContractDeviceLogin(contract_isolation)
|
|
38
44
|
|
|
@@ -46,6 +52,7 @@ class ContractDeviceLoginActivities:
|
|
|
46
52
|
async def sign_in_contract_device_login(
|
|
47
53
|
self, request: ContractDeviceLoginInput
|
|
48
54
|
) -> ContractDeviceLoginResult:
|
|
55
|
+
self._refuse_on_shared_runner()
|
|
49
56
|
prompt = await self._device_login.sign_in(
|
|
50
57
|
request.contract_id, runtime_kind=request.runtime_kind
|
|
51
58
|
)
|
|
@@ -61,6 +68,7 @@ class ContractDeviceLoginActivities:
|
|
|
61
68
|
async def check_contract_device_login_status(
|
|
62
69
|
self, request: ContractDeviceLoginStatusInput
|
|
63
70
|
) -> ContractDeviceLoginStatusResult:
|
|
71
|
+
self._refuse_on_shared_runner()
|
|
64
72
|
present = self._device_login.token_present(
|
|
65
73
|
request.contract_id, runtime_kind=request.runtime_kind
|
|
66
74
|
)
|
|
@@ -77,3 +85,19 @@ class ContractDeviceLoginActivities:
|
|
|
77
85
|
token_present=present,
|
|
78
86
|
delivered_at=delivered_at,
|
|
79
87
|
)
|
|
88
|
+
|
|
89
|
+
def _refuse_on_shared_runner(self) -> None:
|
|
90
|
+
"""Non-retryable, and typed by the rule: there is no Work Record to write Evidence
|
|
91
|
+
on here, so the failure the platform's sign-in workflow receives names it.
|
|
92
|
+
|
|
93
|
+
Only a Runner known to be the person's own signs in: one whose state names no host
|
|
94
|
+
party (registered before issue 42) is refused too, as `choose_auth_mode` never runs
|
|
95
|
+
a subscription on it either."""
|
|
96
|
+
|
|
97
|
+
if self._host_party != "user":
|
|
98
|
+
raise ApplicationError(
|
|
99
|
+
f"this Runner is hosted by {self._host_party!r}; only a person's own Runner "
|
|
100
|
+
"signs in, a shared Runner runs API keys only",
|
|
101
|
+
type=SHARED_RUNNER_SIGN_IN_RULE,
|
|
102
|
+
non_retryable=True,
|
|
103
|
+
)
|
|
@@ -35,7 +35,7 @@ import contextlib
|
|
|
35
35
|
import json
|
|
36
36
|
import secrets
|
|
37
37
|
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Mapping
|
|
38
|
-
from dataclasses import dataclass, field
|
|
38
|
+
from dataclasses import dataclass, field, replace
|
|
39
39
|
from datetime import UTC, datetime
|
|
40
40
|
from types import TracebackType
|
|
41
41
|
from typing import Any, Final, Self
|
|
@@ -67,25 +67,35 @@ __all__ = [
|
|
|
67
67
|
"ATTEMPT_PREFIX",
|
|
68
68
|
"COMPLETION_PATHS",
|
|
69
69
|
"CALL_REFUSED_SOURCE",
|
|
70
|
+
"PROXY_API_KEY_ENV",
|
|
71
|
+
"PROXY_BASE_URL_ENV",
|
|
70
72
|
"PROXY_ENV_NAMES",
|
|
71
73
|
"AttemptHandle",
|
|
72
74
|
"CeilingStore",
|
|
73
75
|
"Ceilings",
|
|
74
76
|
"CredentialSlot",
|
|
77
|
+
"LLM_SLOT_REFERENCES",
|
|
78
|
+
"LlmProvider",
|
|
75
79
|
"LlmProxy",
|
|
76
80
|
"SLOT_REFUSED_SOURCE",
|
|
77
81
|
"SLOT_SWAPPED_SOURCE",
|
|
78
82
|
"SlotStore",
|
|
79
83
|
"UsageOutbox",
|
|
80
84
|
"attempt_env",
|
|
85
|
+
"llm_slot_references",
|
|
81
86
|
]
|
|
82
87
|
|
|
83
88
|
ATTEMPT_PREFIX: Final[str] = "/a/"
|
|
84
89
|
|
|
90
|
+
# The OpenAI-compatible half of `attempt_env`. Codex does not read the base URL from the
|
|
91
|
+
# environment, so its runtime lifts it from here into a provider of its own.
|
|
92
|
+
PROXY_BASE_URL_ENV: Final[str] = "OPENAI_BASE_URL"
|
|
93
|
+
PROXY_API_KEY_ENV: Final[str] = "OPENAI_API_KEY"
|
|
94
|
+
|
|
85
95
|
# Every name `attempt_env` may set. Reserved on a Directive's environment so no hook
|
|
86
96
|
# can redirect an Agent's traffic away from the metering point (`_runtime_support`).
|
|
87
97
|
PROXY_ENV_NAMES: Final[frozenset[str]] = frozenset(
|
|
88
|
-
{"ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN",
|
|
98
|
+
{"ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", PROXY_BASE_URL_ENV, PROXY_API_KEY_ENV}
|
|
89
99
|
)
|
|
90
100
|
|
|
91
101
|
# A Directive's context is the whole reason these bodies are large: Claude Code and Codex
|
|
@@ -105,10 +115,12 @@ _TOKEN_BYTES: Final[int] = 32
|
|
|
105
115
|
_LOOPBACK_HOSTS: Final[frozenset[str]] = frozenset({"127.0.0.1", "::1", "localhost"})
|
|
106
116
|
|
|
107
117
|
# The completion routes, relayed to the same path at the slot's own base URL: the
|
|
108
|
-
# OpenAI-compatible one the backend proxy served,
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
COMPLETION_PATHS: Final[frozenset[str]] = frozenset(
|
|
118
|
+
# OpenAI-compatible one the backend proxy served, Anthropic's, because Claude Code is one
|
|
119
|
+
# of the two harnesses the platform ships and it would otherwise still need a provider key
|
|
120
|
+
# of its own (ADR-0011 §9), and the Responses API, which is the only wire Codex speaks.
|
|
121
|
+
COMPLETION_PATHS: Final[frozenset[str]] = frozenset(
|
|
122
|
+
{"/chat/completions", "/messages", "/responses"}
|
|
123
|
+
)
|
|
112
124
|
_SSE_DONE: Final[bytes] = b"data: [DONE]"
|
|
113
125
|
|
|
114
126
|
# Anthropic rejects a request without it, whichever credential shape the request carries.
|
|
@@ -183,6 +195,62 @@ class CredentialSlot:
|
|
|
183
195
|
return f"{self.base_url.rstrip('/')}/{path.lstrip('/')}"
|
|
184
196
|
|
|
185
197
|
|
|
198
|
+
@dataclass(frozen=True, slots=True)
|
|
199
|
+
class LlmProvider:
|
|
200
|
+
"""Where a delivered key is spent, and how it is presented there.
|
|
201
|
+
|
|
202
|
+
Endpoints are plain config, never a secret (ADR-0013 §11), so the sealed wire does not
|
|
203
|
+
carry one: the Credential Reference's name says which provider a value belongs to and
|
|
204
|
+
this says where that provider is.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
name: str
|
|
208
|
+
runtime_kind: str
|
|
209
|
+
base_url: str
|
|
210
|
+
auth_style: str
|
|
211
|
+
|
|
212
|
+
def slot(self, *, reference: str, key_id: str, value: str) -> CredentialSlot:
|
|
213
|
+
return CredentialSlot(
|
|
214
|
+
reference=reference,
|
|
215
|
+
key_id=key_id,
|
|
216
|
+
provider_name=self.name,
|
|
217
|
+
base_url=self.base_url,
|
|
218
|
+
value=value,
|
|
219
|
+
runtime_kind=self.runtime_kind,
|
|
220
|
+
auth_style=self.auth_style,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# The Credential References a delivered value fills the proxy's slot from (local-agents
|
|
225
|
+
# 04b), named after the variable each harness reads its own key from, so a funder declares
|
|
226
|
+
# the name the vendor's docs already taught them. Any other reference stays a verb-seam or
|
|
227
|
+
# MCP credential and never reaches the proxy.
|
|
228
|
+
LLM_SLOT_REFERENCES: Final[Mapping[str, LlmProvider]] = {
|
|
229
|
+
"OPENAI_API_KEY": LlmProvider(
|
|
230
|
+
name="openai",
|
|
231
|
+
runtime_kind="codex_cli",
|
|
232
|
+
base_url="https://api.openai.com/v1",
|
|
233
|
+
auth_style="bearer",
|
|
234
|
+
),
|
|
235
|
+
"ANTHROPIC_API_KEY": LlmProvider(
|
|
236
|
+
name="anthropic",
|
|
237
|
+
runtime_kind="claude_code",
|
|
238
|
+
base_url="https://api.anthropic.com/v1",
|
|
239
|
+
auth_style="x-api-key",
|
|
240
|
+
),
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def llm_slot_references(base_urls: Mapping[str, str]) -> dict[str, LlmProvider]:
|
|
245
|
+
""":data:`LLM_SLOT_REFERENCES` with an operator's endpoint per provider name -- a
|
|
246
|
+
gateway in front of the vendor, or the chart test's fake provider."""
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
reference: replace(provider, base_url=base_urls.get(provider.name, provider.base_url))
|
|
250
|
+
for reference, provider in LLM_SLOT_REFERENCES.items()
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
|
|
186
254
|
SlotProber = Callable[[CredentialSlot], Awaitable[bool]]
|
|
187
255
|
|
|
188
256
|
|
|
@@ -267,6 +335,13 @@ class SlotStore:
|
|
|
267
335
|
)
|
|
268
336
|
return SlotProbe.VALID
|
|
269
337
|
|
|
338
|
+
def drop(self, contract_id: UUID) -> None:
|
|
339
|
+
"""Forget a Contract's slot: wiped, not listed (22 A9). The next call is refused."""
|
|
340
|
+
|
|
341
|
+
self._slots.pop(contract_id, None)
|
|
342
|
+
self._probes.pop(contract_id, None)
|
|
343
|
+
self._last_used.pop(contract_id, None)
|
|
344
|
+
|
|
270
345
|
def resolve(self, contract_id: UUID | None) -> CredentialSlot | None:
|
|
271
346
|
"""The slot this call spends, read at the call (22 A8)."""
|
|
272
347
|
|
|
@@ -281,6 +356,13 @@ class SlotStore:
|
|
|
281
356
|
self._last_used[contract_id] = datetime.now(UTC)
|
|
282
357
|
return slot
|
|
283
358
|
|
|
359
|
+
def holds(self, contract_id: UUID) -> bool:
|
|
360
|
+
"""Whether a call for this Contract would find a slot -- without the use stamp
|
|
361
|
+
:meth:`resolve` leaves, because choosing a Directive's mode spends nothing."""
|
|
362
|
+
|
|
363
|
+
funder = self._funded_by.get(contract_id)
|
|
364
|
+
return contract_id in self._slots or (funder is not None and funder in self._slots)
|
|
365
|
+
|
|
284
366
|
def statuses(self) -> list[SlotStatus]:
|
|
285
367
|
"""The heartbeat's slot fields (22 A10, issue 31's ``valid|invalid|unprobed``)."""
|
|
286
368
|
|
|
@@ -441,7 +523,7 @@ def attempt_env(cli_kind: str, *, base_url: str, token: str) -> dict[str, str]:
|
|
|
441
523
|
# CLIs are configured with the `/v1` already on. Same attempt, same bearer — only
|
|
442
524
|
# the half of the URL each harness expects to supply differs.
|
|
443
525
|
return {"ANTHROPIC_BASE_URL": base_url, "ANTHROPIC_AUTH_TOKEN": token}
|
|
444
|
-
return {
|
|
526
|
+
return {PROXY_BASE_URL_ENV: f"{base_url}/v1", PROXY_API_KEY_ENV: token}
|
|
445
527
|
|
|
446
528
|
|
|
447
529
|
class LlmProxy:
|
|
@@ -863,13 +945,14 @@ def _usage_block(body: Mapping[str, object]) -> Mapping[str, object]:
|
|
|
863
945
|
if isinstance(usage, Mapping):
|
|
864
946
|
return usage
|
|
865
947
|
# Anthropic's `message_start` frame nests the input counts one level down, under the
|
|
866
|
-
# message it is starting
|
|
867
|
-
#
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
948
|
+
# message it is starting, and the Responses API's `response.completed` under the
|
|
949
|
+
# response it completes. Each is followed only to reach that `usage` block -- nothing
|
|
950
|
+
# else in the frame is read (ADR-0010 §4).
|
|
951
|
+
for inner in (body.get("message"), body.get("response")):
|
|
952
|
+
if isinstance(inner, Mapping):
|
|
953
|
+
nested = inner.get("usage")
|
|
954
|
+
if isinstance(nested, Mapping):
|
|
955
|
+
return nested
|
|
873
956
|
return {}
|
|
874
957
|
|
|
875
958
|
|
|
@@ -931,7 +1014,8 @@ def _cached_tokens(usage: Mapping[str, object]) -> int | None:
|
|
|
931
1014
|
direct = _first_int(usage, "cached_tokens", "cache_read_input_tokens")
|
|
932
1015
|
if direct is not None:
|
|
933
1016
|
return direct
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
1017
|
+
for key in ("prompt_tokens_details", "input_tokens_details"):
|
|
1018
|
+
details = usage.get(key)
|
|
1019
|
+
if isinstance(details, Mapping):
|
|
1020
|
+
return _usage_int(details, "cached_tokens")
|
|
937
1021
|
return None
|