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
62 changes: 58 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,64 @@ All notable changes to this project will be documented in this file. This change

## [Unreleased]

### Added (Client Mode Empty — upstream PR #1428)
- **`:mode` client option** — `#{:copilot-cli :empty}`, default
`:copilot-cli`. Selects between historical CLI behavior and a hardened
multitenancy posture for SaaS hosts that must isolate sessions from
the local machine. Validated on `copilot/client`.
- **`:empty` mode constructor enforcement** — In `:empty` mode the
client requires at least one tenant-scoped storage root
(`:copilot-home`, `:session-fs`, `:cli-url`, or `:is-child-process?`)
so the CLI never falls back to the user's home directory, and forces
`COPILOT_DISABLE_KEYTAR=1` on the spawned CLI so the headless server
never touches the host keychain.
- **Required `:available-tools` in `:empty` mode** —
`create-session` / `resume-session` (sync and async) now reject
empty-mode sessions that don't supply a tool allow-list. An empty
vector `[]` is legitimate (it means "no tools") — the key just has
to be present so silently-empty filters can't happen.
- **9 mode-default session config fields** — In `:empty` mode the SDK
spreads safe defaults UNDER the caller's session config (caller
always wins): `:enable-session-telemetry? false`,
`:mcp-oauth-token-storage :in-memory`, `:skip-embedding-retrieval true`,
`:embedding-cache-storage :in-memory`,
`:enable-on-demand-instruction-discovery false`,
`:enable-file-hooks false`, `:enable-host-git-operations false`,
`:enable-session-store false`, `:enable-skills false`.
- **`session.options.update` plumbing** — After a successful
`session.create` / `session.resume`, the SDK now issues a follow-up
`session.options.update` RPC carrying the four overridable feature
flags (and, in `:empty` mode, `installedPlugins: []`):
- `:skip-custom-instructions` (default `true` in `:empty`)
- `:custom-agents-local-only` (default `true` in `:empty`)
- `:coauthor-enabled` (default `false` in `:empty`)
- `:manage-schedule-enabled` (default `false` in `:empty`)
In `:copilot-cli` mode only flags the caller explicitly set are
forwarded; if the patch ends up empty the RPC is skipped. On failure
the SDK disconnects and removes the half-configured session before
rethrowing. Wired into all four entry points (`create-session`,
`resume-session`, `<create-session`, `<resume-session`).
- **System message normalization in `:empty` mode** — Mirrors upstream
`getSystemMessageConfigForMode`: if the caller did not provide a
`:system-message`, the SDK emits `{:mode "customize" :sections
{:environment_context {:action "remove"}}}`. If the caller provided
`:append`, the SDK promotes it to `:customize` (preserving the
content) and adds the env-context removal. If the caller used
`:customize` and supplied their own `:environment-context` section,
the SDK leaves it untouched. `:replace` mode is passed through
unchanged. `:copilot-cli` mode keeps the legacy behavior — no
normalization.
- **Always-emit `:tool-filter-precedence "excluded"`** — Both modes now
always send `toolFilterPrecedence: "excluded"` on `session.create`
and `session.resume`. Makes the ordering between
`:available-tools` and `:excluded-tools` deterministic regardless of
CLI version.
- **`github.copilot-sdk.tool-set` namespace** — Source-qualified tool
filter constructors (`builtin`, `mcp`, `custom`, `builtins`) plus
`isolated-builtins` / `isolated` — the parity equivalents of
upstream `BuiltInTools.Isolated`. Bare `"*"` (no source) is rejected
at the SDK boundary and at construction time.

### Added (post-v1.0.0-beta.4 sync, round 6)
- **`:agent-mode` and `:display-prompt` send options** — `session/send!`
(and async/streaming variants) now accept:
Expand Down Expand Up @@ -92,10 +150,6 @@ All notable changes to this project will be documented in this file. This change
the three new event types above.

