diff --git a/src/fi/alk/harness/provision.py b/src/fi/alk/harness/provision.py index 0836b5f2..1afdfc9e 100644 --- a/src/fi/alk/harness/provision.py +++ b/src/fi/alk/harness/provision.py @@ -1804,6 +1804,12 @@ def provision( else: compose = None managed = False + # Prefer the agent's own Compose: the generated adapter models only agent plus datastore, so + # any other service its tools are served by is dropped and every tool call fails. + if compose is None: + shipped = compose_file(source_root) + if shipped is not None: + compose = shipped if compose is None and contract is not None: if not packaging.candidates and not (source_root / "Dockerfile").is_file(): try: @@ -2227,6 +2233,15 @@ def _runtime_credential_mounts( # the worker to see the placeholder. Platform credentials always get an # ALK-owned destination that cannot collide with submitted mounts. target = f"/run/harness-secrets/{runtime.name}" + if source is None or not _valid_google_credentials(source): + # The runner's own credential, handed over only when the operator opts in, so a + # hosted runner never mounts its platform key into a submitted agent. + if os.environ.get("ALK_ALLOW_HOST_GOOGLE_CREDENTIALS", "").strip() == "1": + env_value = os.environ.get(name, "").strip() + env_path = Path(env_value).expanduser() if env_value else None + if env_path is not None and _valid_google_credentials(env_path): + source = env_path + target = f"/run/harness-secrets/{env_path.name}" if source is None or not _valid_google_credentials(source): raise ProvisionError( "the submitted runtime needs GOOGLE_APPLICATION_CREDENTIALS, but neither its " @@ -2358,6 +2373,8 @@ def start_runtime( arguments.extend(("--volume", f"{source}:{target}:ro")) injected[name] = target mounted_credentials.add(name) + # Only resolve an injected host path here. A credential the submitted service mounted itself + # already points at a container path, which would never validate as a host file. google_path = injected.get("GOOGLE_APPLICATION_CREDENTIALS", "").strip() if google_path and "GOOGLE_APPLICATION_CREDENTIALS" not in mounted_credentials: google_source = Path(google_path).expanduser() diff --git a/src/fi/alk/harness/run/live.py b/src/fi/alk/harness/run/live.py index 90b4978e..a92aa132 100644 --- a/src/fi/alk/harness/run/live.py +++ b/src/fi/alk/harness/run/live.py @@ -278,6 +278,29 @@ def wire( "TOOLS_API_URL": url, "LIVEKIT_AGENT_NAME": agent_name, } + # The generated runtime carries no .env.local, and the runtime configuration covers + # datastore settings only, so the provider credentials it needs come from here. + for _cred in ( + "LIVEKIT_URL", + "LIVEKIT_API_KEY", + "LIVEKIT_API_SECRET", + "DEEPGRAM_API_KEY", + "CARTESIA_API_KEY", + "GOOGLE_APPLICATION_CREDENTIALS", + "GOOGLE_CLOUD_PROJECT", + "GOOGLE_CLOUD_LOCATION", + ): + _value = os.environ.get(_cred, "").strip() + if _value: + runtime_overrides.setdefault(_cred, _value) + # An agent's Compose often mounts its Vertex credential from this variable, and with it + # unset a placeholder is mounted and the agent's own LLM cannot authenticate. + _google_creds = runtime_overrides.get("GOOGLE_APPLICATION_CREDENTIALS", "").strip() + if _google_creds: + runtime_overrides.setdefault("VERTEX_CREDENTIALS", _google_creds) + # Audio enhancement is usually licensed, and unauthorized it raises on the first + # inbound frame and drops the call. Agents that do not read this flag ignore it. + runtime_overrides.setdefault("DISABLE_AI_COUSTICS", "1") os.environ["LIVEKIT_TARGET_AGENT_NAME"] = agent_name caller_phone = fixture_phone(scenario) if caller_phone: diff --git a/src/fi/alk/harness/secrets.py b/src/fi/alk/harness/secrets.py index 018ecdbf..497fcf6b 100644 --- a/src/fi/alk/harness/secrets.py +++ b/src/fi/alk/harness/secrets.py @@ -95,6 +95,9 @@ def worker_environment( # Runner-owned model configuration. Uploaded agent values with these names remain in the # runtime namespace and cannot replace controller credentials. "ALK_HARNESS_MODEL", + # Lets a local sandbox hand its own Google credential to the runtime it builds. A hosted + # runner leaves this unset so its platform key is never mounted into a submitted agent. + "ALK_ALLOW_HOST_GOOGLE_CREDENTIALS", "ANTHROPIC_MODEL", "ANTHROPIC_VERTEX_PROJECT_ID", "CLAUDE_CODE_USE_VERTEX", @@ -108,10 +111,44 @@ def worker_environment( "FI_BASE_URL", "FI_API_KEY", "FI_SECRET_KEY", + # Runner-owned Docker runtime + voice configuration. The harness starts store and agent + # containers on the host daemon and must reach them: ALK_DOCKER_NETWORK lets the store be + # reached by container name on a shared network, ALK_DOCKER_PUBLISHED_HOST/BIND_HOST give + # the host published services are on. Without these the child defaults to 127.0.0.1 -- + # its own loopback inside the sandbox -- and every store probe is refused. + "ALK_DOCKER_NETWORK", + "ALK_DOCKER_PUBLISHED_HOST", + "ALK_DOCKER_BIND_HOST", + "ALK_RUNNER_CONTAINER", + "ALK_HARNESS_MODEL", + "ALK_AGENT_MODEL", + "ALK_JUDGE_MODEL", + "ALK_USER_MODEL", + "CLOUD_ML_REGION", + "HARNESS_WEBHOOK_HOST", + "HARNESS_WEBHOOK_PORT", + "HARNESS_WEBHOOK_URL", + "HARNESS_RUNTIME_WEBHOOK_URL", + "HARNESS_VOICE_CASE", + "HARNESS_VOICE_INFRA_RETRIES", + "LIVEKIT_TARGET_AGENT_NAME", + # Local-dev convenience: let the developer's provider creds from their local environment + # reach the worker directly. A hosted provider supplies these through secret_refs instead. + "LIVEKIT_URL", + "LIVEKIT_API_KEY", + "LIVEKIT_API_SECRET", + "ACCEPTANCE_LIVEKIT_URL", + "DEEPGRAM_API_KEY", + "CARTESIA_API_KEY", + "GOOGLE_APPLICATION_CREDENTIALS", + "GOOGLE_CLOUD_PROJECT", + "GOOGLE_CLOUD_LOCATION", } child = {name: value for name, value in host.items() if name in allowed} reserved = { "ALK_HARNESS_MODEL", + # A submitted job must not be able to turn on the host-credential fallback for itself. + "ALK_ALLOW_HOST_GOOGLE_CREDENTIALS", "ANTHROPIC_MODEL", "ANTHROPIC_VERTEX_PROJECT_ID", "CLAUDE_CODE_USE_VERTEX", diff --git a/src/fi/alk/harness/session.py b/src/fi/alk/harness/session.py index af652abd..4f2db62a 100644 --- a/src/fi/alk/harness/session.py +++ b/src/fi/alk/harness/session.py @@ -38,7 +38,7 @@ # remain alive forever after a dropped upstream stream, though, which previously left a hosted # job looking healthy while making no progress. Bound *inactivity*, not total stage duration: # long scenario suites remain valid as long as they keep producing observable work. -STAGE_IDLE_TIMEOUT_SECONDS = float(os.getenv("ALK_STAGE_IDLE_TIMEOUT_SECONDS", "180")) +STAGE_IDLE_TIMEOUT_SECONDS = float(os.getenv("ALK_STAGE_IDLE_TIMEOUT_SECONDS", "600")) STAGE_IDLE_RETRIES = int(os.getenv("ALK_STAGE_IDLE_RETRIES", "1")) diff --git a/src/fi/alk/harness/world/stores/postgres.py b/src/fi/alk/harness/world/stores/postgres.py index 0a89b444..8f9f83b6 100644 --- a/src/fi/alk/harness/world/stores/postgres.py +++ b/src/fi/alk/harness/world/stores/postgres.py @@ -237,9 +237,14 @@ def save_to(self, path: str | Path) -> None: def load_from(self, path: str | Path) -> None: root = Path(path) schema = root / SCHEMA - if not schema.exists(): - raise StoreError(f"no saved Postgres schema at {schema}") - self.apply(schema.read_text(encoding="utf-8")) + # A standalone scenario store starts empty and needs the DDL; a compose-provisioned + # (Attached) store saves no schema.sql because store.json already carries the applied + # CREATE scripts, which Held.load_from replays. Apply schema.sql only when it exists and + # the tables are not already there, so restore works either way and never double-applies. + with self._connect() as connection: + has_schema = bool(self._tables(connection)) + if not has_schema and schema.exists(): + self.apply(schema.read_text(encoding="utf-8")) Held.load_from(self, root) # -- what a scenario changes -----------------------------------------------------