@sigitcode-old / sigit / commits / a469ec1
Fork 1

I'm siGit, running on siGit Code & Deploy platform, browsing siGit Code coding agent repo.

Add the embeddable SiGitCode ed-acp profile to sigit-agent

Build on the shared crate to turn it into a profile an app can serve in-process, so a sandboxed host that cannot spawn the sigit binary as a subprocess still runs the agent. sigit-app serves it with ed_acp::serve over an in-memory channel through ed-acp-tauri. auth.rs is SiGitAuth, the AuthProvider wrapping the browser sign-in over the stored session. profile.rs is SiGitCode, the ed_acp::Profile: the workspace tools (or without_shell() for a host that cannot start processes), /init, SiGitAuth as its auth, and official_mcp_servers() so the official MCP server reaches a session per bearer rather than as a process-wide built-in. lib.rs re-exports both. This layer adds the ed-acp and agent-client-protocol dependencies, with ed-acp 1.3.0 taken from crates.io for Profile::auth, so there is no path into the onde-ed checkout next door. release-crates.yml publishes sigit-agent before sigit, each behind its own already-published check. tests/embedded.rs drives the profile through ed-acp-testkit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-Authored-By: siGit Code v1.6.7 <noreply@sigit.si>

Seto Elkahfi committed Oct 15, 2026 at 12:39 UTC a469ec10e794ee519799c91f919c0916993a7aef
9 files changed +772 -12
.agents/AGENTS.md
+17 -1
@@ -79,7 +79,8 @@ the repo is a symlink into it, never a copy:
79 ## Workspace layout
80
81 The repo root is a Cargo workspace (`members = ["crates/cli"]`, with `crates/sigit-*` added as
82 -they land). The `sigit` binary crate lives in `crates/cli`: its `src/` and `tests/` are what the
82 +they land). `crates/sigit-agent` is siGit Code as an `ed-acp` agent profile for apps that embed it (see
83 +below). The `sigit` binary crate lives in `crates/cli`: its `src/` and `tests/` are what the
84 architecture section below calls `src/` and `tests/`, and it publishes as `sigit` from there.
85 Shared package fields and every dependency version are declared once in the root
86 `Cargo.toml` (`[workspace.package]`, `[workspace.dependencies]`) and inherited with
@@ -120,6 +121,21 @@ The agent loop is backend-agnostic. The flow: a turn (messages + tool specs) goe
121 `InferenceBackend`, which returns assistant text and/or tool calls; the loop executes tools and
122 feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete backend.
123
124 +- **`crates/sigit-agent`** — siGit Code as a library for hosts that embed the agent (sigit-app
125 + serves it in-process with `ed_acp::serve` over `Channel::duplex`, through `ed-acp-tauri`).
126 + `SiGitCode` is the `ed_acp::Profile`: the workspace tools (or `SiGitCode::without_shell()` for
127 + a host that cannot start processes), `/init`, and `SiGitAuth` as its `AuthProvider`.
128 + `SiGitAuth` is the browser sign-in `sigit` runs in an editor (`browser_auth`, public OAuth
129 + client `sigit-code-cli`, PKCE) over the same stored session (`credentials`); the CLI imports
130 + both from here so there is one OAuth client. A host calls `credentials::use_config_dir` with
131 + its own data directory, which is inside the container in a sandboxed app, so the app and the
132 + CLI never share or overwrite a session. `llm_config()` is siGit Code Cloud
133 + (`SIGIT_CLOUD_URL`, default `https://sigit.si/api/v1`) with the stored token as bearer.
134 + `prompt.rs` holds `SYSTEM_PROMPT` and `INIT_PROMPT`; the CLI imports them. The official MCP
135 + server reaches a session through `official_mcp_servers()` in the host's `mcpServers`, not as
136 + a process-wide built-in: a shared server would keep its first bearer across a sign-out. It
137 + depends on `ed-acp` 1.3.0 from crates.io (the release that carries `Profile::auth`; see the
138 + root `Cargo.toml`). `release-crates.yml` publishes it before `sigit`.
139 - **`crates/cli/src/main.rs`** — entry point, mode dispatch, the full ACP `Agent` impl (session lifecycle:
140 new/load/fork/prompt/cancel, config options, slash-command advertisement), and the `SYSTEM_PROMPT`.
141 ACP session state owns its roots, conversation, and selected model even though the process has
.github/workflows/release-crates.yml
+46 -2
@@ -55,7 +55,51 @@ jobs:
55 with:
56 key: crates-publish
57
58 - - name: Check whether version already exists on crates.io
58 + # sigit depends on sigit-agent, so the library goes first, each behind its own
59 + # "already published" check: a re-run publishes only what is missing.
60 + - name: Check whether sigit-agent already exists on crates.io
61 + id: agent-check
62 + shell: bash
63 + run: |
64 + VERSION=$(cargo metadata --no-deps --format-version 1 | \
65 + python3 -c "import sys,json; pkgs=json.load(sys.stdin)['packages']; \
66 + print(next(p['version'] for p in pkgs if p['name']=='sigit-agent'))")
67 + echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
68 +
69 + # crates.io answers 403 to requests without a descriptive
70 + # User-Agent, so curl's default one makes this look like a 404 and
71 + # the publish below runs into "crate already exists on the index".
72 + # Branch on the status code rather than on curl's exit status, so an
73 + # answer we do not understand stops the job instead of quietly
74 + # degrading into that same failure.
75 + # Retry first, so a timeout or a 5xx costs a few seconds instead of
76 + # the whole release.
77 + STATUS=$(curl -sS -o /dev/null -w '%{http_code}' \
78 + --retry 3 --retry-delay 2 --retry-all-errors \
79 + -H "User-Agent: ${GITHUB_REPOSITORY} release workflow (https://github.com/${GITHUB_REPOSITORY})" \
80 + "https://crates.io/api/v1/crates/sigit-agent/${VERSION}")
81 +
82 + case "$STATUS" in
83 + 200)
84 + echo "sigit-agent ${VERSION} already exists on crates.io, skipping publish"
85 + echo "exists=true" >> "$GITHUB_OUTPUT"
86 + ;;
87 + 404)
88 + echo "exists=false" >> "$GITHUB_OUTPUT"
89 + ;;
90 + *)
91 + echo "Unexpected ${STATUS} from crates.io while checking whether sigit-agent ${VERSION} is published" >&2
92 + exit 1
93 + ;;
94 + esac
95 +
96 + - name: Publish sigit-agent to crates.io
97 + if: steps.agent-check.outputs.exists != 'true'
98 + env:
99 + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
100 + run: cargo publish --locked -p sigit-agent
101 +
102 + - name: Check whether sigit already exists on crates.io
103 id: crates-check
104 shell: bash
105 run: |
@@ -91,7 +135,7 @@ jobs:
135 ;;
136 esac
137
94 - - name: Publish to crates.io
138 + - name: Publish sigit to crates.io
139 if: steps.crates-check.outputs.exists != 'true'
140 env:
141 CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
Cargo.lock
+52 -2
@@ -1507,6 +1507,15 @@ dependencies = [
1507 "crypto-common",
1508 ]
1509
1510 +[[package]]
1511 +name = "directories"
1512 +version = "6.0.0"
1513 +source = "registry+https://github.com/rust-lang/crates.io-index"
1514 +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
1515 +dependencies = [
1516 + "dirs-sys",
1517 +]
1518 +
1519 [[package]]
1520 name = "dirs"
1521 version = "6.0.0"
@@ -1614,12 +1623,47 @@ version = "0.1.3"
1623 source = "registry+https://github.com/rust-lang/crates.io-index"
1624 checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9"
1625
1626 +[[package]]
1627 +name = "ed-acp"
1628 +version = "1.3.0"
1629 +source = "registry+https://github.com/rust-lang/crates.io-index"
1630 +checksum = "399009729e0200f68a63bc2a38123c2dd8b962e71abcf06793f660b93ed554f3"
1631 +dependencies = [
1632 + "agent-client-protocol",
1633 + "anyhow",
1634 + "async-trait",
1635 + "base64 0.23.1",
1636 + "directories",
1637 + "ed-mcp",
1638 + "futures",
1639 + "reqwest 0.13.4",
1640 + "serde",
1641 + "serde_json",
1642 + "tokio",
1643 + "tokio-util",
1644 + "tracing",
1645 + "tracing-subscriber",
1646 + "uuid 1.26.0",
1647 +]
1648 +
1649 +[[package]]
1650 +name = "ed-acp-testkit"
1651 +version = "1.3.0"
1652 +source = "registry+https://github.com/rust-lang/crates.io-index"
1653 +checksum = "1705037211a9e22b02f62fda7ac6bd8bcc25e07a0ba526477c638ec6584eeb18"
1654 +dependencies = [
1655 + "serde_json",
1656 + "tokio",
1657 + "uuid 1.26.0",
1658 +]
1659 +
1660 [[package]]
1661 name = "ed-mcp"
1619 -version = "1.2.0"
1662 +version = "1.3.0"
1663 source = "registry+https://github.com/rust-lang/crates.io-index"
1621 -checksum = "cf0c6ab7e27186954295ce7974b33d65860cd3b5073848e9d0a5b3eed90f68a2"
1664 +checksum = "c91065ba526284ae3dce156a932343ba7409351ef93ea6a4181fdc3287a7a5bb"
1665 dependencies = [
1666 + "agent-client-protocol",
1667 "anyhow",
1668 "futures",
1669 "http",
@@ -5934,7 +5978,12 @@ dependencies = [
5978 name = "sigit-agent"
5979 version = "1.7.0"
5980 dependencies = [
5981 + "agent-client-protocol",
5982 + "anyhow",
5983 + "async-trait",
5984 "base64 0.22.1",
5985 + "ed-acp",
5986 + "ed-acp-testkit",
5987 "log",
5988 "rand 0.9.5",
5989 "reqwest 0.13.4",
@@ -5943,6 +5992,7 @@ dependencies = [
5992 "sha2",
5993 "tokio",
5994 "toml 0.8.23",
5995 + "uuid 1.26.0",
5996 ]
5997
5998 [[package]]
Cargo.toml
+4 -2
@@ -12,8 +12,10 @@ documentation = "https://github.com/getsigit/sigit"
12 authors = ["Seto Elkahfi <seto@sigit.si>", "paydii <193906237+paydii@users.noreply.github.com>", "keypair34 <216233964+keypair34@users.noreply.github.com>"]
13
14 [workspace.dependencies]
15 -# Shared siGit Code internals (prompt, sign-in, cloud session), used by the CLI and by hosts
16 -# that embed the agent.
15 +# The ACP server `sigit-agent` is a profile for. `Profile::auth` landed in ed-acp 1.3.0, now on
16 +# crates.io, so this resolves from the registry rather than the onde-ed checkout next door.
17 +ed-acp = { version = "1.3.0", default-features = false }
18 +ed-acp-testkit = "1.3.0"
19 sigit-agent = { version = "1.7.0", default-features = false, path = "crates/sigit-agent" }
20
21 # ACP protocol SDK
crates/sigit-agent/Cargo.toml
+11 -1
@@ -2,7 +2,7 @@
2 name = "sigit-agent"
3 version.workspace = true
4 edition.workspace = true
5 -description = "Shared siGit Code internals: the prompt, the sign-in and the cloud session, for the CLI and for apps that embed it."
5 +description = "siGit Code as an ed-acp agent profile: the prompt, the sign-in and the cloud session, for apps that embed it."
6 documentation.workspace = true
7 license.workspace = true
8 repository.workspace = true
@@ -12,6 +12,10 @@ categories = ["development-tools"]
12 authors.workspace = true
13
14 [dependencies]
15 +ed-acp.workspace = true
16 +agent-client-protocol.workspace = true
17 +async-trait.workspace = true
18 +anyhow.workspace = true
19 base64.workspace = true
20 log.workspace = true
21 rand.workspace = true
@@ -21,3 +25,9 @@ serde_json.workspace = true
25 sha2.workspace = true
26 tokio.workspace = true
27 toml.workspace = true
28 +uuid.workspace = true
29 +
30 +[dev-dependencies]
31 +ed-acp-testkit.workspace = true
32 +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
33 +serde_json.workspace = true
crates/sigit-agent/src/auth.rs new
+219
@@ -0,0 +1,219 @@
1 +//! [`SiGitAuth`]: siGit Code's sign-in as an `ed-acp` [`AuthProvider`].
2 +//!
3 +//! The same browser flow `sigit` runs in an editor (OAuth with PKCE against `sigit.si/oauth`,
4 +//! public client `sigit-code-cli`), and the same stored session, so there is one OAuth client
5 +//! to register and one place a token lives. The loopback listener receives the code; the
6 +//! authorize URL goes to the client as a URL-mode elicitation when it offers one, so the page
7 +//! opens where the user is looking.
8 +
9 +use agent_client_protocol::schema::v1::{
10 + AuthMethod, AuthMethodAgent, CompleteElicitationNotification, CreateElicitationRequest,
11 + CreateElicitationResponse, ElicitationAction, ElicitationRequestScope, ElicitationUrlMode,
12 + ErrorCode, RequestId,
13 +};
14 +use agent_client_protocol::{Client, ConnectionTo};
15 +use async_trait::async_trait;
16 +use ed_acp::{AuthCtx, AuthProvider, LlmConfig, Provider};
17 +
18 +use crate::browser_auth::{self, Flow};
19 +use crate::credentials;
20 +use crate::profile::INFO;
21 +
22 +/// The id of the auth method, as the client sends it in `authenticate`.
23 +pub const METHOD_ID: &str = "sigit";
24 +
25 +/// Default API root of siGit Code Cloud; `SIGIT_CLOUD_URL` overrides it.
26 +const DEFAULT_CLOUD_URL: &str = "https://sigit.si/api/v1";
27 +
28 +/// The inference endpoint: `SIGIT_CLOUD_URL` or `https://sigit.si/api/v1`.
29 +pub fn cloud_base_url() -> String {
30 + std::env::var("SIGIT_CLOUD_URL")
31 + .ok()
32 + .filter(|url| !url.trim().is_empty())
33 + .unwrap_or_else(|| DEFAULT_CLOUD_URL.to_string())
34 + .trim_end_matches('/')
35 + .to_string()
36 +}
37 +
38 +/// The model a session starts on: `SIGIT_AGENT_MODEL`, else the profile's default tier.
39 +fn default_model() -> String {
40 + std::env::var(INFO.env.var("MODEL"))
41 + .ok()
42 + .filter(|model| !model.trim().is_empty())
43 + .unwrap_or_else(|| INFO.env.default_model.to_string())
44 +}
45 +
46 +pub struct SiGitAuth;
47 +
48 +#[async_trait]
49 +impl AuthProvider for SiGitAuth {
50 + fn method(&self) -> AuthMethod {
51 + AuthMethod::Agent(
52 + AuthMethodAgent::new(METHOD_ID, "Sign in to siGit Code")
53 + .description("Opens sigit.si in your browser to sign in to siGit Code Cloud."),
54 + )
55 + }
56 +
57 + fn llm_config(&self) -> Option<LlmConfig> {
58 + let token = credentials::load_token()?;
59 + Some(LlmConfig {
60 + provider: Provider::OpenAi,
61 + base_url: cloud_base_url(),
62 + api_key: Some(token),
63 + model: default_model(),
64 + })
65 + }
66 +
67 + async fn authenticate(&self, ctx: AuthCtx<'_>) -> agent_client_protocol::Result<()> {
68 + // An existing session clears the gate without sending anyone to a browser they
69 + // didn't ask for.
70 + if let Some(token) = credentials::load_token()
71 + && session_is_valid(&token).await
72 + {
73 + log::info!("authenticate: the stored session is still valid");
74 + return Ok(());
75 + }
76 +
77 + // There is no way to take a pasted code back from here, so this is the loopback
78 + // variant or nothing.
79 + let flow = browser_auth::begin();
80 + if !flow.is_loopback() {
81 + return Err(error(
82 + "Could not open a local port to receive the sign-in. Run `sigit login` in a \
83 + terminal instead.",
84 + ));
85 + }
86 +
87 + let outcome = if ctx.client_offers_url_elicitation() {
88 + sign_in_via_client(ctx.connection, ctx.request_id, flow).await
89 + } else {
90 + log::info!(
91 + "authenticate: opening the browser at {}",
92 + flow.authorize_url()
93 + );
94 + if !browser_auth::open_browser(flow.authorize_url()) {
95 + log::warn!("authenticate: no browser could be launched");
96 + }
97 + browser_auth::complete_loopback(flow).await
98 + };
99 +
100 + match outcome {
101 + Ok(email) => {
102 + log::info!("authenticate: signed in as {email}");
103 + Ok(())
104 + }
105 + Err(reason) => Err(error(format!(
106 + "Sign-in did not complete ({reason}). Try again, or create an account at \
107 + https://sigit.si."
108 + ))),
109 + }
110 + }
111 +
112 + async fn logout(&self) -> anyhow::Result<()> {
113 + credentials::clear();
114 + Ok(())
115 + }
116 +}
117 +
118 +fn error(message: impl Into<String>) -> agent_client_protocol::Error {
119 + agent_client_protocol::Error::new(ErrorCode::InternalError.into(), message.into())
120 +}
121 +
122 +/// Whether `token` is still accepted by the account API.
123 +async fn session_is_valid(token: &str) -> bool {
124 + let base = std::env::var("SIGIT_API_URL")
125 + .ok()
126 + .filter(|url| !url.trim().is_empty())
127 + .unwrap_or_else(|| "https://sigit.si".to_string());
128 + let url = format!("{}/api/v1/user", base.trim_end_matches('/'));
129 + match reqwest::Client::new()
130 + .get(url)
131 + .bearer_auth(token)
132 + .timeout(std::time::Duration::from_secs(15))
133 + .send()
134 + .await
135 + {
136 + Ok(response) => response.status().is_success(),
137 + Err(error) => {
138 + log::warn!("authenticate: could not verify the stored session ({error})");
139 + false
140 + }
141 + }
142 +}
143 +
144 +/// The loopback sign-in with the client showing the page: the authorize URL goes out as a
145 +/// URL-mode elicitation, so the user sees where they are being sent and the client opens it,
146 +/// which also works where this process cannot launch a browser. A user who declines ends the
147 +/// sign-in; a client that fails the request gets a browser opened from here. Once the code is
148 +/// redeemed the client is told the elicitation is complete.
149 +async fn sign_in_via_client(
150 + connection: &ConnectionTo<Client>,
151 + request_id: RequestId,
152 + flow: Flow,
153 +) -> Result<String, String> {
154 + let url = flow.authorize_url().to_string();
155 + let elicitation_id = uuid::Uuid::new_v4().to_string();
156 + log::info!("authenticate: asking the client to open {url}");
157 + let ask = connection
158 + .send_request(CreateElicitationRequest::new(
159 + ElicitationUrlMode::new(
160 + ElicitationRequestScope::new(request_id),
161 + elicitation_id.clone(),
162 + url.clone(),
163 + ),
164 + "Sign in to siGit Code in your browser to use siGit Code Cloud.",
165 + ))
166 + .block_task();
167 + let sign_in = browser_auth::complete_loopback(flow);
168 + tokio::pin!(sign_in);
169 +
170 + // The page can be finished before the client gets round to answering.
171 + enum First {
172 + Answer(agent_client_protocol::Result<CreateElicitationResponse>),
173 + SignedIn(Result<String, String>),
174 + }
175 + let first = tokio::select! {
176 + answer = ask => First::Answer(answer),
177 + result = &mut sign_in => First::SignedIn(result),
178 + };
179 + let result = match first {
180 + First::SignedIn(result) => result,
181 + First::Answer(Ok(response)) => match response.action {
182 + ElicitationAction::Accept(_) => sign_in.await,
183 + _ => Err("the sign-in page was not opened".to_string()),
184 + },
185 + First::Answer(Err(error)) => {
186 + log::warn!("authenticate: the client could not show the sign-in page ({error})");
187 + if !browser_auth::open_browser(&url) {
188 + log::warn!("authenticate: no browser could be launched");
189 + }
190 + sign_in.await
191 + }
192 + };
193 + if result.is_ok() {
194 + connection
195 + .send_notification(CompleteElicitationNotification::new(elicitation_id))
196 + .ok();
197 + }
198 + result
199 +}
200 +
201 +#[cfg(test)]
202 +mod tests {
203 + use super::*;
204 +
205 + #[test]
206 + fn the_method_is_an_agent_method_named_sigit() {
207 + let method = SiGitAuth.method();
208 + assert_eq!(&*method.id().0, METHOD_ID);
209 + assert!(matches!(method, AuthMethod::Agent(_)));
210 + }
211 +
212 + #[test]
213 + fn the_cloud_url_defaults_to_the_api_root_and_drops_a_trailing_slash() {
214 + // Not set in a test run; the override is covered by `cloud_base_url`'s own branch.
215 + if std::env::var_os("SIGIT_CLOUD_URL").is_none() {
216 + assert_eq!(cloud_base_url(), "https://sigit.si/api/v1");
217 + }
218 + }
219 +}
crates/sigit-agent/src/lib.rs
+13 -4
@@ -1,9 +1,18 @@
1 -//! Shared siGit Code internals for hosts that embed the agent and for the `sigit` binary: the
2 -//! prompts, the browser sign-in, and the stored cloud session.
1 +//! siGit Code as a library: its [`Profile`](ed_acp::Profile) for `ed-acp`, the browser sign-in
2 +//! and the stored session, for hosts that embed the agent instead of launching the `sigit`
3 +//! binary.
4 //!
4 -//! The command-line agent and an embedding host use the same sign-in and the same prompts, so
5 -//! they live here once rather than in the binary crate.
5 +//! A host serves [`SiGitCode`] with `ed_acp::serve` over an in-memory channel and runs an ACP
6 +//! client on the other end, which keeps the agent inside an app sandbox that can't spawn it as
7 +//! a subprocess. Inference goes to siGit Code Cloud with the session [`SiGitAuth`] holds; the
8 +//! command-line agent uses the same sign-in and the same prompts.
9
10 +pub mod auth;
11 pub mod browser_auth;
12 pub mod credentials;
13 +pub mod profile;
14 pub mod prompt;
15 +
16 +pub use auth::{METHOD_ID, SiGitAuth};
17 +pub use ed_acp;
18 +pub use profile::{INFO, SiGitCode, official_mcp_servers};
crates/sigit-agent/src/profile.rs new
+289
@@ -0,0 +1,289 @@
1 +//! siGit Code's [`Profile`] for `ed-acp`: who the agent is, its prompt and tools, its slash
2 +//! commands, its sign-in, and how a host reaches the official MCP server.
3 +
4 +use std::sync::Arc;
5 +
6 +use agent_client_protocol::schema::v1::{
7 + AvailableCommand, AvailableCommandInput, HttpHeader, McpServer, McpServerHttp,
8 + UnstructuredCommandInput,
9 +};
10 +use async_trait::async_trait;
11 +use ed_acp::{
12 + AgentInfo, AuthProvider, DescribeCtx, LlmEnv, Profile, PromptCtx, SessionCtx, ToolCtx,
13 + ToolOutcome, Toolset, WorkspaceTools,
14 +};
15 +use serde_json::Value;
16 +
17 +use crate::auth::{SiGitAuth, cloud_base_url};
18 +use crate::credentials;
19 +use crate::prompt::{CO_AUTHOR_TRAILER_PLACEHOLDER, INIT_PROMPT, SYSTEM_PROMPT};
20 +
21 +pub const INFO: AgentInfo = AgentInfo {
22 + name: "sigit-agent",
23 + display_name: "siGit Code",
24 + version: env!("CARGO_PKG_VERSION"),
25 + env: LlmEnv {
26 + prefix: "SIGIT_AGENT",
27 + dir_name: "sigit-agent",
28 + default_model: "onde-nova",
29 + },
30 +};
31 +
32 +/// Name of the official server in a session, so its tools are `mcp__sigit__*`, which is what
33 +/// [`SYSTEM_PROMPT`] tells the model to call.
34 +const OFFICIAL_MCP: &str = "sigit";
35 +
36 +/// The official siGit MCP server (issues, pull requests) as a session server for the signed-in
37 +/// user, `None` when signed out.
38 +///
39 +/// This is passed per session rather than offered as a process-wide built-in server: a shared
40 +/// server would keep the bearer it started with, and a user who signed out and in as someone
41 +/// else would keep reaching the first account's issues. A host calls this when it opens a
42 +/// session and passes the result in `mcpServers`.
43 +pub fn official_mcp_servers() -> Vec<McpServer> {
44 + if matches!(
45 + std::env::var("SIGIT_MCP_OFFICIAL").as_deref(),
46 + Ok("off" | "0" | "false")
47 + ) {
48 + return Vec::new();
49 + }
50 + let Some(token) = credentials::load_token() else {
51 + return Vec::new();
52 + };
53 + let url = std::env::var("SIGIT_MCP_URL")
54 + .ok()
55 + .filter(|url| !url.trim().is_empty())
56 + .unwrap_or_else(|| format!("{}/mcp", cloud_base_url()));
57 + vec![McpServer::Http(
58 + McpServerHttp::new(OFFICIAL_MCP, url).headers(vec![HttpHeader::new(
59 + "Authorization",
60 + format!("Bearer {token}"),
61 + )]),
62 + )]
63 +}
64 +
65 +/// What the model is told when the host cannot start processes.
66 +const NO_SHELL_NOTE: &str = "\n\nThis host cannot run commands: there is no run_command tool, and no git or shell. The rule \
67 +above about running commands yourself does not apply here. When something needs a command, \
68 +say which one and let the user run it.";
69 +
70 +/// siGit Code as an `ed-acp` agent.
71 +#[derive(Debug, Clone, Copy)]
72 +pub struct SiGitCode {
73 + shell: bool,
74 +}
75 +
76 +impl SiGitCode {
77 + /// With the workspace tools, `run_command` included.
78 + pub fn new() -> Self {
79 + Self { shell: true }
80 + }
81 +
82 + /// For hosts that cannot start processes (an iOS app, a sandboxed Mac app): the workspace
83 + /// tools without `run_command`, and a prompt that says so.
84 + pub fn without_shell() -> Self {
85 + Self { shell: false }
86 + }
87 +
88 + /// The model-facing tool list for this host, in the system prompt.
89 + fn host_note(&self) -> String {
90 + let tools = if self.shell {
91 + "read_file, write_file, edit_file, list_directory and run_command"
92 + } else {
93 + "read_file, write_file, edit_file and list_directory"
94 + };
95 + format!(
96 + "\n\nIn this host your file tools are {tools}, plus the tools of connected MCP \
97 + servers. Tools named above that are not in this list (such as read_website or \
98 + create_file) are not available; use write_file to create files."
99 + )
100 + }
101 +}
102 +
103 +impl Default for SiGitCode {
104 + fn default() -> Self {
105 + Self::new()
106 + }
107 +}
108 +
109 +/// `WorkspaceTools` minus `run_command`.
110 +struct FileTools;
111 +
112 +fn is_run_command(definition: &Value) -> bool {
113 + definition["function"]["name"] == "run_command"
114 +}
115 +
116 +#[async_trait]
117 +impl Toolset for FileTools {
118 + fn definitions(&self) -> Vec<Value> {
119 + WorkspaceTools
120 + .definitions()
121 + .into_iter()
122 + .filter(|definition| !is_run_command(definition))
123 + .collect()
124 + }
125 +
126 + fn handles(&self, name: &str) -> bool {
127 + name != "run_command" && WorkspaceTools.handles(name)
128 + }
129 +
130 + fn describe(
131 + &self,
132 + name: &str,
133 + args: &Value,
134 + ctx: &DescribeCtx<'_>,
135 + ) -> (
136 + String,
137 + agent_client_protocol::schema::v1::ToolKind,
138 + Vec<agent_client_protocol::schema::v1::ToolCallLocation>,
139 + ) {
140 + WorkspaceTools.describe(name, args, ctx)
141 + }
142 +
143 + async fn execute(
144 + &self,
145 + ctx: &ToolCtx,
146 + tool_call_id: &str,
147 + name: &str,
148 + args: Value,
149 + ) -> anyhow::Result<ToolOutcome> {
150 + if name == "run_command" {
151 + anyhow::bail!("This host cannot run commands.");
152 + }
153 + WorkspaceTools.execute(ctx, tool_call_id, name, args).await
154 + }
155 +}
156 +
157 +/// The co-author trailer for a commit made from `surface`, in the format `sigit` uses.
158 +fn co_author_trailer(surface: &str) -> String {
159 + format!(
160 + "Co-Authored-By: siGit Code v{}-{surface} <noreply@sigit.si>",
161 + INFO.version
162 + )
163 +}
164 +
165 +/// `/init`'s name and what it does, advertised to the client.
166 +fn init_command() -> AvailableCommand {
167 + AvailableCommand::new("init", "Create or improve AGENTS.md for this repository").input(
168 + AvailableCommandInput::Unstructured(UnstructuredCommandInput::new("")),
169 + )
170 +}
171 +
172 +#[async_trait]
173 +impl Profile for SiGitCode {
174 + fn info(&self) -> AgentInfo {
175 + INFO
176 + }
177 +
178 + fn system_prompt(&self, ctx: &PromptCtx<'_>) -> String {
179 + let mut prompt = SYSTEM_PROMPT.replace(
180 + CO_AUTHOR_TRAILER_PLACEHOLDER,
181 + &co_author_trailer(ctx.surface),
182 + );
183 + prompt.push_str(&self.host_note());
184 + if !self.shell {
185 + prompt.push_str(NO_SHELL_NOTE);
186 + }
187 + prompt.push_str(&format!("\n\nWorking directory: {}", ctx.cwd.display()));
188 + if ctx.roots.len() > 1 {
189 + let roots: Vec<String> = ctx.roots.iter().map(|r| r.display().to_string()).collect();
190 + prompt.push_str(&format!("\nProject roots: {}", roots.join(", ")));
191 + }
192 + prompt
193 + }
194 +
195 + fn toolsets(&self) -> Vec<Arc<dyn Toolset>> {
196 + if self.shell {
197 + vec![Arc::new(WorkspaceTools)]
198 + } else {
199 + vec![Arc::new(FileTools)]
200 + }
201 + }
202 +
203 + fn slash_commands(&self, _ctx: &SessionCtx<'_>) -> Vec<AvailableCommand> {
204 + vec![init_command()]
205 + }
206 +
207 + fn expand_slash(&self, text: &str, _ctx: &SessionCtx<'_>) -> Option<String> {
208 + let rest = text.trim_start().strip_prefix("/init")?;
209 + // `/initialize` is not `/init`.
210 + if rest.is_empty() || rest.starts_with(char::is_whitespace) {
211 + Some(INIT_PROMPT.to_string())
212 + } else {
213 + None
214 + }
215 + }
216 +
217 + fn auth(&self) -> Option<Arc<dyn AuthProvider>> {
218 + Some(Arc::new(SiGitAuth))
219 + }
220 +}
221 +
222 +#[cfg(test)]
223 +mod tests {
224 + use std::path::Path;
225 +
226 + use super::*;
227 +
228 + fn names(toolsets: &[Arc<dyn Toolset>]) -> Vec<String> {
229 + toolsets
230 + .iter()
231 + .flat_map(|t| t.definitions())
232 + .map(|d| d["function"]["name"].as_str().unwrap().to_string())
233 + .collect()
234 + }
235 +
236 + #[test]
237 + fn the_shell_variant_offers_run_command_and_the_other_does_not() {
238 + let with = names(&SiGitCode::new().toolsets());
239 + let without = names(&SiGitCode::without_shell().toolsets());
240 + assert!(with.contains(&"run_command".to_string()));
241 + assert!(!without.contains(&"run_command".to_string()));
242 + assert!(without.contains(&"edit_file".to_string()));
243 + assert!(!FileTools.handles("run_command"));
244 + assert!(FileTools.handles("read_file"));
245 + }
246 +
247 + #[test]
248 + fn the_prompt_carries_the_surface_trailer_and_names_the_host() {
249 + let mcp = ed_acp::ed_mcp::McpToolset::empty(ed_acp::ed_mcp::ToolsetConfig::new(
250 + ed_acp::ed_mcp::ClientInfo::new("t", "0"),
251 + ));
252 + let roots = vec![Path::new("/work/a").to_path_buf()];
253 + let ctx = PromptCtx {
254 + surface: "app",
255 + cwd: Path::new("/work/a"),
256 + roots: &roots,
257 + mcp: &mcp,
258 + };
259 + let prompt = SiGitCode::without_shell().system_prompt(&ctx);
260 + assert!(prompt.contains("siGit Code v"), "{prompt}");
261 + assert!(prompt.contains("-app <noreply@sigit.si>"));
262 + assert!(!prompt.contains(CO_AUTHOR_TRAILER_PLACEHOLDER));
263 + assert!(prompt.contains("cannot run commands"));
264 + assert!(prompt.contains("Working directory: /work/a"));
265 + let with_shell = SiGitCode::new().system_prompt(&ctx);
266 + assert!(!with_shell.contains("cannot run commands"));
267 + }
268 +
269 + #[test]
270 + fn init_expands_to_the_init_prompt_and_only_init() {
271 + let mcp = ed_acp::ed_mcp::McpToolset::empty(ed_acp::ed_mcp::ToolsetConfig::new(
272 + ed_acp::ed_mcp::ClientInfo::new("t", "0"),
273 + ));
274 + let ctx = SessionCtx { mcp: &mcp };
275 + let profile = SiGitCode::new();
276 + assert_eq!(
277 + profile.expand_slash("/init", &ctx).as_deref(),
278 + Some(INIT_PROMPT)
279 + );
280 + assert!(profile.expand_slash(" /init now", &ctx).is_some());
281 + assert!(profile.expand_slash("/initialize", &ctx).is_none());
282 + assert!(profile.expand_slash("hello", &ctx).is_none());
283 + }
284 +
285 + #[test]
286 + fn the_profile_brings_its_own_sign_in() {
287 + assert!(SiGitCode::new().auth().is_some());
288 + }
289 +}
crates/sigit-agent/tests/embedded.rs new
+121
@@ -0,0 +1,121 @@
1 +//! `SiGitCode` served in-process, the way an app hosts it, against the testkit's mock endpoint:
2 +//! the method is advertised, a signed-out session is refused, a stored session reaches the
3 +//! endpoint as the bearer, and signing out takes it away again.
4 +//!
5 +//! One test function: the sign-in reads process-wide environment variables.
6 +
7 +use std::sync::Arc;
8 +use std::sync::atomic::Ordering;
9 +
10 +use agent_client_protocol::schema::ProtocolVersion;
11 +use agent_client_protocol::schema::v1::{
12 + ContentBlock, InitializeRequest, LogoutRequest, NewSessionRequest, PromptRequest, StopReason,
13 + TextContent,
14 +};
15 +use agent_client_protocol::{Agent, Channel, Client, ConnectionTo};
16 +use ed_acp::ServeOptions;
17 +use ed_acp_testkit::{MockStats, Script, Step, start_mock_llm};
18 +use sigit_agent::{METHOD_ID, SiGitCode, credentials};
19 +
20 +#[tokio::test]
21 +async fn the_profile_signs_in_through_its_provider_and_talks_to_the_cloud() {
22 + let dir = std::env::temp_dir().join(format!("sigit_agent_embedded_{}", std::process::id()));
23 + let _ = std::fs::remove_dir_all(&dir);
24 + std::fs::create_dir_all(&dir).unwrap();
25 +
26 + let stats = Arc::new(MockStats::default());
27 + let url = start_mock_llm(
28 + Script(vec![Step::Final("hello from the cloud")]),
29 + stats.clone(),
30 + )
31 + .await;
32 + // SAFETY: this is the only test in the binary, so nothing else reads the environment.
33 + unsafe {
34 + std::env::set_var("SIGIT_CONFIG_DIR", dir.join("config"));
35 + std::env::set_var("SIGIT_AGENT_DATA_DIR", dir.join("data"));
36 + std::env::set_var("SIGIT_CLOUD_URL", &url);
37 + std::env::set_var("SIGIT_AGENT_MODEL", ed_acp_testkit::MODEL);
38 + // The mock is the only server in play.
39 + std::env::set_var("SIGIT_MCP_OFFICIAL", "off");
40 + }
41 + assert!(credentials::load().is_none());
42 +
43 + let (agent_end, client_end) = Channel::duplex();
44 + let opts = ServeOptions {
45 + yolo: false,
46 + surface: "app",
47 + };
48 + tokio::spawn(ed_acp::serve(
49 + Arc::new(SiGitCode::without_shell()),
50 + opts,
51 + agent_end,
52 + ));
53 + let cwd = dir.clone();
54 + let seen = stats.clone();
55 +
56 + Client
57 + .builder()
58 + .connect_with(client_end, |conn: ConnectionTo<Agent>| async move {
59 + let init = conn
60 + .send_request(InitializeRequest::new(ProtocolVersion::V1))
61 + .block_task()
62 + .await?;
63 + assert_eq!(init.auth_methods.len(), 1);
64 + assert_eq!(&*init.auth_methods[0].id().0, METHOD_ID);
65 + assert_eq!(init.agent_info.unwrap().name, "sigit-agent");
66 +
67 + let refused = conn
68 + .send_request(NewSessionRequest::new(cwd.clone()))
69 + .block_task()
70 + .await;
71 + assert!(refused.is_err(), "signed out, so no session");
72 +
73 + // What a finished browser sign-in leaves behind.
74 + credentials::store(&credentials::Credentials {
75 + // The mock accepts one bearer.
76 + access_token: ed_acp_testkit::TEST_KEY.into(),
77 + email: Some("dev@sigit.si".into()),
78 + })
79 + .unwrap();
80 +
81 + let session = conn
82 + .send_request(NewSessionRequest::new(cwd.clone()))
83 + .block_task()
84 + .await?;
85 + let reply = conn
86 + .send_request(PromptRequest::new(
87 + session.session_id.clone(),
88 + vec![ContentBlock::Text(TextContent::new("hi"))],
89 + ))
90 + .block_task()
91 + .await?;
92 + assert_eq!(reply.stop_reason, StopReason::EndTurn);
93 + assert!(*seen.saw_bearer.lock().unwrap());
94 + assert_eq!(seen.requests.load(Ordering::SeqCst), 1);
95 +
96 + // The model saw siGit Code's prompt and no run_command.
97 + let payload = seen.payloads.lock().unwrap()[0].clone();
98 + let system = payload["messages"][0]["content"]
99 + .as_str()
100 + .unwrap()
101 + .to_string();
102 + assert!(system.contains("siGit"));
103 + assert!(system.contains("cannot run commands"));
104 + let tools = payload["tools"].to_string();
105 + assert!(tools.contains("edit_file"));
106 + assert!(!tools.contains("run_command"));
107 +
108 + conn.send_request(LogoutRequest::new()).block_task().await?;
109 + assert!(credentials::load().is_none());
110 + let after = conn
111 + .send_request(NewSessionRequest::new(cwd.clone()))
112 + .block_task()
113 + .await;
114 + assert!(after.is_err(), "signed out again");
115 + Ok(())
116 + })
117 + .await
118 + .expect("conversation");
119 +
120 + let _ = std::fs::remove_dir_all(&dir);
121 +}