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
16 changes: 10 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ through the opt-in key store (`copilot-byok keys set` or `/byok`), never on its

`--native-byok` publishes the same models through the CLI's supported `providers.json` registry
instead: discovery runs as usual, a private temporary registry is written, `COPILOT_PROVIDERS_CONFIG`
points at it for the child process, and it is deleted when the session ends. That file contains the
provider credentials for the session, which is why the mode stays opt-in.
points at it for the child process, and it is deleted on normal exit, on failure, and on the usual
termination signals. That file contains the provider credentials for the session, which is why the
mode stays opt-in. Native mode needs a CLI that loads `providers.json`; 1.0.105 is the verified
version and 1.0.79 does not load it.

`COPILOT_API_URL` is an internal Copilot CLI variable, absent from `copilot help environment`.
Treat it as the project's main external risk: if a CLI release changes it, the router breaks and
Expand Down Expand Up @@ -131,7 +133,8 @@ This project handles third-party provider API keys. Every change must preserve t
interactive prompt: never accept a key from argv, stdin pipes, or an environment variable meant for
something else. It must stay opt-in — nothing may create it implicitly.
- Never print, log, or embed a credential value. `--dry-run` output must keep passing through `redactEnv()`,
which masks any key matching `/KEY|TOKEN|SECRET|PASSWORD/i`.
which masks any key matching `/KEY|TOKEN|SECRET|PASSWORD/i` and the whole `COPILOT_PROVIDER_HEADERS`
value.
- Error messages must not reveal which environment variable holds a secret (covered by a test).
- Model-catalog requests send credentials only when the catalog URL is same-origin with `baseUrl`,
or when the provider explicitly sets an authenticated `modelsAuth` mode (`bearer`, `x-api-key`, or
Expand All @@ -145,9 +148,10 @@ This project handles third-party provider API keys. Every change must preserve t
- No `.env` file is used or expected; there is no `.env.example`. Do not add one.
- Never commit real keys, tokens, or a `providers.json` containing credentials.
- `--native-byok` writes provider credentials to a temporary registry because the CLI's supported
path requires them inline. That file must keep `0600` (owner-only ACL on Windows), live in the
system temp directory, and be removed on exit, including on failure. It must never be written to
the repository or to the user's Copilot config directory.
path requires them inline. The directory must be owner-only before the file exists, a failed
restriction must abort the session, and the file must be removed on exit, on failure to start, and
on SIGINT/SIGTERM/SIGHUP; a hard kill is covered only by the stale-registry sweep. It must never be
written to the repository or to the user's Copilot config directory.
- `--legacy` must keep isolating itself from a user `providers.json` when one is present: that file
takes precedence over `COPILOT_PROVIDER_*`, and silently using it would break the mode.

Expand Down
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ All notable changes to this project are documented in this file.
`copilot-api.<tenant>.ghe.com` instead of the public Copilot hosts.
- Legacy mode now passes `COPILOT_PROVIDER_HEADERS` (from `headers`) and
`COPILOT_PROVIDER_API_KEY_COMMAND` (from `apiKeyCommand`), and sets `COPILOT_PROVIDER_MODEL_ID`
when `catalogModelId` is configured.
when `catalogModelId` is configured. A provider with only `apiKeyCommand` is accepted in
`--legacy` (Copilot CLI 1.0.84 or newer) and reported as legacy-only elsewhere.

### Changed

Expand All @@ -30,11 +31,27 @@ All notable changes to this project are documented in this file.
- Legacy mode detects a user `providers.json` that would take precedence over `COPILOT_PROVIDER_*`,
points the child at an empty temporary registry, and says so on stderr.

### Security

- Native registry: the temporary directory is made owner-only before the file exists, and a failed
restriction aborts the session instead of leaving keys readable. Cleanup now covers SIGINT,
SIGTERM and SIGHUP, registry removal failures are reported, and stale registries from a hard kill
are swept at the next native startup.
- `--dry-run` no longer writes provider keys for `--native-byok`, and `COPILOT_PROVIDER_HEADERS` is
redacted in dry-run output.

### Fixed

- A reasoning-effort level sent by the CLI and rejected by the provider no longer loops: the retry
now carries the stepped-down level instead of replaying the original request. The close listener
is also registered once per client request instead of once per retry.
- The effort ladder runs only when a `400` body mentions the reasoning effort; ordinary context,
schema or content-policy errors are forwarded unchanged instead of consuming seven retries.
- `doctor` runs the Copilot version probe without provider credentials, reports discovery failures
in `--online`, counts keyed and authless providers separately, and marks `apiKeyCommand` providers
as legacy-only.
- `headers` must be an object of string values, like `modelsHeaders`, and the published schema no
longer describes the old reasoning-effort behavior.
- `docs/troubleshooting.md` had a broken router log path.

## [2.0.1] - 2026-10-21
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Out of scope:

## How credentials are handled

**Provider keys.** They are read from environment variables or, if you opt in, from the local key store. In router mode the keys stay in the router's own process: they are attached to that provider's outgoing request and stripped from the environment handed to Copilot CLI, so the agent process never sees them. In `--native-byok` mode the CLI reads them from a temporary `providers.json` written by this tool: the file is created in the system temp directory with `0600` and an owner-only ACL on Windows, passed to the CLI through `COPILOT_PROVIDERS_CONFIG`, and deleted when the session ends. In `--legacy` mode the selected credential is passed to Copilot itself, because that mode uses Copilot's built-in BYOK variables.
**Provider keys.** They are read from environment variables or, if you opt in, from the local key store. In router mode the keys stay in the router's own process: they are attached to that provider's outgoing request and stripped from the environment handed to Copilot CLI, so the agent process never sees them. In `--native-byok` mode the CLI reads them from a temporary `providers.json` written by this tool: the directory and the file are made owner-only before the file is written, the session is aborted if that restriction fails, and the registry is passed through `COPILOT_PROVIDERS_CONFIG`. It is deleted on normal exit, on failure to start, and on SIGINT/SIGTERM/SIGHUP; a hard kill can leave it behind, so registries older than a week are swept at the next native session. In `--legacy` mode the selected credential is passed to Copilot itself, because that mode uses Copilot's built-in BYOK variables.

**The local key store.** `copilot-byok keys set` and the `/byok` command write to `~/.config/copilot-byok/keys.json` (`%APPDATA%\copilot-byok\keys.json` on Windows), created with mode `0600` and an owner-only ACL on Windows. **Keys stored there are plain text on disk**, protected by file permissions alone; if that does not fit your threat model, use environment variables, which always take precedence and are the default. The store is never created unless you use it. Keys are only accepted from an interactive prompt, never from command-line arguments or pipes, so they do not reach shell history or process listings.

Expand Down
22 changes: 15 additions & 7 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ hold them. Inline `apiKey` fields are rejected.

`apiKeyCommand` is the exception to environment variables: it names a command the Copilot CLI runs
itself to obtain a rotating credential, passed through as `COPILOT_PROVIDER_API_KEY_COMMAND` in
legacy mode. `headers` adds gateway headers to the provider requests, and to the native registry in
legacy mode. It needs Copilot CLI 1.0.84 or newer and works only with `--legacy`: the CLI's
`providers.json` registry has no equivalent, so router and `--native-byok` sessions cannot use it.
`headers` adds gateway headers to the provider requests, and to the native registry in
`--native-byok` mode.

## Commands
Expand Down Expand Up @@ -78,9 +80,12 @@ Use it when you want the supported BYOK path, or when `COPILOT_API_URL` stops wo

Requirements and trade-offs:

- a Copilot CLI that reads `providers.json` (verified on 1.0.105);
- a Copilot CLI that reads `providers.json` (verified on 1.0.105; 1.0.79 does not);
- provider keys are written to that temporary file for the session, unlike router mode where they
never reach the Copilot process;
never reach the Copilot process. The directory and file are owner-only before the file is written;
if that restriction fails the session is aborted rather than leaving keys readable. The registry is
removed on normal exit and on SIGINT/SIGTERM/SIGHUP, and stale registries older than a week are
swept at the next native startup for the uncatchable cases (SIGKILL, power loss);
- the router's reasoning-effort step-down does not apply because the CLI talks to the provider
directly; the levels a model advertises are what the picker offers;
- a `~/.copilot/providers.json` you maintain yourself is ignored for that session, since the
Expand Down Expand Up @@ -161,20 +166,23 @@ with `low` and with `high` were byte-identical: that was the CLI bug tracked in
The effort selector is therefore declared for models that report reasoning support, and can be
hidden per model or per provider with `reasoningEffortPicker: false`.

You can still force a level from the config, useful on older CLIs or when a model should always run
at a fixed effort:
You can still force a level from the config in **router mode**, useful on older CLIs or when a model
should always run at a fixed effort:

```json
{ "model": "deepseek-reasoner", "reasoningEffort": "high" }
```

`reasoningEffort` also works at provider level as a default. Values are the ones Copilot uses:
`none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. `reasoningEffortLevels` narrows what
the picker offers for a model.
the picker offers for a model. `--native-byok` ignores `reasoningEffort`: the CLI sends the level
from the picker or `--reasoning-effort`, and only `reasoningEffortLevels` constrains it there.

**Levels differ per model and providers do not fall back.** GLM-5.2 accepts `max` but answers `400`
to `xhigh`. In router mode the router steps down one level at a time, applies the same ladder to a
level the CLI sent, records the rejection for the session, and logs the adjustment:
level the CLI sent, records the rejection for the session, and logs the adjustment. A `400` that
does not mention the effort is forwarded unchanged, so an ordinary context or schema error is never
retried through the ladder:

```text
notice: chutes: zai-org/GLM-5.2-TEE rejected reasoning effort "xhigh", retrying with "high"
Expand Down
8 changes: 5 additions & 3 deletions docs/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ instead of the router. It runs the usual live discovery, writes a temporary `pro
is no local proxy and no dependency on `COPILOT_API_URL`.

The trade-off is the one the registry itself imposes: provider keys are in that temporary file while
the session runs. The file is created in the system temp directory with owner-only permissions and
removed on exit, including when the session fails to start. Model ids are `provider/model`; a
`--model byok-...` value is translated to the matching native id.
the session runs. The directory and file are owner-only before the file is written; if the
restriction cannot be applied the session is aborted. The file is removed on normal exit, when the
session fails to start, and on SIGINT/SIGTERM/SIGHUP; registries left by a hard kill are swept when
the next native session starts. Model ids are `provider/model`; a `--model byok-...` value is
translated to the matching native id.
6 changes: 5 additions & 1 deletion docs/provider-verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ Re-verified on 2026-11-01 against 1.0.105, with 1.0.79 as the baseline.
| Reasoning effort to BYOK | not sent | sent | `reasoning_effort: "high"` on the wire with native provider variables, and through the router when the model entry advertises levels |
| Native `providers.json` registry | not loaded | loaded | two providers in one file; `--model p2/m2` reached the second base URL with its `wireModel` |
| Extension SDK | unchanged | unchanged | the 1.0.28 SDK still exports `joinSession`, `commands`, `ui.select` and `session.log`; extensions remain experimental and were not run interactively in this audit |
| Data residency origin | not exercised | derived, unit-tested | `dataResidencyOrigin` maps a `*.ghe.com` host to `copilot-api.<tenant>.ghe.com`; no live data residency tenant was available, so the host is inferred from upstream issue #4527 and the CLI's documented host handling, not verified end-to-end |
| `COPILOT_PROVIDER_HEADERS`, `_MODEL_ID`, `_API_KEY_COMMAND` | n/a | documented and passed through | 1.0.105 `copilot help providers` lists all three; `API_KEY_COMMAND` exists since 1.0.84 per upstream issue #4936; the environment pass-through is covered by unit tests, with no real provider run |
| `--native-byok` minimum CLI | does not load the registry | works | a manual `providers.json` was not loaded on 1.0.79; no bisect was done, so 1.0.105 is the verified version. Do not assume native mode works on older releases |

`COPILOT_API_URL` remains undocumented: it is not listed in `copilot help environment` on 1.0.105.
The native registry is the supported alternative, which is why `--native-byok` exists.
Expand Down Expand Up @@ -186,7 +189,8 @@ On 2026-11-01, `copilot-byok --native-byok` ran on CLI 1.0.105 with an isolated
The generated `providers.json` was accepted, Copilot sent
`POST /v1/chat/completions` with the pinned model name, used the reply, and the
temporary registry was gone after exit. This verifies the registry shape and the
cleanup path, not a real provider's inference.
clean-exit cleanup, not a real provider's inference. Signal cleanup and the
stale-registry sweep are covered by unit tests, not by this end-to-end run.

## Reproducing

Expand Down
11 changes: 9 additions & 2 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,15 @@ pointing at it. Requirements:
- model ids in `/model` are `provider/model`; `--model byok-...` is translated for you.

If the session fails immediately, run `copilot-byok --native-byok --dry-run` to see the providers
and selection ids that would be written. The temporary file is removed when Copilot exits, and a
`providers.json` you maintain yourself is ignored for that session.
and selection ids that would be written. The temporary file is removed on normal exit, on failure to
start, and on Ctrl+C or a termination signal; after a hard kill, stale registries are removed at the
next native startup. A `providers.json` you maintain yourself is ignored for that session.

## `apiKeyCommand` is ignored

A provider that only has an `apiKeyCommand` works with `--legacy` and needs Copilot CLI 1.0.84 or
newer. Router mode and `--native-byok` cannot use it: the CLI's `providers.json` registry has no
credential-command equivalent. `--list-providers` marks such providers as `apiKeyCommand (--legacy only)`.

## `Unknown BYOK model`

Expand Down
6 changes: 3 additions & 3 deletions schemas/providers.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -280,15 +280,15 @@
"xhigh",
"max"
],
"description": "Reasoning effort sent to the provider. Copilot does not forward its own setting to BYOK models, so this is what actually takes effect. Levels differ per model; an unsupported one is stepped down automatically."
"description": "Reasoning effort the router applies when the request carries none. Current Copilot CLIs forward the level picked in the selector on their own; this value remains useful on older CLIs or to pin a model to a fixed level. Ignored by --native-byok, which sends the CLI's level directly. Levels differ per model; an unsupported one is stepped down automatically in router mode."
},
"family": {
"type": "string",
"description": "Well-known model family, used by Copilot for model-specific handling."
},
"reasoningEffortPicker": {
"type": "boolean",
"description": "Declare reasoning-effort support so Copilot shows its effort selector. Off by default: on CLI 1.0.79 the chosen level never reaches a BYOK provider (github/copilot-cli#4012). Turn it on once the CLI forwards it again."
"description": "Declares reasoning-effort support so Copilot shows its effort selector. On by default for models that report reasoning, since current CLIs forward the level to BYOK providers. Set it to false to hide the selector; on CLI 1.0.79 the selector was inert."
},
"reasoningEffortLevels": {
"type": "array",
Expand All @@ -305,7 +305,7 @@
"max"
]
},
"description": "Levels offered in the selector, when it is enabled. Defaults to low/medium/high; models differ, so override where needed."
"description": "Levels offered in the selector. Defaults to low/medium/high; models differ, so override where needed. In --native-byok this is the only lever, because the router's step-down does not apply."
}
},
"additionalProperties": true
Expand Down
4 changes: 2 additions & 2 deletions src/catalog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,8 @@ function buildEntry({ id, provider, model }) {
// Declared for reasoning models by default: current CLIs forward the
// level to BYOK providers, so the selector is meaningful. Older CLIs
// accepted the choice without sending it (github/copilot-cli#4012,
// fixed in 1.0.91); there the selector is inert, and
// `reasoningEffortPicker: false` hides it.
// reported resolved in the 1.0.9x line); there the selector is inert,
// and `reasoningEffortPicker: false` hides it.
...(model.reasoning && model.reasoningEffortPicker !== false
? { reasoning_effort: model.reasoningEffortLevels || DEFAULT_EFFORT_LEVELS }
: {}),
Expand Down
Loading
Loading