Skip to content

feat: engine-hardening — fail-fast on headless approval ambiguity (G3) + mcp engine dependency (G4) + host_config schema reference (N1/N2)#34

Merged
manojp99 merged 4 commits into
mainfrom
feat/engine-hardening-g3-g4
Jun 3, 2026
Merged

feat: engine-hardening — fail-fast on headless approval ambiguity (G3) + mcp engine dependency (G4) + host_config schema reference (N1/N2)#34
manojp99 merged 4 commits into
mainfrom
feat/engine-hardening-g3-g4

Conversation

@manojp99

@manojp99 manojp99 commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR consolidates four commits across the G3, G4, N1, and N2 requirements of the engine-hardening initiative:

  1. G4 (mcp dependency): Declares mcp as an explicit engine dependency in pyproject.toml, eliminating the "forgot --with mcp" install pain. Adds a new doctor check (_check_mcp_importable()) that surfaces the missing-mcp condition with clear remediation.

  2. G3 (approval mode in host_config): Accepts approval.mode (yes, no, prompt) in the host_config schema via loader validation, allowing hosts without argv access to express approval intent.

  3. G3 (fail-fast on headless ambiguity): BREAKING CHANGE. Non-TTY runs without explicit approval policy now exit with status 2 and an approval_unconfigured error envelope instead of silently defaulting to deny-all.

    • Migration: Pass -y, -n, or set host_config.approval.mode in --config / $AMPLIFIER_AGENT_CONFIG
    • Precedence: argv flag > host_config > TTY-based default (uniform with --provider resolution)
  4. N1/N2 (documentation): New docs/configuration.md is the authoritative reference for the closed top-level host_config schema, precedence model, error codes, and examples.

Test Results

  • 516 passed
  • 1 skipped
  • 15 pre-existing failures (conformance tests, mcp v2 envelope, phase exit gates) confirmed environmental on origin/main by stash/re-run — NOT regressions

Context

  • G1 was already closed in PR feat: host-config skills: block (D11/D12/D13) + tool-skills bundle composition #30 (bundle validation gate)
  • G2 closes for free (provider modules already read model from mount config)
  • G3/G4 implementation derived from prior R1-R4 cycle in amplifier-paperclip workspace
  • Gap inventory: See /Users/mpaidiparthy/repos/amplifier-paperclip/Amplifier-agent-recommended-gaps.md for remaining edge cases and future enhancements (not blockers for this PR)

Commits

  1. b2e9ccc fix(deps): add mcp as engine dependency + doctor importable check (G4)
  2. b4d8c17 feat(config): accept approval.mode in host_config schema (G3)
  3. b58f442 feat(cli)!: fail-fast on headless approval ambiguity (G3)
  4. b229cac docs: host_config schema reference + precedence model + CHANGELOG (N1, N2)

🤖 Generated with Amplifier

Manoj Prabhakar Paidiparthy and others added 4 commits June 3, 2026 11:35
tool-mcp requires mcp to be importable at mount time; without it the bundle fails
to mount with a downstream 'Bundle' object has no attribute 'origins' AttributeError
that masks the real cause. Declaring mcp in pyproject.toml makes the canonical
'uv run amplifier-agent ...' install command work out of the box.

New doctor check _check_mcp_importable() surfaces the missing-mcp condition with
a clear remediation line pointing users to the --with mcp option or explicit
'pip install mcp'.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Adds approval.mode recognition (one of 'yes', 'no', or 'prompt') at the
loader-validation layer, mirroring the _validate_approval_patterns pattern.

This allows hosts that drive amplifier-agent via host_config (without argv
access) to express the same intent as CLI flags -y/-n without requiring
downstream consumers to parse argv. Precedence is uniform with --provider
resolution: argv flag > host_config > bundle default.

Exports VALID_APPROVAL_MODES for downstream consumers to validate policy.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
BREAKING CHANGE: Non-TTY runs without explicit approval policy now exit with
status 2 and an 'approval_unconfigured' error envelope instead of silently
defaulting to deny-all.

Previous behavior: If stdin is not a TTY and neither -y nor -n is passed and
host_config.approval.mode is not set, every tool invocation is silently denied.
The run exits 0, monitoring sees green, zero work is done — a silent failure.

