Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,3 +305,83 @@ them as DROP (no domain action) so existing event handlers are unaffected:
- `session.mcp_server_status_changed` / `session.remote_steerable_changed`

No caller action required.

---

# Migration Guide: v2.1.x → v2.2.0 (filesystem-layout V1.0)

## Overview

v2.2.0 introduces an explicit, contract-anchored filesystem layout for the
provider (see `contracts/filesystem-layout.md`). Two user-visible changes:

1. **Provider home is now provider-owned** — SDK subprocess state
(`session-store.db`, `session-state/`, `config.json`, `logs/`) is written
under a provider-owned directory instead of the default `~/.copilot/`.
2. **Cache directory was renamed** to a single flat distribution-name segment.

Existing files under `~/.copilot/` are not deleted, but the new provider
does not read them — prior session, auth, and CLI logs effectively start
fresh under the new `provider_home`. Users authenticated via the
documented `GITHUB_TOKEN` flow are unaffected; users who relied on
undocumented `~/.copilot/` state should re-authenticate on first call
(`export GITHUB_TOKEN=$(gh auth token)`). Auto-migration is forbidden
by the contract (`Lifecycle:MUST:4`); the legacy directories may be
deleted at the user's leisure.

## Cache directory rename

| OS | v2.1.x (old) | v2.2.0 (new) |
|---------|-------------------------------------------------------------|-------------------------------------------------------|
| Linux | `~/.cache/amplifier/provider-github-copilot/` | `~/.cache/amplifier-provider-github-copilot/` |
| macOS | `~/Library/Caches/amplifier/provider-github-copilot/` | `~/Library/Caches/amplifier-provider-github-copilot/` |
| Windows | `%LOCALAPPDATA%\amplifier\provider-github-copilot\` | `%LOCALAPPDATA%\amplifier-provider-github-copilot\Cache\` |

**Behavior on upgrade:** the only file written here is `models_cache.json`
(regenerable from `provider.list_models()` on first call). First call after
upgrade will repopulate the new path; the old path is orphaned but harmless.

**Optional cleanup** (Linux example):

```bash
rm -rf ~/.cache/amplifier/provider-github-copilot
```

## Provider home introduction

SDK state previously written to `~/.copilot/` is now written under a
provider-owned `provider_home`. The V1.0 contract (`filesystem-layout.md`
§Paths:MUST:1) defines a **platform-uniform** resolution chain — there is no
per-OS branching for the fallback:

1. `${AMPLIFIER_PROVIDER_GITHUB_COPILOT_HOME}` if set, non-empty, and
absolute after `Path.expanduser()`.
2. `${XDG_DATA_HOME}/amplifier-provider-github-copilot/` if `XDG_DATA_HOME`
is set, non-empty, and absolute.
3. `~/.amplifier-provider-github-copilot/` (dot-prefixed directory under
the user's home, on **all** platforms — Linux, macOS, Windows).

Examples of resolved paths:

| Scenario | Resolved `provider_home` |
|------------------------------------|---------------------------------------------------------|
| Override env set | the absolute override path |
| Linux with `XDG_DATA_HOME` set | `$XDG_DATA_HOME/amplifier-provider-github-copilot/` |
| Linux without `XDG_DATA_HOME` | `~/.amplifier-provider-github-copilot/` |
| macOS (default) | `~/.amplifier-provider-github-copilot/` |
| Windows (default) | `~\.amplifier-provider-github-copilot\` |

Note that `cache_home` follows a different (platform-aware) chain — see
`contracts/filesystem-layout.md` §Paths:MUST:2 for the full table.

**Behavior on upgrade:** any session state previously stored at `~/.copilot/`
remains in place but is not read by this provider. Users who relied on that
state (rare — `session-store.db` is per-bundle and regenerates) may delete it
or re-authenticate via `GITHUB_TOKEN` (the documented auth flow since v2.0.0).

## What did NOT change

- Authentication (`GITHUB_TOKEN`) flow.
- Public API symbols.
- Cache schema or `models_cache.json` format.
- Cache TTL or invalidation policy.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ amplifier provider models github-copilot

## Supported Models

Models are discovered dynamically from the SDK at runtime — the list reflects your GitHub Copilot plan. The tables below show the current set as of SDK 0.3.0; run `amplifier provider models github-copilot` for the live list.
Models are discovered dynamically from the SDK at runtime — the list reflects your GitHub Copilot plan. The tables below show the current set as of SDK 1.0.0b4; run `amplifier provider models github-copilot` for the live list.

**Anthropic:**

Expand Down Expand Up @@ -478,7 +478,7 @@ python -m pytest tests/ -m live -v --tb=short
| `Copilot SDK not installed` | Provider module not installed | Run `amplifier provider install github-copilot` |
| `Not authenticated to GitHub Copilot` | Token not set | **Linux/macOS:** `export GITHUB_TOKEN=$(gh auth token)` **Windows:** `$env:GITHUB_TOKEN = (gh auth token)` |
| `gh: command not found` | GitHub CLI missing | [Install gh CLI](https://cli.github.com/) |
| Stale or wrong model list | Cached models | Delete `%LOCALAPPDATA%\amplifier\provider-github-copilot\models_cache.json` (Windows), `~/Library/Caches/amplifier/provider-github-copilot/models_cache.json` (macOS), or `~/.cache/amplifier/provider-github-copilot/models_cache.json` (Linux) |
| Stale or wrong model list | Cached models | Delete `models_cache.json` from your cache directory: `%LOCALAPPDATA%\amplifier-provider-github-copilot\Cache\` (Windows), `~/Library/Caches/amplifier-provider-github-copilot/` (macOS), or `~/.cache/amplifier-provider-github-copilot/` (Linux). |
| `Permission denied` on SDK binary | `uv` stripped execute bits | Provider auto-repairs on startup; if it fails, run `chmod +x <path-to-copilot-binary>` (Linux/macOS only) |

### Common Mistake
Expand All @@ -504,7 +504,7 @@ Running `amplifier init` before authentication:
## Dependencies

- `amplifier-core` (provided by Amplifier runtime, not installed separately)
- `github-copilot-sdk>=0.3.0,<0.4.0`
- `github-copilot-sdk==1.0.0b4`
- `pyyaml>=6.0`

> **Note:** `github-copilot-sdk` is installed automatically when you install or initialize
Expand Down
6 changes: 4 additions & 2 deletions amplifier_module_provider_github_copilot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
from importlib.metadata import PackageNotFoundError as _PkgNotFoundError
from importlib.metadata import version as _pkg_version

from ._identity import PROVIDER_ID

# Single source of truth for pytest detection — defined in _platform.py.
# Both __init__.py and sdk_adapter/_imports.py import from there.
from ._platform import is_pytest_running # noqa: E402 (before SDK check block)
Expand Down Expand Up @@ -87,7 +89,7 @@ def _check_sdk_version(version_str: str) -> None:

# Contract: provider-protocol:public_api:MUST:1 — must match pyproject.toml [project].version
# Verified by tests/test_behaviors.py::TestPackageVersionConsistency
__version__ = "2.1.1"
__version__ = "2.2.0"

# Amplifier module metadata
__amplifier_module_type__ = "provider"
Expand Down Expand Up @@ -343,7 +345,7 @@ async def _prewarm() -> None:
logger.info(f"[MOUNT] Provider created: {provider.name}")

logger.info("[MOUNT] Mounting to coordinator...")
await coordinator.mount("providers", provider, name="github-copilot")
await coordinator.mount("providers", provider, name=PROVIDER_ID)
logger.info("[MOUNT] Provider mounted successfully")

async def cleanup() -> None:
Expand Down
4 changes: 3 additions & 1 deletion amplifier_module_provider_github_copilot/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

from __future__ import annotations

from ._identity import PROVIDER_ID

__all__ = ["ConfigurationError"]


Expand All @@ -38,7 +40,7 @@ def __init__(
self,
message: str,
*,
provider: str = "github-copilot",
provider: str = PROVIDER_ID,
**kwargs: object,
) -> None:
super().__init__(message)
Expand Down
14 changes: 14 additions & 0 deletions amplifier_module_provider_github_copilot/_identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""Provider identity — single import-time constant.

Contract: contracts/filesystem-layout.md:Identity:MUST:1

`PROVIDER_ID` is the registry-facing string. The literal lives exactly
once at `config/_models.py:12`; every other call site MUST import
`PROVIDER_ID` from here so the source of truth stays singular.
"""

from __future__ import annotations

from .config._models import PROVIDER as _PROVIDER

PROVIDER_ID: str = _PROVIDER["id"]
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@
# from .config import _models
# from .config._sdk_protection import ...
# without needing __init__.py to be empty.
from . import _models, _policy, _sdk_protection # noqa: F401
from . import _models, _paths, _policy, _sdk_protection # noqa: F401
174 changes: 174 additions & 0 deletions amplifier_module_provider_github_copilot/config/_paths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
"""Provider path resolution.

Contract: contracts/filesystem-layout.md (V1.0)

Single source of truth for `provider_home` (XDG-DATA: persistent state)
and `cache_home` (XDG-CACHE: regenerable artifacts). Stdlib-only so the
provider loads identically under every host distribution.
"""

from __future__ import annotations

import os
import stat
import sys
from dataclasses import dataclass
from pathlib import Path

# Identity:MUST:2 — filesystem-facing identity, defined exactly once.
PROVIDER_DISTRIBUTION_NAME: str = "amplifier-provider-github-copilot"

# Identity:MUST:3 — mechanical derivations.
_ENV_OVERRIDE: str = f"{PROVIDER_DISTRIBUTION_NAME.upper().replace('-', '_')}_HOME"
_DOT_DIR_NAME: str = f".{PROVIDER_DISTRIBUTION_NAME}"


@dataclass(frozen=True)
class ProviderPaths:
"""Resolved on-disk locations owned by the provider.

Contract: filesystem-layout:Paths:MUST:5 (frozen).

Note: Instances built through `load_provider_paths()` are
guaranteed disjoint (`_enforce_disjoint` runs on the env-resolution
path). Callers constructing `ProviderPaths` directly — i.e. host
wiring via the `Isolation:MUST:3` injection escape hatch — own the
disjointness invariant themselves; `_enforce_disjoint` is not
invoked on the injection path by design.
"""

provider_home: Path
cache_home: Path


def _resolve_env_value(raw: str | None) -> str | None:
"""Return stripped value if non-empty after strip, else None."""
if raw is None:
return None
stripped = raw.strip()
return stripped if stripped else None


def _resolve_absolute(value: str, env_name: str) -> Path:
"""Expand `~` if present, then enforce absoluteness.

Raises ValueError per Paths:MUST:1 / MUST:2 fail-closed rule.
"""
expanded = Path(value).expanduser()
if not expanded.is_absolute():
raise ValueError(
f"{env_name}={value!r} must be absolute after Path.expanduser(); "
f"got {expanded!s}"
)
return expanded


def _resolve_provider_home() -> Path:
"""Contract: filesystem-layout:Paths:MUST:1."""
override = _resolve_env_value(os.environ.get(_ENV_OVERRIDE))
if override is not None:
return _resolve_absolute(override, _ENV_OVERRIDE)

xdg_data = _resolve_env_value(os.environ.get("XDG_DATA_HOME"))
if xdg_data is not None:
base = _resolve_absolute(xdg_data, "XDG_DATA_HOME")
return base / PROVIDER_DISTRIBUTION_NAME

return Path.home() / _DOT_DIR_NAME


def _resolve_cache_home() -> Path:
"""Contract: filesystem-layout:Paths:MUST:2."""
xdg_cache = _resolve_env_value(os.environ.get("XDG_CACHE_HOME"))
if xdg_cache is not None:
base = _resolve_absolute(xdg_cache, "XDG_CACHE_HOME")
return base / PROVIDER_DISTRIBUTION_NAME

if sys.platform == "darwin":
return Path.home() / "Library" / "Caches" / PROVIDER_DISTRIBUTION_NAME

if sys.platform == "win32":
localappdata = _resolve_env_value(os.environ.get("LOCALAPPDATA"))
if localappdata is not None:
base = _resolve_absolute(localappdata, "LOCALAPPDATA")
return base / PROVIDER_DISTRIBUTION_NAME / "Cache"
return Path.home() / "AppData" / "Local" / PROVIDER_DISTRIBUTION_NAME / "Cache"

return Path.home() / ".cache" / PROVIDER_DISTRIBUTION_NAME


def load_provider_paths() -> ProviderPaths:
"""Resolve `provider_home` and `cache_home` per V1.0 contract.

Contract: filesystem-layout:Paths:MUST:1, MUST:2, MUST:3;
filesystem-layout:Wiring:MUST:3 (uncached — env reads on every call).

Raises ValueError when the resolved paths violate Paths:MUST:3
(equal or one contained in the other) — typically because the
operator set overlapping `AMPLIFIER_PROVIDER_GITHUB_COPILOT_HOME`
and `XDG_CACHE_HOME` / `LOCALAPPDATA` values.
"""
provider_home = _resolve_provider_home()
cache_home = _resolve_cache_home()
_enforce_disjoint(provider_home, cache_home)
return ProviderPaths(provider_home=provider_home, cache_home=cache_home)


def _enforce_disjoint(provider_home: Path, cache_home: Path) -> None:
"""Reject overlap between `provider_home` and `cache_home`.

Contract: filesystem-layout:Paths:MUST:3.
"""
if provider_home == cache_home:
raise ValueError(
f"provider_home and cache_home must be disjoint but resolved "
f"to the same path {provider_home!s}; check "
f"AMPLIFIER_PROVIDER_GITHUB_COPILOT_HOME, XDG_DATA_HOME, "
f"XDG_CACHE_HOME, and LOCALAPPDATA env values."
)
try:
cache_home.relative_to(provider_home)
except ValueError:
pass
else:
raise ValueError(
f"cache_home {cache_home!s} must not be contained within "
f"provider_home {provider_home!s} (filesystem-layout:Paths:MUST:3)."
)
try:
provider_home.relative_to(cache_home)
except ValueError:
pass
else:
raise ValueError(
f"provider_home {provider_home!s} must not be contained within "
f"cache_home {cache_home!s} (filesystem-layout:Paths:MUST:3)."
)


def _create_one(leaf: Path) -> None:
"""Materialize a single leaf with the contract's lifecycle semantics."""
leaf.mkdir(parents=True, exist_ok=True)

if sys.platform == "win32":
# Lifecycle:MUST:2 — mkdir-only; no mode, no chmod.
return

# Lifecycle:MUST:1 — POSIX/macOS: refuse symlinks then chmod 0o700.
st = os.lstat(leaf)
if stat.S_ISLNK(st.st_mode):
raise OSError(
f"refusing to use symbolic link at {leaf!s}: "
f"provider directories must be regular directories"
)
os.chmod(leaf, 0o700)


def ensure_paths_exist(paths: ProviderPaths) -> None:
"""Materialize `provider_home` and `cache_home` lazily and idempotently.

Contract: filesystem-layout:Lifecycle:MUST:1 (POSIX/macOS chmod 0o700 +
symlink refuse), MUST:2 (Windows mkdir-only), MUST:3 (collision chain).
"""
_create_one(paths.provider_home)
_create_one(paths.cache_home)
10 changes: 9 additions & 1 deletion amplifier_module_provider_github_copilot/config/_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ class StreamingConfig:
class CacheConfig:
"""Model cache policy configuration.

Contract: behaviors:ModelCache:SHOULD:2
Contract: behaviors:ModelCache:SHOULD:2, SHOULD:4

Note: max_stale_seconds is defined in the contract but not currently
read by any caller. Included for contract compliance.
Expand All @@ -67,6 +67,14 @@ class CacheConfig:
disk_ttl_seconds: int = 86400 # 24 hours
max_stale_seconds: int = 604800 # 7 days
cache_filename: str = "models_cache.json"
# behaviors:ModelCache:SHOULD:4 — random factor in [-jitter, +jitter]
# multiplied into the default TTL on each cache read to spread refresh
# work across coexisting host processes. 0.0 disables. Caution: values
# approaching 1.0 collapse the effective TTL toward zero (the lower
# bound 1 - factor → 0; `int(nominal_ttl * _jitter())` is then clamped
# at 0 via `max(0, ...)`, so every read becomes stale and degenerates
# into a refresh storm). Recommended range: [0.0, 0.5].
disk_ttl_jitter_factor: float = 0.1


@functools.lru_cache(maxsize=1)
Expand Down
Loading