Skip to content

Commit a3af6b3

Browse files
krukowCopilot
andcommitted
docs: document client mode :empty + empty_mode example
- doc/reference/API.md: add :mode client option to constructor table, add 4 new session-config flags (skip-custom-instructions, custom-agents-local-only, coauthor-enabled, manage-schedule-enabled) to the session-config table, and add two new sections under Advanced Usage: - 'Client Mode (Empty)' — runnable example, mode semantics, the 9 config defaults, system-message normalization, options.update flags, and the always-emit tool-filter-precedence guarantee. - 'Tool Sets' — github.copilot-sdk.tool-set API surface (builtin / mcp / custom / builtins / isolated) with the bare-* rejection rationale. - CHANGELOG.md: new [Unreleased] section for upstream PR #1428, remove the matching 'deferred from round 6' bullet (no longer deferred — ported here). - examples/empty_mode.clj: BYOK-based runnable example showing temp copilot-home + in-memory session-fs + tool-set/isolated. Excluded from run-all-examples.sh because empty mode disables the local keychain and the example requires OPENAI_API_KEY or ANTHROPIC_API_KEY. - examples/README.md: example 20 entry, prerequisites note. - doc/index.md: bump example count to 20. Upstream: github/copilot-sdk#1428 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 94ad7ab commit a3af6b3

5 files changed

Lines changed: 329 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,64 @@ All notable changes to this project will be documented in this file. This change
33

44
## [Unreleased]
55

6+
### Added (Client Mode Empty — upstream PR #1428)
7+
- **`:mode` client option**`#{:copilot-cli :empty}`, default
8+
`:copilot-cli`. Selects between historical CLI behavior and a hardened
9+
multitenancy posture for SaaS hosts that must isolate sessions from
10+
the local machine. Validated on `copilot/client`.
11+
- **`:empty` mode constructor enforcement** — In `:empty` mode the
12+
client requires at least one tenant-scoped storage root
13+
(`:copilot-home`, `:session-fs`, `:cli-url`, or `:is-child-process?`)
14+
so the CLI never falls back to the user's home directory, and forces
15+
`COPILOT_DISABLE_KEYTAR=1` on the spawned CLI so the headless server
16+
never touches the host keychain.
17+
- **Required `:available-tools` in `:empty` mode**
18+
`create-session` / `resume-session` (sync and async) now reject
19+
empty-mode sessions that don't supply a tool allow-list. An empty
20+
vector `[]` is legitimate (it means "no tools") — the key just has
21+
to be present so silently-empty filters can't happen.
22+
- **9 mode-default session config fields** — In `:empty` mode the SDK
23+
spreads safe defaults UNDER the caller's session config (caller
24+
always wins): `:enable-session-telemetry? false`,
25+
`:mcp-oauth-token-storage :in-memory`, `:skip-embedding-retrieval true`,
26+
`:embedding-cache-storage :in-memory`,
27+
`:enable-on-demand-instruction-discovery false`,
28+
`:enable-file-hooks false`, `:enable-host-git-operations false`,
29+
`:enable-session-store false`, `:enable-skills false`.
30+
- **`session.options.update` plumbing** — After a successful
31+
`session.create` / `session.resume`, the SDK now issues a follow-up
32+
`session.options.update` RPC carrying the four overridable feature
33+
flags (and, in `:empty` mode, `installedPlugins: []`):
34+
- `:skip-custom-instructions` (default `true` in `:empty`)
35+
- `:custom-agents-local-only` (default `true` in `:empty`)
36+
- `:coauthor-enabled` (default `false` in `:empty`)
37+
- `:manage-schedule-enabled` (default `false` in `:empty`)
38+
In `:copilot-cli` mode only flags the caller explicitly set are
39+
forwarded; if the patch ends up empty the RPC is skipped. On failure
40+
the SDK disconnects and removes the half-configured session before
41+
rethrowing. Wired into all four entry points (`create-session`,
42+
`resume-session`, `<create-session`, `<resume-session`).
43+
- **System message normalization in `:empty` mode** — Mirrors upstream
44+
`getSystemMessageConfigForMode`: if the caller did not provide a
45+
`:system-message`, the SDK emits `{:mode "customize" :sections
46+
{:environment_context {:action "remove"}}}`. If the caller provided
47+
`:append`, the SDK promotes it to `:customize` (preserving the
48+
content) and adds the env-context removal. If the caller used
49+
`:customize` and supplied their own `:environment-context` section,
50+
the SDK leaves it untouched. `:replace` mode is passed through
51+
unchanged. `:copilot-cli` mode keeps the legacy behavior — no
52+
normalization.
53+
- **Always-emit `:tool-filter-precedence "excluded"`** — Both modes now
54+
always send `toolFilterPrecedence: "excluded"` on `session.create`
55+
and `session.resume`. Makes the ordering between
56+
`:available-tools` and `:excluded-tools` deterministic regardless of
57+
CLI version.
58+
- **`github.copilot-sdk.tool-set` namespace** — Source-qualified tool
59+
filter constructors (`builtin`, `mcp`, `custom`, `builtins`) plus
60+
`isolated-builtins` / `isolated` — the parity equivalents of
61+
upstream `BuiltInTools.Isolated`. Bare `"*"` (no source) is rejected
62+
at the SDK boundary and at construction time.
63+
664
### Added (post-v1.0.0-beta.4 sync, round 6)
765
- **`:agent-mode` and `:display-prompt` send options**`session/send!`
866
(and async/streaming variants) now accept:
@@ -92,10 +150,6 @@ All notable changes to this project will be documented in this file. This change
92150
the three new event types above.
93151

