@jx0/agency 0.2.1 → 0.4.1

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.
Files changed (62) hide show
  1. package/README.md +120 -52
  2. package/dashboard/out/404.html +1 -1
  3. package/dashboard/out/_next/static/chunks/app/_not-found/{page-ad40673d821037f6.js → page-5cb94002960ab71a.js} +1 -1
  4. package/dashboard/out/_next/static/chunks/app/layout-6249f74085ad56b1.js +1 -0
  5. package/dashboard/out/_next/static/chunks/app/page-0a5ee03ddf4553ab.js +1 -0
  6. package/dashboard/out/_next/static/chunks/{main-app-1d848b791b823fa6.js → main-app-0398d52862f5c730.js} +1 -1
  7. package/dashboard/out/_next/static/css/a13af72b10a7d74f.css +1 -0
  8. package/dashboard/out/index.html +1 -1
  9. package/dashboard/out/index.txt +4 -4
  10. package/docs/images/agency_cli_ps.png +0 -0
  11. package/docs/images/agency_ui_ai_prodivder_settings.png +0 -0
  12. package/docs/images/agency_ui_aws_settings.png +0 -0
  13. package/docs/images/agency_ui_identity_settings.png +0 -0
  14. package/docs/images/agency_ui_import_skills.jpeg +0 -0
  15. package/docs/images/agency_ui_knowledge.png +0 -0
  16. package/docs/images/agency_ui_mission_control.png +0 -0
  17. package/docs/images/agency_ui_skills_marketplace.png +0 -0
  18. package/docs/images/agent_ui_agent_config.png +0 -0
  19. package/package.json +1 -1
  20. package/src/api/db/migrations/004_nullable_human_refs.ts +129 -0
  21. package/src/api/db/migrations/005_agent_skills.ts +14 -0
  22. package/src/api/db/migrations/006_runtime_machine.ts +24 -0
  23. package/src/api/db/seed.ts +62 -46
  24. package/src/api/index.ts +11 -4
  25. package/src/api/lib/deploy.ts +412 -0
  26. package/src/api/lib/env-vars.ts +19 -0
  27. package/src/api/lib/exec.ts +77 -0
  28. package/src/api/lib/fleet-sync.ts +49 -32
  29. package/src/api/lib/fs-store.ts +350 -0
  30. package/src/api/lib/import-skills.ts +105 -0
  31. package/src/api/lib/metrics.ts +183 -0
  32. package/src/api/lib/processes.ts +82 -12
  33. package/src/api/lib/provision-openclaw.ts +407 -0
  34. package/src/api/lib/remote-deploy.ts +77 -0
  35. package/src/api/lib/ssh.ts +97 -0
  36. package/src/api/lib/sync-skills.ts +171 -0
  37. package/src/api/lib/tunnels.ts +7 -38
  38. package/src/api/routes/agents.ts +184 -132
  39. package/src/api/routes/documents.ts +24 -5
  40. package/src/api/routes/knowledge.ts +7 -5
  41. package/src/api/routes/machines.ts +107 -0
  42. package/src/api/routes/messages.ts +23 -19
  43. package/src/api/routes/repos.ts +74 -0
  44. package/src/api/routes/role-configs.ts +29 -46
  45. package/src/api/routes/skills.ts +198 -40
  46. package/src/api/routes/tasks.ts +24 -11
  47. package/src/cli/commands/init.ts +47 -18
  48. package/src/cli/commands/machines.ts +97 -0
  49. package/src/cli/commands/ps.ts +6 -4
  50. package/src/cli/commands/repos.ts +78 -0
  51. package/src/cli/commands/ssh.ts +14 -36
  52. package/src/cli/index.ts +5 -1
  53. package/src/daemon.ts +120 -1
  54. package/src/templates/solo/agents-config.md +39 -0
  55. package/src/templates/solo/agents.md +41 -0
  56. package/src/templates/solo/heartbeat.md +48 -0
  57. package/src/templates/solo/tools.md +35 -0
  58. package/dashboard/out/_next/static/chunks/app/layout-056f12675e691d12.js +0 -1
  59. package/dashboard/out/_next/static/chunks/app/page-80f01fdbb09b43c8.js +0 -1
  60. package/dashboard/out/_next/static/css/27d1ea794f04e96a.css +0 -1
  61. /package/dashboard/out/_next/static/{BRrkKiSxqTmex5oLQlOY5 → BIIuuS2pf7AQlPcSE2A6K}/_buildManifest.js +0 -0
  62. /package/dashboard/out/_next/static/{BRrkKiSxqTmex5oLQlOY5 → BIIuuS2pf7AQlPcSE2A6K}/_ssgManifest.js +0 -0
