@wavexzore/sandbox 0.1.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/Dockerfile +14 -0
- package/LICENSE +661 -0
- package/NOTICE +3 -0
- package/README.md +153 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/sandbox/cli/install.d.ts +5 -0
- package/dist/sandbox/cli/install.js +335 -0
- package/dist/sandbox/cli/local-store.d.ts +87 -0
- package/dist/sandbox/cli/local-store.js +604 -0
- package/dist/sandbox/cli/opencode-config.d.ts +25 -0
- package/dist/sandbox/cli/opencode-config.js +240 -0
- package/dist/sandbox/cli/path.d.ts +64 -0
- package/dist/sandbox/cli/path.js +127 -0
- package/dist/sandbox/cli/types.d.ts +145 -0
- package/dist/sandbox/cli/types.js +6 -0
- package/dist/sandbox/cli/wavexzore-sandbox.d.ts +65 -0
- package/dist/sandbox/cli/wavexzore-sandbox.js +577 -0
- package/dist/sandbox/core/cli-helper.d.ts +19 -0
- package/dist/sandbox/core/cli-helper.js +64 -0
- package/dist/sandbox/core/docker-archive-utils.d.ts +3 -0
- package/dist/sandbox/core/docker-archive-utils.js +50 -0
- package/dist/sandbox/core/docker-sandbox.d.ts +51 -0
- package/dist/sandbox/core/docker-sandbox.js +675 -0
- package/dist/sandbox/core/edit/filediff.d.ts +16 -0
- package/dist/sandbox/core/edit/filediff.js +21 -0
- package/dist/sandbox/core/edit/index.d.ts +5 -0
- package/dist/sandbox/core/edit/index.js +5 -0
- package/dist/sandbox/core/edit/line-endings.d.ts +4 -0
- package/dist/sandbox/core/edit/line-endings.js +10 -0
- package/dist/sandbox/core/edit/lock.d.ts +1 -0
- package/dist/sandbox/core/edit/lock.js +18 -0
- package/dist/sandbox/core/edit/replace.d.ts +10 -0
- package/dist/sandbox/core/edit/replace.js +14 -0
- package/dist/sandbox/core/edit/replacers.d.ts +15 -0
- package/dist/sandbox/core/edit/replacers.js +241 -0
- package/dist/sandbox/core/logger.d.ts +15 -0
- package/dist/sandbox/core/logger.js +59 -0
- package/dist/sandbox/core/lsp/client.d.ts +63 -0
- package/dist/sandbox/core/lsp/client.js +533 -0
- package/dist/sandbox/core/lsp/config.d.ts +13 -0
- package/dist/sandbox/core/lsp/config.js +36 -0
- package/dist/sandbox/core/lsp/diagnostics.d.ts +12 -0
- package/dist/sandbox/core/lsp/diagnostics.js +65 -0
- package/dist/sandbox/core/lsp/index.d.ts +4 -0
- package/dist/sandbox/core/lsp/index.js +4 -0
- package/dist/sandbox/core/lsp/language.d.ts +24 -0
- package/dist/sandbox/core/lsp/language.js +249 -0
- package/dist/sandbox/core/lsp/manager.d.ts +77 -0
- package/dist/sandbox/core/lsp/manager.js +237 -0
- package/dist/sandbox/core/lsp/tooling.d.ts +14 -0
- package/dist/sandbox/core/lsp/tooling.js +78 -0
- package/dist/sandbox/core/patch-parser.d.ts +23 -0
- package/dist/sandbox/core/patch-parser.js +248 -0
- package/dist/sandbox/core/path-map.d.ts +9 -0
- package/dist/sandbox/core/path-map.js +73 -0
- package/dist/sandbox/core/project-data-storage.d.ts +42 -0
- package/dist/sandbox/core/project-data-storage.js +167 -0
- package/dist/sandbox/core/read/binary.d.ts +4 -0
- package/dist/sandbox/core/read/binary.js +80 -0
- package/dist/sandbox/core/read/format.d.ts +38 -0
- package/dist/sandbox/core/read/format.js +85 -0
- package/dist/sandbox/core/read/index.d.ts +3 -0
- package/dist/sandbox/core/read/index.js +3 -0
- package/dist/sandbox/core/read/permissions.d.ts +7 -0
- package/dist/sandbox/core/read/permissions.js +13 -0
- package/dist/sandbox/core/session-manager.d.ts +29 -0
- package/dist/sandbox/core/session-manager.js +338 -0
- package/dist/sandbox/core/shell/config.d.ts +7 -0
- package/dist/sandbox/core/shell/config.js +82 -0
- package/dist/sandbox/core/shell/output.d.ts +35 -0
- package/dist/sandbox/core/shell/output.js +80 -0
- package/dist/sandbox/core/shell/parser.d.ts +7 -0
- package/dist/sandbox/core/shell/parser.js +122 -0
- package/dist/sandbox/core/shell/permissions.d.ts +13 -0
- package/dist/sandbox/core/shell/permissions.js +33 -0
- package/dist/sandbox/core/shell/workdir.d.ts +4 -0
- package/dist/sandbox/core/shell/workdir.js +19 -0
- package/dist/sandbox/core/stream-utils.d.ts +23 -0
- package/dist/sandbox/core/stream-utils.js +97 -0
- package/dist/sandbox/core/toast.d.ts +47 -0
- package/dist/sandbox/core/toast.js +73 -0
- package/dist/sandbox/core/types.d.ts +159 -0
- package/dist/sandbox/core/types.js +11 -0
- package/dist/sandbox/core/write/bom.d.ts +8 -0
- package/dist/sandbox/core/write/bom.js +15 -0
- package/dist/sandbox/core/write/config.d.ts +14 -0
- package/dist/sandbox/core/write/config.js +188 -0
- package/dist/sandbox/core/write/diagnostics.d.ts +19 -0
- package/dist/sandbox/core/write/diagnostics.js +120 -0
- package/dist/sandbox/core/write/diff.d.ts +7 -0
- package/dist/sandbox/core/write/diff.js +21 -0
- package/dist/sandbox/core/write/formatter.d.ts +16 -0
- package/dist/sandbox/core/write/formatter.js +51 -0
- package/dist/sandbox/core/write/index.d.ts +6 -0
- package/dist/sandbox/core/write/index.js +5 -0
- package/dist/sandbox/core/write/permissions.d.ts +13 -0
- package/dist/sandbox/core/write/permissions.js +19 -0
- package/dist/sandbox/core/write/pipeline.d.ts +48 -0
- package/dist/sandbox/core/write/pipeline.js +229 -0
- package/dist/sandbox/core/write/read-tracker.d.ts +13 -0
- package/dist/sandbox/core/write/read-tracker.js +30 -0
- package/dist/sandbox/git/host-git-manager.d.ts +40 -0
- package/dist/sandbox/git/host-git-manager.js +278 -0
- package/dist/sandbox/git/index.d.ts +5 -0
- package/dist/sandbox/git/index.js +5 -0
- package/dist/sandbox/git/sandbox-git-manager.d.ts +14 -0
- package/dist/sandbox/git/sandbox-git-manager.js +54 -0
- package/dist/sandbox/git/session-git-manager.d.ts +18 -0
- package/dist/sandbox/git/session-git-manager.js +85 -0
- package/dist/sandbox/index.d.ts +205 -0
- package/dist/sandbox/index.js +70 -0
- package/dist/sandbox/plugins/custom-tools.d.ts +203 -0
- package/dist/sandbox/plugins/custom-tools.js +15 -0
- package/dist/sandbox/plugins/session-events.d.ts +10 -0
- package/dist/sandbox/plugins/session-events.js +56 -0
- package/dist/sandbox/plugins/system-transform.d.ts +10 -0
- package/dist/sandbox/plugins/system-transform.js +23 -0
- package/dist/sandbox/tools/bash-output.d.ts +17 -0
- package/dist/sandbox/tools/bash-output.js +35 -0
- package/dist/sandbox/tools/bash-status.d.ts +13 -0
- package/dist/sandbox/tools/bash-status.js +29 -0
- package/dist/sandbox/tools/bash-stop.d.ts +13 -0
- package/dist/sandbox/tools/bash-stop.js +28 -0
- package/dist/sandbox/tools/bash.d.ts +26 -0
- package/dist/sandbox/tools/bash.js +120 -0
- package/dist/sandbox/tools/edit.d.ts +20 -0
- package/dist/sandbox/tools/edit.js +87 -0
- package/dist/sandbox/tools/get-preview-url.d.ts +17 -0
- package/dist/sandbox/tools/get-preview-url.js +16 -0
- package/dist/sandbox/tools/glob.d.ts +17 -0
- package/dist/sandbox/tools/glob.js +23 -0
- package/dist/sandbox/tools/grep.d.ts +17 -0
- package/dist/sandbox/tools/grep.js +23 -0
- package/dist/sandbox/tools/ls.d.ts +17 -0
- package/dist/sandbox/tools/ls.js +21 -0
- package/dist/sandbox/tools/lsp.d.ts +41 -0
- package/dist/sandbox/tools/lsp.js +198 -0
- package/dist/sandbox/tools/multiedit.d.ts +24 -0
- package/dist/sandbox/tools/multiedit.js +83 -0
- package/dist/sandbox/tools/patch.d.ts +14 -0
- package/dist/sandbox/tools/patch.js +260 -0
- package/dist/sandbox/tools/read.d.ts +22 -0
- package/dist/sandbox/tools/read.js +105 -0
- package/dist/sandbox/tools/write.d.ts +16 -0
- package/dist/sandbox/tools/write.js +27 -0
- package/dist/sandbox/tools.d.ts +200 -0
- package/dist/sandbox/tools.js +43 -0
- package/package.json +55 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { formatBytes } from './binary.js';
|
|
2
|
+
export const DEFAULT_READ_LIMIT = 2000;
|
|
3
|
+
export const MAX_READ_LINES = 2000;
|
|
4
|
+
export const MAX_LINE_LENGTH = 2000;
|
|
5
|
+
export const MAX_TEXT_BYTES = 50 * 1024;
|
|
6
|
+
function clampOffset(offset) {
|
|
7
|
+
if (!Number.isFinite(offset))
|
|
8
|
+
return 1;
|
|
9
|
+
return Math.max(1, Math.floor(offset ?? 1));
|
|
10
|
+
}
|
|
11
|
+
function clampLimit(limit) {
|
|
12
|
+
if (!Number.isFinite(limit))
|
|
13
|
+
return DEFAULT_READ_LIMIT;
|
|
14
|
+
return Math.max(1, Math.min(MAX_READ_LINES, Math.floor(limit ?? DEFAULT_READ_LIMIT)));
|
|
15
|
+
}
|
|
16
|
+
function truncateLine(line) {
|
|
17
|
+
if (line.length <= MAX_LINE_LENGTH)
|
|
18
|
+
return line;
|
|
19
|
+
return `${line.slice(0, MAX_LINE_LENGTH)}… <line truncated>`;
|
|
20
|
+
}
|
|
21
|
+
function numberedLine(lineNumber, line) {
|
|
22
|
+
return `${String(lineNumber).padStart(6, ' ')} | ${truncateLine(line)}`;
|
|
23
|
+
}
|
|
24
|
+
export function formatTextRead(input) {
|
|
25
|
+
const normalized = input.content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
26
|
+
const lines = normalized.split('\n');
|
|
27
|
+
const offset = clampOffset(input.offset);
|
|
28
|
+
const limit = clampLimit(input.limit);
|
|
29
|
+
const start = offset - 1;
|
|
30
|
+
const selected = [];
|
|
31
|
+
let byteCount = 0;
|
|
32
|
+
let truncatedByBytes = false;
|
|
33
|
+
for (let idx = start; idx < Math.min(lines.length, start + limit); idx++) {
|
|
34
|
+
const rendered = numberedLine(idx + 1, lines[idx]);
|
|
35
|
+
const nextBytes = Buffer.byteLength(rendered + '\n');
|
|
36
|
+
if (byteCount + nextBytes > MAX_TEXT_BYTES) {
|
|
37
|
+
truncatedByBytes = true;
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
selected.push(rendered);
|
|
41
|
+
byteCount += nextBytes;
|
|
42
|
+
}
|
|
43
|
+
const endLine = selected.length ? start + selected.length : start;
|
|
44
|
+
const hasMore = endLine < lines.length || truncatedByBytes;
|
|
45
|
+
const suffix = hasMore ? `\n\n<notice>Output truncated. Read with offset ${endLine + 1} to continue.</notice>` : '';
|
|
46
|
+
return {
|
|
47
|
+
output: `<path>${input.displayPath}</path>\n<type>file</type>\n<content>\n${selected.join('\n')}\n</content>${suffix}`,
|
|
48
|
+
metadata: {
|
|
49
|
+
type: 'file',
|
|
50
|
+
offset,
|
|
51
|
+
limit,
|
|
52
|
+
lineCount: lines.length,
|
|
53
|
+
returnedLines: selected.length,
|
|
54
|
+
hasMore,
|
|
55
|
+
truncatedByBytes,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export function formatDirectoryRead(input) {
|
|
60
|
+
const offset = clampOffset(input.offset);
|
|
61
|
+
const limit = clampLimit(input.limit);
|
|
62
|
+
const entries = input.entries
|
|
63
|
+
.filter((entry) => entry.name !== '.' && entry.name !== '..')
|
|
64
|
+
.sort((a, b) => {
|
|
65
|
+
if (a.isDirectory !== b.isDirectory)
|
|
66
|
+
return a.isDirectory ? -1 : 1;
|
|
67
|
+
return a.name.localeCompare(b.name);
|
|
68
|
+
});
|
|
69
|
+
const selected = entries.slice(offset - 1, offset - 1 + limit);
|
|
70
|
+
const lines = selected.map((entry) => `${entry.isDirectory ? entry.name + '/' : entry.name}${entry.isDirectory ? '' : ` (${formatBytes(entry.size)})`}`);
|
|
71
|
+
const endEntry = offset - 1 + selected.length;
|
|
72
|
+
const hasMore = endEntry < entries.length;
|
|
73
|
+
const suffix = hasMore ? `\n\n<notice>Directory output truncated. Read with offset ${endEntry + 1} to continue.</notice>` : '';
|
|
74
|
+
return {
|
|
75
|
+
output: `<path>${input.displayPath}</path>\n<type>directory</type>\n<content>\n${lines.join('\n')}\n</content>${suffix}`,
|
|
76
|
+
metadata: {
|
|
77
|
+
type: 'directory',
|
|
78
|
+
offset,
|
|
79
|
+
limit,
|
|
80
|
+
entryCount: entries.length,
|
|
81
|
+
returnedEntries: selected.length,
|
|
82
|
+
hasMore,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export async function requestReadPermission(input) {
|
|
2
|
+
if (typeof input.ctx.ask !== 'function')
|
|
3
|
+
return;
|
|
4
|
+
await input.ctx.ask({
|
|
5
|
+
permission: 'read',
|
|
6
|
+
patterns: [input.relativePath],
|
|
7
|
+
always: ['*'],
|
|
8
|
+
metadata: {
|
|
9
|
+
filepath: input.hostPath,
|
|
10
|
+
containerPath: input.containerPath,
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright Daytona Platforms Inc.
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
import type { Sandbox } from './types.js';
|
|
6
|
+
import type { PluginInput } from '@opencode-ai/plugin';
|
|
7
|
+
export declare class SandboxSessionManager {
|
|
8
|
+
private readonly dataStorage;
|
|
9
|
+
private sessionSandboxes;
|
|
10
|
+
private currentProjectId?;
|
|
11
|
+
readonly repoPath: string;
|
|
12
|
+
private docker;
|
|
13
|
+
private _image?;
|
|
14
|
+
constructor(_apiKey: string, storageDir: string, repoPath: string);
|
|
15
|
+
setImage(image: string): void;
|
|
16
|
+
private getImage;
|
|
17
|
+
private isFullyInitialized;
|
|
18
|
+
private isPartiallyInitialized;
|
|
19
|
+
private loadProjectSessions;
|
|
20
|
+
setProjectContext(projectId: string): void;
|
|
21
|
+
getBranchNumberForSandbox(projectId: string, sandboxId: string): number | undefined;
|
|
22
|
+
getSandbox(sessionId: string, projectId: string, worktree: string, pluginCtx?: PluginInput): Promise<Sandbox>;
|
|
23
|
+
deleteSandbox(sessionId: string, projectId: string): Promise<void>;
|
|
24
|
+
deleteSandboxSync(sessionId: string, projectId: string): void;
|
|
25
|
+
private deleteContainerSync;
|
|
26
|
+
private reconnectSandbox;
|
|
27
|
+
private createDockerSandbox;
|
|
28
|
+
private doCreateContainer;
|
|
29
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright Daytona Platforms Inc.
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Manages Docker sandbox sessions and persists session-sandbox mappings
|
|
7
|
+
* Stores data per-project in ~/.local/share/opencode/storage/sandbox/{projectId}.json
|
|
8
|
+
*/
|
|
9
|
+
import Dockerode from 'dockerode';
|
|
10
|
+
import { execSync } from 'child_process';
|
|
11
|
+
import { logger } from './logger.js';
|
|
12
|
+
import { SessionGitManager } from '../git/session-git-manager.js';
|
|
13
|
+
import { LocalSandboxGitManager } from '../git/sandbox-git-manager.js';
|
|
14
|
+
import { ProjectDataStorage } from './project-data-storage.js';
|
|
15
|
+
import { toast } from './toast.js';
|
|
16
|
+
import { DockerSandbox } from './docker-sandbox.js';
|
|
17
|
+
export class SandboxSessionManager {
|
|
18
|
+
dataStorage;
|
|
19
|
+
sessionSandboxes;
|
|
20
|
+
currentProjectId;
|
|
21
|
+
repoPath;
|
|
22
|
+
docker;
|
|
23
|
+
_image;
|
|
24
|
+
constructor(_apiKey, storageDir, repoPath) {
|
|
25
|
+
this.dataStorage = new ProjectDataStorage(storageDir);
|
|
26
|
+
this.repoPath = repoPath;
|
|
27
|
+
this.sessionSandboxes = new Map();
|
|
28
|
+
this.docker = new Dockerode({
|
|
29
|
+
socketPath: process.env.DOCKER_SOCKET || '/var/run/docker.sock',
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
setImage(image) {
|
|
33
|
+
this._image = image;
|
|
34
|
+
}
|
|
35
|
+
getImage() {
|
|
36
|
+
return this._image || process.env.SANDBOX_IMAGE || 'opencode-sandbox:latest';
|
|
37
|
+
}
|
|
38
|
+
isFullyInitialized(sandbox) {
|
|
39
|
+
return sandbox !== undefined && 'process' in sandbox;
|
|
40
|
+
}
|
|
41
|
+
isPartiallyInitialized(sandbox) {
|
|
42
|
+
return sandbox !== undefined && 'id' in sandbox && !('process' in sandbox);
|
|
43
|
+
}
|
|
44
|
+
loadProjectSessions(projectId) {
|
|
45
|
+
const projectData = this.dataStorage.load(projectId);
|
|
46
|
+
if (projectData) {
|
|
47
|
+
for (const [sessionId, sessionInfo] of Object.entries(projectData.sessions)) {
|
|
48
|
+
this.sessionSandboxes.set(sessionId, { id: sessionInfo.sandboxId });
|
|
49
|
+
}
|
|
50
|
+
logger.info(`Loaded ${Object.keys(projectData.sessions).length} sessions for project ${projectId}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
setProjectContext(projectId) {
|
|
54
|
+
if (this.currentProjectId !== projectId) {
|
|
55
|
+
this.currentProjectId = projectId;
|
|
56
|
+
this.sessionSandboxes.clear();
|
|
57
|
+
this.loadProjectSessions(projectId);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
getBranchNumberForSandbox(projectId, sandboxId) {
|
|
61
|
+
return this.dataStorage.getBranchNumberForSandbox(projectId, sandboxId);
|
|
62
|
+
}
|
|
63
|
+
async getSandbox(sessionId, projectId, worktree, pluginCtx) {
|
|
64
|
+
if (pluginCtx?.client?.tui) {
|
|
65
|
+
toast.initialize(pluginCtx.client.tui);
|
|
66
|
+
}
|
|
67
|
+
this.setProjectContext(projectId);
|
|
68
|
+
const existing = this.sessionSandboxes.get(sessionId);
|
|
69
|
+
if (this.isFullyInitialized(existing)) {
|
|
70
|
+
await existing.refreshData();
|
|
71
|
+
if (existing.state !== 'started') {
|
|
72
|
+
logger.info(`Starting sandbox ${existing.id} (current state: ${existing.state})`);
|
|
73
|
+
await existing.start();
|
|
74
|
+
}
|
|
75
|
+
this.dataStorage.updateSession(projectId, worktree, sessionId, existing.id);
|
|
76
|
+
return existing;
|
|
77
|
+
}
|
|
78
|
+
if (this.isPartiallyInitialized(existing)) {
|
|
79
|
+
logger.info(`Reconnecting to existing sandbox: ${existing.id}`);
|
|
80
|
+
const sandbox = await this.reconnectSandbox(existing.id);
|
|
81
|
+
if (!sandbox) {
|
|
82
|
+
logger.warn(`Container ${existing.id} not found, clearing stale record and creating new sandbox`);
|
|
83
|
+
this.sessionSandboxes.delete(sessionId);
|
|
84
|
+
this.dataStorage.removeSession(projectId, this.dataStorage.load(projectId)?.worktree || worktree, sessionId);
|
|
85
|
+
return this.getSandbox(sessionId, projectId, worktree, pluginCtx);
|
|
86
|
+
}
|
|
87
|
+
await sandbox.start();
|
|
88
|
+
this.sessionSandboxes.set(sessionId, sandbox);
|
|
89
|
+
let branchNumber = this.dataStorage.getBranchNumberForSandbox(projectId, sandbox.id);
|
|
90
|
+
if (!branchNumber) {
|
|
91
|
+
try {
|
|
92
|
+
branchNumber = SessionGitManager.allocateAndReserveBranchNumber(worktree);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
branchNumber = undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
this.dataStorage.updateSession(projectId, worktree, sessionId, sandbox.id, branchNumber);
|
|
99
|
+
toast.show({ title: 'Sandbox connected', message: 'Connected to existing sandbox.', variant: 'info' });
|
|
100
|
+
if (!branchNumber) {
|
|
101
|
+
try {
|
|
102
|
+
await new LocalSandboxGitManager(sandbox, this.repoPath).ensureDirectory();
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
logger.warn(`Failed to ensure sandbox project directory exists: ${err}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return sandbox;
|
|
109
|
+
}
|
|
110
|
+
if (!existing) {
|
|
111
|
+
const migrated = this.dataStorage.getSession(projectId, worktree, sessionId);
|
|
112
|
+
if (migrated?.sandboxId) {
|
|
113
|
+
logger.info(`Recovered session ${sessionId} for project ${projectId}`);
|
|
114
|
+
this.sessionSandboxes.set(sessionId, { id: migrated.sandboxId });
|
|
115
|
+
return this.getSandbox(sessionId, projectId, worktree, pluginCtx);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
logger.info(`Creating new sandbox for session: ${sessionId} in project: ${projectId}`);
|
|
119
|
+
const createStart = Date.now();
|
|
120
|
+
const waitingLog = setTimeout(() => {
|
|
121
|
+
logger.warn(`Docker create still waiting after ${Date.now() - createStart}ms (sessionId=${sessionId})`);
|
|
122
|
+
}, 15_000);
|
|
123
|
+
const sandbox = await this.createDockerSandbox(sessionId, worktree).finally(() => clearTimeout(waitingLog));
|
|
124
|
+
logger.info(`Docker create done sessionId=${sessionId} sandboxId=${sandbox.id} in ${Date.now() - createStart}ms`);
|
|
125
|
+
this.sessionSandboxes.set(sessionId, sandbox);
|
|
126
|
+
let branchNumber = this.dataStorage.getBranchNumberForSandbox(projectId, sandbox.id);
|
|
127
|
+
if (!branchNumber) {
|
|
128
|
+
try {
|
|
129
|
+
branchNumber = SessionGitManager.allocateAndReserveBranchNumber(worktree);
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
logger.warn(`allocateAndReserveBranchNumber failed sessionId=${sessionId}: ${err}`);
|
|
133
|
+
branchNumber = undefined;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
this.dataStorage.updateSession(projectId, worktree, sessionId, sandbox.id, branchNumber);
|
|
137
|
+
logger.info(`Sandbox created successfully: ${sandbox.id}${branchNumber ? ` with branch number ${branchNumber}` : ''}`);
|
|
138
|
+
try {
|
|
139
|
+
if (branchNumber) {
|
|
140
|
+
const sessionGit = new SessionGitManager(sandbox, this.repoPath, worktree, branchNumber);
|
|
141
|
+
await sessionGit.initializeAndSync(pluginCtx);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
await new LocalSandboxGitManager(sandbox, this.repoPath).ensureDirectory();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
logger.error(`Failed to initialize git repo in sandbox: ${err}`);
|
|
149
|
+
toast.show({
|
|
150
|
+
title: 'Git error',
|
|
151
|
+
message: err?.message || 'Failed to initialize git repo in sandbox.',
|
|
152
|
+
variant: 'error',
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
toast.show({ title: 'Sandbox created', message: 'Created new sandbox for session.', variant: 'success' });
|
|
156
|
+
return sandbox;
|
|
157
|
+
}
|
|
158
|
+
async deleteSandbox(sessionId, projectId) {
|
|
159
|
+
let sandbox = this.sessionSandboxes.get(sessionId);
|
|
160
|
+
let sandboxId;
|
|
161
|
+
if (!sandbox || this.isPartiallyInitialized(sandbox)) {
|
|
162
|
+
const storedWorktree = this.dataStorage.load(projectId)?.worktree ?? '';
|
|
163
|
+
const sessionInfo = this.dataStorage.getSession(projectId, storedWorktree, sessionId);
|
|
164
|
+
if (sessionInfo?.sandboxId) {
|
|
165
|
+
sandboxId = sessionInfo.sandboxId;
|
|
166
|
+
try {
|
|
167
|
+
const reconnected = await this.reconnectSandbox(sessionInfo.sandboxId);
|
|
168
|
+
if (reconnected) {
|
|
169
|
+
sandbox = reconnected;
|
|
170
|
+
this.sessionSandboxes.set(sessionId, sandbox);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
logger.warn(`Container ${sessionInfo.sandboxId} no longer exists during delete, clearing stale record`);
|
|
174
|
+
const projectData = this.dataStorage.load(projectId);
|
|
175
|
+
if (projectData) {
|
|
176
|
+
this.dataStorage.removeSession(projectId, projectData.worktree, sessionId);
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
logger.error(`Failed to reconnect to sandbox ${sessionInfo.sandboxId}: ${err}`);
|
|
183
|
+
sandboxId = sessionInfo.sandboxId;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (this.isFullyInitialized(sandbox)) {
|
|
188
|
+
logger.info(`Removing sandbox for session: ${sessionId}`);
|
|
189
|
+
try {
|
|
190
|
+
await sandbox.delete();
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
if (sandbox.id) {
|
|
194
|
+
logger.warn(`Async delete failed, falling back to sync: ${err}`);
|
|
195
|
+
this.deleteContainerSync(sandbox.id);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
this.sessionSandboxes.delete(sessionId);
|
|
199
|
+
const projectData = this.dataStorage.load(projectId);
|
|
200
|
+
if (projectData) {
|
|
201
|
+
this.dataStorage.removeSession(projectId, projectData.worktree, sessionId);
|
|
202
|
+
}
|
|
203
|
+
logger.info('Sandbox deleted successfully.');
|
|
204
|
+
}
|
|
205
|
+
else if (sandboxId) {
|
|
206
|
+
logger.info(`Removing sandbox by ID for session: ${sessionId} (container: ${sandboxId})`);
|
|
207
|
+
this.deleteContainerSync(sandboxId);
|
|
208
|
+
const projectData = this.dataStorage.load(projectId);
|
|
209
|
+
if (projectData) {
|
|
210
|
+
this.dataStorage.removeSession(projectId, projectData.worktree, sessionId);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
logger.warn(`No sandbox found for session: ${sessionId}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
deleteSandboxSync(sessionId, projectId) {
|
|
218
|
+
logger.info(`deleteSandboxSync called for session=${sessionId} project=${projectId}`);
|
|
219
|
+
const sandbox = this.sessionSandboxes.get(sessionId);
|
|
220
|
+
if (this.isFullyInitialized(sandbox)) {
|
|
221
|
+
this.deleteContainerSync(sandbox.id);
|
|
222
|
+
this.sessionSandboxes.delete(sessionId);
|
|
223
|
+
const projectData = this.dataStorage.load(projectId);
|
|
224
|
+
if (projectData) {
|
|
225
|
+
this.dataStorage.removeSession(projectId, projectData.worktree, sessionId);
|
|
226
|
+
}
|
|
227
|
+
logger.info(`deleteSandboxSync done: removed sandbox ${sandbox.id} for session ${sessionId}`);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const storedWorktree = this.dataStorage.load(projectId)?.worktree ?? '';
|
|
231
|
+
const sessionInfo = this.dataStorage.getSession(projectId, storedWorktree, sessionId);
|
|
232
|
+
if (sessionInfo?.sandboxId) {
|
|
233
|
+
this.deleteContainerSync(sessionInfo.sandboxId);
|
|
234
|
+
this.sessionSandboxes.delete(sessionId);
|
|
235
|
+
this.dataStorage.removeSession(projectId, storedWorktree, sessionId);
|
|
236
|
+
logger.info(`deleteSandboxSync done: removed container ${sessionInfo.sandboxId} for session ${sessionId}`);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
logger.warn(`deleteSandboxSync: no sandbox found for session=${sessionId}`);
|
|
240
|
+
}
|
|
241
|
+
deleteContainerSync(containerId) {
|
|
242
|
+
try {
|
|
243
|
+
execSync(`docker rm -f ${containerId}`, { stdio: 'pipe', timeout: 10000 });
|
|
244
|
+
logger.info(`Sync deleted container: ${containerId}`);
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
logger.warn(`Sync delete failed for ${containerId}: ${err}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
async reconnectSandbox(sandboxId) {
|
|
251
|
+
const container = this.docker.getContainer(sandboxId);
|
|
252
|
+
try {
|
|
253
|
+
await container.inspect();
|
|
254
|
+
return new DockerSandbox(this.docker, container, this.repoPath);
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
if (err?.statusCode === 404) {
|
|
258
|
+
logger.warn(`Container ${sandboxId} no longer exists, will create a new one`);
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
throw err;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
async createDockerSandbox(sessionId, worktree) {
|
|
265
|
+
const image = this.getImage();
|
|
266
|
+
const memory = parseInt(process.env.SANDBOX_MEMORY || '2147483648', 10);
|
|
267
|
+
const cpuQuota = parseInt(process.env.SANDBOX_CPU_QUOTA || '100000', 10);
|
|
268
|
+
let container;
|
|
269
|
+
try {
|
|
270
|
+
container = await this.doCreateContainer(sessionId, image, worktree, memory, cpuQuota);
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
if (err?.statusCode === 404 || err?.json?.message?.includes('No such image')) {
|
|
274
|
+
logger.info(`Image "${image}" not found locally, pulling...`);
|
|
275
|
+
toast.show({ title: 'Pulling image', message: `Downloading ${image}...`, variant: 'info' });
|
|
276
|
+
await new Promise((resolve, reject) => {
|
|
277
|
+
this.docker.pull(image, (pullErr, stream) => {
|
|
278
|
+
if (pullErr)
|
|
279
|
+
return reject(pullErr);
|
|
280
|
+
this.docker.modem.followProgress(stream, (followErr) => {
|
|
281
|
+
if (followErr)
|
|
282
|
+
return reject(followErr);
|
|
283
|
+
resolve();
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
logger.info(`Image "${image}" pulled successfully`);
|
|
288
|
+
container = await this.doCreateContainer(sessionId, image, worktree, memory, cpuQuota);
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
throw err;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
await container.start();
|
|
295
|
+
return new DockerSandbox(this.docker, container, this.repoPath);
|
|
296
|
+
}
|
|
297
|
+
async doCreateContainer(sessionId, image, worktree, memory, cpuQuota) {
|
|
298
|
+
const platform = process.platform;
|
|
299
|
+
const mountOpts = platform === 'darwin' ? ':cached' : '';
|
|
300
|
+
const binds = [`${worktree}:${this.repoPath}${mountOpts}`];
|
|
301
|
+
const defaultUser = typeof process.getuid === 'function'
|
|
302
|
+
? `${process.getuid()}:${process.getgid()}`
|
|
303
|
+
: '1000:1000';
|
|
304
|
+
const hostConfig = {
|
|
305
|
+
Binds: binds,
|
|
306
|
+
Memory: memory,
|
|
307
|
+
CpuQuota: cpuQuota,
|
|
308
|
+
NetworkMode: process.env.SANDBOX_NETWORK || 'bridge',
|
|
309
|
+
AutoRemove: false,
|
|
310
|
+
PortBindings: parsePortBindings(process.env.SANDBOX_PORTS || ''),
|
|
311
|
+
CapDrop: ['ALL'],
|
|
312
|
+
SecurityOpt: ['no-new-privileges'],
|
|
313
|
+
Init: true,
|
|
314
|
+
PidsLimit: 256,
|
|
315
|
+
};
|
|
316
|
+
return this.docker.createContainer({
|
|
317
|
+
Image: image,
|
|
318
|
+
User: process.env.SANDBOX_USER || defaultUser,
|
|
319
|
+
HostConfig: hostConfig,
|
|
320
|
+
WorkingDir: this.repoPath,
|
|
321
|
+
Tty: true,
|
|
322
|
+
OpenStdin: true,
|
|
323
|
+
Labels: { 'opencode.session': sessionId },
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
function parsePortBindings(portsStr) {
|
|
328
|
+
if (!portsStr)
|
|
329
|
+
return {};
|
|
330
|
+
const result = {};
|
|
331
|
+
for (const pair of portsStr.split(',')) {
|
|
332
|
+
const [host, container] = pair.split(':');
|
|
333
|
+
if (host && container) {
|
|
334
|
+
result[`${container}/tcp`] = [{ HostPort: host }];
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return result;
|
|
338
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { DEFAULT_SHELL_MAX_OUTPUT_BYTES, DEFAULT_SHELL_MAX_OUTPUT_LINES, DEFAULT_SHELL_TIMEOUT_MS } from './output.js';
|
|
4
|
+
const DEFAULT_CONFIG = {
|
|
5
|
+
defaultTimeoutMs: DEFAULT_SHELL_TIMEOUT_MS,
|
|
6
|
+
maxOutputBytes: DEFAULT_SHELL_MAX_OUTPUT_BYTES,
|
|
7
|
+
maxOutputLines: DEFAULT_SHELL_MAX_OUTPUT_LINES,
|
|
8
|
+
allowWorkdirOutsideProject: false,
|
|
9
|
+
};
|
|
10
|
+
export function loadShellToolConfig(worktree) {
|
|
11
|
+
const raw = readJsonFile(path.join(worktree, '.opencode', 'sandbox.json'));
|
|
12
|
+
if (!raw || !isRecord(raw.shell))
|
|
13
|
+
return { ...DEFAULT_CONFIG };
|
|
14
|
+
return {
|
|
15
|
+
defaultTimeoutMs: parsePositiveInteger(raw.shell.defaultTimeoutMs, DEFAULT_CONFIG.defaultTimeoutMs),
|
|
16
|
+
maxOutputBytes: parsePositiveInteger(raw.shell.maxOutputBytes, DEFAULT_CONFIG.maxOutputBytes),
|
|
17
|
+
maxOutputLines: parsePositiveInteger(raw.shell.maxOutputLines, DEFAULT_CONFIG.maxOutputLines),
|
|
18
|
+
allowWorkdirOutsideProject: parseBoolean(raw.shell.allowWorkdirOutsideProject, DEFAULT_CONFIG.allowWorkdirOutsideProject),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function parsePositiveInteger(value, fallback) {
|
|
22
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0)
|
|
23
|
+
return fallback;
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
function parseBoolean(value, fallback) {
|
|
27
|
+
return typeof value === 'boolean' ? value : fallback;
|
|
28
|
+
}
|
|
29
|
+
function isRecord(value) {
|
|
30
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
31
|
+
}
|
|
32
|
+
function readJsonFile(filePath) {
|
|
33
|
+
if (!existsSync(filePath))
|
|
34
|
+
return undefined;
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(stripJsonComments(readFileSync(filePath, 'utf8')));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function stripJsonComments(input) {
|
|
43
|
+
let output = '';
|
|
44
|
+
let inString = false;
|
|
45
|
+
let stringQuote = '';
|
|
46
|
+
let escaped = false;
|
|
47
|
+
for (let i = 0; i < input.length; i++) {
|
|
48
|
+
const char = input[i];
|
|
49
|
+
const next = input[i + 1];
|
|
50
|
+
if (inString) {
|
|
51
|
+
output += char;
|
|
52
|
+
if (escaped)
|
|
53
|
+
escaped = false;
|
|
54
|
+
else if (char === '\\')
|
|
55
|
+
escaped = true;
|
|
56
|
+
else if (char === stringQuote)
|
|
57
|
+
inString = false;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (char === '"' || char === "'") {
|
|
61
|
+
inString = true;
|
|
62
|
+
stringQuote = char;
|
|
63
|
+
output += char;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (char === '/' && next === '/') {
|
|
67
|
+
while (i < input.length && input[i] !== '\n')
|
|
68
|
+
i++;
|
|
69
|
+
output += '\n';
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (char === '/' && next === '*') {
|
|
73
|
+
i += 2;
|
|
74
|
+
while (i < input.length && !(input[i] === '*' && input[i + 1] === '/'))
|
|
75
|
+
i++;
|
|
76
|
+
i++;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
output += char;
|
|
80
|
+
}
|
|
81
|
+
return output.replace(/,\s*([}\]])/g, '$1');
|
|
82
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export declare const DEFAULT_SHELL_TIMEOUT_MS = 120000;
|
|
2
|
+
export declare const DEFAULT_SHELL_MAX_OUTPUT_BYTES = 200000;
|
|
3
|
+
export declare const DEFAULT_SHELL_MAX_OUTPUT_LINES = 2000;
|
|
4
|
+
export declare const DEFAULT_SHELL_METADATA_BYTES = 30000;
|
|
5
|
+
export declare function previewOutput(output: string, maxBytes?: number): string;
|
|
6
|
+
export declare function tailBytes(value: string, maxBytes: number): string;
|
|
7
|
+
export declare function truncateShellOutput(value: string, limits: {
|
|
8
|
+
maxBytes: number;
|
|
9
|
+
maxLines: number;
|
|
10
|
+
}): {
|
|
11
|
+
output: string;
|
|
12
|
+
truncated: boolean;
|
|
13
|
+
bytes: number;
|
|
14
|
+
lines: number;
|
|
15
|
+
};
|
|
16
|
+
export declare function formatShellOutput(input: {
|
|
17
|
+
stdout: string;
|
|
18
|
+
stderr: string;
|
|
19
|
+
exitCode: number | null;
|
|
20
|
+
durationMs: number;
|
|
21
|
+
timedOut?: boolean;
|
|
22
|
+
aborted?: boolean;
|
|
23
|
+
truncated?: boolean;
|
|
24
|
+
outputPath?: string;
|
|
25
|
+
stdoutBytes?: number;
|
|
26
|
+
stderrBytes?: number;
|
|
27
|
+
stdoutLines?: number;
|
|
28
|
+
stderrLines?: number;
|
|
29
|
+
}): string;
|
|
30
|
+
export declare function writeHostShellOutput(input: {
|
|
31
|
+
sessionId: string;
|
|
32
|
+
cmdId: string;
|
|
33
|
+
stdout: string;
|
|
34
|
+
stderr: string;
|
|
35
|
+
}): string;
|