94152
### Deferred (round 6)
95-
- **Multitenancy Client Mode (upstream PR #1428)** — Substantial new
96-
public API surface (`mode = "empty" | "copilot-cli"`, `ToolSet`,
97-
`toolFilterPrecedence`, ambient flags via `session.options.update`).
98-
Tracked for a dedicated future sync round with its own plan.
99153
- **Removal of the legacy `:config-dir` / `:output-dir` option keys
100154
(upstream PR #1482 follow-up)** — The new `:config-directory` /
101155
`:output-directory` aliases ship in this release (see Added). The

doc/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Clojure SDK for programmatic control of the GitHub Copilot CLI via JSON-RPC.
55
## Getting Started
66

77
- [Getting Started](getting-started.md) — Step-by-step tutorial building a weather assistant
8-
- [Examples](../examples/README.md)19 working examples with walkthroughs
8+
- [Examples](../examples/README.md)20 working examples with walkthroughs
99

1010
## Guides
1111

doc/reference/API.md

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ Get information about the current shared client state. Returns `nil` if no share
134134
| `:on-list-models` | fn | nil | Zero-arg function returning model info maps. Bypasses `models.list` RPC; does not require `start!`. Results are cached the same way as RPC results |
135135
| `:is-child-process?` | boolean | `false` | When `true`, connect via own stdio to a parent Copilot CLI process (no process spawning). Requires `:use-stdio?` `true`; mutually exclusive with `:cli-url` |
136136
| `:session-fs` | map | nil | Session filesystem provider config. Keys: `:initial-cwd` (string, required), `:session-state-path` (string, required), `:conventions` (`"windows"` or `"posix"`, required). When set, the client calls `sessionFs.setProvider` on connect and routes filesystem operations through per-session handlers. See [Session Filesystem](#session-filesystem) |
137+
| `:mode` | keyword | `:copilot-cli` | Client multitenancy mode: `:copilot-cli` (default — preserve historical CLI behavior) or `:empty` (multi-tenant SaaS hosts that must isolate sessions from local machine state). In `:empty` mode the SDK requires at least one tenant-scoped storage root (`:copilot-home`, `:session-fs`, `:cli-url`, or `:is-child-process?`), sets `COPILOT_DISABLE_KEYTAR=1` on the spawned CLI, spreads 9 safe defaults under caller session config, forces `installedPlugins []`, and normalizes `:system-message` to strip `environment_context`. See [Client Mode](#client-mode-empty). (upstream PR #1428) |
137138

138139
### Methods
139140

@@ -279,6 +280,10 @@ Create a client and session together, ensuring both are cleaned up on exit.
279280
| `:plugin-directories` | vector | Extra plugin directories loaded even when `:enable-config-discovery` is `false`. Wire-encoded as `pluginDirectories`. (upstream PR #1482) |
280281
| `:reasoning-summary` | string | `"none"` / `"concise"` / `"detailed"`. Controls inclusion/granularity of reasoning summaries on assistant turns. Wire-encoded as `reasoningSummary`. String-valued for consistency with `:reasoning-effort`. |
281282
| `:context-tier` | keyword \| `nil` | `#{:default :long-context}` selects the long-context model variant; `nil` explicitly clears any prior tier (wire-encoded as JSON `null`). Omit the key entirely to leave the current setting untouched. Wire-encoded as `contextTier` with values `"default"` / `"long_context"`. |
283+
| `:skip-custom-instructions` | boolean | Skip loading user-level custom instruction files. Forwarded via `session.options.update` (NOT `session.create`). Defaulted to `true` in `:empty` mode. (upstream PR #1428) |
284+
| `:custom-agents-local-only` | boolean | Restrict custom-agent loading to caller-supplied configs only (no on-disk discovery). Forwarded via `session.options.update`. Defaulted to `true` in `:empty` mode. (upstream PR #1428) |
285+
| `:coauthor-enabled` | boolean | Add a Copilot Co-authored-by trailer to commits made by the CLI. Forwarded via `session.options.update`. Defaulted to `false` in `:empty` mode. (upstream PR #1428) |
286+
| `:manage-schedule-enabled` | boolean | Enable the built-in schedule-management tools. Forwarded via `session.options.update`. Defaulted to `false` in `:empty` mode. (upstream PR #1428) |
282287

283288
#### `resume-session`
284289

@@ -1545,6 +1550,101 @@ When `:streaming? true`:
15451550
(copilot/stop! client)
15461551
```
15471552

1553+
### Client Mode (Empty)
1554+
1555+
`:mode :empty` configures the client for multi-tenant SaaS hosts that must
1556+
isolate sessions from the local machine — no on-disk state from a
1557+
specific user account leaks into a session. The default `:copilot-cli`
1558+
mode preserves historical CLI behavior. (upstream PR #1428)
1559+
1560+
```clojure
1561+
(require '[github.copilot-sdk :as copilot]
1562+
'[github.copilot-sdk.tool-set :as tool-set])
1563+
1564+
(def client
1565+
(copilot/client
1566+
{:mode :empty
1567+
;; At least ONE of :copilot-home / :session-fs / :cli-url /
1568+
;; :is-child-process? is required so the CLI has a tenant-scoped
1569+
;; storage root. Using both is fine and common:
1570+
:copilot-home "/srv/tenants/acme/copilot-home"
1571+
:session-fs {:initial-cwd "/srv/tenants/acme/cwd"
1572+
:session-state-path "/srv/tenants/acme/state"
1573+
:conventions "posix"}}))
1574+
1575+
(def session
1576+
(copilot/create-session client
1577+
{:on-permission-request copilot/approve-all
1578+
;; Required in :empty mode (use [] to allow nothing — the key must
1579+
;; be present so silently-empty filters can't happen):
1580+
:available-tools tool-set/isolated
1581+
;; Required when client has :session-fs:
1582+
:create-session-fs-handler (fn [_session] my-fs-handler)}))
1583+
```
1584+
1585+
What `:empty` mode enforces (vs `:copilot-cli`):
1586+
1587+
- **Constructor validation**: at least one of `:copilot-home`,
1588+
`:session-fs`, `:cli-url`, or `:is-child-process?` must be supplied
1589+
(so the CLI never falls back to the user's home directory). The SDK
1590+
also forces `COPILOT_DISABLE_KEYTAR=1` on the spawned CLI.
1591+
- **Session validation**: every `create-session` / `resume-session` call
1592+
must provide `:available-tools` (an empty vector is legitimate). When
1593+
the client has `:session-fs`, `:create-session-fs-handler` is also
1594+
required (this applies to both modes).
1595+
- **Safe session defaults** (spread UNDER caller config — caller always wins):
1596+
`:enable-session-telemetry? false`, `:mcp-oauth-token-storage :in-memory`,
1597+
`:skip-embedding-retrieval true`, `:embedding-cache-storage :in-memory`,
1598+
`:enable-on-demand-instruction-discovery false`, `:enable-file-hooks false`,
1599+
`:enable-host-git-operations false`, `:enable-session-store false`,
1600+
`:enable-skills false`.
1601+
- **System message normalization**: the SDK strips the `environment_context`
1602+
section from the system message (or promotes `:append` to `:customize`) so
1603+
no host-environment context leaks. If the caller already provides their own
1604+
`environment_context` override in `:customize` mode, it is preserved verbatim.
1605+
- **Post-create options**: a follow-up `session.options.update` RPC sets
1606+
`:skip-custom-instructions true`, `:custom-agents-local-only true`,
1607+
`:coauthor-enabled false`, `:manage-schedule-enabled false`, and forces
1608+
`:installed-plugins []`. On failure, the SDK cleans up the half-configured
1609+
session before propagating the error.
1610+
1611+
Both modes always emit `:tool-filter-precedence "excluded"` on
1612+
`session.create` and `session.resume`, and reject bare `"*"` in
1613+
`:available-tools` / `:excluded-tools` at the SDK boundary.
1614+
1615+
### Tool Sets
1616+
1617+
Use [`github.copilot-sdk.tool-set`](#tool-sets) to construct `:available-tools` /
1618+
`:excluded-tools` lists with built-in helpers. Mirrors the upstream
1619+
`BuiltInTools` constants. (upstream PR #1428)
1620+
1621+
```clojure
1622+
(require '[github.copilot-sdk.tool-set :as tool-set])
1623+
1624+
;; Source-qualified single tool — patterns are "<source>:<name>",
1625+
;; source is one of "builtin", "mcp", or "custom":
1626+
(tool-set/builtin "ask_user") ; => "builtin:ask_user"
1627+
(tool-set/builtin "*") ; => "builtin:*" (all built-ins)
1628+
(tool-set/mcp "*") ; => "mcp:*" (all MCP tools)
1629+
(tool-set/custom "my_tool") ; => "custom:my_tool"
1630+
1631+
;; Vector of patterns:
1632+
(tool-set/builtins ["task" "skill"])
1633+
;; => ["builtin:task" "builtin:skill"]
1634+
1635+
;; The "Isolated" preset matches BuiltInTools.Isolated upstream —
1636+
;; every built-in that is safely session-bounded (no host I/O):
1637+
tool-set/isolated
1638+
;; => ["builtin:ask_user" "builtin:task_complete" "builtin:exit_plan_mode" ...]
1639+
```
1640+
1641+
The constructors enforce well-formed entries: a bare `"*"` is rejected (the SDK
1642+
also rejects it in `:available-tools` / `:excluded-tools` at the session
1643+
boundary — apps must explicitly opt into a source so an absent source can
1644+
never silently grant access to unexpected tools). The runtime always receives
1645+
`:tool-filter-precedence "excluded"` on `session.create` / `session.resume`
1646+
so the ordering between allow and deny lists is deterministic.
1647+
15481648
### Tools
15491649

15501650
Let the CLI call back into your process when the model needs capabilities you provide:

examples/README.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ clojure -A:examples -X lifecycle-hooks/run
8585

8686
# Reasoning effort
8787
clojure -A:examples -X reasoning-effort/run
88+
89+
# Empty (multitenancy) mode
90+
clojure -A:examples -X empty-mode/run
8891
```
8992

9093
Or run all examples:
@@ -93,7 +96,7 @@ Or run all examples:
9396
```
9497

9598
> **Note:** `run-all-examples.sh` runs 16 examples that need only the Copilot CLI (examples 1–9, 12–16, 18, and 19).
96-
> Examples 10 (BYOK) and 11 (MCP) require external dependencies (API keys, Node.js), and example 17 (ask-user-failure) is excluded for reliability. Run these manually.
99+
> Examples 10 (BYOK), 11 (MCP), and 20 (empty-mode — uses BYOK) require external dependencies (API keys, Node.js), and example 17 (ask-user-failure) is excluded for reliability. Run these manually.
97100
98101
With a custom CLI path:
99102
```bash
@@ -879,6 +882,39 @@ Commands are registered by passing them in the session config:
879882

880883
---
881884

885+
## Example 20: Empty (Multitenancy) Mode (`empty_mode.clj`)
886+
887+
**Description**: Run a session under `:mode :empty` — the hardened
888+
posture for SaaS hosts that run sessions on behalf of multiple users.
889+
890+
**Features**: `:mode :empty`, `:copilot-home`, `:session-fs` with an
891+
in-memory provider, `tool-set/isolated`, BYOK provider.
892+
893+
Demonstrates the multitenancy hardening introduced in upstream PR #1428.
894+
The example creates fresh temp directories for `:copilot-home`, the
895+
session's `cwd`, and the session state path, supplies an in-memory
896+
`:session-fs` provider, and runs a single query through a BYOK provider
897+
(empty mode disables the local keychain, so the host must bring its own
898+
auth). In `:empty` mode the SDK forces `COPILOT_DISABLE_KEYTAR=1` on the
899+
spawned CLI, spreads safe session defaults (telemetry off, embeddings
900+
in-memory, host-git off, skills off, ...), strips `environment_context`
901+
from the system message, and sends a follow-up `session.options.update`
902+
RPC turning off coauthor / manage-schedule and forcing
903+
`installedPlugins []`.
904+
905+
**Prerequisites**: Set `OPENAI_API_KEY` or `ANTHROPIC_API_KEY`. Excluded
906+
from `run-all-examples.sh` because it requires an external API key.
907+
908+
```bash
909+
OPENAI_API_KEY=sk-... clojure -A:examples -X empty-mode/run
910+
OPENAI_API_KEY=sk-... clojure -A:examples -X empty-mode/run :prompt '"What is Clojure?"'
911+
```
912+
913+
See [`doc/reference/API.md`](../doc/reference/API.md#client-mode-empty)
914+
for the full Client Mode reference.
915+
916+
---
917+
882918
## Clojure vs JavaScript Comparison
883919

884920
Here's how common patterns compare between the Clojure and JavaScript SDKs:

0 commit comments

Comments
 (0)