@@ -1,32 +1,102 @@
1
1
  import type { Subprocess } from "bun";
2
+ import { db } from "../db/client.js";
3
+ import { provisionAgent, gatewayPort } from "./provision-openclaw.js";
4
+ import { getEnvVars } from "./env-vars.js";
2
5
 
3
6
  const localProcesses = new Map<string, Subprocess>();
4
7
 
5
- export function startLocal(name: string, role: string): Subprocess {
8
+ /**
9
+ * Scan for already-running openclaw gateway processes and reconcile agent
10
+ * statuses in the DB. This handles daemon restarts where the gateway
11
+ * processes survive but the in-memory localProcesses map is lost.
12
+ */
13
+ export async function reconcileRunningProcesses(): Promise<void> {
14
+ try {
15
+ const proc = Bun.spawn(
16
+ ["ps", "axo", "pid,args"],
17
+ { stdout: "pipe", stderr: "pipe" },
18
+ );
19
+ const [exitCode, stdout] = await Promise.all([
20
+ proc.exited,
21
+ new Response(proc.stdout).text(),
22
+ ]);
23
+ if (exitCode !== 0) return;
24
+
25
+ // Find running openclaw gateway processes and extract profile names
26
+ const runningProfiles = new Set<string>();
27
+ for (const line of stdout.split("\n")) {
28
+ // Match lines like: "1226 openclaw --profile sonny gateway run --port 19123"
29
+ // or: "1226 openclaw-gateway" with OPENCLAW_PROFILE env
30
+ const profileMatch = line.match(/openclaw\s+--profile\s+(\S+)\s+gateway/);
31
+ if (profileMatch) {
32
+ runningProfiles.add(profileMatch[1]);
33
+ }
34
+ }
35
+
36
+ // Also check processes that run as openclaw-gateway (binary name) by reading their environ
37
+ for (const line of stdout.split("\n")) {
38
+ const pidMatch = line.match(/^\s*(\d+)\s+.*openclaw-gateway/);
39
+ if (pidMatch) {
40
+ try {
41
+ const environ = await Bun.file(`/proc/${pidMatch[1]}/environ`).text();
42
+ const profileEnv = environ.split("\0").find((e) => e.startsWith("OPENCLAW_PROFILE="));
43
+ if (profileEnv) {
44
+ runningProfiles.add(profileEnv.split("=")[1]);
45
+ }
46
+ } catch {
47
+ // Can't read environ — skip
48
+ }
49
+ }
50
+ }
51
+
52
+ if (runningProfiles.size === 0) return;
53
+
54
+ // Update DB status for agents whose gateway is actually running
55
+ const agents = await db.selectFrom("agents").selectAll().execute();
56
+ for (const agent of agents) {
57
+ if (runningProfiles.has(agent.name) && agent.status !== "active") {
58
+ await db
59
+ .updateTable("agents")
60
+ .where("id", "=", agent.id)
61
+ .set({ status: "active", updated_at: new Date().toISOString() })
62
+ .execute();
63
+ console.log(`[process] reconciled ${agent.name}: marking active (gateway running as pid)`);
64
+ }
65
+ }
66
+ } catch (err) {
67
+ console.error("[process] reconcileRunningProcesses error:", err);
68
+ }
69
+ }
70
+
71
+ export async function startLocal(name: string, role: string): Promise<Subprocess> {
6
72
  const existing = localProcesses.get(name);
7
73
  if (existing) {
8
74
  existing.kill();
9
75
  localProcesses.delete(name);
10
76
  }
11
77
 
12
- const proc = Bun.spawn(["bun", "packages/agent/src/index.ts"], {
13
- cwd: process.cwd(),
14
- env: {
15
- ...process.env,
16
- AGENCY_AGENT_NAME: name,
17
- AGENCY_ROLE: role,
78
+ // Ensure config + auth are written before starting
79
+ await provisionAgent(name, role, "system");
80
+
81
+ const port = gatewayPort(name, "system");
82
+ const proc = Bun.spawn(
83
+ ["openclaw", "--profile", name, "gateway", "run", "--port", String(port)],
84
+ {
85
+ cwd: process.cwd(),
86
+ env: { ...process.env, ...(await getEnvVars()), AGENCY_AGENT_NAME: name },
87
+ stdout: "inherit",
88
+ stderr: "inherit",
18
89
  },
19
- stdout: "inherit",
20
- stderr: "inherit",
21
- });
90
+ );
22
91
 
23
92
  localProcesses.set(name, proc);
24
93
 
25
- // Clean up map entry when process exits
26
- proc.exited.then(() => {
94
+ proc.exited.then((code) => {
27
95
  if (localProcesses.get(name) === proc) {
28
96
  localProcesses.delete(name);
29
97
  }
98
+ console.log(`[process] ${name} gateway exited with code ${code}`);
99
+ db.updateTable("agents").set({ status: "idle" }).where("name", "=", name).execute().catch(console.error);
30
100
  });
31
101
 
32
102
  return proc;
@@ -0,0 +1,407 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { db } from "../db/client.js";
4
+
5
+ const HOME = process.env.HOME ?? "";
6
+ const ROLES_DIR = path.join(process.cwd(), "roles");
7
+
8
+ /**
9
+ * Deterministic gateway port from agent name.
10
+ * System agents each need a unique port; Docker always uses 18789 (isolated).
11
+ */
12
+ export function gatewayPort(name: string, runtime: string): number {
13
+ if (runtime !== "system") return 18789;
14
+ let hash = 0;
15
+ for (const c of name) hash = ((hash << 5) - hash + c.charCodeAt(0)) | 0;
16
+ return 19000 + (Math.abs(hash) % 1000);
17
+ }
18
+
19
+ /**
20
+ * Build the full openclaw.json config object from DB settings + agent-specific values.
21
+ */
22
+ export async function buildOpenClawJson(
23
+ agentName: string,
24
+ role: string,
25
+ runtime: string,
26
+ workspace?: string,
27
+ slackTokens?: { botToken?: string; appToken?: string },
28
+ ): Promise<Record<string, unknown>> {
29
+ const rows = await db
30
+ .selectFrom("settings")
31
+ .where("category", "=", "agent")
32
+ .selectAll()
33
+ .execute();
34
+
35
+ const s: Record<string, string> = {};
36
+ for (const r of rows) s[r.key] = r.value;
37
+
38
+ // Use provided workspace or fall back to role directory
39
+ const workspacePath = workspace ?? path.resolve(process.cwd(), `roles/${role}`);
40
+ const port = gatewayPort(agentName, runtime);
41
+
42
+ // Agent defaults
43
+ const defaults: Record<string, unknown> = {
44
+ workspace: workspacePath,
45
+ };
46
+
47
+ // Model
48
+ const model: Record<string, unknown> = {
49
+ primary: s["agent.model"] || "claude-sonnet-4-20250514",
50
+ };
51
+ if (s["agent.model_fallbacks"]) {
52
+ model.fallbacks = s["agent.model_fallbacks"].split(",").map((m) => m.trim()).filter(Boolean);
53
+ }
54
+ defaults.model = model;
55
+
56
+ defaults.thinkingDefault = s["agent.thinking"] || "low";
57
+ defaults.timeoutSeconds = Number(s["agent.timeout_seconds"] || "600");
58
+ defaults.maxConcurrent = Number(s["agent.max_concurrent"] || "1");
59
+ defaults.compaction = { mode: s["agent.compaction"] || "default" };
60
+ defaults.sandbox = {
61
+ mode: s["agent.sandbox_mode"] || "non-main",
62
+ scope: s["agent.sandbox_scope"] || "agent",
63
+ };
64
+ defaults.heartbeat = { every: s["agent.heartbeat_interval"] || "30m" };
65
+ defaults.verboseDefault = "off";
66
+
67
+ // Tools
68
+ const tools: Record<string, unknown> = {
69
+ profile: s["agent.tools_profile"] || "full",
70
+ web: { search: { enabled: s["agent.web_search"] !== "false" } },
71
+ exec: { timeoutSec: Number(s["agent.exec_timeout_sec"] || "1800") },
72
+ };
73
+ if (s["agent.tools_allow"]) {
74
+ tools.allow = s["agent.tools_allow"].split(",").map((t) => t.trim()).filter(Boolean);
75
+ }
76
+ if (s["agent.tools_deny"]) {
77
+ tools.deny = s["agent.tools_deny"].split(",").map((t) => t.trim()).filter(Boolean);
78
+ }
79
+
80
+ // Copy channels config from main openclaw profile (for Slack, etc.)
81
+ const mainConfigPath = path.join(HOME, ".openclaw", "openclaw.json");
82
+ let channels: Record<string, unknown> | undefined;
83
+ if (fs.existsSync(mainConfigPath)) {
84
+ try {
85
+ const mainConfig = JSON.parse(fs.readFileSync(mainConfigPath, "utf-8"));
86
+ if (mainConfig.channels) {
87
+ channels = mainConfig.channels;
88
+ }
89
+ } catch {
90
+ // ignore parse errors
91
+ }
92
+ }
93
+
94
+ // Override Slack tokens with agent-specific ones if provided
95
+ if (slackTokens?.botToken || slackTokens?.appToken) {
96
+ if (!channels) channels = {};
97
+ const slack = (channels.slack ?? {}) as Record<string, unknown>;
98
+ if (slackTokens.botToken) slack.botToken = slackTokens.botToken;
99
+ if (slackTokens.appToken) slack.appToken = slackTokens.appToken;
100
+ if (!slack.mode) slack.mode = "socket";
101
+ if (!slack.enabled) slack.enabled = true;
102
+ if (slack.dm === undefined) slack.dm = { enabled: true, policy: "allowlist" };
103
+ channels.slack = slack;
104
+ }
105
+
106
+ // Ensure agency CLI is in PATH for non-sandboxed agents
107
+ const envPath = runtime === "docker"
108
+ ? "/root/.bun/bin:/usr/local/bin:/usr/bin:/bin"
109
+ : `${HOME}/.bun/bin:/usr/local/bin:/usr/bin:/bin`;
110
+
111
+ // Skills config - bundled skills always available, custom skills filtered via workspace
112
+ const skillsConfig: Record<string, unknown> = {
113
+ load: { watch: true },
114
+ };
115
+
116
+ const config: Record<string, unknown> = {
117
+ agents: {
118
+ defaults,
119
+ list: [
120
+ { id: "main" },
121
+ { id: agentName, name: agentName, workspace: workspacePath },
122
+ ],
123
+ },
124
+ tools,
125
+ skills: skillsConfig,
126
+ browser: { enabled: s["agent.browser_enabled"] !== "false" },
127
+ logging: {
128
+ level: s["agent.logging_level"] || "info",
129
+ redactSensitive: s["agent.logging_redact"] || "tools",
130
+ },
131
+ gateway: {
132
+ mode: "local",
133
+ port,
134
+ bind: "loopback",
135
+ auth: {
136
+ mode: "token",
137
+ token: crypto.randomUUID(),
138
+ },
139
+ },
140
+ commands: { native: "auto", restart: true },
141
+ env: {
142
+ PATH: envPath,
143
+ AGENCY_AGENT_NAME: agentName,
144
+ AGENCY_API_URL: runtime === "docker"
145
+ ? `http://host.docker.internal:${Number(process.env.PORT ?? 3100)}`
146
+ : `http://localhost:${Number(process.env.PORT ?? 3100)}`,
147
+ },
148
+ ...(channels ? { channels } : {}),
149
+ };
150
+
151
+ return config;
152
+ }
153
+
154
+ /**
155
+ * Build auth-profiles.json from available credential sources.
156
+ *
157
+ * Priority order:
158
+ * 1. DB settings (ai.oauth_access_token or ai.anthropic_api_key)
159
+ * 2. Claude Code credentials (~/.claude/.credentials.json)
160
+ * 3. OpenClaw profile (~/.openclaw/agents/main/agent/auth-profiles.json)
161
+ */
162
+ export async function buildAuthProfiles(): Promise<Record<string, unknown>> {
163
+ const mainAuthPath = path.join(HOME, ".openclaw", "agents", "main", "agent", "auth-profiles.json");
164
+ const claudeCredentialsPath = path.join(HOME, ".claude", ".credentials.json");
165
+
166
+ let authFile: {
167
+ version: number;
168
+ profiles: Record<string, unknown>;
169
+ lastGood: Record<string, string>;
170
+ usageStats: Record<string, unknown>;
171
+ };
172
+
173
+ if (fs.existsSync(mainAuthPath)) {
174
+ authFile = JSON.parse(fs.readFileSync(mainAuthPath, "utf-8"));
175
+ authFile.usageStats = {};
176
+ } else {
177
+ authFile = { version: 1, profiles: {}, lastGood: {}, usageStats: {} };
178
+ }
179
+
180
+ const rows = await db
181
+ .selectFrom("settings")
182
+ .where("category", "=", "ai")
183
+ .selectAll()
184
+ .execute();
185
+
186
+ const s: Record<string, string> = {};
187
+ for (const r of rows) s[r.key] = r.value;
188
+
189
+ const authMethod = s["ai.auth_method"] || "api_key";
190
+
191
+ if (authMethod === "oauth" && s["ai.oauth_access_token"]) {
192
+ // Source 1a: DB OAuth token
193
+ authFile.profiles["anthropic:agency"] = {
194
+ type: "token",
195
+ provider: "anthropic",
196
+ token: s["ai.oauth_access_token"],
197
+ };
198
+ if (!authFile.lastGood["anthropic"]) {
199
+ authFile.lastGood["anthropic"] = "anthropic:agency";
200
+ }
201
+ } else if (s["ai.anthropic_api_key"]) {
202
+ // Source 1b: DB API key
203
+ authFile.profiles["anthropic:agency"] = {
204
+ type: "token",
205
+ provider: "anthropic",
206
+ token: s["ai.anthropic_api_key"],
207
+ };
208
+ if (!authFile.lastGood["anthropic"]) {
209
+ authFile.lastGood["anthropic"] = "anthropic:agency";
210
+ }
211
+ }
212
+
213
+ // Source 2: Claude Code OAuth credentials (~/.claude/.credentials.json)
214
+ if (Object.keys(authFile.profiles).length === 0 && fs.existsSync(claudeCredentialsPath)) {
215
+ try {
216
+ const creds = JSON.parse(fs.readFileSync(claudeCredentialsPath, "utf-8"));
217
+ const oauth = creds.claudeAiOauth;
218
+ if (oauth?.accessToken) {
219
+ authFile.profiles["anthropic:claude-code"] = {
220
+ type: "token",
221
+ provider: "anthropic",
222
+ token: oauth.accessToken,
223
+ };
224
+ authFile.lastGood["anthropic"] = "anthropic:claude-code";
225
+ console.log("[provision] using Claude Code OAuth credentials from ~/.claude/.credentials.json");
226
+ }
227
+ } catch {
228
+ // ignore parse errors
229
+ }
230
+ }
231
+
232
+ if (Object.keys(authFile.profiles).length === 0) {
233
+ throw new Error(
234
+ "No auth credentials available. Either:\n" +
235
+ " - Configure in Settings → AI (API key or OAuth)\n" +
236
+ " - Log in with Claude Code: claude login\n" +
237
+ " - Run: openclaw configure"
238
+ );
239
+ }
240
+
241
+ return authFile;
242
+ }
243
+
244
+ /**
245
+ * Create a per-agent workspace with all role files and skills.
246
+ * Uses symlinks to avoid duplicating role files.
247
+ */
248
+ function buildAgentWorkspace(agentName: string, role: string): string {
249
+ const profileDir = path.join(HOME, `.openclaw-${agentName}`);
250
+ const workspaceDir = path.join(profileDir, "workspace");
251
+ const roleDir = path.join(ROLES_DIR, role);
252
+
253
+ // Clear and recreate workspace
254
+ if (fs.existsSync(workspaceDir)) {
255
+ fs.rmSync(workspaceDir, { recursive: true, force: true });
256
+ }
257
+ fs.mkdirSync(workspaceDir, { recursive: true });
258
+
259
+ // Symlink all role files and directories
260
+ if (fs.existsSync(roleDir)) {
261
+ for (const entry of fs.readdirSync(roleDir)) {
262
+ const src = path.join(roleDir, entry);
263
+ const dest = path.join(workspaceDir, entry);
264
+ try {
265
+ fs.symlinkSync(src, dest);
266
+ } catch {
267
+ // Fall back to copy if symlink fails
268
+ if (fs.statSync(src).isDirectory()) {
269
+ fs.cpSync(src, dest, { recursive: true });
270
+ } else {
271
+ fs.copyFileSync(src, dest);
272
+ }
273
+ }
274
+ }
275
+ }
276
+
277
+ // Write IDENTITY.md so the agent knows its own name
278
+ const identity = `# Identity\n\nYour name is **${agentName}**. You are the "${agentName}" agent with the "${role}" role.\n\nWhen asked who you are, use your name — not "Claude" or any other default.\n`;
279
+ fs.writeFileSync(path.join(workspaceDir, "IDENTITY.md"), identity);
280
+
281
+ return workspaceDir;
282
+ }
283
+
284
+ /**
285
+ * Provision an agent: write openclaw.json + auth-profiles.json to ~/.openclaw-<name>/.
286
+ * Returns the paths written.
287
+ */
288
+ export async function provisionAgent(
289
+ agentName: string,
290
+ role: string,
291
+ runtime: string = "system",
292
+ slackTokens?: { botToken?: string; appToken?: string },
293
+ ): Promise<{ configPath: string; authPath: string }> {
294
+ const profileDir = path.join(HOME, `.openclaw-${agentName}`);
295
+ const configPath = path.join(profileDir, "openclaw.json");
296
+ const authDir = path.join(profileDir, "agents", "main", "agent");
297
+ const authPath = path.join(authDir, "auth-profiles.json");
298
+
299
+ // Build per-agent workspace
300
+ const workspace = buildAgentWorkspace(agentName, role);
301
+
302
+ const [config, auth] = await Promise.all([
303
+ buildOpenClawJson(agentName, role, runtime, workspace, slackTokens),
304
+ buildAuthProfiles(),
305
+ ]);
306
+
307
+ fs.mkdirSync(profileDir, { recursive: true });
308
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
309
+
310
+ fs.mkdirSync(authDir, { recursive: true });
311
+ fs.writeFileSync(authPath, JSON.stringify(auth, null, 2));
312
+
313
+ console.log(`[provision] wrote ${configPath} + ${authPath}`);
314
+ return { configPath, authPath };
315
+ }
316
+
317
+ /**
318
+ * Push the full openclaw profile + role workspace to a remote host via rsync.
319
+ */
320
+ export async function pushToRemote(
321
+ agentName: string,
322
+ role: string,
323
+ host: string,
324
+ machineName?: string,
325
+ ): Promise<void> {
326
+ const { getSSHConfig } = await import("./ssh.js");
327
+ const config = await getSSHConfig(machineName);
328
+
329
+ const profileDir = path.join(HOME, `.openclaw-${agentName}/`);
330
+ const rolesDir = path.join(process.cwd(), `roles/${role}/`);
331
+
332
+ // Rsync openclaw profile dir
333
+ const configProc = Bun.spawn(
334
+ ["rsync", "-az", "-e", config.sshCmd, profileDir, `${config.dest}:~/.openclaw-${agentName}/`],
335
+ { stdout: "inherit", stderr: "inherit" },
336
+ );
337
+ // Rsync role workspace
338
+ const rolesProc = Bun.spawn(
339
+ ["rsync", "-az", "--delete", "-e", config.sshCmd, rolesDir, `${config.dest}:~/agency/roles/${role}/`],
340
+ { stdout: "inherit", stderr: "inherit" },
341
+ );
342
+
343
+ const [configCode, rolesCode] = await Promise.all([configProc.exited, rolesProc.exited]);
344
+ if (configCode !== 0) throw new Error(`rsync config to ${host} failed (exit ${configCode})`);
345
+ if (rolesCode !== 0) throw new Error(`rsync roles to ${host} failed (exit ${rolesCode})`);
346
+
347
+ console.log(`[provision] synced config+roles to ${agentName}@${host}`);
348
+ }
349
+
350
+ /**
351
+ * Push openclaw config + auth into a running Docker container.
352
+ */
353
+ export async function pushToDocker(agentName: string, role: string): Promise<void> {
354
+ const profileDir = path.join(HOME, `.openclaw-${agentName}`);
355
+ const container = `agent-${agentName}`;
356
+ const containerProfileDir = `/root/.openclaw-${agentName}`;
357
+
358
+ // Rewrite workspace paths in config for container context
359
+ const configSrc = path.join(profileDir, "openclaw.json");
360
+ const configRaw = fs.readFileSync(configSrc, "utf-8");
361
+ const containerConfig = configRaw.replaceAll(profileDir, containerProfileDir);
362
+ const tmpConfig = path.join(profileDir, "openclaw.docker.json");
363
+ fs.writeFileSync(tmpConfig, containerConfig);
364
+
365
+ // Create directory structure inside container (clear workspace to remove stale symlinks)
366
+ const mkdirProc = Bun.spawn(
367
+ ["docker", "exec", container, "sh", "-c",
368
+ `rm -rf ${containerProfileDir}/workspace && mkdir -p ${containerProfileDir}/agents/main/agent ${containerProfileDir}/workspace`],
369
+ { stdout: "inherit", stderr: "inherit" },
370
+ );
371
+ await mkdirProc.exited;
372
+
373
+ // Copy rewritten openclaw.json
374
+ const configProc = Bun.spawn(
375
+ ["docker", "cp", tmpConfig, `${container}:${containerProfileDir}/openclaw.json`],
376
+ { stdout: "inherit", stderr: "inherit" },
377
+ );
378
+
379
+ // Copy auth-profiles.json
380
+ const authProc = Bun.spawn(
381
+ ["docker", "cp", path.join(profileDir, "agents/main/agent/auth-profiles.json"),
382
+ `${container}:${containerProfileDir}/agents/main/agent/auth-profiles.json`],
383
+ { stdout: "inherit", stderr: "inherit" },
384
+ );
385
+
386
+ // Copy workspace files (role files) — use tar with --dereference to resolve
387
+ // symlinks, since docker cp preserves them and the host paths don't exist in the container
388
+ const workspaceDir = path.join(profileDir, "workspace");
389
+ const tar = Bun.spawn(
390
+ ["tar", "-ch", "--dereference", "-C", workspaceDir, "."],
391
+ { stdout: "pipe", stderr: "inherit" },
392
+ );
393
+ const untar = Bun.spawn(
394
+ ["docker", "cp", "-", `${container}:${containerProfileDir}/workspace`],
395
+ { stdin: tar.stdout, stdout: "inherit", stderr: "inherit" },
396
+ );
397
+
398
+ const [configCode, authCode, workspaceCode] = await Promise.all([
399
+ configProc.exited, authProc.exited, untar.exited,
400
+ ]);
401
+ if (configCode !== 0) console.error(`[provision] docker cp config to ${container} failed`);
402
+ if (authCode !== 0) console.error(`[provision] docker cp auth to ${container} failed`);
403
+ if (workspaceCode !== 0) console.error(`[provision] docker cp workspace to ${container} failed`);
404
+
405
+ // Clean up temp file
406
+ try { fs.unlinkSync(tmpConfig); } catch {}
407
+ }
@@ -0,0 +1,77 @@
1
+ import { sshExec, getSSHConfig } from "./ssh.js";
2
+ import { startTunnel, stopTunnel } from "./tunnels.js";
3
+ import { readFleet } from "./fleet-sync.js";
4
+ import { provisionAgent, pushToRemote } from "./provision-openclaw.js";
5
+ import { getEnvVars } from "./env-vars.js";
6
+
7
+ function shellEscape(s: string): string {
8
+ return "'" + s.replace(/'/g, "'\\''") + "'";
9
+ }
10
+
11
+ export async function deployRemote(
12
+ agentName: string,
13
+ role: string,
14
+ machineName?: string,
15
+ ): Promise<void> {
16
+ const fleet = readFleet();
17
+ const fleetAgent = fleet.agents[agentName];
18
+ const machine = machineName ?? fleetAgent?.machine;
19
+
20
+ if (!machine) {
21
+ throw new Error(
22
+ "No machine configured for remote agent. Select a machine when creating the agent.",
23
+ );
24
+ }
25
+
26
+ const { host } = await getSSHConfig(machine);
27
+
28
+ // 1. Provision config + auth locally
29
+ await provisionAgent(agentName, role, "remote");
30
+
31
+ // 2. Push everything to remote (config dir + role workspace)
32
+ await pushToRemote(agentName, role, host, machine);
33
+
34
+ // 3. Export env vars and start openclaw gateway on remote
35
+ const envVars = await getEnvVars();
36
+ const exportCmds = [
37
+ `export AGENCY_AGENT_NAME=${shellEscape(agentName)}`,
38
+ ...Object.entries(envVars).map(([k, v]) => `export ${k}=${shellEscape(v)}`),
39
+ ];
40
+ const startCmd = [
41
+ ...exportCmds,
42
+ `nohup openclaw --profile ${agentName} gateway run --port 18789 > /tmp/agent-${agentName}.log 2>&1 &`,
43
+ `echo $!`,
44
+ ].join(" && ");
45
+
46
+ const result = await sshExec(host, startCmd, machine);
47
+ if (result.exitCode !== 0) {
48
+ throw new Error(`Failed to start agent process: ${result.stderr}`);
49
+ }
50
+ console.log(
51
+ `[remote-deploy] started agent ${agentName} on ${host}, pid=${result.stdout.trim()}`,
52
+ );
53
+
54
+ // 4. Open reverse tunnel
55
+ await startTunnel(agentName, host, machine);
56
+ }
57
+
58
+ export async function stopRemote(agentName: string, machineName?: string): Promise<void> {
59
+ const fleet = readFleet();
60
+ const fleetAgent = fleet.agents[agentName];
61
+ const machine = machineName ?? fleetAgent?.machine;
62
+
63
+ if (machine) {
64
+ try {
65
+ const { host } = await getSSHConfig(machine);
66
+ await sshExec(
67
+ host,
68
+ `pkill -f "openclaw --profile ${agentName}" || true`,
69
+ machine,
70
+ );
71
+ } catch {
72
+ // best-effort
73
+ }
74
+ }
75
+
76
+ stopTunnel(agentName);
77
+ }
@@ -0,0 +1,97 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import * as os from "os";
4
+ import { readMachines, type Machine } from "../routes/machines.js";
5
+
6
+ export interface SSHConfig {
7
+ /** Full ssh arg list up to (but not including) user@host */
8
+ args: string[];
9
+ user: string;
10
+ host: string;
11
+ port: number;
12
+ /** For rsync -e: the ssh command string with flags */
13
+ sshCmd: string;
14
+ /** user@host convenience */
15
+ dest: string;
16
+ }
17
+
18
+ export function getSSHConfigFromMachine(machine: Machine): SSHConfig {
19
+ const user = machine.user || "ubuntu";
20
+ const host = machine.host;
21
+ const port = machine.port || 22;
22
+ const dest = `${user}@${host}`;
23
+
24
+ if (machine.auth === "key") {
25
+ if (!machine.ssh_key) {
26
+ throw new Error(`SSH key not configured for machine "${machine.name}". Add ssh_key in Settings → Machines.`);
27
+ }
28
+ const keyDir = path.join(os.tmpdir(), "agency-ssh");
29
+ fs.mkdirSync(keyDir, { recursive: true, mode: 0o700 });
30
+ const keyPath = path.join(keyDir, `key_${machine.name}`);
31
+ fs.writeFileSync(keyPath, machine.ssh_key + "\n", { mode: 0o600 });
32
+
33
+ const args = [
34
+ "ssh",
35
+ "-i", keyPath,
36
+ "-p", String(port),
37
+ "-o", "StrictHostKeyChecking=no",
38
+ ];
39
+ const sshCmd = `ssh -i ${keyPath} -p ${port} -o StrictHostKeyChecking=no`;
40
+ return { args, user, host, port, sshCmd, dest };
41
+ }
42
+
43
+ // Password auth via sshpass
44
+ if (!machine.password) {
45
+ throw new Error(`Password not configured for machine "${machine.name}". Add password in Settings → Machines.`);
46
+ }
47
+
48
+ const args = [
49
+ "sshpass", "-p", machine.password,
50
+ "ssh",
51
+ "-p", String(port),
52
+ "-o", "StrictHostKeyChecking=no",
53
+ ];
54
+ const sshCmd = `sshpass -p ${machine.password} ssh -p ${port} -o StrictHostKeyChecking=no`;
55
+ return { args, user, host, port, sshCmd, dest };
56
+ }
57
+
58
+ export function getMachineByName(machineName?: string): Machine {
59
+ const machines = readMachines();
60
+ if (machines.length === 0) {
61
+ throw new Error("No machines configured. Add one in Settings → Machines.");
62
+ }
63
+
64
+ if (machineName) {
65
+ const machine = machines.find((m) => m.name === machineName);
66
+ if (!machine) throw new Error(`Machine "${machineName}" not found.`);
67
+ return machine;
68
+ }
69
+
70
+ return machines[0];
71
+ }
72
+
73
+ export async function getSSHConfig(machineName?: string): Promise<SSHConfig> {
74
+ const machine = getMachineByName(machineName);
75
+ return getSSHConfigFromMachine(machine);
76
+ }
77
+
78
+ export async function sshExec(
79
+ host: string,
80
+ command: string,
81
+ machineName?: string,
82
+ ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
83
+ const config = await getSSHConfig(machineName);
84
+
85
+ const proc = Bun.spawn(
86
+ [...config.args, "-o", "ConnectTimeout=10", config.dest, command],
87
+ { stdout: "pipe", stderr: "pipe" },
88
+ );
89
+
90
+ const [exitCode, stdout, stderr] = await Promise.all([
91
+ proc.exited,
92
+ new Response(proc.stdout).text(),
93
+ new Response(proc.stderr).text(),
94
+ ]);
95
+
96
+ return { exitCode, stdout, stderr };
97
+ }