symbio 0.5.0 → 0.7.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 +23 -0
- package/README.md +29 -9
- package/bin/symbio.js +91 -4
- package/bin/update-runner.mjs +92 -0
- package/dist/server/connections.js +64 -7
- package/dist/server/housekeeping.js +14 -5
- package/dist/server/index.js +41 -1
- package/dist/server/prompts.js +26 -8
- package/dist/server/remote-access.js +1 -1
- package/dist/server/remote.js +65 -23
- package/dist/server/self-update.js +254 -0
- package/dist/server/team.js +27 -0
- package/dist/server/tools.js +24 -5
- package/dist/server/updates.js +17 -0
- package/dist/web/assets/{index-CtcmbMmw.css → index-BmRTqm-A.css} +1 -1
- package/dist/web/assets/{index-yZ8d_EGH.js → index-Cila9fjW.js} +27 -27
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
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
|
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// Keeping Symbio up to date: checks npm for a newer version and, when the human allows it,
|
|
2
|
+
// updates while the team is idle. The install itself happens in bin/update-runner.mjs, a separate
|
|
3
|
+
// process that waits for Symbio to exit (OpenCode and native modules lock their files while
|
|
4
|
+
// running on Windows), installs, puts the old version back if that fails, and starts Symbio again.
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { existsSync, promises as fs, realpathSync } from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
11
|
+
/** A global npm install lives in <prefix>/node_modules/symbio (lib/node_modules on Unix); anything else, like `npm link` to a clone, is a development copy. */
|
|
12
|
+
export function installInfo(root = packageRoot) {
|
|
13
|
+
const real = (() => {
|
|
14
|
+
try {
|
|
15
|
+
return realpathSync(root);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return root;
|
|
19
|
+
}
|
|
20
|
+
})();
|
|
21
|
+
const nodeModules = path.dirname(real);
|
|
22
|
+
if (path.basename(nodeModules) !== "node_modules" || existsSync(path.join(real, ".git")))
|
|
23
|
+
return { kind: "dev", root: real, prefix: null };
|
|
24
|
+
const prefix = process.platform === "win32" ? path.dirname(nodeModules) : path.dirname(path.dirname(nodeModules));
|
|
25
|
+
return { kind: "npm", root: real, prefix };
|
|
26
|
+
}
|
|
27
|
+
/** 1 if a > b, -1 if a < b, 0 if equal. Pre-releases ("0.3.0-beta.1") sort before their release. */
|
|
28
|
+
export function compareVersions(a, b) {
|
|
29
|
+
const parse = (v) => {
|
|
30
|
+
const [main, pre] = v.replace(/^v/, "").split("-", 2);
|
|
31
|
+
return { nums: main.split(".").map((n) => Number(n) || 0), pre: pre ?? null };
|
|
32
|
+
};
|
|
33
|
+
const x = parse(a);
|
|
34
|
+
const y = parse(b);
|
|
35
|
+
for (let i = 0; i < 3; i++)
|
|
36
|
+
if ((x.nums[i] ?? 0) !== (y.nums[i] ?? 0))
|
|
37
|
+
return (x.nums[i] ?? 0) > (y.nums[i] ?? 0) ? 1 : -1;
|
|
38
|
+
if (x.pre === y.pre)
|
|
39
|
+
return 0;
|
|
40
|
+
if (x.pre === null)
|
|
41
|
+
return 1;
|
|
42
|
+
if (y.pre === null)
|
|
43
|
+
return -1;
|
|
44
|
+
return x.pre > y.pre ? 1 : -1;
|
|
45
|
+
}
|
|
46
|
+
// Overridable for tests (a local registry and package file); never set in normal use.
|
|
47
|
+
const REGISTRY = (process.env.SYMBIO_UPDATE_REGISTRY ?? "https://registry.npmjs.org").replace(/\/+$/, "");
|
|
48
|
+
/** The version npm installs as `symbio@latest`. */
|
|
49
|
+
export async function latestVersion() {
|
|
50
|
+
const res = await fetch(`${REGISTRY}/symbio/latest`, { signal: AbortSignal.timeout(15_000), headers: { accept: "application/json" } });
|
|
51
|
+
if (!res.ok)
|
|
52
|
+
throw new Error(`npm registry answered ${res.status}`);
|
|
53
|
+
const data = (await res.json());
|
|
54
|
+
if (!data.version)
|
|
55
|
+
throw new Error("npm registry gave no version");
|
|
56
|
+
return data.version;
|
|
57
|
+
}
|
|
58
|
+
/** Changelog entries newer than `current`, from the latest version's CHANGELOG.md (best effort). */
|
|
59
|
+
export async function changesSince(current, latest) {
|
|
60
|
+
const url = process.env.SYMBIO_UPDATE_CHANGELOG ?? `https://raw.githubusercontent.com/linkzy/symbio/v${latest}/CHANGELOG.md`;
|
|
61
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(15_000) }).catch(() => null);
|
|
62
|
+
if (!res?.ok)
|
|
63
|
+
return [];
|
|
64
|
+
return parseChangelog(await res.text()).filter((s) => compareVersions(s.version, current) > 0);
|
|
65
|
+
}
|
|
66
|
+
export function parseChangelog(text) {
|
|
67
|
+
const out = [];
|
|
68
|
+
let section = null;
|
|
69
|
+
for (const line of text.replace(/\r/g, "").split("\n")) {
|
|
70
|
+
const head = /^## \[(\d+\.\d+\.\d+[^\]]*)\]/.exec(line);
|
|
71
|
+
if (head) {
|
|
72
|
+
section = { version: head[1], items: [] };
|
|
73
|
+
out.push(section);
|
|
74
|
+
}
|
|
75
|
+
else if (/^## /.test(line))
|
|
76
|
+
section = null;
|
|
77
|
+
else if (section && /^- /.test(line))
|
|
78
|
+
section.items.push(line.slice(2).trim());
|
|
79
|
+
else if (section && /^\s+\S/.test(line) && section.items.length)
|
|
80
|
+
section.items[section.items.length - 1] += ` ${line.trim()}`;
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
const CHECK_EVERY_MS = 6 * 60 * 60_000;
|
|
85
|
+
const TICK_MS = 5 * 60_000;
|
|
86
|
+
export const QUIET_MS = 10 * 60_000; // the team must have been quiet this long before an automatic update
|
|
87
|
+
const FAILED_PAUSE_MS = 24 * 60 * 60_000; // after a failed update, don't retry the same version for a day
|
|
88
|
+
export class SelfUpdate {
|
|
89
|
+
host;
|
|
90
|
+
latest = null;
|
|
91
|
+
checkedAt = null;
|
|
92
|
+
error = null;
|
|
93
|
+
notes = [];
|
|
94
|
+
mode = "auto";
|
|
95
|
+
skip = null;
|
|
96
|
+
lastResult = null;
|
|
97
|
+
applying = false;
|
|
98
|
+
timer = null;
|
|
99
|
+
settingsFile;
|
|
100
|
+
resultFile;
|
|
101
|
+
install;
|
|
102
|
+
constructor(host, install = installInfo()) {
|
|
103
|
+
this.host = host;
|
|
104
|
+
this.install = install;
|
|
105
|
+
this.settingsFile = path.join(host.home, "updates.json");
|
|
106
|
+
this.resultFile = path.join(host.home, "update-result.json");
|
|
107
|
+
}
|
|
108
|
+
async load() {
|
|
109
|
+
const settings = JSON.parse(await fs.readFile(this.settingsFile, "utf8").catch(() => "{}"));
|
|
110
|
+
if (settings.mode === "auto" || settings.mode === "notify" || settings.mode === "off")
|
|
111
|
+
this.mode = settings.mode;
|
|
112
|
+
this.skip = settings.skip ?? null;
|
|
113
|
+
this.lastResult = JSON.parse(await fs.readFile(this.resultFile, "utf8").catch(() => "null"));
|
|
114
|
+
// The runner wrote this before starting us again: tell the humans once.
|
|
115
|
+
if (this.lastResult && !this.lastResult.seen) {
|
|
116
|
+
const r = this.lastResult;
|
|
117
|
+
if (r.ok)
|
|
118
|
+
await this.host.announce(`Symbio was updated from ${r.from} to ${r.to}.`).catch(() => { });
|
|
119
|
+
else {
|
|
120
|
+
await this.host.announce(`Symbio couldn't update to ${r.to} and is still on ${this.host.version}: ${r.error}`).catch(() => { });
|
|
121
|
+
this.skip = { version: r.to, until: Date.now() + FAILED_PAUSE_MS };
|
|
122
|
+
await this.saveSettings();
|
|
123
|
+
}
|
|
124
|
+
r.seen = true;
|
|
125
|
+
await fs.writeFile(this.resultFile, JSON.stringify(r, null, 2)).catch(() => { });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
start() {
|
|
129
|
+
void this.check().catch(() => { });
|
|
130
|
+
this.timer ??= setInterval(() => void this.tick().catch((err) => console.error("[update]", err.message)), TICK_MS);
|
|
131
|
+
}
|
|
132
|
+
stop() {
|
|
133
|
+
if (this.timer)
|
|
134
|
+
clearInterval(this.timer);
|
|
135
|
+
this.timer = null;
|
|
136
|
+
}
|
|
137
|
+
state() {
|
|
138
|
+
const available = !!this.latest && compareVersions(this.latest, this.host.version) > 0;
|
|
139
|
+
return {
|
|
140
|
+
current: this.host.version,
|
|
141
|
+
latest: this.latest,
|
|
142
|
+
available,
|
|
143
|
+
checkedAt: this.checkedAt,
|
|
144
|
+
error: this.error,
|
|
145
|
+
mode: this.mode,
|
|
146
|
+
install: this.install.kind,
|
|
147
|
+
busy: this.host.busy(),
|
|
148
|
+
waiting: available ? this.blocker(false) : null,
|
|
149
|
+
notes: this.notes,
|
|
150
|
+
lastResult: this.lastResult,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
async setMode(mode) {
|
|
154
|
+
this.mode = mode;
|
|
155
|
+
await this.saveSettings();
|
|
156
|
+
}
|
|
157
|
+
async check() {
|
|
158
|
+
if (this.mode === "off" && this.checkedAt)
|
|
159
|
+
return this.state();
|
|
160
|
+
try {
|
|
161
|
+
this.latest = await latestVersion();
|
|
162
|
+
this.error = null;
|
|
163
|
+
this.notes = compareVersions(this.latest, this.host.version) > 0 ? await changesSince(this.host.version, this.latest) : [];
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
this.error = `Couldn't check for updates: ${err.message}`;
|
|
167
|
+
}
|
|
168
|
+
this.checkedAt = Date.now();
|
|
169
|
+
return this.state();
|
|
170
|
+
}
|
|
171
|
+
/** Why the update can't be applied now (null = it can). `forced`: the human asked, so only hard blockers count. */
|
|
172
|
+
blocker(forced) {
|
|
173
|
+
if (this.install.kind === "dev")
|
|
174
|
+
return "This Symbio runs from a development folder; update it with git pull, npm install and npm run build.";
|
|
175
|
+
if (this.applying)
|
|
176
|
+
return "Already updating.";
|
|
177
|
+
if (!forced && this.mode === "notify")
|
|
178
|
+
return "Updates are set to notify only: update from Settings or with `sym update`.";
|
|
179
|
+
if (!forced && this.mode === "off")
|
|
180
|
+
return "Automatic updates are off: update from Settings or with `sym update`.";
|
|
181
|
+
if (!forced && this.skip && this.latest === this.skip.version && Date.now() < this.skip.until)
|
|
182
|
+
return `The update to ${this.skip.version} failed recently; it will be retried tomorrow.`;
|
|
183
|
+
if (!forced && this.host.remoteActive())
|
|
184
|
+
return "Remote access is on (updating would end it); waiting until it's off.";
|
|
185
|
+
if (!forced) {
|
|
186
|
+
const busy = this.host.busy();
|
|
187
|
+
if (busy)
|
|
188
|
+
return `Waiting for the team to be idle (${busy}).`;
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
/** Automatic updates: check now and then; apply when allowed and the team has been quiet. */
|
|
193
|
+
async tick() {
|
|
194
|
+
if (this.mode === "off" || this.install.kind === "dev")
|
|
195
|
+
return;
|
|
196
|
+
if (!this.checkedAt || Date.now() - this.checkedAt > CHECK_EVERY_MS)
|
|
197
|
+
await this.check();
|
|
198
|
+
const s = this.state();
|
|
199
|
+
if (s.available && this.mode === "auto" && !this.blocker(false))
|
|
200
|
+
await this.apply(false);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Hands over to the runner and stops Symbio. `forced` (the human asked): applies even with
|
|
204
|
+
* agents working (they're interrupted and resumed after the restart).
|
|
205
|
+
*/
|
|
206
|
+
async apply(forced) {
|
|
207
|
+
if (!this.latest || compareVersions(this.latest, this.host.version) <= 0)
|
|
208
|
+
await this.check();
|
|
209
|
+
if (!this.latest || compareVersions(this.latest, this.host.version) <= 0)
|
|
210
|
+
throw new Error(`Symbio ${this.host.version} is up to date.`);
|
|
211
|
+
const blocked = this.blocker(forced);
|
|
212
|
+
if (blocked)
|
|
213
|
+
throw new Error(blocked);
|
|
214
|
+
this.applying = true;
|
|
215
|
+
const to = this.latest;
|
|
216
|
+
try {
|
|
217
|
+
await this.host.backup().catch((err) => console.error("[update] backup failed:", err.message));
|
|
218
|
+
await this.host.announce(`Symbio is updating from ${this.host.version} to ${to}; it'll be back in a minute or two.`).catch(() => { });
|
|
219
|
+
await this.runRunner({ waitPid: process.pid, to, start: true });
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
this.applying = false;
|
|
223
|
+
throw err;
|
|
224
|
+
}
|
|
225
|
+
setTimeout(() => void this.host.shutdown(), 300);
|
|
226
|
+
return `Updating to ${to}: Symbio restarts in a minute or two.`;
|
|
227
|
+
}
|
|
228
|
+
/** Starts the runner from a copy in the temp folder (npm replaces the package folder). */
|
|
229
|
+
async runRunner(opts) {
|
|
230
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "symbio-update-"));
|
|
231
|
+
const runner = path.join(dir, "update-runner.mjs");
|
|
232
|
+
await fs.copyFile(path.join(this.install.root, "bin", "update-runner.mjs"), runner);
|
|
233
|
+
const job = path.join(dir, "job.json");
|
|
234
|
+
await fs.writeFile(job, JSON.stringify({
|
|
235
|
+
waitPid: opts.waitPid,
|
|
236
|
+
prefix: this.install.prefix,
|
|
237
|
+
spec: process.env.SYMBIO_UPDATE_SPEC ?? `symbio@${opts.to}`,
|
|
238
|
+
from: this.host.version,
|
|
239
|
+
to: opts.to,
|
|
240
|
+
root: this.install.root,
|
|
241
|
+
home: this.host.home,
|
|
242
|
+
port: this.host.port,
|
|
243
|
+
start: opts.start,
|
|
244
|
+
resultFile: this.resultFile,
|
|
245
|
+
logFile: path.join(this.host.home, "logs", "update.log"),
|
|
246
|
+
}));
|
|
247
|
+
const child = spawn(process.execPath, [runner, job], { detached: true, stdio: "ignore", windowsHide: true });
|
|
248
|
+
child.unref();
|
|
249
|
+
}
|
|
250
|
+
async saveSettings() {
|
|
251
|
+
await fs.mkdir(path.dirname(this.settingsFile), { recursive: true });
|
|
252
|
+
await fs.writeFile(this.settingsFile, JSON.stringify({ mode: this.mode, skip: this.skip }, null, 2));
|
|
253
|
+
}
|
|
254
|
+
}
|
package/dist/server/team.js
CHANGED
|
@@ -229,6 +229,33 @@ export class Team {
|
|
|
229
229
|
this.recycling = false;
|
|
230
230
|
}
|
|
231
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* What would be interrupted by a restart right now (null = nothing): an agent working or waiting
|
|
234
|
+
* for an answer, a permission request, or activity in the last `quietMs`.
|
|
235
|
+
*/
|
|
236
|
+
busyReason(quietMs) {
|
|
237
|
+
const now = Date.now();
|
|
238
|
+
for (const project of this.store.list()) {
|
|
239
|
+
for (const agent of project.agents.filter(active)) {
|
|
240
|
+
if (agent.status === "busy" || agent.status === "stuck")
|
|
241
|
+
return `${agent.name} in ${project.name} is working`;
|
|
242
|
+
if (agent.status === "waiting")
|
|
243
|
+
return `${agent.name} in ${project.name} is waiting for an answer`;
|
|
244
|
+
if (now - agent.lastActivity < quietMs)
|
|
245
|
+
return `${agent.name} in ${project.name} was active in the last ${Math.round(quietMs / 60_000)} minutes`;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (this.permissions.size)
|
|
249
|
+
return "a permission request is waiting for an answer";
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
/** A note for the human in every project's Messages (e.g. "Symbio was updated"). */
|
|
253
|
+
async announce(text) {
|
|
254
|
+
for (const project of this.store.list()) {
|
|
255
|
+
await this.log(project, { from: "symbio", to: "human", kind: "system", text });
|
|
256
|
+
bus.publish({ type: "project.updated", projectId: project.id });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
232
259
|
stopWatchdog() {
|
|
233
260
|
if (this.watchdog)
|
|
234
261
|
clearInterval(this.watchdog);
|
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) {
|