New behavior: Detects the headless + unconfigured scenario and fast-fails with
a clear error message listing all three escape hatches:
  1. Pass -y or -n on the command line
  2. Set AMPLIFIER_AGENT_CONFIG with 'approval.mode' in host_config
  3. Implement TTY-aware logic and re-run with TTY attached

Precedence model (consistent with --provider resolution):
  1. Explicit argv flag (-y or -n) takes absolute precedence
  2. host_config.approval.mode is consulted next
  3. TTY-based default (prompt if TTY, deny-all otherwise) as fallback

Test infrastructure updates:
  - conftest.py adds autouse fixture defaulting is_stdin_tty to True for all
    tests, so existing tests behave as TTY-attached by default
  - conftest.py adds session-scoped fixture seeding AMPLIFIER_AGENT_CONFIG env
    var with '{approval:{mode:yes}}' for subprocess tests, providing
    a safe default so test subprocesses don't hit the new headless check
  - Pre-existing tests updated to pass -y flag where needed to be explicit
    about approval intent

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…, N2)

New docs/configuration.md is the authoritative reference for the closed top-level
host_config schema contract. Covers:
  - Per-key semantics and allowed values
  - Precedence model (argv flag > host_config > bundle default)
  - Error codes (approval_unconfigured) and remediation steps
  - Concrete examples for adapter authors and host integrators
  - Migration guidance for G3 BREAKING change (headless approval ambiguity)

CHANGELOG aggregates all additions across G3 + G4:
  - G4: mcp dependency declaration + doctor importable check
  - G3: approval.mode in host_config schema + headless fail-fast behavior
  - N1/N2: docs/configuration.md + error envelope reference

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
@manojp99 manojp99 merged commit 9ce0bd4 into main Jun 3, 2026
1 of 3 checks passed
manojp99 pushed a commit that referenced this pull request Jun 3, 2026
…10)

Previously, SpawnAgentParams.approval threw AaaError(
approval_not_supported_in_v1) whenever set because it required the
mid-turn onRequest callback that v1 doesn't support.

This change extends SpawnAgentParams.approval to also accept the
static-policy shape { mode: 'yes' | 'no' | 'prompt' }, which maps to
engine argv:

  - 'yes'    -> -y (auto-allow every tool call)
  - 'no'     -> -n (auto-deny every tool call)
  - 'prompt' -> emit no flag; engine falls back to
                host_config.approval.mode or the bundle's TTY-based
                default. This is how a host hands policy resolution
                back to the engine.

The legacy { onRequest, timeoutMs } form still throws
approval_not_supported_in_v1 — the Mode A wire has no mid-turn
channel. Mid-turn callbacks will return when WG-4 lands.

