introspection-harbor 0.1.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.
@@ -0,0 +1,2 @@
1
+ __pycache__/
2
+ *.py[cod]
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-08-08)
4
+
5
+
6
+ ### Features
7
+
8
+ * **cli:** add local Recipe evaluations ([#203](https://github.com/introspection-org/introspection-cli/issues/203)) ([b14d97a](https://github.com/introspection-org/introspection-cli/commit/b14d97a13d5ebe3c805df6321ad4d5b89788a082))
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: introspection-harbor
3
+ Version: 0.1.0
4
+ Summary: Harbor installed-agent adapter for Introspection Recipes
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: <3.14,>=3.12
7
+ Requires-Dist: harbor<0.21.0,>=0.20.0
8
+ Description-Content-Type: text/markdown
9
+
10
+ # `introspection-harbor`
11
+
12
+ Harbor installed-agent adapter for local Introspection Recipes. Install it
13
+ through `introspection setup --target harbor`, then run a task through:
14
+
15
+ ```sh
16
+ introspection eval run --runner harbor --path evals/refund-task
17
+ ```
18
+
19
+ Select an inherited Recipe agent variant by its declared YAML `name`:
20
+
21
+ ```sh
22
+ introspection eval run --runner harbor --path evals/refund-task --agent agent2
23
+ ```
24
+
25
+ For example, `agents/agent2.yaml` can declare `name: agent2` and `from: agent`,
26
+ then override only its model, tools, extensions, or other variant settings.
27
+
28
+ The CLI selects the Recipe and optional hydrated Pi session. The adapter copies
29
+ that snapshot into Harbor's environment, invokes `introspection local`, and
30
+ writes both native Pi events and `trajectory.json` (ATIF) under the agent log
31
+ directory.
@@ -0,0 +1,22 @@
1
+ # `introspection-harbor`
2
+
3
+ Harbor installed-agent adapter for local Introspection Recipes. Install it
4
+ through `introspection setup --target harbor`, then run a task through:
5
+
6
+ ```sh
7
+ introspection eval run --runner harbor --path evals/refund-task
8
+ ```
9
+
10
+ Select an inherited Recipe agent variant by its declared YAML `name`:
11
+
12
+ ```sh
13
+ introspection eval run --runner harbor --path evals/refund-task --agent agent2
14
+ ```
15
+
16
+ For example, `agents/agent2.yaml` can declare `name: agent2` and `from: agent`,
17
+ then override only its model, tools, extensions, or other variant settings.
18
+
19
+ The CLI selects the Recipe and optional hydrated Pi session. The adapter copies
20
+ that snapshot into Harbor's environment, invokes `introspection local`, and
21
+ writes both native Pi events and `trajectory.json` (ATIF) under the agent log
22
+ directory.
@@ -0,0 +1,15 @@
1
+ [project]
2
+ name = "introspection-harbor"
3
+ version = "0.1.0"
4
+ description = "Harbor installed-agent adapter for Introspection Recipes"
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ requires-python = ">=3.12,<3.14"
8
+ dependencies = ["harbor>=0.20.0,<0.21.0"]
9
+
10
+ [build-system]
11
+ requires = ["hatchling"]
12
+ build-backend = "hatchling.build"
13
+
14
+ [tool.hatch.build.targets.wheel]
15
+ packages = ["src/introspection_harbor"]
@@ -0,0 +1,3 @@
1
+ from .agent import IntrospectionRecipeAgent
2
+
3
+ __all__ = ["IntrospectionRecipeAgent"]
@@ -0,0 +1,433 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import shlex
6
+ import shutil
7
+ import tempfile
8
+ from importlib.metadata import PackageNotFoundError, version
9
+ from pathlib import Path
10
+ from typing import Any, override
11
+
12
+ from harbor.agents.installed.base import BaseInstalledAgent, with_prompt_template
13
+ from harbor.agents.installed.node_install import nvm_node_install_snippet
14
+ from harbor.environments.base import BaseEnvironment
15
+ from harbor.models.agent.context import AgentContext
16
+ from harbor.models.trajectories import (
17
+ Agent,
18
+ FinalMetrics,
19
+ Metrics,
20
+ Observation,
21
+ ObservationResult,
22
+ Step,
23
+ ToolCall,
24
+ Trajectory,
25
+ )
26
+
27
+ _RUN_SPEC_ENV = "INTROSPECTION_EVAL_RUN_SPEC"
28
+ _REMOTE_SOURCE_DIR = "/tmp/introspection-recipe-source"
29
+ _REMOTE_SESSION_PATH = "/tmp/introspection-session.jsonl"
30
+ _NODE_MAJOR = 24
31
+ _NODE_VERSION_CHECK = (
32
+ "node -e 'process.exit(Number(process.versions.node.split(\".\")[0]) >= "
33
+ f"{_NODE_MAJOR} ? 0 : 1)'"
34
+ )
35
+
36
+ _EXCLUDED_DIRECTORY_NAMES = {
37
+ ".git",
38
+ ".hg",
39
+ ".svn",
40
+ ".aws",
41
+ ".ssh",
42
+ "node_modules",
43
+ "__pycache__",
44
+ ".pytest_cache",
45
+ ".mypy_cache",
46
+ ".ruff_cache",
47
+ }
48
+ _EXCLUDED_EVAL_DIRECTORIES = {
49
+ ".evalite",
50
+ "eval-runs",
51
+ "eval-artifacts",
52
+ }
53
+ _EXCLUDED_SECRET_FILES = {
54
+ ".npmrc",
55
+ ".pypirc",
56
+ "credentials.json",
57
+ "service-account.json",
58
+ "service_account.json",
59
+ }
60
+ _SECRET_SUFFIXES = {".key", ".pem", ".p12", ".pfx"}
61
+ _SAFE_ENV_SUFFIXES = {".example", ".sample", ".template"}
62
+
63
+
64
+ def _adapter_version() -> str:
65
+ try:
66
+ return version("introspection-harbor")
67
+ except PackageNotFoundError:
68
+ return "0.1.0"
69
+
70
+
71
+ _ADAPTER_VERSION = _adapter_version()
72
+
73
+
74
+ def _node_prerequisite_command() -> str:
75
+ """Prepare Node installation without assuming a Debian task image."""
76
+
77
+ return (
78
+ "set -eu; "
79
+ f"if {_NODE_VERSION_CHECK} 2>/dev/null; then exit 0; fi; "
80
+ "if command -v apk >/dev/null 2>&1; then "
81
+ "apk add --no-cache nodejs npm; "
82
+ f"{_NODE_VERSION_CHECK} || {{ "
83
+ f"echo 'The Alpine package repository must provide Node {_NODE_MAJOR} or newer' >&2; "
84
+ "exit 1; }; "
85
+ "elif command -v curl >/dev/null 2>&1; then :; "
86
+ "elif command -v apt-get >/dev/null 2>&1; then "
87
+ "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y curl; "
88
+ "elif command -v dnf >/dev/null 2>&1; then dnf install -y curl; "
89
+ "elif command -v microdnf >/dev/null 2>&1; then microdnf install -y curl; "
90
+ "elif command -v yum >/dev/null 2>&1; then yum install -y curl; "
91
+ "elif command -v zypper >/dev/null 2>&1; then zypper --non-interactive install curl; "
92
+ "else echo 'Node setup requires curl or a supported system package manager' >&2; "
93
+ "exit 1; fi"
94
+ )
95
+
96
+
97
+ class IntrospectionRecipeAgent(BaseInstalledAgent):
98
+ """Run the exact Recipe selected by ``introspection eval run``."""
99
+
100
+ SUPPORTS_ATIF = True
101
+ _OUTPUT_FILENAME = "pi.jsonl"
102
+
103
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
104
+ super().__init__(*args, **kwargs)
105
+ spec_path = os.environ.get(_RUN_SPEC_ENV)
106
+ if not spec_path:
107
+ raise ValueError(
108
+ "introspection-recipe must be launched by `introspection eval run --runner harbor`"
109
+ )
110
+ self._spec = json.loads(Path(spec_path).read_text())
111
+ if self._spec.get("schema_version") != 1:
112
+ raise ValueError("unsupported Introspection evaluation run specification")
113
+ candidate = self._spec.get("candidate") or {}
114
+ workspace_path = Path(candidate.get("workspace_path", ""))
115
+ recipe_path = Path(candidate.get("recipe_path", ""))
116
+ if not workspace_path.is_dir():
117
+ raise ValueError(f"Recipe workspace does not exist: {workspace_path}")
118
+ if not recipe_path.is_dir():
119
+ raise ValueError(f"Recipe source does not exist: {recipe_path}")
120
+ try:
121
+ self._recipe_relative_path = recipe_path.relative_to(workspace_path)
122
+ except ValueError as error:
123
+ raise ValueError("Recipe source must be inside its workspace") from error
124
+ self._workspace_path = workspace_path
125
+ self._recipe_path = recipe_path
126
+
127
+ @staticmethod
128
+ @override
129
+ def name() -> str:
130
+ return "introspection-recipe"
131
+
132
+ @override
133
+ def version(self) -> str | None:
134
+ return _ADAPTER_VERSION
135
+
136
+ @override
137
+ async def install(self, environment: BaseEnvironment) -> None:
138
+ candidate = self._spec["candidate"]
139
+ await self.exec_as_root(
140
+ environment,
141
+ command=f"rm -rf {shlex.quote(_REMOTE_SOURCE_DIR)}",
142
+ )
143
+ with tempfile.TemporaryDirectory(prefix="introspection-recipe-source-") as temp_dir:
144
+ staged = Path(temp_dir) / "workspace"
145
+ _materialize_workspace(self._workspace_path, staged)
146
+ await environment.upload_dir(staged, _REMOTE_SOURCE_DIR)
147
+ await self.exec_as_root(
148
+ environment,
149
+ command=f"chmod -R a+rX {shlex.quote(_REMOTE_SOURCE_DIR)}",
150
+ )
151
+
152
+ session_path = (self._spec.get("input") or {}).get("pi_session_path")
153
+ if session_path:
154
+ await environment.upload_file(Path(session_path), _REMOTE_SESSION_PATH)
155
+
156
+ await self.exec_as_root(environment, command=_node_prerequisite_command())
157
+ cli_version = shlex.quote(candidate["cli_version"])
158
+ source = shlex.quote(_REMOTE_SOURCE_DIR)
159
+ node_install = nvm_node_install_snippet(node_major=_NODE_MAJOR)
160
+ await self.exec_as_agent(
161
+ environment,
162
+ command=(
163
+ "set -euo pipefail; "
164
+ f"if {_NODE_VERSION_CHECK}; then npm -v; else {node_install}; fi && "
165
+ f"npm install -g @introspection-ai/cli@{cli_version} && "
166
+ f"if [ -f {source}/pnpm-lock.yaml ]; then "
167
+ f"corepack pnpm --dir {source} install --frozen-lockfile; "
168
+ f"elif [ -f {source}/yarn.lock ]; then "
169
+ f"corepack yarn --cwd {source} install --immutable; "
170
+ f"elif [ -f {source}/bun.lock ] || [ -f {source}/bun.lockb ]; then "
171
+ f"npm install -g bun && bun install --cwd {source} --frozen-lockfile; "
172
+ f"elif [ -f {source}/package-lock.json ]; then npm --prefix {source} ci; "
173
+ f"else npm --prefix {source} install; fi && "
174
+ "introspection setup --yes"
175
+ ),
176
+ )
177
+
178
+ @override
179
+ @with_prompt_template
180
+ async def run(
181
+ self,
182
+ instruction: str,
183
+ environment: BaseEnvironment,
184
+ context: AgentContext,
185
+ ) -> None:
186
+ candidate = self._spec["candidate"]
187
+ prompt = _selected_prompt(self._spec, instruction)
188
+ args = [
189
+ "introspection",
190
+ "local",
191
+ "--work-dir",
192
+ _REMOTE_SOURCE_DIR,
193
+ "--runtime",
194
+ candidate["runtime"],
195
+ ]
196
+ if candidate.get("agent"):
197
+ args.extend(["--agent", candidate["agent"]])
198
+ args.extend(["--print", prompt, "--mode", "json"])
199
+ pi_args: list[str] = []
200
+ if (self._spec.get("input") or {}).get("pi_session_path"):
201
+ pi_args.extend(["--session", _REMOTE_SESSION_PATH])
202
+ model_name = candidate.get("model") or self.model_name
203
+ if model_name:
204
+ provider, model = model_name.split("/", 1)
205
+ pi_args.extend(["--provider", provider, "--model", model])
206
+ if pi_args:
207
+ args.extend(["--", *pi_args])
208
+
209
+ env = _model_environment(model_name)
210
+ await self.exec_as_agent(
211
+ environment,
212
+ command=_recipe_run_command(args, self._OUTPUT_FILENAME),
213
+ env=env,
214
+ cwd="/app",
215
+ )
216
+
217
+ @override
218
+ def populate_context_post_run(self, context: AgentContext) -> None:
219
+ output_path = self.logs_dir / self._OUTPUT_FILENAME
220
+ if not output_path.is_file():
221
+ return
222
+ trajectory = _pi_events_to_atif(
223
+ output_path,
224
+ model_name=(self._spec.get("candidate") or {}).get("model") or self.model_name,
225
+ )
226
+ (self.logs_dir / "trajectory.json").write_text(
227
+ json.dumps(trajectory.to_json_dict(), indent=2)
228
+ )
229
+ if trajectory.final_metrics:
230
+ context.n_input_tokens = trajectory.final_metrics.total_prompt_tokens
231
+ context.n_output_tokens = trajectory.final_metrics.total_completion_tokens
232
+ context.n_cache_tokens = trajectory.final_metrics.total_cached_tokens
233
+ context.cost_usd = trajectory.final_metrics.total_cost_usd
234
+ context.metadata = {
235
+ "pi_events": self._OUTPUT_FILENAME,
236
+ "atif": "trajectory.json",
237
+ "source_snapshot": (self._spec.get("source") or {}).get("snapshot_hash"),
238
+ }
239
+
240
+
241
+ def _materialize_workspace(source: Path, destination: Path) -> None:
242
+ destination.mkdir()
243
+ for relative in sorted(_workspace_paths(source), key=lambda path: path.as_posix()):
244
+ if _excluded_workspace_path(relative):
245
+ continue
246
+ source_path = source / relative
247
+ if not source_path.exists() and not source_path.is_symlink():
248
+ continue
249
+ target_path = destination / relative
250
+ target_path.parent.mkdir(parents=True, exist_ok=True)
251
+ if source_path.is_symlink():
252
+ target_path.symlink_to(
253
+ os.readlink(source_path), target_is_directory=source_path.is_dir()
254
+ )
255
+ elif source_path.is_file():
256
+ shutil.copy2(source_path, target_path)
257
+
258
+
259
+ def _workspace_paths(source: Path) -> list[Path]:
260
+ paths: list[Path] = []
261
+ for current, directories, files in os.walk(source, followlinks=False):
262
+ current_path = Path(current)
263
+ kept_directories: list[str] = []
264
+ for name in directories:
265
+ relative = (current_path / name).relative_to(source)
266
+ if _excluded_workspace_path(relative):
267
+ continue
268
+ if (current_path / name).is_symlink():
269
+ paths.append(relative)
270
+ else:
271
+ kept_directories.append(name)
272
+ directories[:] = kept_directories
273
+ paths.extend((current_path / name).relative_to(source) for name in files)
274
+ return paths
275
+
276
+
277
+ def _excluded_workspace_path(path: Path) -> bool:
278
+ if path.is_absolute() or ".." in path.parts:
279
+ return True
280
+ if any(part in _EXCLUDED_DIRECTORY_NAMES for part in path.parts):
281
+ return True
282
+ if any(part in _EXCLUDED_EVAL_DIRECTORIES for part in path.parts):
283
+ return True
284
+ name = path.name
285
+ if name == ".DS_Store" or name in _EXCLUDED_SECRET_FILES:
286
+ return True
287
+ if name == ".env":
288
+ return True
289
+ if name.startswith(".env.") and Path(name).suffix not in _SAFE_ENV_SUFFIXES:
290
+ return True
291
+ return path.suffix.lower() in _SECRET_SUFFIXES
292
+
293
+
294
+ def _selected_prompt(spec: dict[str, Any], instruction: str) -> str:
295
+ source = spec.get("source") or {}
296
+ selected_prompt = (spec.get("input") or {}).get("prompt")
297
+ prompt = selected_prompt if source.get("kind") == "production_task" else instruction
298
+ if not isinstance(prompt, str) or not prompt.strip():
299
+ raise ValueError("Introspection evaluation prompt must be non-empty")
300
+ return prompt
301
+
302
+
303
+ def _recipe_run_command(args: list[str], output_filename: str) -> str:
304
+ return (
305
+ "set -o pipefail; . ~/.nvm/nvm.sh; "
306
+ f"{shlex.join(args)} 2>&1 </dev/null | "
307
+ f"stdbuf -oL tee /logs/agent/{shlex.quote(output_filename)}"
308
+ )
309
+
310
+
311
+ def _model_environment(model_name: str | None) -> dict[str, str]:
312
+ if not model_name or "/" not in model_name:
313
+ return {}
314
+ provider = model_name.split("/", 1)[0]
315
+ names = {
316
+ "amazon-bedrock": [
317
+ "AWS_ACCESS_KEY_ID",
318
+ "AWS_SECRET_ACCESS_KEY",
319
+ "AWS_SESSION_TOKEN",
320
+ "AWS_REGION",
321
+ ],
322
+ "anthropic": ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"],
323
+ "github-copilot": ["GITHUB_TOKEN"],
324
+ "google": [
325
+ "GEMINI_API_KEY",
326
+ "GOOGLE_GENERATIVE_AI_API_KEY",
327
+ "GOOGLE_CLOUD_PROJECT",
328
+ "GOOGLE_CLOUD_LOCATION",
329
+ "GOOGLE_GENAI_USE_VERTEXAI",
330
+ "GOOGLE_API_KEY",
331
+ ],
332
+ "groq": ["GROQ_API_KEY"],
333
+ "huggingface": ["HF_TOKEN"],
334
+ "mistral": ["MISTRAL_API_KEY"],
335
+ "openai": ["OPENAI_API_KEY"],
336
+ "openrouter": ["OPENROUTER_API_KEY"],
337
+ "xai": ["XAI_API_KEY"],
338
+ }.get(provider, [])
339
+ return {name: os.environ[name] for name in names if name in os.environ}
340
+
341
+
342
+ def _pi_events_to_atif(path: Path, model_name: str | None) -> Trajectory:
343
+ messages: list[dict[str, Any]] = []
344
+ for line in path.read_text().splitlines():
345
+ try:
346
+ event = json.loads(line)
347
+ except json.JSONDecodeError:
348
+ continue
349
+ if event.get("type") == "message_end" and isinstance(event.get("message"), dict):
350
+ messages.append(event["message"])
351
+
352
+ steps: list[Step] = []
353
+ calls: dict[str, tuple[int, ToolCall]] = {}
354
+ totals = {"input": 0, "output": 0, "cache": 0, "cost": 0.0}
355
+ for message in messages:
356
+ role = message.get("role")
357
+ if role == "user":
358
+ content = message.get("content")
359
+ if isinstance(content, list):
360
+ content = "".join(
361
+ block.get("text", "") for block in content if block.get("type") == "text"
362
+ )
363
+ steps.append(Step(step_id=len(steps) + 1, source="user", message=content or ""))
364
+ elif role == "assistant":
365
+ text: list[str] = []
366
+ thinking: list[str] = []
367
+ tool_calls: list[ToolCall] = []
368
+ for block in message.get("content") or []:
369
+ if block.get("type") == "text":
370
+ text.append(block.get("text", ""))
371
+ elif block.get("type") == "thinking":
372
+ thinking.append(block.get("thinking", ""))
373
+ elif block.get("type") == "toolCall":
374
+ call = ToolCall(
375
+ tool_call_id=block["id"],
376
+ function_name=block["name"],
377
+ arguments=block.get("arguments") or {},
378
+ )
379
+ tool_calls.append(call)
380
+ usage = message.get("usage") or {}
381
+ cost = (usage.get("cost") or {}).get("total", 0) or 0
382
+ totals["input"] += usage.get("input", 0) or 0
383
+ totals["output"] += usage.get("output", 0) or 0
384
+ totals["cache"] += usage.get("cacheRead", 0) or 0
385
+ totals["cost"] += cost
386
+ step = Step(
387
+ step_id=len(steps) + 1,
388
+ source="agent",
389
+ model_name=model_name,
390
+ message="".join(text),
391
+ reasoning_content="\n".join(thinking) or None,
392
+ tool_calls=tool_calls or None,
393
+ metrics=Metrics(
394
+ prompt_tokens=usage.get("input", 0) or 0,
395
+ completion_tokens=usage.get("output", 0) or 0,
396
+ cached_tokens=usage.get("cacheRead", 0) or 0,
397
+ cost_usd=cost,
398
+ ),
399
+ llm_call_count=1,
400
+ )
401
+ steps.append(step)
402
+ for call in tool_calls:
403
+ calls[call.tool_call_id] = (len(steps) - 1, call)
404
+ elif role == "toolResult" and message.get("toolCallId") in calls:
405
+ step_index, _ = calls[message["toolCallId"]]
406
+ content = message.get("content") or []
407
+ rendered = "".join(
408
+ block.get("text", "") for block in content if block.get("type") == "text"
409
+ )
410
+ step = steps[step_index]
411
+ results = list(step.observation.results) if step.observation else []
412
+ results.append(
413
+ ObservationResult(
414
+ source_call_id=message["toolCallId"],
415
+ content=rendered,
416
+ )
417
+ )
418
+ step.observation = Observation(results=results)
419
+
420
+ if not steps:
421
+ steps.append(Step(step_id=1, source="agent", message="No Pi events were recorded", llm_call_count=0))
422
+ return Trajectory(
423
+ session_id=None,
424
+ agent=Agent(name="introspection-recipe", version=_ADAPTER_VERSION, model_name=model_name),
425
+ steps=steps,
426
+ final_metrics=FinalMetrics(
427
+ total_prompt_tokens=int(totals["input"]),
428
+ total_completion_tokens=int(totals["output"]),
429
+ total_cached_tokens=int(totals["cache"]),
430
+ total_cost_usd=float(totals["cost"]),
431
+ total_steps=len(steps),
432
+ ),
433
+ )
@@ -0,0 +1,210 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ import subprocess
6
+ import tempfile
7
+ import unittest
8
+ from pathlib import Path
9
+ from unittest.mock import AsyncMock, Mock, patch
10
+
11
+ from introspection_harbor.agent import (
12
+ IntrospectionRecipeAgent,
13
+ _materialize_workspace,
14
+ _model_environment,
15
+ _node_prerequisite_command,
16
+ _recipe_run_command,
17
+ _selected_prompt,
18
+ )
19
+
20
+
21
+ class AgentHelpersTest(unittest.TestCase):
22
+ def test_production_run_uses_selected_prompt(self) -> None:
23
+ spec = {
24
+ "source": {"kind": "production_task"},
25
+ "input": {"prompt": "counterfactual prompt"},
26
+ }
27
+ self.assertEqual(_selected_prompt(spec, "Harbor instruction"), "counterfactual prompt")
28
+
29
+ def test_authored_run_uses_harbor_instruction(self) -> None:
30
+ spec = {"source": {"kind": "authored"}, "input": {"prompt": None}}
31
+ self.assertEqual(_selected_prompt(spec, "Harbor instruction"), "Harbor instruction")
32
+
33
+ def test_recipe_pipeline_preserves_the_first_failure(self) -> None:
34
+ command = _recipe_run_command(["introspection", "local"], "pi.jsonl")
35
+ self.assertTrue(command.startswith("set -o pipefail;"))
36
+
37
+ def test_bedrock_forwards_temporary_session_credentials(self) -> None:
38
+ credentials = {
39
+ "AWS_ACCESS_KEY_ID": "key",
40
+ "AWS_SECRET_ACCESS_KEY": "secret",
41
+ "AWS_SESSION_TOKEN": "token",
42
+ "AWS_REGION": "us-east-1",
43
+ }
44
+ with patch.dict(os.environ, credentials, clear=True):
45
+ self.assertEqual(_model_environment("amazon-bedrock/model"), credentials)
46
+
47
+ def test_google_does_not_forward_host_credential_file_paths(self) -> None:
48
+ credentials = {
49
+ "GEMINI_API_KEY": "key",
50
+ "GOOGLE_APPLICATION_CREDENTIALS": "/host/service-account.json",
51
+ }
52
+ with patch.dict(os.environ, credentials, clear=True):
53
+ self.assertEqual(
54
+ _model_environment("google/model"), {"GEMINI_API_KEY": "key"}
55
+ )
56
+
57
+ def test_node_setup_supports_non_debian_task_images(self) -> None:
58
+ command = _node_prerequisite_command()
59
+ self.assertIn("command -v apk", command)
60
+ self.assertIn("command -v dnf", command)
61
+ self.assertIn("command -v yum", command)
62
+ self.assertIn("command -v zypper", command)
63
+ self.assertIn("command -v curl", command)
64
+ self.assertFalse(command.startswith("apt-get"))
65
+
66
+ def test_workspace_staging_preserves_monorepo_sources_and_excludes_secrets(self) -> None:
67
+ with tempfile.TemporaryDirectory() as temp_dir:
68
+ root = Path(temp_dir) / "source"
69
+ staged = Path(temp_dir) / "staged"
70
+ files = {
71
+ "package.json": "{}",
72
+ "pnpm-lock.yaml": "lockfileVersion: '9.0'",
73
+ "pnpm-workspace.yaml": "packages: ['packages/*']",
74
+ ".introspection/runtimes/agent.yaml": "runtime: {}",
75
+ "packages/agent/package.json": "{}",
76
+ "packages/agent/.gitignore": "dist/\n",
77
+ "packages/agent/dist/index.js": "export const generated = true;",
78
+ "packages/shared/index.ts": "export const shared = true;",
79
+ ".env.example": "TOKEN=replace-me",
80
+ ".env": "TOKEN=secret",
81
+ ".npmrc": "//registry.example/:_authToken=secret",
82
+ "certificate.pem": "secret",
83
+ "node_modules/pkg/index.js": "generated",
84
+ ".introspection/eval-runs/run/result.json": "{}",
85
+ }
86
+ for relative, contents in files.items():
87
+ path = root / relative
88
+ path.parent.mkdir(parents=True, exist_ok=True)
89
+ path.write_text(contents)
90
+ subprocess.run(["git", "init", "--quiet", root], check=True)
91
+
92
+ _materialize_workspace(root, staged)
93
+
94
+ for relative in [
95
+ "package.json",
96
+ "pnpm-lock.yaml",
97
+ "pnpm-workspace.yaml",
98
+ ".introspection/runtimes/agent.yaml",
99
+ "packages/agent/package.json",
100
+ "packages/agent/dist/index.js",
101
+ "packages/shared/index.ts",
102
+ ".env.example",
103
+ ]:
104
+ self.assertTrue((staged / relative).is_file(), relative)
105
+ for relative in [
106
+ ".env",
107
+ ".npmrc",
108
+ "certificate.pem",
109
+ ".git/config",
110
+ "node_modules/pkg/index.js",
111
+ ".introspection/eval-runs/run/result.json",
112
+ ]:
113
+ self.assertFalse((staged / relative).exists(), relative)
114
+
115
+ def test_workspace_staging_preserves_directory_symlink_type(self) -> None:
116
+ with tempfile.TemporaryDirectory() as temp_dir:
117
+ root = Path(temp_dir) / "source"
118
+ staged = Path(temp_dir) / "staged"
119
+ target = root / "packages/shared"
120
+ target.mkdir(parents=True)
121
+ link = root / "packages/linked"
122
+ try:
123
+ link.symlink_to("shared", target_is_directory=True)
124
+ except OSError as error:
125
+ self.skipTest(f"directory symlinks are unavailable: {error}")
126
+
127
+ with patch.object(Path, "symlink_to") as symlink_to:
128
+ _materialize_workspace(root, staged)
129
+
130
+ symlink_to.assert_called_once()
131
+ self.assertTrue(symlink_to.call_args.kwargs["target_is_directory"])
132
+
133
+ def test_harbor_run_omits_agent_when_recipe_default_is_selected(self) -> None:
134
+ instance = object.__new__(IntrospectionRecipeAgent)
135
+ instance._spec = {
136
+ "candidate": {"runtime": "refunds", "agent": None, "model": None},
137
+ "source": {"kind": "authored"},
138
+ "input": {},
139
+ }
140
+ instance.model_name = None
141
+ instance._OUTPUT_FILENAME = "pi.jsonl"
142
+ environment = Mock()
143
+
144
+ async def capture_exec(*args, **kwargs):
145
+ environment.command = kwargs["command"]
146
+ environment.cwd = kwargs["cwd"]
147
+
148
+ instance.exec_as_agent = capture_exec
149
+ asyncio.run(instance.run.__wrapped__(instance, "prompt", environment, Mock()))
150
+ self.assertIn("--work-dir /tmp/introspection-recipe-source", environment.command)
151
+ self.assertNotIn("--agent", environment.command)
152
+ self.assertEqual(environment.cwd, "/app")
153
+
154
+ def test_harbor_run_selects_inherited_agent_by_declared_name(self) -> None:
155
+ instance = object.__new__(IntrospectionRecipeAgent)
156
+ instance._spec = {
157
+ "candidate": {"runtime": "refunds", "agent": "agent2", "model": None},
158
+ "source": {"kind": "authored"},
159
+ "input": {},
160
+ }
161
+ instance.model_name = None
162
+ instance._OUTPUT_FILENAME = "pi.jsonl"
163
+ environment = Mock()
164
+
165
+ async def capture_exec(*args, **kwargs):
166
+ environment.command = kwargs["command"]
167
+
168
+ instance.exec_as_agent = capture_exec
169
+ asyncio.run(instance.run.__wrapped__(instance, "prompt", environment, Mock()))
170
+ self.assertIn("--agent agent2", environment.command)
171
+
172
+
173
+ class AgentStagingTest(unittest.IsolatedAsyncioTestCase):
174
+ async def test_install_uploads_filtered_workspace_without_tar_or_opt_layout(self) -> None:
175
+ with tempfile.TemporaryDirectory() as temp_dir:
176
+ workspace = Path(temp_dir) / "workspace"
177
+ workspace.mkdir()
178
+ (workspace / "package.json").write_text("{}")
179
+
180
+ instance = object.__new__(IntrospectionRecipeAgent)
181
+ instance._spec = {
182
+ "candidate": {"cli_version": "0.23.0"},
183
+ "input": {},
184
+ }
185
+ instance._workspace_path = workspace
186
+ instance.exec_as_root = AsyncMock()
187
+ instance.exec_as_agent = AsyncMock()
188
+ environment = Mock()
189
+ environment.upload_dir = AsyncMock()
190
+ environment.upload_file = AsyncMock()
191
+
192
+ await instance.install(environment)
193
+
194
+ upload = environment.upload_dir.await_args
195
+ self.assertEqual(upload.args[1], "/tmp/introspection-recipe-source")
196
+ self.assertEqual(environment.upload_file.await_count, 0)
197
+ commands = [
198
+ call.kwargs["command"] for call in instance.exec_as_root.await_args_list
199
+ ]
200
+ commands.append(instance.exec_as_agent.await_args.kwargs["command"])
201
+ combined = "\n".join(commands)
202
+ self.assertIn("nvm install 24", combined)
203
+ self.assertNotIn("nvm install 22", combined)
204
+ self.assertNotIn("/opt/introspection-workspace", combined)
205
+ self.assertNotIn("recipe-source.tar", combined)
206
+ self.assertNotIn("tar -xf", combined)
207
+
208
+
209
+ if __name__ == "__main__":
210
+ unittest.main()