@13rac1/openclaw-plugin-claude-code 1.0.3
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/LICENSE +190 -0
- package/README.md +341 -0
- package/dist/claude-code.d.ts +50 -0
- package/dist/claude-code.d.ts.map +1 -0
- package/dist/claude-code.js +3878 -0
- package/dist/claude-code.js.map +1 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +481 -0
- package/dist/index.js.map +1 -0
- package/dist/podman-runner.d.ts +108 -0
- package/dist/podman-runner.d.ts.map +1 -0
- package/dist/podman-runner.js +537 -0
- package/dist/podman-runner.js.map +1 -0
- package/dist/session-manager.d.ts +96 -0
- package/dist/session-manager.d.ts.map +1 -0
- package/dist/session-manager.js +297 -0
- package/dist/session-manager.js.map +1 -0
- package/dist/test-harness.d.ts +10 -0
- package/dist/test-harness.d.ts.map +1 -0
- package/dist/test-harness.js +134 -0
- package/dist/test-harness.js.map +1 -0
- package/openclaw.plugin.json +88 -0
- package/package.json +77 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
export interface PodmanConfig {
|
|
2
|
+
runtime: string;
|
|
3
|
+
image: string;
|
|
4
|
+
startupTimeout: number;
|
|
5
|
+
idleTimeout: number;
|
|
6
|
+
memory: string;
|
|
7
|
+
cpus: string;
|
|
8
|
+
network: string;
|
|
9
|
+
apparmorProfile?: string;
|
|
10
|
+
maxOutputSize: number;
|
|
11
|
+
}
|
|
12
|
+
export type ErrorType = "startup_timeout" | "idle_timeout" | "oom" | "crash" | "spawn_failed";
|
|
13
|
+
export interface ResourceMetrics {
|
|
14
|
+
memoryUsageMB?: number;
|
|
15
|
+
memoryLimitMB?: number;
|
|
16
|
+
memoryPercent?: number;
|
|
17
|
+
cpuPercent?: number;
|
|
18
|
+
}
|
|
19
|
+
export interface ClaudeCodeResult {
|
|
20
|
+
content: string;
|
|
21
|
+
sessionId: string | null;
|
|
22
|
+
exitCode: number;
|
|
23
|
+
elapsedSeconds?: number;
|
|
24
|
+
errorType?: ErrorType;
|
|
25
|
+
outputTruncated?: boolean;
|
|
26
|
+
originalSize?: number;
|
|
27
|
+
metrics?: ResourceMetrics;
|
|
28
|
+
}
|
|
29
|
+
/** Container status returned by getContainerStatus */
|
|
30
|
+
export interface ContainerStatus {
|
|
31
|
+
running: boolean;
|
|
32
|
+
exitCode: number | null;
|
|
33
|
+
startedAt: string | null;
|
|
34
|
+
finishedAt: string | null;
|
|
35
|
+
}
|
|
36
|
+
/** Container info returned by listContainersByPrefix */
|
|
37
|
+
export interface ContainerInfo {
|
|
38
|
+
name: string;
|
|
39
|
+
running: boolean;
|
|
40
|
+
createdAt: string;
|
|
41
|
+
}
|
|
42
|
+
/** Result of starting a detached container */
|
|
43
|
+
export interface DetachedStartResult {
|
|
44
|
+
containerName: string;
|
|
45
|
+
containerId: string;
|
|
46
|
+
}
|
|
47
|
+
export declare class PodmanRunner {
|
|
48
|
+
private config;
|
|
49
|
+
constructor(config: PodmanConfig);
|
|
50
|
+
runClaudeCode(params: {
|
|
51
|
+
sessionKey: string;
|
|
52
|
+
prompt: string;
|
|
53
|
+
claudeDir: string;
|
|
54
|
+
workspaceDir: string;
|
|
55
|
+
resumeSessionId?: string;
|
|
56
|
+
apiKey?: string;
|
|
57
|
+
}): Promise<ClaudeCodeResult>;
|
|
58
|
+
private buildArgs;
|
|
59
|
+
private execute;
|
|
60
|
+
private parseOutput;
|
|
61
|
+
checkImage(): Promise<boolean>;
|
|
62
|
+
killContainer(sessionKey: string): Promise<void>;
|
|
63
|
+
verifyContainerRunning(containerName: string, retries?: number): Promise<boolean>;
|
|
64
|
+
/**
|
|
65
|
+
* Get resource metrics for a running container.
|
|
66
|
+
* Returns undefined if container is not running or stats unavailable.
|
|
67
|
+
*/
|
|
68
|
+
getContainerStats(containerName: string): Promise<ResourceMetrics | undefined>;
|
|
69
|
+
/**
|
|
70
|
+
* Parse memory string like "123.4MiB" or "1.2GiB" to MB
|
|
71
|
+
*/
|
|
72
|
+
parseMemoryString(memStr: string | undefined): number | undefined;
|
|
73
|
+
/**
|
|
74
|
+
* Start a container in detached mode. Returns immediately with container ID.
|
|
75
|
+
*/
|
|
76
|
+
startDetached(params: {
|
|
77
|
+
sessionKey: string;
|
|
78
|
+
prompt: string;
|
|
79
|
+
claudeDir: string;
|
|
80
|
+
workspaceDir: string;
|
|
81
|
+
resumeSessionId?: string;
|
|
82
|
+
apiKey?: string;
|
|
83
|
+
}): Promise<DetachedStartResult>;
|
|
84
|
+
/**
|
|
85
|
+
* Get the status of a container by name.
|
|
86
|
+
*/
|
|
87
|
+
getContainerStatus(containerName: string): Promise<ContainerStatus | null>;
|
|
88
|
+
/**
|
|
89
|
+
* Get logs from a container.
|
|
90
|
+
*/
|
|
91
|
+
getContainerLogs(containerName: string, opts?: {
|
|
92
|
+
since?: string;
|
|
93
|
+
tail?: number;
|
|
94
|
+
}): Promise<string | null>;
|
|
95
|
+
/**
|
|
96
|
+
* List all containers matching a name prefix.
|
|
97
|
+
*/
|
|
98
|
+
listContainersByPrefix(prefix: string): Promise<ContainerInfo[]>;
|
|
99
|
+
/**
|
|
100
|
+
* Generate container name from session key.
|
|
101
|
+
*/
|
|
102
|
+
containerNameFromSessionKey(sessionKey: string): string;
|
|
103
|
+
/**
|
|
104
|
+
* Extract session key from container name.
|
|
105
|
+
*/
|
|
106
|
+
sessionKeyFromContainerName(containerName: string): string | null;
|
|
107
|
+
}
|
|
108
|
+
//# sourceMappingURL=podman-runner.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"podman-runner.d.ts","sourceRoot":"","sources":["../src/podman-runner.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
function isClaudeCodeOutput(value) {
|
|
3
|
+
return typeof value === "object" && value !== null;
|
|
4
|
+
}
|
|
5
|
+
function isPodmanStatsOutput(value) {
|
|
6
|
+
return typeof value === "object" && value !== null;
|
|
7
|
+
}
|
|
8
|
+
function isPodmanInspectOutput(value) {
|
|
9
|
+
return typeof value === "object" && value !== null;
|
|
10
|
+
}
|
|
11
|
+
function isPodmanPsOutput(value) {
|
|
12
|
+
return typeof value === "object" && value !== null;
|
|
13
|
+
}
|
|
14
|
+
export class PodmanRunner {
|
|
15
|
+
config;
|
|
16
|
+
constructor(config) {
|
|
17
|
+
this.config = config;
|
|
18
|
+
}
|
|
19
|
+
async runClaudeCode(params) {
|
|
20
|
+
const containerName = `claude-${params.sessionKey.replace(/[^a-zA-Z0-9-]/g, "-")}`;
|
|
21
|
+
// Clean up stale container from previous crash
|
|
22
|
+
await this.killContainer(params.sessionKey);
|
|
23
|
+
const args = this.buildArgs(params, containerName);
|
|
24
|
+
return this.execute(args, containerName);
|
|
25
|
+
}
|
|
26
|
+
buildArgs(params, containerName) {
|
|
27
|
+
// Build the claude command to run inside bash
|
|
28
|
+
// Claude doesn't output when run as PID 1, must run through bash
|
|
29
|
+
const resumeFlag = params.resumeSessionId ? `--resume '${params.resumeSessionId}'` : "";
|
|
30
|
+
const escapedPrompt = params.prompt.replace(/'/g, "'\\''");
|
|
31
|
+
const claudeCmd = `claude --print --dangerously-skip-permissions ${resumeFlag} -p '${escapedPrompt}' < /dev/null 2>&1`;
|
|
32
|
+
const args = [
|
|
33
|
+
"run",
|
|
34
|
+
"--rm",
|
|
35
|
+
"--name",
|
|
36
|
+
containerName,
|
|
37
|
+
"--network",
|
|
38
|
+
this.config.network,
|
|
39
|
+
"--cap-drop",
|
|
40
|
+
"ALL",
|
|
41
|
+
];
|
|
42
|
+
// Add AppArmor profile if configured
|
|
43
|
+
if (this.config.apparmorProfile) {
|
|
44
|
+
args.push("--security-opt", `apparmor=${this.config.apparmorProfile}`);
|
|
45
|
+
}
|
|
46
|
+
// Note: no --read-only, claude needs to write temp files
|
|
47
|
+
args.push("--memory", this.config.memory, "--cpus", this.config.cpus, "--pids-limit", "100", "--tmpfs", "/tmp:rw,noexec,nosuid,size=64m",
|
|
48
|
+
// :U flag handles UID mapping for permissions
|
|
49
|
+
"-v", `${params.claudeDir}:/home/claude/.claude:U`, "-v", `${params.workspaceDir}:/workspace:U`);
|
|
50
|
+
// Only add API key if provided (otherwise uses credentials file)
|
|
51
|
+
if (params.apiKey) {
|
|
52
|
+
args.push("-e", `ANTHROPIC_API_KEY=${params.apiKey}`);
|
|
53
|
+
}
|
|
54
|
+
args.push("-w", "/workspace", "--entrypoint", "/bin/bash", this.config.image, "-c", claudeCmd);
|
|
55
|
+
return args;
|
|
56
|
+
}
|
|
57
|
+
execute(args, containerName) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const startTime = Date.now();
|
|
60
|
+
const proc = spawn(this.config.runtime, args, {
|
|
61
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
62
|
+
});
|
|
63
|
+
let stdout = "";
|
|
64
|
+
let stderr = "";
|
|
65
|
+
let killed = false;
|
|
66
|
+
let killReason = null;
|
|
67
|
+
let lastActivity = Date.now();
|
|
68
|
+
let hadOutput = false;
|
|
69
|
+
let totalOutputSize = 0;
|
|
70
|
+
let outputTruncated = false;
|
|
71
|
+
const maxSize = this.config.maxOutputSize;
|
|
72
|
+
let lastMetrics;
|
|
73
|
+
const cleanup = () => {
|
|
74
|
+
clearTimeout(startupTimeoutId);
|
|
75
|
+
clearInterval(idleCheckInterval);
|
|
76
|
+
clearInterval(metricsInterval);
|
|
77
|
+
};
|
|
78
|
+
const killProcess = (reason) => {
|
|
79
|
+
if (killed)
|
|
80
|
+
return;
|
|
81
|
+
killed = true;
|
|
82
|
+
killReason = reason;
|
|
83
|
+
cleanup();
|
|
84
|
+
proc.kill("SIGTERM");
|
|
85
|
+
// Give 5s for graceful shutdown, then SIGKILL
|
|
86
|
+
setTimeout(() => {
|
|
87
|
+
if (!proc.killed) {
|
|
88
|
+
proc.kill("SIGKILL");
|
|
89
|
+
}
|
|
90
|
+
}, 5000);
|
|
91
|
+
};
|
|
92
|
+
// Startup timeout - must produce first output within N seconds
|
|
93
|
+
const startupTimeoutId = setTimeout(() => {
|
|
94
|
+
if (!hadOutput) {
|
|
95
|
+
killProcess("startup_timeout");
|
|
96
|
+
}
|
|
97
|
+
}, this.config.startupTimeout * 1000);
|
|
98
|
+
// Idle timeout check - no output for N seconds = hung
|
|
99
|
+
const idleCheckInterval = setInterval(() => {
|
|
100
|
+
if (!hadOutput)
|
|
101
|
+
return; // Still waiting for startup, don't check idle
|
|
102
|
+
const idleSeconds = (Date.now() - lastActivity) / 1000;
|
|
103
|
+
if (idleSeconds > this.config.idleTimeout) {
|
|
104
|
+
killProcess("idle_timeout");
|
|
105
|
+
}
|
|
106
|
+
}, 5000);
|
|
107
|
+
// Periodically sample container metrics (every 10s)
|
|
108
|
+
const metricsInterval = setInterval(() => {
|
|
109
|
+
if (!hadOutput)
|
|
110
|
+
return; // Container not started yet
|
|
111
|
+
void this.getContainerStats(containerName).then((metrics) => {
|
|
112
|
+
if (metrics) {
|
|
113
|
+
lastMetrics = metrics;
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}, 10000);
|
|
117
|
+
const onOutput = () => {
|
|
118
|
+
if (!hadOutput) {
|
|
119
|
+
hadOutput = true;
|
|
120
|
+
clearTimeout(startupTimeoutId);
|
|
121
|
+
}
|
|
122
|
+
lastActivity = Date.now();
|
|
123
|
+
};
|
|
124
|
+
proc.stdout.on("data", (data) => {
|
|
125
|
+
onOutput();
|
|
126
|
+
const dataStr = data.toString();
|
|
127
|
+
if (maxSize > 0 && totalOutputSize + dataStr.length > maxSize) {
|
|
128
|
+
if (!outputTruncated) {
|
|
129
|
+
const remaining = maxSize - totalOutputSize;
|
|
130
|
+
stdout += dataStr.slice(0, remaining);
|
|
131
|
+
outputTruncated = true;
|
|
132
|
+
}
|
|
133
|
+
totalOutputSize += dataStr.length;
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
stdout += dataStr;
|
|
137
|
+
totalOutputSize += dataStr.length;
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
proc.stderr.on("data", (data) => {
|
|
141
|
+
onOutput();
|
|
142
|
+
const dataStr = data.toString();
|
|
143
|
+
if (maxSize > 0 && totalOutputSize + dataStr.length > maxSize) {
|
|
144
|
+
if (!outputTruncated) {
|
|
145
|
+
const remaining = maxSize - totalOutputSize;
|
|
146
|
+
stderr += dataStr.slice(0, remaining);
|
|
147
|
+
outputTruncated = true;
|
|
148
|
+
}
|
|
149
|
+
totalOutputSize += dataStr.length;
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
stderr += dataStr;
|
|
153
|
+
totalOutputSize += dataStr.length;
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
proc.on("error", (err) => {
|
|
157
|
+
cleanup();
|
|
158
|
+
const elapsed = (Date.now() - startTime) / 1000;
|
|
159
|
+
reject(Object.assign(new Error(`Failed to spawn ${this.config.runtime}: ${err.message}`), {
|
|
160
|
+
errorType: "spawn_failed",
|
|
161
|
+
elapsedSeconds: elapsed,
|
|
162
|
+
}));
|
|
163
|
+
});
|
|
164
|
+
proc.on("close", (code, signal) => {
|
|
165
|
+
cleanup();
|
|
166
|
+
const elapsed = (Date.now() - startTime) / 1000;
|
|
167
|
+
if (killed && killReason) {
|
|
168
|
+
const message = killReason === "startup_timeout"
|
|
169
|
+
? `Container startup timeout (no output for ${String(this.config.startupTimeout)}s)`
|
|
170
|
+
: `Container idle timeout (no output for ${String(this.config.idleTimeout)}s) after ${elapsed.toFixed(1)}s total`;
|
|
171
|
+
reject(Object.assign(new Error(message), {
|
|
172
|
+
errorType: killReason,
|
|
173
|
+
elapsedSeconds: elapsed,
|
|
174
|
+
}));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (signal === "SIGKILL" || code === 137) {
|
|
178
|
+
reject(Object.assign(new Error(`Container killed (OOM or resource limit) after ${elapsed.toFixed(1)}s: ${stderr || stdout}`), { errorType: "oom", elapsedSeconds: elapsed }));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (code !== 0) {
|
|
182
|
+
reject(Object.assign(new Error(`Container failed (exit ${String(code)}) after ${elapsed.toFixed(1)}s: ${stderr || stdout}`), { errorType: "crash", elapsedSeconds: elapsed }));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const result = this.parseOutput(stdout, code);
|
|
186
|
+
result.elapsedSeconds = elapsed;
|
|
187
|
+
if (outputTruncated) {
|
|
188
|
+
result.outputTruncated = true;
|
|
189
|
+
result.originalSize = totalOutputSize;
|
|
190
|
+
}
|
|
191
|
+
if (lastMetrics) {
|
|
192
|
+
result.metrics = lastMetrics;
|
|
193
|
+
}
|
|
194
|
+
resolve(result);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
parseOutput(output, exitCode) {
|
|
199
|
+
// Claude Code --print outputs JSON when available
|
|
200
|
+
// Try to parse as JSON first
|
|
201
|
+
try {
|
|
202
|
+
const lines = output.trim().split("\n");
|
|
203
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
204
|
+
const line = lines[i].trim();
|
|
205
|
+
if (line.startsWith("{") && line.endsWith("}")) {
|
|
206
|
+
const parsed = JSON.parse(line);
|
|
207
|
+
if (isClaudeCodeOutput(parsed)) {
|
|
208
|
+
return {
|
|
209
|
+
content: parsed.result ?? parsed.content ?? parsed.message ?? output,
|
|
210
|
+
sessionId: parsed.session_id ?? parsed.sessionId ?? null,
|
|
211
|
+
exitCode,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
// Not JSON, use raw output
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
content: output.trim(),
|
|
222
|
+
sessionId: null,
|
|
223
|
+
exitCode,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
async checkImage() {
|
|
227
|
+
return new Promise((resolve) => {
|
|
228
|
+
const proc = spawn(this.config.runtime, ["image", "exists", this.config.image], {
|
|
229
|
+
stdio: "ignore",
|
|
230
|
+
});
|
|
231
|
+
proc.on("error", () => resolve(false));
|
|
232
|
+
proc.on("close", (code) => resolve(code === 0));
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
async killContainer(sessionKey) {
|
|
236
|
+
const containerName = `claude-${sessionKey.replace(/[^a-zA-Z0-9-]/g, "-")}`;
|
|
237
|
+
// First try to kill, then remove (in case it's stopped but not removed)
|
|
238
|
+
await new Promise((resolve) => {
|
|
239
|
+
const proc = spawn(this.config.runtime, ["kill", containerName], {
|
|
240
|
+
stdio: "ignore",
|
|
241
|
+
});
|
|
242
|
+
proc.on("error", () => resolve());
|
|
243
|
+
proc.on("close", () => resolve());
|
|
244
|
+
});
|
|
245
|
+
await new Promise((resolve) => {
|
|
246
|
+
const proc = spawn(this.config.runtime, ["rm", "-f", containerName], {
|
|
247
|
+
stdio: "ignore",
|
|
248
|
+
});
|
|
249
|
+
proc.on("error", () => resolve());
|
|
250
|
+
proc.on("close", () => resolve());
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
async verifyContainerRunning(containerName, retries = 3) {
|
|
254
|
+
for (let i = 0; i < retries; i++) {
|
|
255
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
256
|
+
const exists = await new Promise((resolve) => {
|
|
257
|
+
const proc = spawn(this.config.runtime, ["container", "exists", containerName], {
|
|
258
|
+
stdio: "ignore",
|
|
259
|
+
});
|
|
260
|
+
proc.on("close", (code) => resolve(code === 0));
|
|
261
|
+
proc.on("error", () => resolve(false));
|
|
262
|
+
});
|
|
263
|
+
if (exists)
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Get resource metrics for a running container.
|
|
270
|
+
* Returns undefined if container is not running or stats unavailable.
|
|
271
|
+
*/
|
|
272
|
+
getContainerStats(containerName) {
|
|
273
|
+
return new Promise((resolve) => {
|
|
274
|
+
const proc = spawn(this.config.runtime, ["stats", "--no-stream", "--format", "json", containerName], { stdio: ["ignore", "pipe", "ignore"] });
|
|
275
|
+
let stdout = "";
|
|
276
|
+
let timeoutId = null;
|
|
277
|
+
proc.stdout.on("data", (data) => {
|
|
278
|
+
stdout += data.toString();
|
|
279
|
+
});
|
|
280
|
+
proc.on("error", () => resolve(undefined));
|
|
281
|
+
proc.on("close", (code) => {
|
|
282
|
+
if (timeoutId)
|
|
283
|
+
clearTimeout(timeoutId);
|
|
284
|
+
if (code !== 0) {
|
|
285
|
+
resolve(undefined);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
const stats = JSON.parse(stdout);
|
|
290
|
+
// Podman stats JSON format - may be array or single object
|
|
291
|
+
const statArray = Array.isArray(stats) ? stats : [stats];
|
|
292
|
+
const stat = statArray[0];
|
|
293
|
+
if (!isPodmanStatsOutput(stat)) {
|
|
294
|
+
resolve(undefined);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const memUsage = this.parseMemoryString(stat.MemUsage ?? stat.mem_usage);
|
|
298
|
+
const memLimit = this.parseMemoryString(stat.MemLimit ?? stat.mem_limit);
|
|
299
|
+
resolve({
|
|
300
|
+
memoryUsageMB: memUsage,
|
|
301
|
+
memoryLimitMB: memLimit,
|
|
302
|
+
memoryPercent: stat.MemPerc
|
|
303
|
+
? parseFloat(String(stat.MemPerc).replace("%", ""))
|
|
304
|
+
: undefined,
|
|
305
|
+
cpuPercent: stat.CPUPerc
|
|
306
|
+
? parseFloat(String(stat.CPUPerc).replace("%", ""))
|
|
307
|
+
: undefined,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
resolve(undefined);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
// Timeout after 5s
|
|
315
|
+
timeoutId = setTimeout(() => {
|
|
316
|
+
proc.kill();
|
|
317
|
+
resolve(undefined);
|
|
318
|
+
}, 5000);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Parse memory string like "123.4MiB" or "1.2GiB" to MB
|
|
323
|
+
*/
|
|
324
|
+
parseMemoryString(memStr) {
|
|
325
|
+
if (!memStr)
|
|
326
|
+
return undefined;
|
|
327
|
+
// Handle "used / limit" format (e.g., "256MiB / 512MiB")
|
|
328
|
+
const parts = memStr.split("/");
|
|
329
|
+
const valueStr = parts[0].trim();
|
|
330
|
+
const match = /^([\d.]+)\s*(B|KB|KiB|MB|MiB|GB|GiB)/i.exec(valueStr);
|
|
331
|
+
if (!match)
|
|
332
|
+
return undefined;
|
|
333
|
+
const value = parseFloat(match[1]);
|
|
334
|
+
const unit = match[2].toLowerCase();
|
|
335
|
+
switch (unit) {
|
|
336
|
+
case "b":
|
|
337
|
+
return value / (1024 * 1024);
|
|
338
|
+
case "kb":
|
|
339
|
+
case "kib":
|
|
340
|
+
return value / 1024;
|
|
341
|
+
case "mb":
|
|
342
|
+
case "mib":
|
|
343
|
+
return value;
|
|
344
|
+
case "gb":
|
|
345
|
+
case "gib":
|
|
346
|
+
return value * 1024;
|
|
347
|
+
default:
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Start a container in detached mode. Returns immediately with container ID.
|
|
353
|
+
*/
|
|
354
|
+
async startDetached(params) {
|
|
355
|
+
const containerName = `claude-${params.sessionKey.replace(/[^a-zA-Z0-9-]/g, "-")}`;
|
|
356
|
+
// Clean up stale container from previous run
|
|
357
|
+
await this.killContainer(params.sessionKey);
|
|
358
|
+
// Build the claude command
|
|
359
|
+
const resumeFlag = params.resumeSessionId ? `--resume '${params.resumeSessionId}'` : "";
|
|
360
|
+
const escapedPrompt = params.prompt.replace(/'/g, "'\\''");
|
|
361
|
+
const claudeCmd = `claude --print --dangerously-skip-permissions ${resumeFlag} -p '${escapedPrompt}' < /dev/null 2>&1`;
|
|
362
|
+
const args = [
|
|
363
|
+
"run",
|
|
364
|
+
"--detach",
|
|
365
|
+
"--name",
|
|
366
|
+
containerName,
|
|
367
|
+
"--network",
|
|
368
|
+
this.config.network,
|
|
369
|
+
"--cap-drop",
|
|
370
|
+
"ALL",
|
|
371
|
+
];
|
|
372
|
+
if (this.config.apparmorProfile) {
|
|
373
|
+
args.push("--security-opt", `apparmor=${this.config.apparmorProfile}`);
|
|
374
|
+
}
|
|
375
|
+
args.push("--memory", this.config.memory, "--cpus", this.config.cpus, "--pids-limit", "100", "--tmpfs", "/tmp:rw,noexec,nosuid,size=64m", "-v", `${params.claudeDir}:/home/claude/.claude:U`, "-v", `${params.workspaceDir}:/workspace:U`);
|
|
376
|
+
if (params.apiKey) {
|
|
377
|
+
args.push("-e", `ANTHROPIC_API_KEY=${params.apiKey}`);
|
|
378
|
+
}
|
|
379
|
+
args.push("-w", "/workspace", "--entrypoint", "/bin/bash", this.config.image, "-c", claudeCmd);
|
|
380
|
+
return new Promise((resolve, reject) => {
|
|
381
|
+
const proc = spawn(this.config.runtime, args, {
|
|
382
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
383
|
+
});
|
|
384
|
+
let stdout = "";
|
|
385
|
+
let stderr = "";
|
|
386
|
+
proc.stdout.on("data", (data) => {
|
|
387
|
+
stdout += data.toString();
|
|
388
|
+
});
|
|
389
|
+
proc.stderr.on("data", (data) => {
|
|
390
|
+
stderr += data.toString();
|
|
391
|
+
});
|
|
392
|
+
proc.on("error", (err) => {
|
|
393
|
+
reject(new Error(`Failed to spawn ${this.config.runtime}: ${err.message}`));
|
|
394
|
+
});
|
|
395
|
+
proc.on("close", (code) => {
|
|
396
|
+
if (code !== 0) {
|
|
397
|
+
reject(new Error(`Failed to start container: ${stderr || stdout}`));
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const containerId = stdout.trim();
|
|
401
|
+
resolve({ containerName, containerId });
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Get the status of a container by name.
|
|
407
|
+
*/
|
|
408
|
+
async getContainerStatus(containerName) {
|
|
409
|
+
return new Promise((resolve) => {
|
|
410
|
+
const proc = spawn(this.config.runtime, ["inspect", "--format", "json", containerName], {
|
|
411
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
412
|
+
});
|
|
413
|
+
let stdout = "";
|
|
414
|
+
proc.stdout.on("data", (data) => {
|
|
415
|
+
stdout += data.toString();
|
|
416
|
+
});
|
|
417
|
+
proc.on("error", () => resolve(null));
|
|
418
|
+
proc.on("close", (code) => {
|
|
419
|
+
if (code !== 0) {
|
|
420
|
+
resolve(null);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
try {
|
|
424
|
+
const parsed = JSON.parse(stdout);
|
|
425
|
+
const inspectArray = Array.isArray(parsed) ? parsed : [parsed];
|
|
426
|
+
const inspect = inspectArray[0];
|
|
427
|
+
if (!isPodmanInspectOutput(inspect) || !inspect.State) {
|
|
428
|
+
resolve(null);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const state = inspect.State;
|
|
432
|
+
resolve({
|
|
433
|
+
running: state.Running ?? false,
|
|
434
|
+
exitCode: state.ExitCode ?? null,
|
|
435
|
+
startedAt: state.StartedAt ?? null,
|
|
436
|
+
finishedAt: state.FinishedAt ?? null,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
resolve(null);
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Get logs from a container.
|
|
447
|
+
*/
|
|
448
|
+
async getContainerLogs(containerName, opts) {
|
|
449
|
+
return new Promise((resolve) => {
|
|
450
|
+
const args = ["logs"];
|
|
451
|
+
if (opts?.since) {
|
|
452
|
+
args.push("--since", opts.since);
|
|
453
|
+
}
|
|
454
|
+
if (opts?.tail !== undefined) {
|
|
455
|
+
args.push("--tail", String(opts.tail));
|
|
456
|
+
}
|
|
457
|
+
args.push(containerName);
|
|
458
|
+
const proc = spawn(this.config.runtime, args, {
|
|
459
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
460
|
+
});
|
|
461
|
+
let output = "";
|
|
462
|
+
proc.stdout.on("data", (data) => {
|
|
463
|
+
output += data.toString();
|
|
464
|
+
});
|
|
465
|
+
proc.stderr.on("data", (data) => {
|
|
466
|
+
output += data.toString();
|
|
467
|
+
});
|
|
468
|
+
proc.on("error", () => resolve(null));
|
|
469
|
+
proc.on("close", (code) => {
|
|
470
|
+
if (code !== 0) {
|
|
471
|
+
resolve(null);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
resolve(output);
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* List all containers matching a name prefix.
|
|
480
|
+
*/
|
|
481
|
+
async listContainersByPrefix(prefix) {
|
|
482
|
+
return new Promise((resolve) => {
|
|
483
|
+
const proc = spawn(this.config.runtime, ["ps", "-a", "--filter", `name=^${prefix}`, "--format", "json"], { stdio: ["ignore", "pipe", "ignore"] });
|
|
484
|
+
let stdout = "";
|
|
485
|
+
proc.stdout.on("data", (data) => {
|
|
486
|
+
stdout += data.toString();
|
|
487
|
+
});
|
|
488
|
+
proc.on("error", () => resolve([]));
|
|
489
|
+
proc.on("close", (code) => {
|
|
490
|
+
if (code !== 0) {
|
|
491
|
+
resolve([]);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
try {
|
|
495
|
+
// Podman outputs one JSON object per line
|
|
496
|
+
const lines = stdout.trim().split("\n").filter(Boolean);
|
|
497
|
+
const containers = [];
|
|
498
|
+
for (const line of lines) {
|
|
499
|
+
const parsed = JSON.parse(line);
|
|
500
|
+
if (!isPodmanPsOutput(parsed))
|
|
501
|
+
continue;
|
|
502
|
+
const name = Array.isArray(parsed.Names) ? parsed.Names[0] : parsed.Names;
|
|
503
|
+
if (!name)
|
|
504
|
+
continue;
|
|
505
|
+
const running = parsed.State === "running" ||
|
|
506
|
+
(typeof parsed.Status === "string" && parsed.Status.startsWith("Up"));
|
|
507
|
+
containers.push({
|
|
508
|
+
name,
|
|
509
|
+
running,
|
|
510
|
+
createdAt: parsed.Created ?? parsed.CreatedAt ?? "",
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
resolve(containers);
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
resolve([]);
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Generate container name from session key.
|
|
523
|
+
*/
|
|
524
|
+
containerNameFromSessionKey(sessionKey) {
|
|
525
|
+
return `claude-${sessionKey.replace(/[^a-zA-Z0-9-]/g, "-")}`;
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Extract session key from container name.
|
|
529
|
+
*/
|
|
530
|
+
sessionKeyFromContainerName(containerName) {
|
|
531
|
+
if (!containerName.startsWith("claude-")) {
|
|
532
|
+
return null;
|
|
533
|
+
}
|
|
534
|
+
return containerName.slice(7); // Remove "claude-" prefix
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
//# sourceMappingURL=podman-runner.js.map
|