Skip to content

Commit 94ad7ab

Browse files
krukowCopilot
andcommitted
feat(client): implement client mode :empty (multitenancy hardening)
Add :mode #{:copilot-cli :empty} client option (default :copilot-cli) mirroring upstream PR github/copilot-sdk#1428. In :empty mode the SDK: - Requires at least one tenant-scoped storage root at construction time (:copilot-home, :session-fs, :cli-url, or :is-child-process?) so the spawned CLI never falls back to the user's home directory. - Forces COPILOT_DISABLE_KEYTAR=1 on the spawned CLI via the cli-env-overrides :overrides slot so the caller cannot accidentally re-enable the host keychain. - Requires every create-session / resume-session call (sync + async) to supply :available-tools; an empty vector is legitimate, the key just has to be present so silently-empty filters cannot happen. - Spreads 9 safe defaults UNDER caller 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. - Normalizes :system-message so environment_context is stripped unless the app has taken control of it (mirrors upstream getSystemMessageConfigForMode): no system-message emits {:mode customize :sections {:environment_context {:action remove}}}; :append is promoted to :customize preserving content; :customize without an env-context override gets one added; :replace passes through unchanged. :copilot-cli mode keeps legacy behavior. - After session.create / session.resume succeeds, issues a follow-up session.options.update RPC carrying four overridable feature flags (:skip-custom-instructions true, :custom-agents-local-only true, :coauthor-enabled false, :manage-schedule-enabled false) plus :installed-plugins []. In :copilot-cli mode only flags the caller explicitly set are forwarded; an empty patch skips the RPC entirely. On failure the SDK disconnects and removes the half-configured session before rethrowing. Wired into create-session, resume-session, <create-session, <resume-session. Both modes always emit :tool-filter-precedence "excluded" on session.create / session.resume so the ordering between :available-tools and :excluded-tools is deterministic regardless of CLI version, and reject bare "*" in :available-tools / :excluded-tools at the SDK boundary (matches upstream resolveToolFilterOptions). Adds 23 new integration tests covering validation, env-var overrides, wire payload mode-defaults, system-message normalization, and the session.options.update RPC (including async path + cleanup-on-failure). 339 tests / 1578 assertions / 0 failures. Upstream: github/copilot-sdk#1428 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 451d47c commit 94ad7ab

6 files changed

Lines changed: 857 additions & 21 deletions

File tree

src/github/copilot_sdk/client.clj

Lines changed: 264 additions & 10 deletions
Large diffs are not rendered by default.