### Deferred (round 6)
- **Multitenancy Client Mode (upstream PR #1428)** — Substantial new
public API surface (`mode = "empty" | "copilot-cli"`, `ToolSet`,
`toolFilterPrecedence`, ambient flags via `session.options.update`).
Tracked for a dedicated future sync round with its own plan.
- **Removal of the legacy `:config-dir` / `:output-dir` option keys
(upstream PR #1482 follow-up)** — The new `:config-directory` /
`:output-directory` aliases ship in this release (see Added). The
Expand Down
2 changes: 1 addition & 1 deletion doc/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Clojure SDK for programmatic control of the GitHub Copilot CLI via JSON-RPC.
## Getting Started

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

## Guides

Expand Down
100 changes: 100 additions & 0 deletions doc/reference/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ Get information about the current shared client state. Returns `nil` if no share
| `: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 |
| `: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` |
| `: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) |
| `: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) |

### Methods

Expand Down Expand Up @@ -279,6 +280,10 @@ Create a client and session together, ensuring both are cleaned up on exit.
| `:plugin-directories` | vector | Extra plugin directories loaded even when `:enable-config-discovery` is `false`. Wire-encoded as `pluginDirectories`. (upstream PR #1482) |
| `: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`. |
| `: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"`. |
| `: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) |
| `: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) |
| `: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) |
| `: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) |

#### `resume-session`

