symbio 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ versions follow [Semantic Versioning](https://semver.org/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.6.0]
9
+
10
+ - Azure DevOps connections: add an organization with a personal access token in Settings; agents
11
+ push and pull through it (HTTPS remotes of that organization) and call its REST API with the new
12
+ `azure_devops_api` tool, e.g. to open pull requests. The token never reaches the agent and only
13
+ goes to the connection's own organization.
14
+ - Credentials rule clarified: Symbio's own secrets stay off-limits to agents, but a project's own
15
+ credentials (connection strings, `.env`…) and ones the human gives may be used for the task;
16
+ agents must not copy them into anything committed or shared, and only read from systems the
17
+ human calls read-only or production. Agents had refused legitimate work (querying a database
18
+ with the app's connection string) under the old blanket rule.
19
+
8
20
  ## [0.5.0]
9
21
 
10
22
  - Docs tab: a folder tree instead of a flat list; search across all docs (any case or accent) that
package/README.md CHANGED
@@ -77,8 +77,8 @@ place. Here Symbio was restarted mid-task, and it told the helper to pick up whe
77
77
  - **You stay in control.** Questions, plan approvals, permission requests (like installing
78
78
  software) and deploy approvals collect in a **Needs you** inbox. Pause one team or everything at
79
79
  once.
80
- - **Connections without exposing secrets.** Give agents a GitHub account or an SSH server. They
81
- ask Symbio to push, call the API or deploy, and Symbio does it with credentials the agents never
80
+ - **Connections without exposing secrets.** Give agents a GitHub account, an Azure DevOps
81
+ organization or an SSH server. They ask Symbio to push, open pull requests, call the API or deploy, and Symbio does it with credentials the agents never
82
82
  see. Each connection can require your approval for every action.
83
83
  - **Resilient.** A supervisor restarts OpenCode if it crashes, a watchdog unsticks hung agents, and
84
84
  when a model fails (quota, outage) the agent switches to the next allowed model and carries on.
@@ -142,7 +142,7 @@ itself. Whoever gets in controls your agents, so keep the link and code to yours
142
142
  - **Folder:** browse for or paste a folder. An empty folder starts a new project; an existing
143
143
  one is studied first. Symbio offers to set up git if the folder doesn't have it.
144
144
  - **Setup:** choose where the project documentation goes (in the project, or private), and which
145
- connections (GitHub, servers) the agents may use.
145
+ connections (GitHub, Azure DevOps, servers) the agents may use.
146
146
  - **Models:** pick the models agents may use, and the one core runs on (the crown).
147
147
  3. **Talk to core.** Describe what you want. core asks questions, proposes a plan, and waits for
148
148
  your approval in the **Plan** tab (it shows a *Review* badge).
@@ -159,7 +159,7 @@ itself. Whoever gets in controls your agents, so keep the link and code to yours
159
159
  | **core** | The project's lead agent. It plans, delegates, reviews and merges; it coordinates rather than doing the work itself. |
160
160
  | **Helper** | An agent core creates for a piece of work. It works in its own git worktree (branch `symbio/<name>`) or, if the project is set to, in the project folder itself. |
161
161
  | **Shared asset** | Something more than one agent could change. Access goes through core: request, grant, release. |
162
- | **Connection** | A GitHub account or SSH server agents can use through Symbio, without ever seeing its credentials. |
162
+ | **Connection** | A GitHub account, Azure DevOps organization or SSH server agents can use through Symbio, without ever seeing its credentials. |
163
163
  | **Allowed models** | The models a project's agents may run on. If one fails, agents fall back to the next. |
164
164
 
165
165
  ## Where your data lives
@@ -1,14 +1,22 @@
1
- // Global connections (set once in Settings, used by any project): GitHub accounts
2
- // and servers reachable over SSH. Public details live in ~/.symbio/connections.json;
3
- // secrets live in the encrypted vault and are never returned by the API.
1
+ // Global connections (set once in Settings, used by any project): GitHub accounts,
2
+ // Azure DevOps organizations and servers reachable over SSH. Public details live in
3
+ // ~/.symbio/connections.json; secrets live in the encrypted vault and are never
4
+ // returned by the API.
4
5
  import { promises as fs, readFileSync } from "node:fs";
5
6
  import os from "node:os";
6
7
  import path from "node:path";
7
8
  import { SecretVault } from "./secrets.js";
8
9
  import { sshExec } from "./ssh.js";
9
10
  import { globalDir, newId } from "./store.js";
11
+ /** Organization from what people paste: "org", https://dev.azure.com/org/…, https://org.visualstudio.com/…. */
12
+ export function azureOrganization(value) {
13
+ const v = value.trim();
14
+ const m = /^https?:\/\/(?:[^@/]+@)?dev\.azure\.com\/([^/?#]+)/i.exec(v) ?? /^https?:\/\/(?:[^@/]+@)?([^./]+)\.visualstudio\.com/i.exec(v);
15
+ return decodeURIComponent(m ? m[1] : v.replace(/\/+$/, ""));
16
+ }
10
17
  const SECRET_FIELDS = {
11
18
  github: ["token"],
19
+ azure: ["token"],
12
20
  server: ["privateKey", "passphrase", "password"],
13
21
  };
14
22
  export class Connections {
@@ -37,13 +45,15 @@ export class Connections {
37
45
  return { ...c, secrets };
38
46
  }
39
47
  async create(input) {
40
- if (input.type !== "github" && input.type !== "server")
41
- throw new Error("type must be github or server");
48
+ if (input.type !== "github" && input.type !== "azure" && input.type !== "server")
49
+ throw new Error("type must be github, azure or server");
42
50
  const now = Date.now();
43
51
  const base = { id: newId("con"), name: "", createdAt: now, updatedAt: now, lastTest: null };
44
52
  const conn = input.type === "github"
45
53
  ? { ...base, type: "github", account: null }
46
- : { ...base, type: "server", host: "", port: 22, username: "", auth: "key", keyPath: null };
54
+ : input.type === "azure"
55
+ ? { ...base, type: "azure", organization: "", account: null }
56
+ : { ...base, type: "server", host: "", port: 22, username: "", auth: "key", keyPath: null };
47
57
  this.apply(conn, input);
48
58
  if (!conn.name)
49
59
  throw new Error("Give the connection a name.");
@@ -91,6 +101,14 @@ export class Connections {
91
101
  conn.account = user.login;
92
102
  result = { ok: true, message: `Signed in as ${user.login}${user.name ? ` (${user.name})` : ""}`, time: Date.now() };
93
103
  }
104
+ else if (conn.type === "azure") {
105
+ const data = await this.azureRequest(conn, "GET", "_apis/connectionData", undefined, "7.1-preview");
106
+ const user = data?.authenticatedUser;
107
+ conn.account = user?.providerDisplayName ?? user?.customDisplayName ?? null;
108
+ const projects = await this.azureRequest(conn, "GET", "_apis/projects").catch(() => null);
109
+ const count = Array.isArray(projects?.value) ? ` — ${projects.value.length} project(s) visible` : "";
110
+ result = { ok: true, message: `Signed in to ${conn.organization}${conn.account ? ` as ${conn.account}` : ""}${count}`, time: Date.now() };
111
+ }
94
112
  else {
95
113
  const res = await sshExec(await this.sshTarget(conn), "uname -srm 2>/dev/null || ver", 30_000);
96
114
  const out = (res.stdout || res.stderr).trim().split("\n")[0];
@@ -129,6 +147,40 @@ export class Connections {
129
147
  }
130
148
  return target;
131
149
  }
150
+ /** Calls the Azure DevOps REST API of the connection's organization with its token. */
151
+ async azureRequest(conn, method, apiPath, body, apiVersion = "7.1") {
152
+ const rel = apiPath.trim().replace(/^\/+/, "");
153
+ if (/^[a-z]+:/i.test(rel))
154
+ throw new Error("Give a path inside the organization (e.g. MyProject/_apis/git/repositories), not a full URL.");
155
+ const url = new URL(`https://dev.azure.com/${encodeURIComponent(conn.organization)}/${rel}`);
156
+ if (!url.searchParams.has("api-version"))
157
+ url.searchParams.set("api-version", apiVersion);
158
+ const token = await this.githubToken(conn);
159
+ const res = await fetch(url, {
160
+ method,
161
+ redirect: "manual", // a sign-in redirect means the token was refused
162
+ headers: {
163
+ authorization: `Basic ${Buffer.from(`:${token}`).toString("base64")}`,
164
+ accept: "application/json",
165
+ "user-agent": "symbio",
166
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
167
+ },
168
+ body: body === undefined ? undefined : JSON.stringify(body),
169
+ });
170
+ const text = await res.text();
171
+ // A rejected token gets a sign-in page (203 or a redirect) rather than a 401.
172
+ const html = /^\s*</.test(text) || (res.headers.get("content-type") ?? "").includes("text/html");
173
+ if (res.status === 401 || res.status === 203 || (res.status >= 300 && res.status < 400) || (html && res.ok)) {
174
+ throw new Error(`Azure DevOps rejected the token for "${conn.organization}" (expired, revoked, or without access to this organization?)`);
175
+ }
176
+ const data = text ? safeJson(text) : null;
177
+ if (!res.ok) {
178
+ const msg = data?.message ?? res.statusText;
179
+ throw new Error(`Azure DevOps ${res.status}: ${msg}`);
180
+ }
181
+ return data;
182
+ }
183
+ /** The stored token of a GitHub or Azure DevOps connection. */
132
184
  async githubToken(conn) {
133
185
  const token = await this.vault.get(`${conn.id}:token`);
134
186
  if (!token)
@@ -159,6 +211,8 @@ export class Connections {
159
211
  apply(conn, input) {
160
212
  if (input.name !== undefined)
161
213
  conn.name = input.name.trim();
214
+ if (conn.type === "azure" && input.organization !== undefined)
215
+ conn.organization = azureOrganization(input.organization);
162
216
  if (conn.type === "server") {
163
217
  if (input.host !== undefined)
164
218
  conn.host = input.host.trim();
@@ -173,6 +227,9 @@ export class Connections {
173
227
  }
174
228
  }
175
229
  validate(conn) {
230
+ if (conn.type === "azure" && !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/.test(conn.organization)) {
231
+ throw new Error("Give the organization, e.g. \"contoso\" or https://dev.azure.com/contoso.");
232
+ }
176
233
  if (conn.type === "server") {
177
234
  if (!conn.host)
178
235
  throw new Error("Host is required.");
@@ -189,7 +246,7 @@ export class Connections {
189
246
  if (typeof value === "string" && value.trim())
190
247
  await this.vault.set(`${conn.id}:${field}`, value.trim());
191
248
  }
192
- if (conn.type === "github" && !(await this.vault.has(`${conn.id}:token`)))
249
+ if ((conn.type === "github" || conn.type === "azure") && !(await this.vault.has(`${conn.id}:token`)))
193
250
  throw new Error("The token is required.");
194
251
  if (conn.type === "server") {
195
252
  if (conn.auth === "password" && !(await this.vault.has(`${conn.id}:password`)))
@@ -337,15 +337,33 @@ export function kickoffMessage(project) {
337
337
  : "Symbio: the project was just created on a folder that already has content (see the folder summary in your instructions). Greet the human briefly, mention in one or two sentences what you can see at a glance, and offer to study the folder before planning.";
338
338
  }
339
339
  function connectionsSection(connections) {
340
- return `## Connections (GitHub, servers)
340
+ return `## Connections (GitHub, Azure DevOps, servers)
341
341
 
342
342
  ${connections || "None."}
343
343
 
344
- Use connections only through Symbio's tools: \`list_connections\`, \`git_push\`,
345
- \`git_pull\`, \`github_api\` (GitHub) and \`remote_exec\`, \`remote_upload\` (servers).
346
- Symbio performs the action with the stored credentials — you never see them.
347
- Never ask for credentials, never write secrets into files or commands, and never
348
- look for them on this machine. Remote commands must be non-interactive. Always
349
- give a clear \`reason\`: when a connection needs approval, the human decides based
350
- on it, and the result arrives later as a message.`;
344
+ Use connections only through Symbio's tools: \`list_connections\`; \`git_push\`,
345
+ \`git_pull\` (GitHub or Azure DevOps); \`github_api\` (GitHub); \`azure_devops_api\`
346
+ (Azure DevOps: pull requests, repositories, branches); \`remote_exec\`,
347
+ \`remote_upload\` (servers). Symbio performs the action with the stored
348
+ credentials — you never see them. To open a pull request: push the branch with
349
+ \`git_push\`, then create it with \`github_api\` or \`azure_devops_api\`.
350
+ Remote commands must be non-interactive. Always give a clear \`reason\`: when a
351
+ connection needs approval, the human decides based on it, and the result
352
+ arrives later as a message.
353
+
354
+ ## Credentials
355
+
356
+ - **Symbio's secrets are off-limits:** its vault and connections, SSH keys, its
357
+ UI key. Use connections only through the tools above; never try to read them.
358
+ - **The project's own credentials** (in its config files: connection strings,
359
+ \`.env\`, \`Web.config\`, \`appsettings.json\`…) and **credentials the human gives
360
+ you** may be used for the task, e.g. connecting to a database the work needs.
361
+ A developer on this project would use them too; don't refuse a task over that.
362
+ - **Don't spread them:** never put a credential into anything that is committed
363
+ or shared — new files in the project, docs, the rules register, the plan,
364
+ messages to other agents — and don't print it in your answers. Prefer
365
+ commands that read it from the config file over pasting it into a command line.
366
+ - **Respect the environment:** if the human says a system is a read-only mirror
367
+ or production, only read from it; changing production needs the human's
368
+ explicit OK.`;
351
369
  }
@@ -1,7 +1,7 @@
1
1
  // Actions agents take through connections (SSH commands and uploads, git push/pull,
2
- // GitHub API). Symbio runs them itself with credentials from the vault — agents
3
- // only see results. Connections set to "ask" queue an approval for the human; the
4
- // result is delivered to the agent as a message once approved.
2
+ // GitHub and Azure DevOps APIs). Symbio runs them itself with credentials from the
3
+ // vault — agents only see results. Connections set to "ask" queue an approval for
4
+ // the human; the result is delivered to the agent as a message once approved.
5
5
  import { execFile } from "node:child_process";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { promises as fs, existsSync, realpathSync } from "node:fs";
@@ -28,13 +28,16 @@ export class RemoteOps {
28
28
  }
29
29
  describeFor(project) {
30
30
  const list = this.enabled(project);
31
- if (list.length === 0)
32
- return "This project has no connections. If the work needs GitHub or a server, tell the human to add one in the project's sidebar (Connections).";
31
+ if (list.length === 0) {
32
+ return "This project has no connections. If the work needs GitHub, Azure DevOps or a server, tell the human to add one in the project's Settings (Connections).";
33
+ }
33
34
  return list
34
35
  .map(({ conn, link }) => {
35
36
  const what = conn.type === "github"
36
37
  ? `GitHub${conn.account ? ` account ${conn.account}` : ""} — git_push, git_pull, github_api`
37
- : `server ${conn.username}@${conn.host} — remote_exec, remote_upload`;
38
+ : conn.type === "azure"
39
+ ? `Azure DevOps organization ${conn.organization}${conn.account ? ` (as ${conn.account})` : ""} — git_push, git_pull, azure_devops_api`
40
+ : `server ${conn.username}@${conn.host} — remote_exec, remote_upload`;
38
41
  return `- "${conn.name}": ${what} (${link.policy === "ask" ? "each action needs the human's approval" : "allowed without approval"})`;
39
42
  })
40
43
  .join("\n");
@@ -43,7 +46,7 @@ export class RemoteOps {
43
46
  async request(project, agent, action, reason) {
44
47
  const { conn, link } = this.resolve(project, action.connectionId, action.kind);
45
48
  const summary = this.summarize(conn, action, agent);
46
- const readOnly = action.kind === "github_api" && action.method.toUpperCase() === "GET";
49
+ const readOnly = (action.kind === "github_api" || action.kind === "azure_devops_api") && action.method.toUpperCase() === "GET";
47
50
  if (link.policy === "allow" || readOnly) {
48
51
  return this.execute(project, agent, conn, action, summary, reason);
49
52
  }
@@ -106,9 +109,17 @@ export class RemoteOps {
106
109
  const names = this.enabled(project).map(({ conn }) => conn.name).join(", ") || "none";
107
110
  throw new Error(`No connection "${connectionRef}" in this project. Available: ${names}.`);
108
111
  }
109
- const needs = kind.startsWith("remote_") ? "server" : "github";
110
- if (found.conn.type !== needs)
111
- throw new Error(`"${found.conn.name}" is a ${found.conn.type} connection; ${kind} needs a ${needs} connection.`);
112
+ const needs = kind.startsWith("remote_")
113
+ ? ["server"]
114
+ : kind === "github_api"
115
+ ? ["github"]
116
+ : kind === "azure_devops_api"
117
+ ? ["azure"]
118
+ : ["github", "azure"]; // git_push / git_pull
119
+ if (!needs.includes(found.conn.type)) {
120
+ const label = (t) => (t === "azure" ? "Azure DevOps" : t === "github" ? "GitHub" : "server");
121
+ throw new Error(`"${found.conn.name}" is a ${label(found.conn.type)} connection; ${kind} needs a ${needs.map(label).join(" or ")} connection.`);
122
+ }
112
123
  return found;
113
124
  }
114
125
  summarize(conn, a, agent) {
@@ -123,6 +134,8 @@ export class RemoteOps {
123
134
  return `git pull ${a.remote ?? "origin"} ${a.branch ?? "(current branch)"} into ${agent.name}'s workspace using ${conn.name}`;
124
135
  case "github_api":
125
136
  return `GitHub API ${a.method.toUpperCase()} ${a.path} using ${conn.name}`;
137
+ case "azure_devops_api":
138
+ return `Azure DevOps API ${a.method.toUpperCase()} ${a.path} using ${conn.name}`;
126
139
  }
127
140
  }
128
141
  async execute(project, agent, conn, a, summary, reason) {
@@ -163,12 +176,11 @@ export class RemoteOps {
163
176
  }
164
177
  case "git_push":
165
178
  case "git_pull": {
166
- const token = await this.connections.githubToken(conn);
179
+ const gitConn = conn;
180
+ const token = await this.connections.githubToken(gitConn);
167
181
  const remote = a.remote ?? "origin";
168
182
  const url = remote.startsWith("https://") ? remote : await gitOut(agent.directory, ["remote", "get-url", remote]);
169
- if (!/^https:\/\/github\.com\//i.test(url)) {
170
- throw new Error(`The remote must be a GitHub HTTPS URL (got ${url || "nothing"}). Add one with: git remote add origin https://github.com/<owner>/<repo>.git`);
171
- }
183
+ const auth = gitAuth(gitConn, url, token);
172
184
  const branch = a.branch ?? (await gitOut(agent.directory, ["rev-parse", "--abbrev-ref", "HEAD"]));
173
185
  // The agent controls this repository, so its config and hooks are untrusted: a hook
174
186
  // would see the token, a proxy or URL rewrite would send it elsewhere.
@@ -177,7 +189,7 @@ export class RemoteOps {
177
189
  const safe = ["-c", `core.hooksPath=${noHooks}`, "-c", "http.proxy=", "-c", "http.sslVerify=true", "-c", "credential.helper="];
178
190
  // The token goes in an HTTP header via environment-only git config: never in
179
191
  // the command line, the repo config or the agent's view.
180
- const header = `Authorization: Basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`;
192
+ const { headerKey, header } = auth;
181
193
  const git = (args, withToken) => run("git", [...safe, ...args], {
182
194
  cwd: agent.directory,
183
195
  windowsHide: true,
@@ -185,9 +197,7 @@ export class RemoteOps {
185
197
  env: {
186
198
  ...process.env,
187
199
  GIT_TERMINAL_PROMPT: "0",
188
- ...(withToken
189
- ? { GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "http.https://github.com/.extraheader", GIT_CONFIG_VALUE_0: header }
190
- : {}),
200
+ ...(withToken ? { GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: headerKey, GIT_CONFIG_VALUE_0: header } : {}),
191
201
  },
192
202
  }).catch((err) => {
193
203
  throw new Error(scrub(String(err.stderr || err.message), token));
@@ -201,8 +211,11 @@ export class RemoteOps {
201
211
  const merged = await git(["merge", "--no-edit", "FETCH_HEAD"], false);
202
212
  return scrub(`${fetched.stdout}${fetched.stderr}${merged.stdout}${merged.stderr}`.trim() || "done", token);
203
213
  }
204
- case "github_api": {
205
- const data = await this.connections.githubRequest(conn, a.method.toUpperCase(), a.path, a.body);
214
+ case "github_api":
215
+ case "azure_devops_api": {
216
+ const data = a.kind === "github_api"
217
+ ? await this.connections.githubRequest(conn, a.method.toUpperCase(), a.path, a.body)
218
+ : await this.connections.azureRequest(conn, a.method.toUpperCase(), a.path, a.body);
206
219
  const text = JSON.stringify(data, null, 2) ?? "(no content)";
207
220
  return text.length > 20_000 ? `${text.slice(0, 20_000)}\n… (truncated)` : text;
208
221
  }
@@ -258,7 +271,7 @@ export async function refuseRiskyGitConfig(dir) {
258
271
  .filter(([scope, key]) => (scope === "local" || scope === "worktree") && key && RISKY_GIT_CONFIG.some((re) => re.test(key.toLowerCase())))
259
272
  .map(([, key]) => key);
260
273
  if (risky.length) {
261
- throw new Error(`Refusing to use GitHub credentials: this repository's git config sets ${risky.join(", ")}, which could redirect or capture them. Remove ${risky.length > 1 ? "these settings" : "it"} (git config --unset <name>) and try again.`);
274
+ throw new Error(`Refusing to use the connection's credentials: this repository's git config sets ${risky.join(", ")}, which could redirect or capture them. Remove ${risky.length > 1 ? "these settings" : "it"} (git config --unset <name>) and try again.`);
262
275
  }
263
276
  }
264
277
  async function gitOut(cwd, args) {
@@ -266,6 +279,35 @@ async function gitOut(cwd, args) {
266
279
  return stdout.trim();
267
280
  }
268
281
  function scrub(text, secret) {
269
- const b64 = Buffer.from(`x-access-token:${secret}`).toString("base64");
270
- return text.split(secret).join("***").split(b64).join("***");
282
+ let out = text.split(secret).join("***");
283
+ for (const user of ["x-access-token:", ":"])
284
+ out = out.split(Buffer.from(`${user}${secret}`).toString("base64")).join("***");
285
+ return out;
286
+ }
287
+ /**
288
+ * How to send a connection's token to a git remote, after checking the remote belongs to it:
289
+ * a GitHub HTTPS URL for GitHub; for Azure DevOps, a dev.azure.com (or older visualstudio.com)
290
+ * URL of the connection's own organization, so its token never goes to someone else's.
291
+ */
292
+ export function gitAuth(conn, url, token) {
293
+ if (conn.type === "github") {
294
+ if (!/^https:\/\/github\.com\//i.test(url)) {
295
+ throw new Error(`The remote must be a GitHub HTTPS URL (got ${url || "nothing"}). Add one with: git remote add origin https://github.com/<owner>/<repo>.git`);
296
+ }
297
+ return { headerKey: "http.https://github.com/.extraheader", header: `Authorization: Basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}` };
298
+ }
299
+ const devAzure = /^https:\/\/(?:[^@/]+@)?dev\.azure\.com\/([^/]+)\//i.exec(url);
300
+ const legacy = /^https:\/\/(?:[^@/]+@)?([^./]+)\.visualstudio\.com\//i.exec(url);
301
+ if (!devAzure && !legacy) {
302
+ throw new Error(`The remote must be an Azure DevOps HTTPS URL (got ${url || "nothing"}). ` +
303
+ (/^git@|^ssh:/i.test(url)
304
+ ? "It's an SSH remote; switch it to HTTPS: git remote set-url origin https://dev.azure.com/<organization>/<project>/_git/<repository>"
305
+ : "Add one with: git remote add origin https://dev.azure.com/<organization>/<project>/_git/<repository>"));
306
+ }
307
+ const org = decodeURIComponent((devAzure ?? legacy)[1]);
308
+ if (org.toLowerCase() !== conn.organization.toLowerCase()) {
309
+ throw new Error(`This remote belongs to the Azure DevOps organization "${org}", but "${conn.name}" is for "${conn.organization}". Use a connection for "${org}".`);
310
+ }
311
+ const host = devAzure ? "https://dev.azure.com/" : `https://${legacy[1]}.visualstudio.com/`;
312
+ return { headerKey: `http.${host}.extraheader`, header: `Authorization: Basic ${Buffer.from(`:${token}`).toString("base64")}` };
271
313
  }
@@ -330,20 +330,20 @@ const remoteUpload = {
330
330
  run: ({ connection, local_path, remote_path, reason }, { team, project, agent }) => team.remote.request(project, agent, { kind: "remote_upload", connectionId: String(connection), localPath: String(local_path), remotePath: String(remote_path) }, String(reason)),
331
331
  };
332
332
  const gitFields = {
333
- connection: str("GitHub connection name"),
334
- remote: str("Remote name or https://github.com/… URL (default: origin)"),
333
+ connection: str("GitHub or Azure DevOps connection name"),
334
+ remote: str("Remote name or HTTPS URL (github.com, dev.azure.com) (default: origin)"),
335
335
  branch: str("Branch (default: your current branch)"),
336
336
  reason: reasonField,
337
337
  };
338
338
  const gitPush = {
339
339
  name: "git_push",
340
- description: "Push committed work from your working folder to GitHub (the token is applied by Symbio; plain `git push` has no credentials).",
340
+ description: "Push committed work from your working folder to GitHub or Azure DevOps (the token is applied by Symbio; plain `git push` may have no credentials).",
341
341
  inputSchema: obj(gitFields, ["connection", "reason"]),
342
342
  run: ({ connection, remote, branch, reason }, { team, project, agent }) => team.remote.request(project, agent, { kind: "git_push", connectionId: String(connection), remote: remote ? String(remote) : null, branch: branch ? String(branch) : null }, String(reason)),
343
343
  };
344
344
  const gitPull = {
345
345
  name: "git_pull",
346
- description: "Pull from GitHub into your working folder (merge, no rebase).",
346
+ description: "Pull from GitHub or Azure DevOps into your working folder (merge, no rebase).",
347
347
  inputSchema: obj(gitFields, ["connection", "reason"]),
348
348
  run: ({ connection, remote, branch, reason }, { team, project, agent }) => team.remote.request(project, agent, { kind: "git_pull", connectionId: String(connection), remote: remote ? String(remote) : null, branch: branch ? String(branch) : null }, String(reason)),
349
349
  };
@@ -354,11 +354,29 @@ const githubApi = {
354
354
  connection: str("GitHub connection name"),
355
355
  method: { type: "string", enum: ["GET", "POST", "PATCH", "PUT", "DELETE"] },
356
356
  path: str("API path, e.g. /user/repos"),
357
- body: { type: "object", description: "JSON body for POST/PATCH/PUT" },
357
+ // Some endpoints take a list (e.g. Azure DevOps ref updates), others an object.
358
+ body: { anyOf: [{ type: "object" }, { type: "array" }], description: "JSON body for POST/PATCH/PUT (an object, or a list where the API expects one)" },
358
359
  reason: reasonField,
359
360
  }, ["connection", "method", "path", "reason"]),
360
361
  run: ({ connection, method, path, body, reason }, { team, project, agent }) => team.remote.request(project, agent, { kind: "github_api", connectionId: String(connection), method: String(method), path: String(path), body }, String(reason)),
361
362
  };
363
+ const azureDevopsApi = {
364
+ name: "azure_devops_api",
365
+ description: "Call the Azure DevOps REST API of the connection's organization. The path is relative to https://dev.azure.com/<organization>/ " +
366
+ "and api-version is added if missing. E.g. GET <project>/_apis/git/repositories to find a repository's id; " +
367
+ "POST <project>/_apis/git/repositories/<repo>/pullrequests with {sourceRefName: 'refs/heads/<branch>', targetRefName: " +
368
+ "'refs/heads/main', title, description} to open a pull request (push the branch first with git_push); " +
369
+ "GET <project>/_apis/git/repositories/<repo>/pullrequests?searchCriteria.status=active to list them. Read-only GET calls never need approval.",
370
+ inputSchema: obj({
371
+ connection: str("Azure DevOps connection name"),
372
+ method: { type: "string", enum: ["GET", "POST", "PATCH", "PUT", "DELETE"] },
373
+ path: str("Path inside the organization, e.g. MyProject/_apis/git/repositories"),
374
+ // Some endpoints take a list (e.g. Azure DevOps ref updates), others an object.
375
+ body: { anyOf: [{ type: "object" }, { type: "array" }], description: "JSON body for POST/PATCH/PUT (an object, or a list where the API expects one)" },
376
+ reason: reasonField,
377
+ }, ["connection", "method", "path", "reason"]),
378
+ run: ({ connection, method, path, body, reason }, { team, project, agent }) => team.remote.request(project, agent, { kind: "azure_devops_api", connectionId: String(connection), method: String(method), path: String(path), body }, String(reason)),
379
+ };
362
380
  // ---- rules register (only when the project keeps one)
363
381
  function registerOn(project) {
364
382
  if (!project.rulesRegister)
@@ -460,6 +478,7 @@ const COMMON = [
460
478
  gitPush,
461
479
  gitPull,
462
480
  githubApi,
481
+ azureDevopsApi,
463
482
  sendMessage,
464
483
  askHuman,
465
484
  listTeam,
@@ -74,6 +74,23 @@ export const AGENT_UPDATES = [
74
74
  "leave reviewing many images to a helper. If a conversation still becomes too large, Symbio moves that agent to a fresh " +
75
75
  "session with a handoff note instead of trying other models.",
76
76
  },
77
+ {
78
+ id: "2026-11-06-credentials",
79
+ text: "Credentials, clarified: the old rule \"never look for credentials or put secrets in commands\" was meant for Symbio's own " +
80
+ "secrets (its vault, connections, SSH keys), and it stopped agents from doing legitimate work. Now: Symbio's secrets stay " +
81
+ "off-limits (use connections only through the tools). The project's own credentials (connection strings, .env, Web.config…) " +
82
+ "and credentials the human gives you may be used for the task, e.g. querying a database the work needs. Don't spread them: " +
83
+ "never put one into anything committed or shared (new files, docs, the rules register, the plan, messages to other agents), " +
84
+ "and prefer commands that read it from the config file. If the human says a system is a read-only mirror or production, only " +
85
+ "read from it unless they explicitly OK changes. If you refused such a task before, you can do it now.",
86
+ },
87
+ {
88
+ id: "2026-11-06-azure-devops",
89
+ text: "Azure DevOps connections: the human can now add an Azure DevOps organization (with a personal access token) in Settings and " +
90
+ "enable it per project. git_push and git_pull work with it (HTTPS remotes of that organization; SSH remotes must be switched to " +
91
+ "HTTPS), and the new azure_devops_api tool calls its REST API, e.g. to open a pull request after pushing the branch. Reading " +
92
+ "(GET) never needs approval. If a push or a pull request failed for lack of credentials, ask the human to add the connection.",
93
+ },
77
94
  ];
78
95
  /** Entries a project hasn't been told about yet. */
79
96
  export function unseenUpdates(seen) {