agentic-runner 3.2.0__tar.gz → 3.4.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.4.0}/PKG-INFO +1 -1
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/__init__.py +1 -1
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/activities.py +224 -26
- agentic_runner-3.4.0/src/agentic_runner/auth_mode.py +86 -0
- agentic_runner-3.4.0/src/agentic_runner/cli_floor.py +33 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/config.py +5 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/device_login_activities.py +24 -0
- agentic_runner-3.4.0/src/agentic_runner/harness_self_test.py +162 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/llm_proxy.py +101 -17
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/sealed_box.py +24 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/service.py +143 -19
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/testing/__main__.py +12 -1
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/testing/control_plane.py +174 -3
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/_runtime_support.py +6 -1
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/agent_runtime.py +36 -5
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/claude_runtime.py +17 -2
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/codex_runtime.py +77 -4
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/command_policy.py +9 -1
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/contract_device_login.py +15 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/contract_isolation.py +32 -0
- agentic_runner-3.4.0/src/agentic_runner/workers/harness_outcome.py +213 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/workstation.py +4 -20
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/.gitignore +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/LICENSE +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/README.md +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/pyproject.toml +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/attempts.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/build.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/callback.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/child_watcher.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/cli.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/credentials.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/egress.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/heartbeat_link.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/hooks.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/host_store.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/__init__.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/github/auth.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/lifecycle.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/mcp.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/message_store.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/private_state.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/py.typed +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/recipient_key_secret.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/registration.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/runtime/__init__.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/testing/__init__.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/testing/plugin.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/testing/test_conformance.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/testing/workflow.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/tiny_http.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/triage_activities.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/user_sources.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/__init__.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/harness_usage.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/mcp_config.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/settings.py +0 -0
- {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/skills.py +0 -0
|
@@ -31,6 +31,7 @@ import tempfile
|
|
|
31
31
|
import time
|
|
32
32
|
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
|
|
33
33
|
from dataclasses import asdict, dataclass, field, fields, replace
|
|
34
|
+
from datetime import UTC, datetime
|
|
34
35
|
from pathlib import Path
|
|
35
36
|
from typing import Any, NoReturn, Protocol
|
|
36
37
|
from uuid import UUID
|
|
@@ -44,6 +45,11 @@ from agentic_runner.attempts import (
|
|
|
44
45
|
fence_work_record,
|
|
45
46
|
refuse_if_prior_attempt_alive,
|
|
46
47
|
)
|
|
48
|
+
from agentic_runner.auth_mode import (
|
|
49
|
+
AuthModeRefusedError,
|
|
50
|
+
choose_auth_mode,
|
|
51
|
+
is_shared_runner,
|
|
52
|
+
)
|
|
47
53
|
from agentic_runner.callback import (
|
|
48
54
|
CALLBACK_TOKEN_ENV,
|
|
49
55
|
AnnotateRequest,
|
|
@@ -70,6 +76,7 @@ from agentic_runner.credentials import (
|
|
|
70
76
|
UnresolvableCredentialReferenceError,
|
|
71
77
|
)
|
|
72
78
|
from agentic_runner.egress import EGRESS_REFUSED_SOURCE, EgressProxy, Resolver, host_of
|
|
79
|
+
from agentic_runner.harness_self_test import DirectivesInFlight
|
|
73
80
|
from agentic_runner.heartbeat_link import HeartbeatLink, SeamUnavailableError
|
|
74
81
|
from agentic_runner.hooks import (
|
|
75
82
|
HOOK_EVIDENCE_SOURCE,
|
|
@@ -118,11 +125,12 @@ from agentic_runner.workers._runtime_support import (
|
|
|
118
125
|
)
|
|
119
126
|
from agentic_runner.workers.agent_runtime import (
|
|
120
127
|
AgentRuntime,
|
|
121
|
-
|
|
128
|
+
AuthMode,
|
|
122
129
|
DirectiveRequest,
|
|
123
130
|
DirectiveResult,
|
|
124
131
|
ResumableAgentRuntime,
|
|
125
132
|
)
|
|
133
|
+
from agentic_runner.workers.contract_device_login import login_file_counts
|
|
126
134
|
from agentic_runner.workers.contract_isolation import (
|
|
127
135
|
NO_CONTRACT,
|
|
128
136
|
ContractIsolation,
|
|
@@ -134,6 +142,7 @@ from agentic_runner.workers.fastapi_client import (
|
|
|
134
142
|
WorkerFastApiClientError,
|
|
135
143
|
directive_token,
|
|
136
144
|
)
|
|
145
|
+
from agentic_runner.workers.harness_outcome import classify_harness_outcome
|
|
137
146
|
from agentic_runner.workers.harness_usage import extract_claude_result, extract_codex_turn_usage
|
|
138
147
|
from agentic_runner.workers.mcp_config import McpServerEntry
|
|
139
148
|
from agentic_runner.workers.skills import (
|
|
@@ -162,6 +171,7 @@ from agentic_runner_contracts.activity_io import (
|
|
|
162
171
|
FixDirectiveOutput,
|
|
163
172
|
GitHubCallError,
|
|
164
173
|
GitHubCallFailure,
|
|
174
|
+
HarnessHold,
|
|
165
175
|
LearningDirectiveInput,
|
|
166
176
|
LearningDirectiveOutput,
|
|
167
177
|
MemberDirectiveInput,
|
|
@@ -638,6 +648,9 @@ class _DirectiveAttempt:
|
|
|
638
648
|
# Console-v2 issue 28: the attached Skills, for a runtime with no skills directory to
|
|
639
649
|
# write them into. Prefixed to the Directive prompt; empty when there is none.
|
|
640
650
|
skills_preamble: str = ""
|
|
651
|
+
# Chosen before anything is spawned (local-agents 04); the runtime runs in it and
|
|
652
|
+
# the harness-usage report follows it.
|
|
653
|
+
auth_mode: AuthMode = AuthMode.API_KEY
|
|
641
654
|
|
|
642
655
|
async def run_environment_hook(self) -> None:
|
|
643
656
|
self.extra_env.update(await self.hooks.environment())
|
|
@@ -1432,6 +1445,8 @@ HARNESS_SESSION_EVIDENCE_SOURCE = "runner.harness_session"
|
|
|
1432
1445
|
DIRECTIVE_TOKEN_EVIDENCE_SOURCE = "runner.directive_token"
|
|
1433
1446
|
AGENT_RUNTIME_EVIDENCE_SOURCE = "runner.agent_runtime"
|
|
1434
1447
|
SKILLS_EVIDENCE_SOURCE = "runner.skills"
|
|
1448
|
+
HARNESS_LOGIN_RESIDUE_SOURCE = "runner.harness_login_residue"
|
|
1449
|
+
HARNESS_HOLD_SOURCE = "runner.harness_hold"
|
|
1435
1450
|
|
|
1436
1451
|
|
|
1437
1452
|
class _EvidenceWriter(Protocol):
|
|
@@ -1545,8 +1560,11 @@ class RunnerRalphActivities:
|
|
|
1545
1560
|
egress_resolver: Resolver | None = None,
|
|
1546
1561
|
tool_server_health: ToolServerHealthLog | None = None,
|
|
1547
1562
|
attempt_records: AttemptRecords | None = None,
|
|
1563
|
+
directives_in_flight: DirectivesInFlight | None = None,
|
|
1548
1564
|
) -> None:
|
|
1549
1565
|
self._fastapi_client = fastapi_client
|
|
1566
|
+
# Read by the harness self-test, which never runs beside a Contract's Directive.
|
|
1567
|
+
self._directives_in_flight = directives_in_flight or DirectivesInFlight()
|
|
1550
1568
|
# Wall-clock source for per-Directive runtime, accrued against the Budget's
|
|
1551
1569
|
# wall-clock dimension (ADR-0007). Activities run outside the Temporal sandbox,
|
|
1552
1570
|
# so a real monotonic clock is allowed here (it must never be used in the
|
|
@@ -1578,6 +1596,10 @@ class RunnerRalphActivities:
|
|
|
1578
1596
|
# no proxy here keeps calling the backend one, which is exactly the transition
|
|
1579
1597
|
# the issue describes.
|
|
1580
1598
|
self._llm_proxy = llm_proxy
|
|
1599
|
+
# Contracts whose harness roots this process has already checked for a login
|
|
1600
|
+
# left behind (local-agents 04). Only de-duplicates an Evidence line -- no later
|
|
1601
|
+
# activity reads it -- so a restart reporting the residue again is the intent.
|
|
1602
|
+
self._residue_checked: set[str | None] = set()
|
|
1581
1603
|
# Credential References (PRD issue 48, 22 A1). None means this Runner resolves
|
|
1582
1604
|
# none: a Contract that declares a manifest then fails its Directives closed,
|
|
1583
1605
|
# which is the right answer -- a Runner with no host store cannot host that
|
|
@@ -1969,6 +1991,7 @@ class RunnerRalphActivities:
|
|
|
1969
1991
|
extra_env=attempt.env,
|
|
1970
1992
|
mcp_servers=attempt.mcp_servers,
|
|
1971
1993
|
egress_allow_list=attempt.egress_allow_list,
|
|
1994
|
+
auth_mode=attempt.auth_mode,
|
|
1972
1995
|
prompt=attempt.skills_preamble
|
|
1973
1996
|
+ _directive_prompt(
|
|
1974
1997
|
completion_criteria=runtime_state.completion_criteria,
|
|
@@ -2000,8 +2023,34 @@ class RunnerRalphActivities:
|
|
|
2000
2023
|
work_record_id=request.work_record_id,
|
|
2001
2024
|
directive_number=request.directive_number,
|
|
2002
2025
|
result=codex_result,
|
|
2026
|
+
auth_mode=attempt.auth_mode,
|
|
2003
2027
|
)
|
|
2004
2028
|
plan = _take_plan(checkout_workspace)
|
|
2029
|
+
harness_hold = await self._harness_hold(
|
|
2030
|
+
runtime_state=runtime_state,
|
|
2031
|
+
work_record_id=request.work_record_id,
|
|
2032
|
+
directive_number=request.directive_number,
|
|
2033
|
+
result=codex_result,
|
|
2034
|
+
auth_mode=attempt.auth_mode,
|
|
2035
|
+
)
|
|
2036
|
+
if harness_hold is not None:
|
|
2037
|
+
return BranchPullRequestOutput(
|
|
2038
|
+
repository=request.repository,
|
|
2039
|
+
branch_name=request.branch_name,
|
|
2040
|
+
base_ref=request.base_ref,
|
|
2041
|
+
pr_number=0,
|
|
2042
|
+
pr_url="",
|
|
2043
|
+
branch_created=branch.created,
|
|
2044
|
+
pr_created=False,
|
|
2045
|
+
duration_seconds=self._monotonic() - directive_started,
|
|
2046
|
+
usage=await self._directive_usage(
|
|
2047
|
+
work_record_id=request.work_record_id,
|
|
2048
|
+
directive_number=request.directive_number,
|
|
2049
|
+
),
|
|
2050
|
+
workspace_path=str(workspace_path),
|
|
2051
|
+
grant_snapshot=dict(request.grant_snapshot),
|
|
2052
|
+
harness_hold=harness_hold,
|
|
2053
|
+
)
|
|
2005
2054
|
if codex_result.exit_code != 0:
|
|
2006
2055
|
if _is_guard_mode_refusal(codex_result):
|
|
2007
2056
|
# The runtime refused this Directive (guard-mode). Return a flagged
|
|
@@ -2419,6 +2468,8 @@ class RunnerRalphActivities:
|
|
|
2419
2468
|
the bearer defensible under ADR-0011 §9.
|
|
2420
2469
|
"""
|
|
2421
2470
|
|
|
2471
|
+
auth_mode = await self._directive_auth_mode(work_record_id, state)
|
|
2472
|
+
await self._report_login_residue(work_record_id, state)
|
|
2422
2473
|
facts = AttemptFacts(
|
|
2423
2474
|
work_record_id=work_record_id,
|
|
2424
2475
|
directive_id=directive_id(
|
|
@@ -2457,6 +2508,7 @@ class RunnerRalphActivities:
|
|
|
2457
2508
|
scratch_root = sandbox.tmp_dir if sandbox is not None else Path(tempfile.gettempdir())
|
|
2458
2509
|
attempt_dir = prepare_attempt_dir(scratch_root / f"attempt-{key}", uid)
|
|
2459
2510
|
async with contextlib.AsyncExitStack() as stack:
|
|
2511
|
+
stack.enter_context(self._directives_in_flight.running(facts.contract_id))
|
|
2460
2512
|
stack.callback(shutil.rmtree, attempt_dir, ignore_errors=True)
|
|
2461
2513
|
callback_env: dict[str, str] = {}
|
|
2462
2514
|
if self._socket_dir is not None:
|
|
@@ -2476,8 +2528,8 @@ class RunnerRalphActivities:
|
|
|
2476
2528
|
)
|
|
2477
2529
|
)
|
|
2478
2530
|
callback_env = server.env()
|
|
2479
|
-
if self._llm_proxy is not None and
|
|
2480
|
-
# API-key mode only (issue 43): a
|
|
2531
|
+
if self._llm_proxy is not None and auth_mode == AuthMode.API_KEY:
|
|
2532
|
+
# API-key mode only (issue 43): a subscription Directive never has a slot
|
|
2481
2533
|
# to resolve, and issue 31 meters it from the harness's own output. One
|
|
2482
2534
|
# bearer serves the callback socket and the proxy alike.
|
|
2483
2535
|
proxy_attempt = await stack.enter_async_context(
|
|
@@ -2528,6 +2580,7 @@ class RunnerRalphActivities:
|
|
|
2528
2580
|
mcp_servers=mcp_servers,
|
|
2529
2581
|
egress_allow_list=egress_allow_list,
|
|
2530
2582
|
skills_preamble=delivered_preamble,
|
|
2583
|
+
auth_mode=auth_mode,
|
|
2531
2584
|
)
|
|
2532
2585
|
|
|
2533
2586
|
async def _refuse_skill_digest_mismatch(
|
|
@@ -2805,26 +2858,86 @@ class RunnerRalphActivities:
|
|
|
2805
2858
|
)
|
|
2806
2859
|
raise
|
|
2807
2860
|
|
|
2808
|
-
def
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2861
|
+
async def _directive_auth_mode(
|
|
2862
|
+
self, work_record_id: str, state: _RuntimeContextState
|
|
2863
|
+
) -> AuthMode:
|
|
2864
|
+
"""This Directive's auth mode, or a non-retryable refusal with Evidence naming the
|
|
2865
|
+
rule (local-agents 04).
|
|
2866
|
+
|
|
2867
|
+
Decided from the host party this process was registered with and from whether a
|
|
2868
|
+
key is present for the Contract -- never from the payload. A runtime that declares
|
|
2869
|
+
no modes (a test double built before the port grew the field) reads as
|
|
2870
|
+
``api_key``-only, and a kind this Runner does not serve reads the same way; that
|
|
2871
|
+
Directive fails closed in :meth:`_agent_runtime_for` regardless.
|
|
2815
2872
|
"""
|
|
2816
2873
|
|
|
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
2874
|
runtime = self._agent_runtimes.get(state.cli_kind)
|
|
2826
|
-
|
|
2827
|
-
|
|
2875
|
+
host_party = self._routing_identity.host_party if self._routing_identity else None
|
|
2876
|
+
try:
|
|
2877
|
+
return choose_auth_mode(
|
|
2878
|
+
host_party=host_party,
|
|
2879
|
+
key_present=self._api_key_present(state, runtime),
|
|
2880
|
+
runtime_modes=getattr(runtime, "auth_modes", frozenset({AuthMode.API_KEY})),
|
|
2881
|
+
)
|
|
2882
|
+
except AuthModeRefusedError as refusal:
|
|
2883
|
+
await _raise_recorded(
|
|
2884
|
+
ApplicationError(str(refusal), type=refusal.rule, non_retryable=True),
|
|
2885
|
+
self._fastapi_client,
|
|
2886
|
+
work_record_id,
|
|
2887
|
+
source=AGENT_RUNTIME_EVIDENCE_SOURCE,
|
|
2888
|
+
actor=self._actor(),
|
|
2889
|
+
payload={
|
|
2890
|
+
"event": "directive.auth_mode_refused",
|
|
2891
|
+
"rule": refusal.rule,
|
|
2892
|
+
"cli_kind": state.cli_kind,
|
|
2893
|
+
"host_party": host_party,
|
|
2894
|
+
"contract_id": state.contract_id,
|
|
2895
|
+
},
|
|
2896
|
+
)
|
|
2897
|
+
|
|
2898
|
+
def _api_key_present(self, state: _RuntimeContextState, runtime: AgentRuntime | None) -> bool:
|
|
2899
|
+
"""A delivered slot for the Contract (or its funder), or the host operator's key."""
|
|
2900
|
+
|
|
2901
|
+
if bool(getattr(runtime, "host_api_key", False)):
|
|
2902
|
+
return True
|
|
2903
|
+
contract_id = _optional_uuid(state.contract_id)
|
|
2904
|
+
return (
|
|
2905
|
+
self._llm_proxy is not None
|
|
2906
|
+
and contract_id is not None
|
|
2907
|
+
and self._llm_proxy.slots.holds(contract_id)
|
|
2908
|
+
)
|
|
2909
|
+
|
|
2910
|
+
async def _report_login_residue(self, work_record_id: str, state: _RuntimeContextState) -> None:
|
|
2911
|
+
"""On a shared Runner, say once that a Contract's harness root holds a login.
|
|
2912
|
+
|
|
2913
|
+
A count per harness, never a path or a byte of the file: the Runner does not open
|
|
2914
|
+
it, a shared Runner never runs on it, and ``wipe_contract_residue`` removes it with
|
|
2915
|
+
the Contract. Saying so is what turns a sign-in made before local-agents 03 (or by
|
|
2916
|
+
hand) into something the Organisation can see and act on.
|
|
2917
|
+
"""
|
|
2918
|
+
|
|
2919
|
+
host_party = self._routing_identity.host_party if self._routing_identity else None
|
|
2920
|
+
if (
|
|
2921
|
+
self._contract_isolation is None
|
|
2922
|
+
or not is_shared_runner(host_party)
|
|
2923
|
+
or state.contract_id in self._residue_checked
|
|
2924
|
+
):
|
|
2925
|
+
return
|
|
2926
|
+
self._residue_checked.add(state.contract_id)
|
|
2927
|
+
counts = login_file_counts(self._contract_isolation, state.contract_id)
|
|
2928
|
+
if not counts:
|
|
2929
|
+
return
|
|
2930
|
+
await self._fastapi_client.append_evidence(
|
|
2931
|
+
work_record_id,
|
|
2932
|
+
source=HARNESS_LOGIN_RESIDUE_SOURCE,
|
|
2933
|
+
actor=self._actor(),
|
|
2934
|
+
payload={
|
|
2935
|
+
"event": "harness.login_residue",
|
|
2936
|
+
"contract_id": state.contract_id,
|
|
2937
|
+
"host_party": host_party,
|
|
2938
|
+
"login_files": counts,
|
|
2939
|
+
},
|
|
2940
|
+
)
|
|
2828
2941
|
|
|
2829
2942
|
async def _agent_runtime_for(
|
|
2830
2943
|
self, work_record_id: str, state: _RuntimeContextState
|
|
@@ -4405,7 +4518,13 @@ class RunnerRalphActivities:
|
|
|
4405
4518
|
guard_mode_refused=True,
|
|
4406
4519
|
workspace_path=request.workspace_path,
|
|
4407
4520
|
)
|
|
4408
|
-
if
|
|
4521
|
+
if (
|
|
4522
|
+
consumed
|
|
4523
|
+
and self._message_store is not None
|
|
4524
|
+
and not ran.guard_mode_refused
|
|
4525
|
+
# A held turn never read its Messages; the wake after the hold must see them.
|
|
4526
|
+
and ran.harness_hold is None
|
|
4527
|
+
):
|
|
4409
4528
|
self._message_store.mark_consumed(
|
|
4410
4529
|
contract_id=contract[0],
|
|
4411
4530
|
work_record_id=request.work_record_id,
|
|
@@ -4516,6 +4635,7 @@ class RunnerRalphActivities:
|
|
|
4516
4635
|
extra_env=attempt.env,
|
|
4517
4636
|
mcp_servers=attempt.mcp_servers,
|
|
4518
4637
|
egress_allow_list=attempt.egress_allow_list,
|
|
4638
|
+
auth_mode=attempt.auth_mode,
|
|
4519
4639
|
prompt=attempt.skills_preamble
|
|
4520
4640
|
+ _learning_prompt(
|
|
4521
4641
|
request,
|
|
@@ -4534,6 +4654,14 @@ class RunnerRalphActivities:
|
|
|
4534
4654
|
work_record_id=request.work_record_id,
|
|
4535
4655
|
directive_number=request.directive_number,
|
|
4536
4656
|
result=result,
|
|
4657
|
+
auth_mode=attempt.auth_mode,
|
|
4658
|
+
)
|
|
4659
|
+
harness_hold = await self._harness_hold(
|
|
4660
|
+
runtime_state=state,
|
|
4661
|
+
work_record_id=request.work_record_id,
|
|
4662
|
+
directive_number=request.directive_number,
|
|
4663
|
+
result=result,
|
|
4664
|
+
auth_mode=attempt.auth_mode,
|
|
4537
4665
|
)
|
|
4538
4666
|
usage = await self._directive_usage(
|
|
4539
4667
|
work_record_id=request.work_record_id,
|
|
@@ -4571,6 +4699,7 @@ class RunnerRalphActivities:
|
|
|
4571
4699
|
usage=usage,
|
|
4572
4700
|
outcome=LEARNING_FAILED,
|
|
4573
4701
|
summary=f"the Learner's runtime exited {result.exit_code}",
|
|
4702
|
+
harness_hold=harness_hold,
|
|
4574
4703
|
)
|
|
4575
4704
|
return self._learning_output(
|
|
4576
4705
|
request,
|
|
@@ -4587,6 +4716,7 @@ class RunnerRalphActivities:
|
|
|
4587
4716
|
usage: DirectiveUsage | None = None,
|
|
4588
4717
|
outcome: str = LEARNING_PROPOSED,
|
|
4589
4718
|
summary: str = "",
|
|
4719
|
+
harness_hold: HarnessHold | None = None,
|
|
4590
4720
|
) -> LearningDirectiveOutput:
|
|
4591
4721
|
return LearningDirectiveOutput(
|
|
4592
4722
|
work_record_id=request.work_record_id,
|
|
@@ -4595,6 +4725,7 @@ class RunnerRalphActivities:
|
|
|
4595
4725
|
usage=usage or DirectiveUsage(),
|
|
4596
4726
|
outcome=outcome,
|
|
4597
4727
|
summary=summary,
|
|
4728
|
+
harness_hold=harness_hold,
|
|
4598
4729
|
)
|
|
4599
4730
|
|
|
4600
4731
|
def _learner_transcript(self, state: _RuntimeContextState, work_record_id: str) -> str:
|
|
@@ -4764,6 +4895,7 @@ class RunnerRalphActivities:
|
|
|
4764
4895
|
extra_env=attempt.env,
|
|
4765
4896
|
mcp_servers=attempt.mcp_servers,
|
|
4766
4897
|
egress_allow_list=attempt.egress_allow_list,
|
|
4898
|
+
auth_mode=attempt.auth_mode,
|
|
4767
4899
|
prompt=attempt.skills_preamble + await kind.prompt(state, prompt_notes),
|
|
4768
4900
|
base_branch=request.base_ref,
|
|
4769
4901
|
work_branch=state.work_branch,
|
|
@@ -4789,9 +4921,34 @@ class RunnerRalphActivities:
|
|
|
4789
4921
|
work_record_id=request.work_record_id,
|
|
4790
4922
|
directive_number=request.directive_number,
|
|
4791
4923
|
result=codex_result,
|
|
4924
|
+
auth_mode=attempt.auth_mode,
|
|
4792
4925
|
)
|
|
4793
4926
|
# Taken before the commit below so it never lands on the branch (PRD 59).
|
|
4794
4927
|
plan = _take_plan(state.workspace_path)
|
|
4928
|
+
harness_hold = await self._harness_hold(
|
|
4929
|
+
runtime_state=state,
|
|
4930
|
+
work_record_id=request.work_record_id,
|
|
4931
|
+
directive_number=request.directive_number,
|
|
4932
|
+
result=codex_result,
|
|
4933
|
+
auth_mode=attempt.auth_mode,
|
|
4934
|
+
)
|
|
4935
|
+
if harness_hold is not None:
|
|
4936
|
+
return FixDirectiveOutput(
|
|
4937
|
+
work_record_id=request.work_record_id,
|
|
4938
|
+
repository=request.repository,
|
|
4939
|
+
pr_number=request.pr_number,
|
|
4940
|
+
directive_number=request.directive_number,
|
|
4941
|
+
branch_head_sha="",
|
|
4942
|
+
summary=f"held: the harness reported {harness_hold.kind.value}",
|
|
4943
|
+
duration_seconds=self._monotonic() - directive_started,
|
|
4944
|
+
usage=await self._directive_usage(
|
|
4945
|
+
work_record_id=request.work_record_id,
|
|
4946
|
+
directive_number=request.directive_number,
|
|
4947
|
+
),
|
|
4948
|
+
workspace_path=str(state.workspace_path),
|
|
4949
|
+
grant_snapshot=state.grant_snapshot_payload,
|
|
4950
|
+
harness_hold=harness_hold,
|
|
4951
|
+
)
|
|
4795
4952
|
if codex_result.exit_code != 0:
|
|
4796
4953
|
if _is_guard_mode_refusal(codex_result):
|
|
4797
4954
|
return FixDirectiveOutput(
|
|
@@ -4987,14 +5144,15 @@ class RunnerRalphActivities:
|
|
|
4987
5144
|
work_record_id: str,
|
|
4988
5145
|
directive_number: int,
|
|
4989
5146
|
result: DirectiveResult,
|
|
5147
|
+
auth_mode: AuthMode,
|
|
4990
5148
|
) -> None:
|
|
4991
|
-
"""A
|
|
4992
|
-
|
|
4993
|
-
|
|
4994
|
-
|
|
5149
|
+
"""A subscription Directive bypasses the LLM proxy, so this is the only metering
|
|
5150
|
+
the Runner can produce for it (PRD issue 31, 17 A9). A no-op for an ``api_key``
|
|
5151
|
+
Directive -- the proxy already metered that call at request time -- and never the
|
|
5152
|
+
reason a Directive fails (same posture as :meth:`_directive_usage`).
|
|
4995
5153
|
"""
|
|
4996
5154
|
|
|
4997
|
-
if
|
|
5155
|
+
if auth_mode == AuthMode.API_KEY:
|
|
4998
5156
|
return
|
|
4999
5157
|
directive = directive_id(work_record_id=work_record_id, directive_number=directive_number)
|
|
5000
5158
|
if runtime_state.cli_kind == "claude_code":
|
|
@@ -5030,6 +5188,46 @@ class RunnerRalphActivities:
|
|
|
5030
5188
|
}
|
|
5031
5189
|
)
|
|
5032
5190
|
|
|
5191
|
+
async def _harness_hold(
|
|
5192
|
+
self,
|
|
5193
|
+
*,
|
|
5194
|
+
runtime_state: _RuntimeContextState,
|
|
5195
|
+
work_record_id: str,
|
|
5196
|
+
directive_number: int,
|
|
5197
|
+
result: DirectiveResult,
|
|
5198
|
+
auth_mode: AuthMode,
|
|
5199
|
+
) -> HarnessHold | None:
|
|
5200
|
+
"""A subscription harness that stopped on its usage limit or sign-in, recorded
|
|
5201
|
+
once (local-agents 08). An ``api_key`` Directive's limits are the proxy's, so it is
|
|
5202
|
+
never classified. The Evidence names the kind and the reset, never the output."""
|
|
5203
|
+
|
|
5204
|
+
if auth_mode != AuthMode.SUBSCRIPTION:
|
|
5205
|
+
return None
|
|
5206
|
+
hold = classify_harness_outcome(
|
|
5207
|
+
runtime_state.cli_kind,
|
|
5208
|
+
exit_code=result.exit_code,
|
|
5209
|
+
stdout=result.stdout,
|
|
5210
|
+
stderr=result.stderr,
|
|
5211
|
+
error=result.error,
|
|
5212
|
+
now=datetime.now(UTC),
|
|
5213
|
+
)
|
|
5214
|
+
if hold is None:
|
|
5215
|
+
return None
|
|
5216
|
+
await self._fastapi_client.append_evidence(
|
|
5217
|
+
work_record_id,
|
|
5218
|
+
source=HARNESS_HOLD_SOURCE,
|
|
5219
|
+
payload={
|
|
5220
|
+
"event": "directive.harness_hold",
|
|
5221
|
+
"kind": hold.kind.value,
|
|
5222
|
+
"cli_kind": runtime_state.cli_kind,
|
|
5223
|
+
"retry_not_before": hold.retry_not_before,
|
|
5224
|
+
"directive_number": directive_number,
|
|
5225
|
+
"agent_id": runtime_state.agent_id,
|
|
5226
|
+
"contract_id": runtime_state.contract_id,
|
|
5227
|
+
},
|
|
5228
|
+
)
|
|
5229
|
+
return hold
|
|
5230
|
+
|
|
5033
5231
|
@activity.defn(name="wipe_contract_residue")
|
|
5034
5232
|
async def wipe_contract_residue(self, request: ContractResidueInput) -> ContractResidueOutput:
|
|
5035
5233
|
"""Delete everything one Contract left on this Runner and retire its uid (17 A6).
|
|
@@ -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)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Which executable each Agent Runtime CLI is, and the version floor it must clear."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Final
|
|
8
|
+
|
|
9
|
+
__all__ = ["CLI_FLOORS", "CLI_NAMES", "meets_floor", "parse_cli_version"]
|
|
10
|
+
|
|
11
|
+
# What the Profile's `cli_kind` runs, by executable name on PATH.
|
|
12
|
+
CLI_NAMES: Final[Mapping[str, str]] = {"codex_cli": "codex", "claude_code": "claude"}
|
|
13
|
+
# The versions the Runner image is built and tested against (Dockerfile.runner's
|
|
14
|
+
# CODEX_CLI_VERSION / CLAUDE_CODE_VERSION; a test holds the two together). Below the
|
|
15
|
+
# floor is *reported*, not refused: the user's own CLI is the user's to upgrade.
|
|
16
|
+
CLI_FLOORS: Final[Mapping[str, str]] = {"codex": "0.141.0", "claude": "2.1.280"}
|
|
17
|
+
|
|
18
|
+
_VERSION = re.compile(r"(\d+\.\d+\.\d+[0-9A-Za-z.+-]{0,40})")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_cli_version(output: str) -> str | None:
|
|
22
|
+
match = _VERSION.search(output)
|
|
23
|
+
return None if match is None else match.group(1)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def meets_floor(name: str, version: str) -> bool:
|
|
27
|
+
"""Whether ``version`` of the executable ``name`` clears the Runner's floor for it."""
|
|
28
|
+
|
|
29
|
+
return _version_tuple(version) >= _version_tuple(CLI_FLOORS[name])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _version_tuple(version: str) -> tuple[int, ...]:
|
|
33
|
+
return tuple(int(part) for part in re.findall(r"\d+", version)[:3])
|
|
@@ -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
|
+
)
|