Expand Down Expand Up @@ -1545,6 +1550,101 @@ When `:streaming? true`:
(copilot/stop! client)
```

### Client Mode (Empty)

`:mode :empty` configures the client for multi-tenant SaaS hosts that must
isolate sessions from the local machine — no on-disk state from a
specific user account leaks into a session. The default `:copilot-cli`
mode preserves historical CLI behavior. (upstream PR #1428)

```clojure
(require '[github.copilot-sdk :as copilot]
'[github.copilot-sdk.tool-set :as tool-set])

(def client
(copilot/client
{:mode :empty
;; At least ONE of :copilot-home / :session-fs / :cli-url /
;; :is-child-process? is required so the CLI has a tenant-scoped
;; storage root. Using both is fine and common:
:copilot-home "/srv/tenants/acme/copilot-home"
:session-fs {:initial-cwd "/srv/tenants/acme/cwd"
:session-state-path "/srv/tenants/acme/state"
:conventions "posix"}}))

(def session
(copilot/create-session client
{:on-permission-request copilot/approve-all
;; Required in :empty mode (use [] to allow nothing — the key must
;; be present so silently-empty filters can't happen):
:available-tools tool-set/isolated
;; Required when client has :session-fs:
:create-session-fs-handler (fn [_session] my-fs-handler)}))
```

What `:empty` mode enforces (vs `:copilot-cli`):

- **Constructor validation**: at least one of `:copilot-home`,
`:session-fs`, `:cli-url`, or `:is-child-process?` must be supplied
(so the CLI never falls back to the user's home directory). The SDK
also forces `COPILOT_DISABLE_KEYTAR=1` on the spawned CLI.
- **Session validation**: every `create-session` / `resume-session` call
must provide `:available-tools` (an empty vector is legitimate). When
the client has `:session-fs`, `:create-session-fs-handler` is also
required (this applies to both modes).
- **Safe session defaults** (spread UNDER caller config — caller always wins):
`:enable-session-telemetry? false`, `:mcp-oauth-token-storage :in-memory`,
`:skip-embedding-retrieval true`, `:embedding-cache-storage :in-memory`,
`:enable-on-demand-instruction-discovery false`, `:enable-file-hooks false`,
`:enable-host-git-operations false`, `:enable-session-store false`,
`:enable-skills false`.
- **System message normalization**: the SDK strips the `environment_context`
section from the system message (or promotes `:append` to `:customize`) so
no host-environment context leaks. If the caller already provides their own
`environment_context` override in `:customize` mode, it is preserved verbatim.
- **Post-create options**: a follow-up `session.options.update` RPC sets
`:skip-custom-instructions true`, `:custom-agents-local-only true`,
`:coauthor-enabled false`, `:manage-schedule-enabled false`, and forces
`:installed-plugins []`. On failure, the SDK cleans up the half-configured
session before propagating the error.

Both modes always emit `:tool-filter-precedence "excluded"` on
`session.create` and `session.resume`, and reject bare `"*"` in
`:available-tools` / `:excluded-tools` at the SDK boundary.

### Tool Sets

Use [`github.copilot-sdk.tool-set`](#tool-sets) to construct `:available-tools` /
`:excluded-tools` lists with built-in helpers. Mirrors the upstream
`BuiltInTools` constants. (upstream PR #1428)

```clojure
(require '[github.copilot-sdk.tool-set :as tool-set])

;; Source-qualified single tool — patterns are "<source>:<name>",
;; source is one of "builtin", "mcp", or "custom":
(tool-set/builtin "ask_user") ; => "builtin:ask_user"
(tool-set/builtin "*") ; => "builtin:*" (all built-ins)
(tool-set/mcp "*") ; => "mcp:*" (all MCP tools)
(tool-set/custom "my_tool") ; => "custom:my_tool"

;; Vector of patterns:
(tool-set/builtins ["task" "skill"])
;; => ["builtin:task" "builtin:skill"]

;; The "Isolated" preset matches BuiltInTools.Isolated upstream —
;; every built-in that is safely session-bounded (no host I/O):
tool-set/isolated
;; => ["builtin:ask_user" "builtin:task_complete" "builtin:exit_plan_mode" ...]
```

The constructors enforce well-formed entries: a bare `"*"` is rejected (the SDK
also rejects it in `:available-tools` / `:excluded-tools` at the session
boundary — apps must explicitly opt into a source so an absent source can
never silently grant access to unexpected tools). The runtime always receives
`:tool-filter-precedence "excluded"` on `session.create` / `session.resume`
so the ordering between allow and deny lists is deterministic.

### Tools

Let the CLI call back into your process when the model needs capabilities you provide:
Expand Down
38 changes: 37 additions & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ clojure -A:examples -X lifecycle-hooks/run

# Reasoning effort
clojure -A:examples -X reasoning-effort/run

# Empty (multitenancy) mode
clojure -A:examples -X empty-mode/run
```

Or run all examples:
Expand All @@ -93,7 +96,7 @@ Or run all examples:
```

> **Note:** `run-all-examples.sh` runs 16 examples that need only the Copilot CLI (examples 1–9, 12–16, 18, and 19).
> 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.
> 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.

With a custom CLI path:
```bash
Expand Down Expand Up @@ -879,6 +882,39 @@ Commands are registered by passing them in the session config:

---

## Example 20: Empty (Multitenancy) Mode (`empty_mode.clj`)

**Description**: Run a session under `:mode :empty` — the hardened
posture for SaaS hosts that run sessions on behalf of multiple users.

**Features**: `:mode :empty`, `:copilot-home`, `:session-fs` with an
in-memory provider, `tool-set/isolated`, BYOK provider.

Demonstrates the multitenancy hardening introduced in upstream PR #1428.
The example creates fresh temp directories for `:copilot-home`, the
session's `cwd`, and the session state path, supplies an in-memory
`:session-fs` provider, and runs a single query through a BYOK provider
(empty mode disables the local keychain, so the host must bring its own
auth). In `:empty` mode the SDK forces `COPILOT_DISABLE_KEYTAR=1` on the
spawned CLI, spreads safe session defaults (telemetry off, embeddings
in-memory, host-git off, skills off, ...), strips `environment_context`
from the system message, and sends a follow-up `session.options.update`
RPC turning off coauthor / manage-schedule and forcing
`installedPlugins []`.

**Prerequisites**: Set `OPENAI_API_KEY` or `ANTHROPIC_API_KEY`. Excluded
from `run-all-examples.sh` because it requires an external API key.

```bash
OPENAI_API_KEY=sk-... clojure -A:examples -X empty-mode/run
OPENAI_API_KEY=sk-... clojure -A:examples -X empty-mode/run :prompt '"What is Clojure?"'
```

See [`doc/reference/API.md`](../doc/reference/API.md#client-mode-empty)
for the full Client Mode reference.

---

## Clojure vs JavaScript Comparison

Here's how common patterns compare between the Clojure and JavaScript SDKs:
Expand Down
Loading
Loading