src/github/copilot_sdk/process.clj

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
4343
This is a pure helper extracted so we can unit-test the env contract
4444
without spawning a real process."
45-
[{:keys [github-token telemetry copilot-home tcp-connection-token] :as _opts}]
45+
[{:keys [github-token telemetry copilot-home tcp-connection-token mode] :as _opts}]
4646
{:defaults {"NODE_DEBUG" nil}
4747
:overrides
4848
(cond-> {}
@@ -55,6 +55,11 @@
5555
;; tcpConnectionToken (upstream PR #1176) — required when server enforces a token
5656
tcp-connection-token
5757
(assoc "COPILOT_CONNECTION_TOKEN" tcp-connection-token)
58+
;; Client mode :empty disables the system keychain (upstream PR #1428).
59+
;; Applied in :overrides so the caller's `:env` cannot accidentally
60+
;; re-enable it under multitenancy hardening.
61+
(= mode :empty)
62+
(assoc "COPILOT_DISABLE_KEYTAR" "1")
5863
;; OpenTelemetry (upstream PR #785)
5964
telemetry
6065
(as-> m
@@ -88,6 +93,9 @@
8893
- :tcp-connection-token - Connection token sent via COPILOT_CONNECTION_TOKEN (upstream PR #1176)
8994
- :remote? - When true, append `--remote` so the CLI exposes its session
9095
over a GitHub-hosted remote endpoint (upstream PR #1192)
96+
- :mode - Client mode `:empty` or `:copilot-cli` (default). When `:empty`,
97+
`COPILOT_DISABLE_KEYTAR=1` is forced into the spawned env so the
98+
child CLI cannot reach the host keychain (upstream PR #1428).
9199
92100
Returns a ManagedProcess record."
93101
[{:keys [cli-path cwd env use-stdio?]

src/github/copilot_sdk/specs.clj

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -186,23 +186,40 @@
186186
;; Factory fn: (session) → session-fs-handler
187187
(s/def ::create-session-fs-handler fn?)
188188

189+
;; Client mode (upstream PR #1428). `:copilot-cli` (the default) preserves
190+
;; the historical behavior; `:empty` enables multitenancy hardening:
191+
;; tenant-scoped storage is required, the system keychain is disabled, and
192+
;; a set of safe session defaults is spread under the caller's config.
193+
;;
194+
;; Note: the unqualified key in client-options is `:mode`, but the existing
195+
;; ::mode spec at line ~899 is already taken by message-options for
196+
;; #{:enqueue :immediate}. We use a uniquely-named ::client-mode spec and
197+
;; validate the unqualified `:mode` key via a predicate in ::client-options
198+
;; (mirrors the ::remote-session-mode pattern further down).
199+
(s/def ::client-mode #{:empty :copilot-cli})
200+
189201
(def client-options-keys
190202
#{:cli-path :cli-args :cli-url :cwd :port
191203
:use-stdio? :log-level :auto-start? :auto-restart?
192204
:notification-queue-size :router-queue-size
193205
:tool-timeout-ms :env :github-token :use-logged-in-user?
194206
:is-child-process? :on-list-models :telemetry :on-get-trace-context
195-
:session-fs :copilot-home :tcp-connection-token :remote?})
207+
:session-fs :copilot-home :tcp-connection-token :remote?
208+
:mode})
196209

197210
(s/def ::client-options
198-
(closed-keys
199-
(s/keys :opt-un [::cli-path ::cli-args ::cli-url ::cwd ::port
200-
::use-stdio? ::log-level ::auto-start? ::auto-restart?
201-
::notification-queue-size ::router-queue-size
202-
::tool-timeout-ms ::env ::github-token ::use-logged-in-user?
203-
::is-child-process? ::on-list-models ::telemetry ::on-get-trace-context
204-
::session-fs ::copilot-home ::tcp-connection-token ::remote?])
205-
client-options-keys))
211+
(s/and
212+
(closed-keys
213+
(s/keys :opt-un [::cli-path ::cli-args ::cli-url ::cwd ::port
214+
::use-stdio? ::log-level ::auto-start? ::auto-restart?
215+
::notification-queue-size ::router-queue-size
216+
::tool-timeout-ms ::env ::github-token ::use-logged-in-user?
217+
::is-child-process? ::on-list-models ::telemetry ::on-get-trace-context
218+
::session-fs ::copilot-home ::tcp-connection-token ::remote?])
219+
client-options-keys)
220+
(fn [m]
221+
(or (not (contains? m :mode))
222+
(s/valid? ::client-mode (:mode m))))))
206223

207224
;; -----------------------------------------------------------------------------
208225
;; Tool definitions
@@ -601,7 +618,7 @@
601618

602619
;; Multitenancy hardening flags (upstream PR #1474). All optional, plain
603620
;; booleans/strings. Application-mode behavior is driven by Client Mode
604-
;; (upstream PR #1428), which has been deferred to a dedicated future round.
621+
;; (upstream PR #1428).
605622
(s/def ::skip-embedding-retrieval boolean?)
606623
(s/def ::organization-custom-instructions string?)
607624
(s/def ::enable-on-demand-instruction-discovery boolean?)
@@ -610,6 +627,15 @@
610627
(s/def ::enable-session-store boolean?)
611628
(s/def ::enable-skills boolean?)
612629

630+
;; Client-mode session option flags (upstream PR #1428). Sent via
631+
;; session.options.update after session.create / session.resume. In empty
632+
;; mode these get safe defaults applied beneath the caller's config; in
633+
;; CLI mode they are only emitted when explicitly set.
634+
(s/def ::skip-custom-instructions boolean?)
635+
(s/def ::custom-agents-local-only boolean?)
636+
(s/def ::coauthor-enabled boolean?)
637+
(s/def ::manage-schedule-enabled boolean?)
638+
613639
;; Reasoning summary mode (upstream PR #813 - pre-existing parity gap).
614640
;; Wire enum: "none" | "concise" | "detailed". Mirrors upstream's ReasoningSummary type.
615641
(s/def ::reasoning-summary #{"none" "concise" "detailed"})
@@ -648,6 +674,10 @@
648674
:enable-host-git-operations
649675
:enable-session-store
650676
:enable-skills
677+
:skip-custom-instructions
678+
:custom-agents-local-only
679+
:coauthor-enabled
680+
:manage-schedule-enabled
651681
:include-sub-agent-streaming-events?})
652682

653683
(s/def ::session-config
@@ -682,6 +712,10 @@
682712
::enable-host-git-operations
683713
::enable-session-store
684714
::enable-skills
715+
::skip-custom-instructions
716+
::custom-agents-local-only
717+
::coauthor-enabled
718+
::manage-schedule-enabled
685719
::include-sub-agent-streaming-events?])
686720
session-config-keys))
687721

@@ -710,6 +744,10 @@
710744
:enable-host-git-operations
711745
:enable-session-store
712746
:enable-skills
747+
:skip-custom-instructions
748+
:custom-agents-local-only
749+
:coauthor-enabled
750+
:manage-schedule-enabled
713751
:include-sub-agent-streaming-events?})
714752

715753
(s/def ::resume-session-config
@@ -741,6 +779,10 @@
741779
::enable-host-git-operations
742780
::enable-session-store
743781
::enable-skills
782+
::skip-custom-instructions
783+
::custom-agents-local-only
784+
::coauthor-enabled
785+
::manage-schedule-enabled
744786
::include-sub-agent-streaming-events?])
745787
resume-session-config-keys))
746788

@@ -774,6 +816,10 @@
774816
::enable-host-git-operations
775817
::enable-session-store
776818
::enable-skills
819+
::skip-custom-instructions
820+
::custom-agents-local-only
821+
::coauthor-enabled
822+
::manage-schedule-enabled
777823
::include-sub-agent-streaming-events?])
778824
resume-session-config-keys))
779825

0 commit comments

Comments
 (0)