@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,229 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { hostLspManager } from '../lsp/manager.js';
|
|
3
|
+
import { decodeUtf8, encodeUtf8, joinBom, splitBom } from './bom.js';
|
|
4
|
+
import { loadWriteToolConfig } from './config.js';
|
|
5
|
+
import { createWriteDiff } from './diff.js';
|
|
6
|
+
import { collectWriteDiagnostics } from './diagnostics.js';
|
|
7
|
+
import { runConfiguredFormatter } from './formatter.js';
|
|
8
|
+
import { requestEditPermission, setToolMetadata } from './permissions.js';
|
|
9
|
+
import { readRegistry } from './read-tracker.js';
|
|
10
|
+
function shellQuote(value) {
|
|
11
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
12
|
+
}
|
|
13
|
+
function assertMappedWrite(input) {
|
|
14
|
+
if (input.allowUnmapped)
|
|
15
|
+
return;
|
|
16
|
+
if (input.paths.isMapped(input.inputPath) &&
|
|
17
|
+
input.paths.isMapped(input.hostPath) &&
|
|
18
|
+
input.paths.isMapped(input.containerPath))
|
|
19
|
+
return;
|
|
20
|
+
throw new Error(`File writes are restricted to the mapped project worktree. ` +
|
|
21
|
+
`Received ${input.inputPath}, resolved container path ${input.containerPath}.`);
|
|
22
|
+
}
|
|
23
|
+
async function readExistingFile(input) {
|
|
24
|
+
if (input.oldContentBuffer)
|
|
25
|
+
return { exists: true, buffer: input.oldContentBuffer };
|
|
26
|
+
try {
|
|
27
|
+
return { exists: true, buffer: await input.sandbox.fs.downloadFile(input.containerPath) };
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return { exists: false, buffer: Buffer.from('') };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function assertMappedContainerPathResolvesInsideRoot(input) {
|
|
34
|
+
const script = `
|
|
35
|
+
root=${shellQuote(input.paths.repoPath)}
|
|
36
|
+
target=${shellQuote(input.containerPath)}
|
|
37
|
+
|
|
38
|
+
resolve_existing() {
|
|
39
|
+
if command -v readlink >/dev/null 2>&1; then
|
|
40
|
+
readlink -f "$1" 2>/dev/null && return 0
|
|
41
|
+
fi
|
|
42
|
+
if command -v realpath >/dev/null 2>&1; then
|
|
43
|
+
realpath "$1" 2>/dev/null && return 0
|
|
44
|
+
fi
|
|
45
|
+
if [ -d "$1" ]; then
|
|
46
|
+
(cd "$1" 2>/dev/null && pwd -P) && return 0
|
|
47
|
+
fi
|
|
48
|
+
printf '%s\\n' "$1"
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
root_resolved="$(resolve_existing "$root")"
|
|
52
|
+
probe="$target"
|
|
53
|
+
while [ "$probe" != "/" ] && [ ! -e "$probe" ]; do
|
|
54
|
+
probe="$(dirname "$probe")"
|
|
55
|
+
done
|
|
56
|
+
|
|
57
|
+
probe_resolved="$(resolve_existing "$probe")"
|
|
58
|
+
case "$probe_resolved" in
|
|
59
|
+
"$root_resolved"|"$root_resolved"/*) exit 0 ;;
|
|
60
|
+
*)
|
|
61
|
+
printf 'Resolved container path escapes mapped worktree: %s -> %s (root %s)\\n' "$target" "$probe_resolved" "$root_resolved" >&2
|
|
62
|
+
exit 76
|
|
63
|
+
;;
|
|
64
|
+
esac
|
|
65
|
+
`;
|
|
66
|
+
const result = await input.sandbox.process.executeCommand(script, input.paths.repoPath);
|
|
67
|
+
if (result.exitCode !== 0) {
|
|
68
|
+
throw new Error(`File writes are restricted to the mapped project worktree. ` +
|
|
69
|
+
`${result.stderr || result.stdout || `Resolved container path ${input.containerPath} escapes ${input.paths.repoPath}`}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function metadataDiagnostics(diagnostics) {
|
|
73
|
+
return {
|
|
74
|
+
current: diagnostics.current,
|
|
75
|
+
other: diagnostics.other,
|
|
76
|
+
unavailable: diagnostics.unavailable,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
export async function sandboxWriteFile(input) {
|
|
80
|
+
const config = loadWriteToolConfig(input.worktree);
|
|
81
|
+
const containerPath = input.paths.toContainer(input.filePath);
|
|
82
|
+
const hostPath = input.paths.toHost(containerPath);
|
|
83
|
+
const relativePath = input.paths.toRelative(hostPath);
|
|
84
|
+
assertMappedWrite({
|
|
85
|
+
paths: input.paths,
|
|
86
|
+
inputPath: input.filePath,
|
|
87
|
+
hostPath,
|
|
88
|
+
containerPath,
|
|
89
|
+
allowUnmapped: config.allowUnmappedWrites,
|
|
90
|
+
});
|
|
91
|
+
if (!config.allowUnmappedWrites) {
|
|
92
|
+
await assertMappedContainerPathResolvesInsideRoot({
|
|
93
|
+
sandbox: input.sandbox,
|
|
94
|
+
paths: input.paths,
|
|
95
|
+
containerPath,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const existing = await readExistingFile({
|
|
99
|
+
sandbox: input.sandbox,
|
|
100
|
+
containerPath,
|
|
101
|
+
oldContentBuffer: input.oldContentBuffer,
|
|
102
|
+
});
|
|
103
|
+
const exists = input.oldExists ?? existing.exists;
|
|
104
|
+
if (input.requireFreshRead &&
|
|
105
|
+
exists &&
|
|
106
|
+
config.requireReadBeforeOverwrite &&
|
|
107
|
+
!readRegistry.hasFresh(input.sessionID, hostPath, existing.buffer)) {
|
|
108
|
+
throw new Error(`Existing file must be read before write. Use read first: ${relativePath}`);
|
|
109
|
+
}
|
|
110
|
+
const oldText = decodeUtf8(existing.buffer);
|
|
111
|
+
const old = splitBom(oldText);
|
|
112
|
+
const desired = splitBom(input.content);
|
|
113
|
+
const keepBom = exists ? old.bom : desired.bom;
|
|
114
|
+
const contentWithBom = joinBom(desired.text, keepBom);
|
|
115
|
+
const diff = createWriteDiff({
|
|
116
|
+
oldPath: input.diffOldPath ? input.paths.toRelative(input.diffOldPath) : relativePath,
|
|
117
|
+
newPath: relativePath,
|
|
118
|
+
oldContent: old.text,
|
|
119
|
+
newContent: desired.text,
|
|
120
|
+
});
|
|
121
|
+
if (!input.skipPermission) {
|
|
122
|
+
await requestEditPermission({ ctx: input.ctx, relativePath, hostPath, containerPath, diff });
|
|
123
|
+
}
|
|
124
|
+
await input.sandbox.fs.uploadFile(encodeUtf8(contentWithBom), containerPath);
|
|
125
|
+
const formatted = await runConfiguredFormatter({
|
|
126
|
+
sandbox: input.sandbox,
|
|
127
|
+
paths: input.paths,
|
|
128
|
+
worktree: input.worktree,
|
|
129
|
+
hostPath,
|
|
130
|
+
containerPath,
|
|
131
|
+
formatters: config.formatters,
|
|
132
|
+
});
|
|
133
|
+
let finalBuffer = await input.sandbox.fs.downloadFile(containerPath);
|
|
134
|
+
let finalContent = decodeUtf8(finalBuffer);
|
|
135
|
+
if (keepBom && !splitBom(finalContent).bom) {
|
|
136
|
+
finalContent = joinBom(finalContent, true);
|
|
137
|
+
await input.sandbox.fs.uploadFile(encodeUtf8(finalContent), containerPath);
|
|
138
|
+
finalBuffer = encodeUtf8(finalContent);
|
|
139
|
+
}
|
|
140
|
+
const diagnostics = await collectWriteDiagnostics({
|
|
141
|
+
sessionID: input.sessionID,
|
|
142
|
+
worktree: input.worktree,
|
|
143
|
+
paths: input.paths,
|
|
144
|
+
hostPath,
|
|
145
|
+
expectedContent: finalContent,
|
|
146
|
+
});
|
|
147
|
+
readRegistry.mark(input.sessionID, hostPath, finalBuffer);
|
|
148
|
+
if (input.setMetadata !== false) {
|
|
149
|
+
setToolMetadata({
|
|
150
|
+
ctx: input.ctx,
|
|
151
|
+
title: relativePath,
|
|
152
|
+
metadata: {
|
|
153
|
+
operation: input.operation,
|
|
154
|
+
diagnostics: metadataDiagnostics(diagnostics),
|
|
155
|
+
filepath: hostPath,
|
|
156
|
+
containerPath,
|
|
157
|
+
exists,
|
|
158
|
+
diff,
|
|
159
|
+
formatted,
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
hostPath,
|
|
165
|
+
containerPath,
|
|
166
|
+
relativePath,
|
|
167
|
+
exists,
|
|
168
|
+
diff,
|
|
169
|
+
finalContent,
|
|
170
|
+
formatted,
|
|
171
|
+
diagnostics,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
export async function sandboxDeleteFile(input) {
|
|
175
|
+
const config = loadWriteToolConfig(input.worktree);
|
|
176
|
+
const containerPath = input.paths.toContainer(input.filePath);
|
|
177
|
+
const hostPath = input.paths.toHost(containerPath);
|
|
178
|
+
const relativePath = input.paths.toRelative(hostPath);
|
|
179
|
+
assertMappedWrite({
|
|
180
|
+
paths: input.paths,
|
|
181
|
+
inputPath: input.filePath,
|
|
182
|
+
hostPath,
|
|
183
|
+
containerPath,
|
|
184
|
+
allowUnmapped: config.allowUnmappedWrites,
|
|
185
|
+
});
|
|
186
|
+
if (!config.allowUnmappedWrites) {
|
|
187
|
+
await assertMappedContainerPathResolvesInsideRoot({
|
|
188
|
+
sandbox: input.sandbox,
|
|
189
|
+
paths: input.paths,
|
|
190
|
+
containerPath,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
let oldBuffer = Buffer.from('');
|
|
194
|
+
let exists = false;
|
|
195
|
+
try {
|
|
196
|
+
oldBuffer = await input.sandbox.fs.downloadFile(containerPath);
|
|
197
|
+
exists = true;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
exists = false;
|
|
201
|
+
}
|
|
202
|
+
const oldText = splitBom(decodeUtf8(oldBuffer)).text;
|
|
203
|
+
const diff = createWriteDiff({ oldPath: relativePath, oldContent: oldText, newContent: '' });
|
|
204
|
+
if (!input.skipPermission) {
|
|
205
|
+
await requestEditPermission({ ctx: input.ctx, relativePath, hostPath, containerPath, diff });
|
|
206
|
+
}
|
|
207
|
+
await input.sandbox.process.executeCommand(`rm -f ${shellQuote(containerPath)}`, path.posix.dirname(containerPath));
|
|
208
|
+
readRegistry.forget(input.sessionID, hostPath);
|
|
209
|
+
if (input.paths.isMapped(hostPath)) {
|
|
210
|
+
await hostLspManager
|
|
211
|
+
.closeFile({ sessionID: input.sessionID, worktree: input.worktree, filePath: hostPath })
|
|
212
|
+
.catch(() => undefined);
|
|
213
|
+
}
|
|
214
|
+
if (input.setMetadata !== false) {
|
|
215
|
+
setToolMetadata({
|
|
216
|
+
ctx: input.ctx,
|
|
217
|
+
title: relativePath,
|
|
218
|
+
metadata: {
|
|
219
|
+
operation: 'delete',
|
|
220
|
+
filepath: hostPath,
|
|
221
|
+
containerPath,
|
|
222
|
+
exists,
|
|
223
|
+
diff,
|
|
224
|
+
deleted: true,
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
return { hostPath, containerPath, relativePath, exists, diff };
|
|
229
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type TrackedRead = {
|
|
2
|
+
hash: string;
|
|
3
|
+
at: number;
|
|
4
|
+
};
|
|
5
|
+
declare class ReadRegistry {
|
|
6
|
+
private readonly reads;
|
|
7
|
+
mark(sessionID: string, filePath: string, content: Buffer | string): void;
|
|
8
|
+
hasFresh(sessionID: string, filePath: string, currentContent: Buffer | string): boolean;
|
|
9
|
+
forget(sessionID: string, filePath: string): void;
|
|
10
|
+
clearSession(sessionID: string): void;
|
|
11
|
+
}
|
|
12
|
+
export declare const readRegistry: ReadRegistry;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
function hashContent(content) {
|
|
4
|
+
return createHash('sha256').update(content).digest('hex');
|
|
5
|
+
}
|
|
6
|
+
function normalizeHostPath(filePath) {
|
|
7
|
+
return path.resolve(filePath);
|
|
8
|
+
}
|
|
9
|
+
class ReadRegistry {
|
|
10
|
+
reads = new Map();
|
|
11
|
+
mark(sessionID, filePath, content) {
|
|
12
|
+
const normalized = normalizeHostPath(filePath);
|
|
13
|
+
const session = this.reads.get(sessionID) ?? new Map();
|
|
14
|
+
session.set(normalized, { hash: hashContent(content), at: Date.now() });
|
|
15
|
+
this.reads.set(sessionID, session);
|
|
16
|
+
}
|
|
17
|
+
hasFresh(sessionID, filePath, currentContent) {
|
|
18
|
+
const tracked = this.reads.get(sessionID)?.get(normalizeHostPath(filePath));
|
|
19
|
+
if (!tracked)
|
|
20
|
+
return false;
|
|
21
|
+
return tracked.hash === hashContent(currentContent);
|
|
22
|
+
}
|
|
23
|
+
forget(sessionID, filePath) {
|
|
24
|
+
this.reads.get(sessionID)?.delete(normalizeHostPath(filePath));
|
|
25
|
+
}
|
|
26
|
+
clearSession(sessionID) {
|
|
27
|
+
this.reads.delete(sessionID);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export const readRegistry = new ReadRegistry();
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright Daytona Platforms Inc.
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
export declare class HostGitManager {
|
|
6
|
+
private operationQueue;
|
|
7
|
+
/** Cached OID of an empty commit used to reserve branch refs (branches must point at commits, not blobs). */
|
|
8
|
+
private emptyCommitOidCache;
|
|
9
|
+
/**
|
|
10
|
+
* Checks if a git repository exists in the current directory
|
|
11
|
+
* @returns true if a git repo exists, false otherwise
|
|
12
|
+
*/
|
|
13
|
+
hasRepo(cwd?: string): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Allocates the next available opencode/N branch number by scanning local refs and
|
|
16
|
+
* reserving the chosen number by creating the ref immediately.
|
|
17
|
+
*
|
|
18
|
+
* This avoids relying on OpenCode's project ID and works even in repos with no commits.
|
|
19
|
+
*/
|
|
20
|
+
allocateAndReserveBranchNumber(cwd: string, prefix?: string): number;
|
|
21
|
+
private refExists;
|
|
22
|
+
/**
|
|
23
|
+
* Returns a commit OID that branch refs can point at. Uses HEAD if the repo has commits,
|
|
24
|
+
* otherwise creates and caches an empty commit (empty tree + commit). Branch refs must
|
|
25
|
+
* point at commits, not blobs.
|
|
26
|
+
*/
|
|
27
|
+
private getOrCreateEmptyCommitOid;
|
|
28
|
+
/**
|
|
29
|
+
* Pushes local changes to the sandbox remote.
|
|
30
|
+
* @param remoteName Numbered remote (e.g. sandbox-2) matching opencode/N.
|
|
31
|
+
* @param sshUrl The SSH URL of the sandbox remote.
|
|
32
|
+
* @param branch The branch to push to.
|
|
33
|
+
* @param cwd Worktree path to run git in.
|
|
34
|
+
* @returns true if push was successful, false if no repo exists
|
|
35
|
+
*/
|
|
36
|
+
pushLocalToSandboxRemote(remoteName: string, sshUrl: string, branch: string, cwd: string): Promise<boolean>;
|
|
37
|
+
private setRemote;
|
|
38
|
+
pull(remoteName: string, sshUrl: string, branch: string, cwd: string, localBranch?: string): Promise<void>;
|
|
39
|
+
push(remoteName: string, sshUrl: string, branch: string, cwd: string): Promise<void>;
|
|
40
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright Daytona Platforms Inc.
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
import { logger } from '../core/logger.js';
|
|
6
|
+
import { execSync, spawnSync } from 'child_process';
|
|
7
|
+
function execCommand(cmd, options = {}) {
|
|
8
|
+
try {
|
|
9
|
+
const stdout = execSync(cmd, {
|
|
10
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
11
|
+
encoding: 'utf8',
|
|
12
|
+
...options,
|
|
13
|
+
});
|
|
14
|
+
return { ok: true, stdout: stdout ?? '', stderr: '', status: 0 };
|
|
15
|
+
}
|
|
16
|
+
catch (err) {
|
|
17
|
+
const stdout = err?.stdout?.toString?.() ?? '';
|
|
18
|
+
const stderr = err?.stderr?.toString?.() ?? err?.message ?? String(err);
|
|
19
|
+
const status = typeof err?.status === 'number' ? err.status : null;
|
|
20
|
+
return { ok: false, stdout, stderr, status };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export class HostGitManager {
|
|
24
|
+
// No constructor needed; use global logger
|
|
25
|
+
operationQueue = Promise.resolve();
|
|
26
|
+
/** Cached OID of an empty commit used to reserve branch refs (branches must point at commits, not blobs). */
|
|
27
|
+
emptyCommitOidCache = new Map();
|
|
28
|
+
/**
|
|
29
|
+
* Checks if a git repository exists in the current directory
|
|
30
|
+
* @returns true if a git repo exists, false otherwise
|
|
31
|
+
*/
|
|
32
|
+
hasRepo(cwd) {
|
|
33
|
+
return execCommand('git rev-parse --is-inside-work-tree', cwd ? { cwd } : {}).ok;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Allocates the next available opencode/N branch number by scanning local refs and
|
|
37
|
+
* reserving the chosen number by creating the ref immediately.
|
|
38
|
+
*
|
|
39
|
+
* This avoids relying on OpenCode's project ID and works even in repos with no commits.
|
|
40
|
+
*/
|
|
41
|
+
allocateAndReserveBranchNumber(cwd, prefix = 'opencode') {
|
|
42
|
+
const start = Date.now();
|
|
43
|
+
if (!this.hasRepo(cwd)) {
|
|
44
|
+
throw new Error('No local git repository found.');
|
|
45
|
+
}
|
|
46
|
+
const base = `refs/heads/${prefix}/`;
|
|
47
|
+
const listRes = execCommand(`git for-each-ref --format='%(refname:strip=3)' ${base}`, { cwd });
|
|
48
|
+
if (!listRes.ok)
|
|
49
|
+
throw new Error(listRes.stderr);
|
|
50
|
+
const list = listRes.stdout.trim();
|
|
51
|
+
const nums = list.length === 0
|
|
52
|
+
? []
|
|
53
|
+
: list
|
|
54
|
+
.split('\n')
|
|
55
|
+
.map((s) => s.trim())
|
|
56
|
+
.filter(Boolean)
|
|
57
|
+
.map((s) => Number.parseInt(s, 10))
|
|
58
|
+
.filter((n) => Number.isFinite(n) && n > 0);
|
|
59
|
+
let n = (nums.length ? Math.max(...nums) : 0) + 1;
|
|
60
|
+
const maxAttempts = 50; // Circuit-breaker
|
|
61
|
+
let attempts = 0;
|
|
62
|
+
while (n < 1_000_000 && attempts < maxAttempts) {
|
|
63
|
+
attempts++;
|
|
64
|
+
const ref = `${base}${n}`;
|
|
65
|
+
if (this.refExists(cwd, ref)) {
|
|
66
|
+
n++;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const oid = this.getOrCreateEmptyCommitOid(cwd);
|
|
70
|
+
const result = execCommand(`git update-ref "${ref}" "${oid}"`, { cwd });
|
|
71
|
+
if (result.ok) {
|
|
72
|
+
logger.info(`[branch-alloc] reserved ${prefix}/${n} in ${Date.now() - start}ms`);
|
|
73
|
+
return n;
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
// If we raced or hit an edge case, try the next number.
|
|
77
|
+
n++;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const oid = this.getOrCreateEmptyCommitOid(cwd);
|
|
81
|
+
const last = execCommand(`git update-ref "${base}${n}" "${oid}"`, { cwd });
|
|
82
|
+
throw new Error(`Failed to allocate branch number after ${attempts} attempts. Last error: ${last.stderr}`);
|
|
83
|
+
}
|
|
84
|
+
refExists(cwd, ref) {
|
|
85
|
+
return execCommand(`git show-ref --verify --quiet "${ref}"`, { cwd }).ok;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Returns a commit OID that branch refs can point at. Uses HEAD if the repo has commits,
|
|
89
|
+
* otherwise creates and caches an empty commit (empty tree + commit). Branch refs must
|
|
90
|
+
* point at commits, not blobs.
|
|
91
|
+
*/
|
|
92
|
+
getOrCreateEmptyCommitOid(cwd) {
|
|
93
|
+
const cached = this.emptyCommitOidCache.get(cwd);
|
|
94
|
+
if (cached)
|
|
95
|
+
return cached;
|
|
96
|
+
const headRes = execCommand('git rev-parse HEAD', { cwd });
|
|
97
|
+
const head = headRes.ok ? headRes.stdout.trim() : '';
|
|
98
|
+
if (head) {
|
|
99
|
+
this.emptyCommitOidCache.set(cwd, head);
|
|
100
|
+
return head;
|
|
101
|
+
}
|
|
102
|
+
// Create an empty tree (idempotent) then a commit pointing at it.
|
|
103
|
+
// Branch refs must point at commits, so we can't reserve with a blob.
|
|
104
|
+
const treeResult = spawnSync('git', ['hash-object', '-t', 'tree', '-w', '--stdin'], {
|
|
105
|
+
// Empty stdin => empty tree
|
|
106
|
+
input: '',
|
|
107
|
+
cwd,
|
|
108
|
+
encoding: 'utf8',
|
|
109
|
+
});
|
|
110
|
+
const treeOid = treeResult.stdout?.trim();
|
|
111
|
+
if (treeResult.status !== 0 || !treeOid) {
|
|
112
|
+
const errorMsg = treeResult.stderr?.toString() || treeResult.error?.message || String(treeResult.error || 'unknown');
|
|
113
|
+
throw new Error(`Failed to create empty tree: ${errorMsg}`);
|
|
114
|
+
}
|
|
115
|
+
// Provide a default identity for reservation commits when repo has no user.name/user.email (e.g. CI).
|
|
116
|
+
const reservationCommitName = 'OpenCode Plugin';
|
|
117
|
+
const reservationCommitEmail = 'opencode@sandbox.local';
|
|
118
|
+
const reservationCommitMessage = 'OpenCode reservation';
|
|
119
|
+
const commitEnv = {
|
|
120
|
+
...process.env,
|
|
121
|
+
GIT_AUTHOR_NAME: reservationCommitName,
|
|
122
|
+
GIT_AUTHOR_EMAIL: reservationCommitEmail,
|
|
123
|
+
GIT_COMMITTER_NAME: reservationCommitName,
|
|
124
|
+
GIT_COMMITTER_EMAIL: reservationCommitEmail,
|
|
125
|
+
};
|
|
126
|
+
// Create the commit
|
|
127
|
+
const commitRes = execCommand(`git commit-tree ${treeOid} -m "${reservationCommitMessage}"`, {
|
|
128
|
+
cwd,
|
|
129
|
+
env: commitEnv,
|
|
130
|
+
});
|
|
131
|
+
if (!commitRes.ok)
|
|
132
|
+
throw new Error(`Failed to create empty commit: ${commitRes.stderr}`);
|
|
133
|
+
const commitOid = commitRes.stdout.trim();
|
|
134
|
+
this.emptyCommitOidCache.set(cwd, commitOid);
|
|
135
|
+
return commitOid;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Pushes local changes to the sandbox remote.
|
|
139
|
+
* @param remoteName Numbered remote (e.g. sandbox-2) matching opencode/N.
|
|
140
|
+
* @param sshUrl The SSH URL of the sandbox remote.
|
|
141
|
+
* @param branch The branch to push to.
|
|
142
|
+
* @param cwd Worktree path to run git in.
|
|
143
|
+
* @returns true if push was successful, false if no repo exists
|
|
144
|
+
*/
|
|
145
|
+
async pushLocalToSandboxRemote(remoteName, sshUrl, branch, cwd) {
|
|
146
|
+
if (!this.hasRepo(cwd)) {
|
|
147
|
+
logger.warn('No local git repository found. Skipping push to sandbox.');
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
logger.info(`Pushing to ${remoteName} (${sshUrl}) on branch ${branch}`);
|
|
152
|
+
const operation = this.operationQueue.then(async () => {
|
|
153
|
+
const statusRes = execCommand('git status --porcelain', { cwd });
|
|
154
|
+
if (!statusRes.ok) {
|
|
155
|
+
throw new Error(statusRes.stderr);
|
|
156
|
+
}
|
|
157
|
+
if (statusRes.stdout.trim().length > 0) {
|
|
158
|
+
logger.warn('Local repository has uncommitted changes; pushing HEAD only (no auto-commit).');
|
|
159
|
+
}
|
|
160
|
+
this.setRemote(remoteName, sshUrl, cwd);
|
|
161
|
+
let attempts = 0;
|
|
162
|
+
while (attempts < 3) {
|
|
163
|
+
try {
|
|
164
|
+
const pushRes = execCommand(`git push ${remoteName} HEAD:${branch}`, { cwd });
|
|
165
|
+
if (!pushRes.ok)
|
|
166
|
+
throw new Error(pushRes.stderr);
|
|
167
|
+
logger.info(`✓ Pushed local changes to ${remoteName}`);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
attempts++;
|
|
172
|
+
if (attempts >= 3) {
|
|
173
|
+
logger.error(`Error pushing to ${remoteName} after 3 attempts: ${e}`);
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
logger.warn(`Push attempt ${attempts} failed, retrying...`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
this.operationQueue = operation;
|
|
182
|
+
await operation;
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
catch (e) {
|
|
186
|
+
logger.error(`Error pushing to sandbox: ${e}`);
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
setRemote(remoteName, sshUrl, cwd) {
|
|
191
|
+
try {
|
|
192
|
+
// remove existing remote if it exists
|
|
193
|
+
execCommand(`git remote remove ${remoteName}`, { cwd });
|
|
194
|
+
execCommand(`git remote add ${remoteName} ${sshUrl}`, { cwd });
|
|
195
|
+
}
|
|
196
|
+
catch (e) {
|
|
197
|
+
logger.warn(`Could not set sandbox remote: ${e}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async pull(remoteName, sshUrl, branch, cwd, localBranch) {
|
|
201
|
+
const operation = this.operationQueue.then(async () => {
|
|
202
|
+
this.setRemote(remoteName, sshUrl, cwd);
|
|
203
|
+
let attempts = 0;
|
|
204
|
+
let lastError = undefined;
|
|
205
|
+
// The first pull attempt sometimes fails. I'm not sure what the cause is.
|
|
206
|
+
while (attempts < 3) {
|
|
207
|
+
try {
|
|
208
|
+
if (localBranch) {
|
|
209
|
+
// Fetch into FETCH_HEAD only (never into refs/heads) so we don't hit
|
|
210
|
+
// "refusing to fetch into branch checked out" when this branch is checked out.
|
|
211
|
+
const fetchRes = execCommand(`git fetch ${remoteName} ${branch}`, { cwd });
|
|
212
|
+
if (!fetchRes.ok)
|
|
213
|
+
throw new Error(fetchRes.stderr);
|
|
214
|
+
const updateRefRes = execCommand(`git update-ref refs/heads/${localBranch} FETCH_HEAD`, { cwd });
|
|
215
|
+
if (!updateRefRes.ok)
|
|
216
|
+
throw new Error(updateRefRes.stderr);
|
|
217
|
+
// Only reset working directory if we're currently on this branch
|
|
218
|
+
const currentBranchRes = execCommand(`git rev-parse --abbrev-ref HEAD`, { cwd });
|
|
219
|
+
const currentBranch = currentBranchRes.ok ? currentBranchRes.stdout.trim() : '';
|
|
220
|
+
if (currentBranch === localBranch) {
|
|
221
|
+
const resetRes = execCommand(`git reset --hard refs/heads/${localBranch}`, { cwd });
|
|
222
|
+
if (!resetRes.ok)
|
|
223
|
+
throw new Error(resetRes.stderr);
|
|
224
|
+
}
|
|
225
|
+
logger.info(`✓ Force pulled latest changes from sandbox into ${localBranch}`);
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
const pullRes = execCommand(`git pull ${remoteName} ${branch}`, { cwd });
|
|
229
|
+
if (!pullRes.ok)
|
|
230
|
+
throw new Error(pullRes.stderr);
|
|
231
|
+
logger.info('✓ Pulled latest changes from sandbox');
|
|
232
|
+
}
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
catch (e) {
|
|
236
|
+
lastError = e;
|
|
237
|
+
attempts++;
|
|
238
|
+
if (attempts >= 3) {
|
|
239
|
+
logger.error(`Error pulling from sandbox after 3 attempts: ${e}`);
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
logger.warn(`Pull attempt ${attempts} failed, retrying...`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// If we got here, all attempts failed.
|
|
247
|
+
throw lastError ?? new Error('Pull failed after 3 attempts');
|
|
248
|
+
});
|
|
249
|
+
this.operationQueue = operation;
|
|
250
|
+
await operation;
|
|
251
|
+
}
|
|
252
|
+
async push(remoteName, sshUrl, branch, cwd) {
|
|
253
|
+
const operation = this.operationQueue.then(async () => {
|
|
254
|
+
this.setRemote(remoteName, sshUrl, cwd);
|
|
255
|
+
let attempts = 0;
|
|
256
|
+
while (attempts < 3) {
|
|
257
|
+
try {
|
|
258
|
+
const pushRes = execCommand(`git push ${remoteName} HEAD:${branch}`, { cwd });
|
|
259
|
+
if (!pushRes.ok)
|
|
260
|
+
throw new Error(pushRes.stderr);
|
|
261
|
+
logger.info('✓ Pushed changes to sandbox');
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
catch (e) {
|
|
265
|
+
attempts++;
|
|
266
|
+
if (attempts >= 3) {
|
|
267
|
+
logger.error(`Error pushing to sandbox after 3 attempts: ${e}`);
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
logger.warn(`Push attempt ${attempts} failed, retrying...`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
this.operationQueue = operation;
|
|
276
|
+
await operation;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright Daytona Platforms Inc.
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
import type { Sandbox } from '../core/types.js';
|
|
6
|
+
export declare class LocalSandboxGitManager {
|
|
7
|
+
private readonly sandbox;
|
|
8
|
+
private readonly repoPath;
|
|
9
|
+
constructor(sandbox: Sandbox, repoPath: string);
|
|
10
|
+
ensureDirectory(): Promise<void>;
|
|
11
|
+
ensureRepo(): Promise<void>;
|
|
12
|
+
autoCommit(): Promise<boolean>;
|
|
13
|
+
resetToRemote(branch: string): Promise<void>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright Daytona Platforms Inc.
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
import { logger } from '../core/logger.js';
|
|
6
|
+
export class LocalSandboxGitManager {
|
|
7
|
+
sandbox;
|
|
8
|
+
repoPath;
|
|
9
|
+
constructor(sandbox, repoPath) {
|
|
10
|
+
this.sandbox = sandbox;
|
|
11
|
+
this.repoPath = repoPath;
|
|
12
|
+
}
|
|
13
|
+
async ensureDirectory() {
|
|
14
|
+
await this.sandbox.fs.createFolder(this.repoPath, '755');
|
|
15
|
+
}
|
|
16
|
+
async ensureRepo() {
|
|
17
|
+
await this.ensureDirectory();
|
|
18
|
+
const isGit = await this.sandbox.process.executeCommand('git rev-parse --is-inside-work-tree', this.repoPath);
|
|
19
|
+
if (!isGit || isGit.result.trim() !== 'true') {
|
|
20
|
+
await this.sandbox.process.executeCommand('git init', this.repoPath);
|
|
21
|
+
await this.sandbox.process.executeCommand('git config user.email "sandbox@opencode.local"', this.repoPath);
|
|
22
|
+
await this.sandbox.process.executeCommand('git config user.name "OpenCode Sandbox"', this.repoPath);
|
|
23
|
+
logger.info(`Initialized git repo in sandbox at ${this.repoPath}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async autoCommit() {
|
|
27
|
+
try {
|
|
28
|
+
const statusResult = await this.sandbox.process.executeCommand('git status --porcelain', this.repoPath);
|
|
29
|
+
if (!statusResult.result.trim()) {
|
|
30
|
+
logger.info(`No changes to commit in sandbox at ${this.repoPath}`);
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
await this.sandbox.process.executeCommand('git add .', this.repoPath);
|
|
34
|
+
await this.sandbox.process.executeCommand('git commit -am "Auto-commit from OpenCode sandbox"', this.repoPath);
|
|
35
|
+
logger.info(`Auto-committed changes in sandbox at ${this.repoPath}`);
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
logger.error(`Failed to auto-commit in sandbox at ${this.repoPath}: ${err}`);
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async resetToRemote(branch) {
|
|
44
|
+
try {
|
|
45
|
+
await this.sandbox.process.executeCommand(`git checkout -B ${branch}`, this.repoPath);
|
|
46
|
+
await this.sandbox.process.executeCommand('git reset --hard', this.repoPath);
|
|
47
|
+
await this.sandbox.process.executeCommand('git clean -fd', this.repoPath);
|
|
48
|
+
logger.info('Reset sandbox worktree.');
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
logger.error(`Failed to reset sandbox worktree: ${err}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|