Engine compatibility: { mode: 'prompt' } requires
amplifier-agent >= 0.4.0 (PR #34 added host_config.approval.mode).

Closes #10.

BREAKING CHANGE: SpawnAgentParams.approval is now a union shape;
callers passing { mode } no longer hit approval_not_supported_in_v1.
Callers that defensively catch that error need to remove the try/catch
when migrating to the mode shape.
manojp99 added a commit that referenced this pull request Jun 3, 2026
…, approval, getEngineInfo, +5 more) (#36)

* feat(wrapper-ts): re-export internal helpers from index.ts (#5)

Adds named re-exports from the package entry point so consumers can
import internal helpers without reaching into private deep paths:

  assembleArgv, AssembleArgvInput
  resolveMcpConfigPath, cleanupSpillFile, McpSpillResult
  buildEnv, resolveBinaryPath, probeEngineVersion,
    DEFAULT_ALLOWLIST, BLOCKED_ENV_KEYS,
    ResolveBinaryPathOptions, BuildEnvOptions
  Transport, TransportOptions, ExitInfo
  checkProtocolVersion, VersionCheckResult, VersionCheckOk,
    VersionCheckFail, CheckProtocolVersionOptions
  parseRunOutput, STDERR_TAIL_BYTES, SubprocessOutcome
  makeApprovalHandler, ApprovalAdapter, ApprovalRequest,
    ApprovalHandler

Each export is annotated @public.

Closes #5.

* feat(wrapper-ts): wire checkProtocolVersion() into init path (#9)

spawnAgent() now probes the engine's protocol version once during
initialization (via amplifier-agent version --json) and runs
checkProtocolVersion() against PROTOCOL_VERSION_REQUIRED_BY_WRAPPER
BEFORE constructing a SessionHandle. Mismatch fails fast wrapper-side
with AaaError(protocol_version_mismatch), saving a full subprocess
roundtrip later.

Adds two new SpawnAgentParams fields:
  - allowProtocolSkew?: boolean — bypass the check (mirrors engine's
    host_config.allowProtocolSkew)
  - _engineVersionProbe?: () => Promise<EngineVersionPayload> —
    test-only injection point for the probe

Also bumps PROTOCOL_VERSION_REQUIRED_BY_WRAPPER from "0.2.0" to
"0.3.0" to match the engine's current wire protocol
(amplifier_agent_lib.protocol.methods.PROTOCOL_VERSION). The wrapper
was shipping with a stale pin; the new check would have surfaced this
at startup.

Closes #9.

* feat(wrapper-ts): add runChildProcess injection point (#3)

Adds SpawnAgentParams.runChildProcess?: ChildProcessFactory — a public
seam to substitute the subprocess factory used inside SessionHandle.
When set, the wrapper invokes the factory in place of
child_process.spawn, preserving the same options shape (detached, stdio,
env, optional cwd).

Useful for:
  - Sandboxing (e.g. wrapping the child in a container or namespace)
  - Test doubles (e.g. EventEmitter fakes that drive scripted outputs)
  - Harness wrapping (e.g. observing the subprocess from outside)

ChildProcessFactory is exported as a @public type from index.ts.

Closes #3.

* feat(wrapper-ts)!: wire Transport NDJSON pipeline + dispatch to display.onEvent (#2, #4, #6)

The engine emits one JSON object per line on the child subprocess's
stderr stream for each wire-protocol notification (progress,
result/delta, result/final, thinking/delta, thinking/final,
tool/started, tool/completed, approval/request, approval/timeout,
plus wire-level error). Before this change the wrapper buffered
stderr as raw text and silently dropped every event — the existing
Transport class implemented NDJSON parsing but was never wired
anywhere (dead code).

This change:

  - Adds parseNdjsonStream(stream, {onJson, onNonJson?}) — a
    standalone helper extracted from the parsing logic Transport
    already had. Resolves when the stream emits 'close'. Exported
    @public.

  - Wires parseNdjsonStream onto child.stderr inside
    SessionHandle.makeIterable(). JSON lines are parsed into
    'notification' DisplayEvents and dispatched to
    params.display?.onEvent. Non-JSON lines (and JSON lines, for
    completeness) are still accumulated into stderrBuf so the
    stderrTail surface on parseRunOutput remains diagnostically
    useful.

  - Extends the DisplayEvent discriminated union with a new
    {type: 'notification', method: string, params: unknown}
    variant. **BREAKING**: existing exhaustive switch statements
    on event.type will no longer be exhaustive without a
    notification branch.

  - Threads SpawnAgentParams.display through to SessionHandle so
    the callback that was previously silently dropped is now
    actually fired (Issue #4).

Closes #2, #4, #6.

BREAKING CHANGE: display.onEvent callbacks are now actually invoked
with wire-event notifications. Callers that registered onEvent
expecting it to be a no-op may observe new event flow. The
DisplayEvent union has a new 'notification' variant; exhaustive
switch statements need a corresponding branch.

* feat(wrapper-ts): surface --config flag via SpawnAgentParams.configPath (#1)

Engine PR #27 / v0.4.0 added the --config <path> flag and the
host_config layer (approval mode, MCP servers, provider defaults,
allowProtocolSkew, etc.). The wrapper had no surface to forward this,
so callers had to fall back to AMPLIFIER_AGENT_CONFIG in env.extra.

This change:

  - Adds SpawnAgentParams.configPath?: string (public, @public TSDoc).
  - Adds AssembleArgvInput.configPath?: string.
  - assembleArgv emits --config <path> when configPath is set.
  - Threads configPath through SessionHandleParams to the per-submit
    argv assembly.

Also drive-by adds approvalMode field to AssembleArgvInput (used by
#10's commit). The argv-builder now reads input.approvalMode and emits
-y / -n / nothing accordingly. Default remains -y for backward compat
with callers that haven't opted into the approval API.

Closes #1.

* feat(wrapper-ts)!: wire approval API to engine -y/-n + approval.mode (#10)

Previously, SpawnAgentParams.approval threw AaaError(
approval_not_supported_in_v1) whenever set because it required the
mid-turn onRequest callback that v1 doesn't support.

This change extends SpawnAgentParams.approval to also accept the
static-policy shape { mode: 'yes' | 'no' | 'prompt' }, which maps to
engine argv:

  - 'yes'    -> -y (auto-allow every tool call)
  - 'no'     -> -n (auto-deny every tool call)
  - 'prompt' -> emit no flag; engine falls back to
                host_config.approval.mode or the bundle's TTY-based
                default. This is how a host hands policy resolution
                back to the engine.

The legacy { onRequest, timeoutMs } form still throws
approval_not_supported_in_v1 — the Mode A wire has no mid-turn
channel. Mid-turn callbacks will return when WG-4 lands.

Engine compatibility: { mode: 'prompt' } requires
amplifier-agent >= 0.4.0 (PR #34 added host_config.approval.mode).

Closes #10.

BREAKING CHANGE: SpawnAgentParams.approval is now a union shape;
callers passing { mode } no longer hit approval_not_supported_in_v1.
Callers that defensively catch that error need to remove the try/catch
when migrating to the mode shape.

* feat(wrapper-ts): implement getEngineInfo() — engineVersion + bundleDigest (#7)

Closes the Task-9 TODO: getEngineInfo() now returns the values
captured during the engine version probe that spawnAgent() runs at
init (Issue #9). Previously both fields were hardcoded empty strings.

  - engineVersion populated from `amplifier-agent version --json`
    payload's `version` field.
  - bundleDigest populated from the probe payload's optional
    `bundleDigest` field. The engine's current `version --json`
    output (from admin/version_info.py) only emits {version,
    protocolVersion} — bundleDigest will be empty string until a
    future engine release exposes it. Forward-compatible: when the
    engine adds it, the wrapper picks it up automatically with no
    further changes.

DONE_WITH_CONCERNS for the bundleDigest follow-up: filed as an
engine-side ask for a future PR. The wrapper does what it can with
the data the engine surface exposes today; the contract is wired
so the field will populate the moment the engine emits it.

Closes #7.

* chore(wrapper-ts): rebuild dist after hardening release changes

Mirrors PR #29 / #31 pattern: dist/ is tracked so consumers installing
from the git tarball get the compiled artifacts without a build step.

Regenerated from npm run build after issues #1, #2, #3, #4, #5, #6, #7,
#9, #10 landed.

* chore(release): bump amplifier-agent-ts to 0.6.0 + CHANGELOG

Wrapper hardening release closing 8 consumer-reported gaps at 0.5.0:
  #1  configPath surface
  #2  stderr NDJSON parsing
  #3  runChildProcess injection
  #4  display.onEvent dispatch
  #5  public re-exports
  #6  Transport dead code (root cause of #2/#4)
  #7  getEngineInfo() implementation
  #9  checkProtocolVersion() wired into init path
  #10 approval API mapped to engine -y/-n + approval.mode

Issue #8 in the consumer report was a misread — InitializeParams.
mcpConfigPath is intentionally retained in protocol-0.3.0. No
type change needed; the schema is canonical and correct.

This is a minor bump per 0.x convention even though some changes
are BREAKING — the wrapper hasn't shipped a 1.0 yet, so breaking
changes ride minor bumps. See CHANGELOG for the BREAKING list.

Engine compatibility: requires amplifier-agent >= 0.4.0.
Pinned protocol: 0.3.0.

---------

Co-authored-by: Manoj Prabhakar Paidiparthy <mpaidiparthy@microsoft.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant