@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.
- package/README.md +120 -52
- package/dashboard/out/404.html +1 -1
- package/dashboard/out/_next/static/chunks/app/_not-found/{page-ad40673d821037f6.js → page-5cb94002960ab71a.js} +1 -1
- package/dashboard/out/_next/static/chunks/app/layout-6249f74085ad56b1.js +1 -0
- package/dashboard/out/_next/static/chunks/app/page-0a5ee03ddf4553ab.js +1 -0
- package/dashboard/out/_next/static/chunks/{main-app-1d848b791b823fa6.js → main-app-0398d52862f5c730.js} +1 -1
- package/dashboard/out/_next/static/css/a13af72b10a7d74f.css +1 -0
- package/dashboard/out/index.html +1 -1
- package/dashboard/out/index.txt +4 -4
- package/docs/images/agency_cli_ps.png +0 -0
- package/docs/images/agency_ui_ai_prodivder_settings.png +0 -0
- package/docs/images/agency_ui_aws_settings.png +0 -0
- package/docs/images/agency_ui_identity_settings.png +0 -0
- package/docs/images/agency_ui_import_skills.jpeg +0 -0
- package/docs/images/agency_ui_knowledge.png +0 -0
- package/docs/images/agency_ui_mission_control.png +0 -0
- package/docs/images/agency_ui_skills_marketplace.png +0 -0
- package/docs/images/agent_ui_agent_config.png +0 -0
- package/package.json +1 -1
- package/src/api/db/migrations/004_nullable_human_refs.ts +129 -0
- package/src/api/db/migrations/005_agent_skills.ts +14 -0
- package/src/api/db/migrations/006_runtime_machine.ts +24 -0
- package/src/api/db/seed.ts +62 -46
- package/src/api/index.ts +11 -4
- package/src/api/lib/deploy.ts +412 -0
- package/src/api/lib/env-vars.ts +19 -0
- package/src/api/lib/exec.ts +77 -0
- package/src/api/lib/fleet-sync.ts +49 -32
- package/src/api/lib/fs-store.ts +350 -0
- package/src/api/lib/import-skills.ts +105 -0
- package/src/api/lib/metrics.ts +183 -0
- package/src/api/lib/processes.ts +82 -12
- package/src/api/lib/provision-openclaw.ts +407 -0
- package/src/api/lib/remote-deploy.ts +77 -0
- package/src/api/lib/ssh.ts +97 -0
- package/src/api/lib/sync-skills.ts +171 -0
- package/src/api/lib/tunnels.ts +7 -38
- package/src/api/routes/agents.ts +184 -132
- package/src/api/routes/documents.ts +24 -5
- package/src/api/routes/knowledge.ts +7 -5
- package/src/api/routes/machines.ts +107 -0
- package/src/api/routes/messages.ts +23 -19
- package/src/api/routes/repos.ts +74 -0
- package/src/api/routes/role-configs.ts +29 -46
- package/src/api/routes/skills.ts +198 -40
- package/src/api/routes/tasks.ts +24 -11
- package/src/cli/commands/init.ts +47 -18
- package/src/cli/commands/machines.ts +97 -0
- package/src/cli/commands/ps.ts +6 -4
- package/src/cli/commands/repos.ts +78 -0
- package/src/cli/commands/ssh.ts +14 -36
- package/src/cli/index.ts +5 -1
- package/src/daemon.ts +120 -1
- package/src/templates/solo/agents-config.md +39 -0
- package/src/templates/solo/agents.md +41 -0
- package/src/templates/solo/heartbeat.md +48 -0
- package/src/templates/solo/tools.md +35 -0
- package/dashboard/out/_next/static/chunks/app/layout-056f12675e691d12.js +0 -1
- package/dashboard/out/_next/static/chunks/app/page-80f01fdbb09b43c8.js +0 -1
- package/dashboard/out/_next/static/css/27d1ea794f04e96a.css +0 -1
- /package/dashboard/out/_next/static/{BRrkKiSxqTmex5oLQlOY5 → BIIuuS2pf7AQlPcSE2A6K}/_buildManifest.js +0 -0
- /package/dashboard/out/_next/static/{BRrkKiSxqTmex5oLQlOY5 → BIIuuS2pf7AQlPcSE2A6K}/_ssgManifest.js +0 -0
package/src/api/lib/processes.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
20
|
-
stderr: "inherit",
|
|
21
|
-
});
|
|
90
|
+
);
|
|
22
91
|
|
|
23
92
|
localProcesses.set(name, proc);
|
|
24
93
|
|
|
25
|
-
|
|
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
|
+
}
|