@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.
Files changed (149) hide show
  1. package/Dockerfile +14 -0
  2. package/LICENSE +661 -0
  3. package/NOTICE +3 -0
  4. package/README.md +153 -0
  5. package/dist/index.d.ts +9 -0
  6. package/dist/index.js +9 -0
  7. package/dist/sandbox/cli/install.d.ts +5 -0
  8. package/dist/sandbox/cli/install.js +335 -0
  9. package/dist/sandbox/cli/local-store.d.ts +87 -0
  10. package/dist/sandbox/cli/local-store.js +604 -0
  11. package/dist/sandbox/cli/opencode-config.d.ts +25 -0
  12. package/dist/sandbox/cli/opencode-config.js +240 -0
  13. package/dist/sandbox/cli/path.d.ts +64 -0
  14. package/dist/sandbox/cli/path.js +127 -0
  15. package/dist/sandbox/cli/types.d.ts +145 -0
  16. package/dist/sandbox/cli/types.js +6 -0
  17. package/dist/sandbox/cli/wavexzore-sandbox.d.ts +65 -0
  18. package/dist/sandbox/cli/wavexzore-sandbox.js +577 -0
  19. package/dist/sandbox/core/cli-helper.d.ts +19 -0
  20. package/dist/sandbox/core/cli-helper.js +64 -0
  21. package/dist/sandbox/core/docker-archive-utils.d.ts +3 -0
  22. package/dist/sandbox/core/docker-archive-utils.js +50 -0
  23. package/dist/sandbox/core/docker-sandbox.d.ts +51 -0
  24. package/dist/sandbox/core/docker-sandbox.js +675 -0
  25. package/dist/sandbox/core/edit/filediff.d.ts +16 -0
  26. package/dist/sandbox/core/edit/filediff.js +21 -0
  27. package/dist/sandbox/core/edit/index.d.ts +5 -0
  28. package/dist/sandbox/core/edit/index.js +5 -0
  29. package/dist/sandbox/core/edit/line-endings.d.ts +4 -0
  30. package/dist/sandbox/core/edit/line-endings.js +10 -0
  31. package/dist/sandbox/core/edit/lock.d.ts +1 -0
  32. package/dist/sandbox/core/edit/lock.js +18 -0
  33. package/dist/sandbox/core/edit/replace.d.ts +10 -0
  34. package/dist/sandbox/core/edit/replace.js +14 -0
  35. package/dist/sandbox/core/edit/replacers.d.ts +15 -0
  36. package/dist/sandbox/core/edit/replacers.js +241 -0
  37. package/dist/sandbox/core/logger.d.ts +15 -0
  38. package/dist/sandbox/core/logger.js +59 -0
  39. package/dist/sandbox/core/lsp/client.d.ts +63 -0
  40. package/dist/sandbox/core/lsp/client.js +533 -0
  41. package/dist/sandbox/core/lsp/config.d.ts +13 -0
  42. package/dist/sandbox/core/lsp/config.js +36 -0
  43. package/dist/sandbox/core/lsp/diagnostics.d.ts +12 -0
  44. package/dist/sandbox/core/lsp/diagnostics.js +65 -0
  45. package/dist/sandbox/core/lsp/index.d.ts +4 -0
  46. package/dist/sandbox/core/lsp/index.js +4 -0
  47. package/dist/sandbox/core/lsp/language.d.ts +24 -0
  48. package/dist/sandbox/core/lsp/language.js +249 -0
  49. package/dist/sandbox/core/lsp/manager.d.ts +77 -0
  50. package/dist/sandbox/core/lsp/manager.js +237 -0
  51. package/dist/sandbox/core/lsp/tooling.d.ts +14 -0
  52. package/dist/sandbox/core/lsp/tooling.js +78 -0
  53. package/dist/sandbox/core/patch-parser.d.ts +23 -0
  54. package/dist/sandbox/core/patch-parser.js +248 -0
  55. package/dist/sandbox/core/path-map.d.ts +9 -0
  56. package/dist/sandbox/core/path-map.js +73 -0
  57. package/dist/sandbox/core/project-data-storage.d.ts +42 -0
  58. package/dist/sandbox/core/project-data-storage.js +167 -0
  59. package/dist/sandbox/core/read/binary.d.ts +4 -0
  60. package/dist/sandbox/core/read/binary.js +80 -0
  61. package/dist/sandbox/core/read/format.d.ts +38 -0
  62. package/dist/sandbox/core/read/format.js +85 -0
  63. package/dist/sandbox/core/read/index.d.ts +3 -0
  64. package/dist/sandbox/core/read/index.js +3 -0
  65. package/dist/sandbox/core/read/permissions.d.ts +7 -0
  66. package/dist/sandbox/core/read/permissions.js +13 -0
  67. package/dist/sandbox/core/session-manager.d.ts +29 -0
  68. package/dist/sandbox/core/session-manager.js +338 -0
  69. package/dist/sandbox/core/shell/config.d.ts +7 -0
  70. package/dist/sandbox/core/shell/config.js +82 -0
  71. package/dist/sandbox/core/shell/output.d.ts +35 -0
  72. package/dist/sandbox/core/shell/output.js +80 -0
  73. package/dist/sandbox/core/shell/parser.d.ts +7 -0
  74. package/dist/sandbox/core/shell/parser.js +122 -0
  75. package/dist/sandbox/core/shell/permissions.d.ts +13 -0
  76. package/dist/sandbox/core/shell/permissions.js +33 -0
  77. package/dist/sandbox/core/shell/workdir.d.ts +4 -0
  78. package/dist/sandbox/core/shell/workdir.js +19 -0
  79. package/dist/sandbox/core/stream-utils.d.ts +23 -0
  80. package/dist/sandbox/core/stream-utils.js +97 -0
  81. package/dist/sandbox/core/toast.d.ts +47 -0
  82. package/dist/sandbox/core/toast.js +73 -0
  83. package/dist/sandbox/core/types.d.ts +159 -0
  84. package/dist/sandbox/core/types.js +11 -0
  85. package/dist/sandbox/core/write/bom.d.ts +8 -0
  86. package/dist/sandbox/core/write/bom.js +15 -0
  87. package/dist/sandbox/core/write/config.d.ts +14 -0
  88. package/dist/sandbox/core/write/config.js +188 -0
  89. package/dist/sandbox/core/write/diagnostics.d.ts +19 -0
  90. package/dist/sandbox/core/write/diagnostics.js +120 -0
  91. package/dist/sandbox/core/write/diff.d.ts +7 -0
  92. package/dist/sandbox/core/write/diff.js +21 -0
  93. package/dist/sandbox/core/write/formatter.d.ts +16 -0
  94. package/dist/sandbox/core/write/formatter.js +51 -0
  95. package/dist/sandbox/core/write/index.d.ts +6 -0
  96. package/dist/sandbox/core/write/index.js +5 -0
  97. package/dist/sandbox/core/write/permissions.d.ts +13 -0
  98. package/dist/sandbox/core/write/permissions.js +19 -0
  99. package/dist/sandbox/core/write/pipeline.d.ts +48 -0
  100. package/dist/sandbox/core/write/pipeline.js +229 -0
  101. package/dist/sandbox/core/write/read-tracker.d.ts +13 -0
  102. package/dist/sandbox/core/write/read-tracker.js +30 -0
  103. package/dist/sandbox/git/host-git-manager.d.ts +40 -0
  104. package/dist/sandbox/git/host-git-manager.js +278 -0
  105. package/dist/sandbox/git/index.d.ts +5 -0
  106. package/dist/sandbox/git/index.js +5 -0
  107. package/dist/sandbox/git/sandbox-git-manager.d.ts +14 -0
  108. package/dist/sandbox/git/sandbox-git-manager.js +54 -0
  109. package/dist/sandbox/git/session-git-manager.d.ts +18 -0
  110. package/dist/sandbox/git/session-git-manager.js +85 -0
  111. package/dist/sandbox/index.d.ts +205 -0
  112. package/dist/sandbox/index.js +70 -0
  113. package/dist/sandbox/plugins/custom-tools.d.ts +203 -0
  114. package/dist/sandbox/plugins/custom-tools.js +15 -0
  115. package/dist/sandbox/plugins/session-events.d.ts +10 -0
  116. package/dist/sandbox/plugins/session-events.js +56 -0
  117. package/dist/sandbox/plugins/system-transform.d.ts +10 -0
  118. package/dist/sandbox/plugins/system-transform.js +23 -0
  119. package/dist/sandbox/tools/bash-output.d.ts +17 -0
  120. package/dist/sandbox/tools/bash-output.js +35 -0
  121. package/dist/sandbox/tools/bash-status.d.ts +13 -0
  122. package/dist/sandbox/tools/bash-status.js +29 -0
  123. package/dist/sandbox/tools/bash-stop.d.ts +13 -0
  124. package/dist/sandbox/tools/bash-stop.js +28 -0
  125. package/dist/sandbox/tools/bash.d.ts +26 -0
  126. package/dist/sandbox/tools/bash.js +120 -0
  127. package/dist/sandbox/tools/edit.d.ts +20 -0
  128. package/dist/sandbox/tools/edit.js +87 -0
  129. package/dist/sandbox/tools/get-preview-url.d.ts +17 -0
  130. package/dist/sandbox/tools/get-preview-url.js +16 -0
  131. package/dist/sandbox/tools/glob.d.ts +17 -0
  132. package/dist/sandbox/tools/glob.js +23 -0
  133. package/dist/sandbox/tools/grep.d.ts +17 -0
  134. package/dist/sandbox/tools/grep.js +23 -0
  135. package/dist/sandbox/tools/ls.d.ts +17 -0
  136. package/dist/sandbox/tools/ls.js +21 -0
  137. package/dist/sandbox/tools/lsp.d.ts +41 -0
  138. package/dist/sandbox/tools/lsp.js +198 -0
  139. package/dist/sandbox/tools/multiedit.d.ts +24 -0
  140. package/dist/sandbox/tools/multiedit.js +83 -0
  141. package/dist/sandbox/tools/patch.d.ts +14 -0
  142. package/dist/sandbox/tools/patch.js +260 -0
  143. package/dist/sandbox/tools/read.d.ts +22 -0
  144. package/dist/sandbox/tools/read.js +105 -0
  145. package/dist/sandbox/tools/write.d.ts +16 -0
  146. package/dist/sandbox/tools/write.js +27 -0
  147. package/dist/sandbox/tools.d.ts +200 -0
  148. package/dist/sandbox/tools.js +43 -0
  149. 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,5 @@
1
+ /**
2
+ * Copyright Daytona Platforms Inc.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ export { SessionGitManager } from './session-git-manager.js';
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Copyright Daytona Platforms Inc.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ export { SessionGitManager } from './session-git-manager.js';
@@ -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
+ }