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 +12 -0
- package/README.md +4 -4
- package/dist/server/connections.js +64 -7
- package/dist/server/prompts.js +26 -8
- package/dist/server/remote.js +65 -23
- package/dist/server/tools.js +24 -5
- package/dist/server/updates.js +17 -0
- package/dist/web/assets/index-CmYLMQvL.js +53 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/web/assets/index-yZ8d_EGH.js +0 -53
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
|
|
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
|
|
3
|
-
// secrets live in the encrypted vault and are never
|
|
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
|
-
:
|
|
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`)))
|
package/dist/server/prompts.js
CHANGED
|
@@ -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
|
|
345
|
-
\`git_pull
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
}
|
package/dist/server/remote.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Actions agents take through connections (SSH commands and uploads, git push/pull,
|
|
2
|
-
// GitHub
|
|
3
|
-
// only see results. Connections set to "ask" queue an approval for
|
|
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
|
|
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
|
-
:
|
|
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_")
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
270
|
-
|
|
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
|
}
|
package/dist/server/tools.js
CHANGED
|
@@ -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
|
|
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`
|
|
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
|
-
|
|
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,
|
package/dist/server/updates.js
CHANGED
|
@@ -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) {
|