jeo-code 0.12.15 → 0.12.16
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/CHANGELOG.md +13 -1
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/package.json +1 -1
- package/src/agent/sandbox/authority.ts +105 -42
- package/src/agent/sandbox/bundle.ts +5 -2
- package/src/agent/sandbox/docker.ts +13 -3
- package/src/agent/sandbox/hands-source.text +230 -230
- package/src/agent/sandbox/policy.ts +23 -6
- package/src/commands/launch.ts +17 -3
- package/src/commands/messenger.ts +2 -0
- package/src/messenger/config.ts +27 -1
- package/src/messenger/discord-setup.ts +97 -17
- package/src/messenger/gateway.ts +58 -33
- package/src/messenger/types.ts +2 -0
- package/src/util/retry.ts +12 -0
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,17 @@ The README mirrors the latest 5 entries — regenerate with `bun run changelog:s
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [0.12.16] - 2026-11-04
|
|
13
|
+
|
|
14
|
+
### Upgrade
|
|
15
|
+
- **Discord messenger:** Existing configurations whose `cwd` overlaps the messenger profile directory now fail closed. Set `cwd` to a directory disjoint from the profile before restarting; re-running setup preserves an explicitly configured `cwd` and does not relocate files. Changing `cwd` starts new sessions for existing threads.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- **Discord concurrency** — independent threads can progress concurrently, and same-thread follow-ups wait without occupying a worker slot; rejected full-queue requests receive a failure status without creating a thread.
|
|
19
|
+
- **Discord sandbox setup** — create fresh workspaces outside private configuration paths, validate workspace admission before replacing settings, include `sandbox_request` in the default tool list, and keep messenger profile files outside workspace and permission grants.
|
|
20
|
+
- **Antigravity model responses** — retry malformed function-call responses within the existing retry limit without executing incomplete tool calls or replaying already-streamed output.
|
|
21
|
+
- **Windows sandbox** — preserve owner-only ACL protection and serialize authority directory lifecycle.
|
|
22
|
+
|
|
12
23
|
## [0.12.15] - 2026-11-03
|
|
13
24
|
|
|
14
25
|
The GitHub-only 0.12.14 release was not published to npm; its changes (listed under [0.12.14]) are included in this release.
|
|
@@ -2671,7 +2682,8 @@ _Initial release._
|
|
|
2671
2682
|
### Added
|
|
2672
2683
|
- Initial jeo-code agent and CLI.
|
|
2673
2684
|
|
|
2674
|
-
[Unreleased]: https://github.com/akillness/jeo-code/compare/v0.12.
|
|
2685
|
+
[Unreleased]: https://github.com/akillness/jeo-code/compare/v0.12.16...HEAD
|
|
2686
|
+
[0.12.16]: https://github.com/akillness/jeo-code/releases/tag/v0.12.16
|
|
2675
2687
|
[0.12.6]: https://github.com/akillness/jeo-code/releases/tag/v0.12.6
|
|
2676
2688
|
[0.11.4]: https://github.com/akillness/jeo-code/releases/tag/v0.11.4
|
|
2677
2689
|
[0.11.3]: https://github.com/akillness/jeo-code/releases/tag/v0.11.3
|
package/README.ja.md
CHANGED
|
@@ -447,10 +447,10 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
447
447
|
|
|
448
448
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
449
449
|
- **[Unreleased]**
|
|
450
|
+
- **[0.12.16]** (2026-11-04)
|
|
450
451
|
- **[0.12.15]** (2026-11-03)
|
|
451
452
|
- **[0.12.14]** (2026-11-03)
|
|
452
453
|
- **[0.12.13]** (2026-11-03)
|
|
453
|
-
- **[0.12.12]** (2026-11-03)
|
|
454
454
|
|
|
455
455
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
456
456
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -502,10 +502,10 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
502
502
|
|
|
503
503
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
504
504
|
- **[Unreleased]**
|
|
505
|
+
- **[0.12.16]** (2026-11-04)
|
|
505
506
|
- **[0.12.15]** (2026-11-03)
|
|
506
507
|
- **[0.12.14]** (2026-11-03)
|
|
507
508
|
- **[0.12.13]** (2026-11-03)
|
|
508
|
-
- **[0.12.12]** (2026-11-03)
|
|
509
509
|
|
|
510
510
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
511
511
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -491,10 +491,10 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
|
|
|
491
491
|
|
|
492
492
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
493
493
|
- **[Unreleased]**
|
|
494
|
+
- **[0.12.16]** (2026-11-04)
|
|
494
495
|
- **[0.12.15]** (2026-11-03)
|
|
495
496
|
- **[0.12.14]** (2026-11-03)
|
|
496
497
|
- **[0.12.13]** (2026-11-03)
|
|
497
|
-
- **[0.12.12]** (2026-11-03)
|
|
498
498
|
|
|
499
499
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
500
500
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -445,10 +445,10 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
445
445
|
|
|
446
446
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
447
447
|
- **[Unreleased]**
|
|
448
|
+
- **[0.12.16]** (2026-11-04)
|
|
448
449
|
- **[0.12.15]** (2026-11-03)
|
|
449
450
|
- **[0.12.14]** (2026-11-03)
|
|
450
451
|
- **[0.12.13]** (2026-11-03)
|
|
451
|
-
- **[0.12.12]** (2026-11-03)
|
|
452
452
|
|
|
453
453
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
454
454
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import * as fs from "node:fs/promises";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
-
import { canonicalSandboxPath, createSandboxPolicy, sandboxPathContains } from "./policy";
|
|
4
|
+
import { canonicalSandboxPath, createSandboxPolicy, sandboxPathContains, windowsSystemRoot } from "./policy";
|
|
5
5
|
import type { SandboxConfig, SandboxMount, SandboxSupervisor, SandboxTermination } from "./types";
|
|
6
6
|
|
|
7
7
|
/** Constructed by trusted parent entry points, never from model/tool arguments. */
|
|
@@ -50,61 +50,124 @@ export interface GrantAuthorityOptions {
|
|
|
50
50
|
|
|
51
51
|
type DecisionWaiter = { resolve(value: GrantRequest): void; reject(reason: Error): void };
|
|
52
52
|
|
|
53
|
+
const pendingPrivateDirectories = new Map<string, Promise<string>>();
|
|
54
|
+
|
|
53
55
|
/** Create app-private state, or verify existing state without widening its ACL. */
|
|
54
56
|
export async function ensurePrivateSandboxDirectory(directory: string): Promise<string> {
|
|
55
57
|
const canonical = await canonicalSandboxPath(directory, false);
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
const key = process.platform === "win32" ? canonical.toLowerCase() : canonical;
|
|
59
|
+
const active = pendingPrivateDirectories.get(key);
|
|
60
|
+
if (active) return active;
|
|
61
|
+
const operation = (async (): Promise<string> => {
|
|
62
|
+
if (process.platform === "win32") {
|
|
63
|
+
const systemRoot = windowsSystemRoot();
|
|
64
|
+
await fs.mkdir(path.dirname(canonical), { recursive: true });
|
|
65
|
+
// Fixed parent-owned program; paths are data, never interpolated into code.
|
|
66
|
+
// Windows PowerShell 5.1 provides .NET Framework's ACL creation and SetAccessControl APIs.
|
|
67
|
+
// Note: Win32 CreateDirectoryW applies explicit ACEs from SECURITY_ATTRIBUTES but strips
|
|
68
|
+
// SE_DACL_PROTECTED unless SetAccessControl (SetNamedSecurityInfoW) is called afterwards.
|
|
69
|
+
const script = `
|
|
63
70
|
$ErrorActionPreference = 'Stop'
|
|
71
|
+
$mutex = New-Object System.Threading.Mutex($false, 'Local\\jeo-sandbox-private-directory')
|
|
72
|
+
$locked = $false
|
|
64
73
|
try {
|
|
74
|
+
try { $locked = $mutex.WaitOne(30000) } catch [System.Threading.AbandonedMutexException] { $locked = $true }
|
|
75
|
+
if (-not $locked) { throw 'mutex timeout' }
|
|
65
76
|
$directory = $env:JEO_SANDBOX_PRIVATE_DIRECTORY
|
|
66
|
-
$
|
|
77
|
+
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
|
|
78
|
+
$sid = $identity.User
|
|
79
|
+
$tokenOwner = $identity.Owner
|
|
67
80
|
$inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
|
|
81
|
+
$accessRule = New-Object System.Security.AccessControl.FileSystemAccessRule($sid, 'FullControl', $inherit, 'None', 'Allow')
|
|
82
|
+
$daclSecurity = New-Object System.Security.AccessControl.DirectorySecurity
|
|
83
|
+
$daclSecurity.SetAccessRuleProtection($true, $false)
|
|
84
|
+
$daclSecurity.AddAccessRule($accessRule)
|
|
85
|
+
$created = $false
|
|
68
86
|
if (-not [System.IO.Directory]::Exists($directory)) {
|
|
69
|
-
$
|
|
70
|
-
$
|
|
71
|
-
$
|
|
72
|
-
$
|
|
73
|
-
|
|
74
|
-
|
|
87
|
+
$createSecurity = New-Object System.Security.AccessControl.DirectorySecurity
|
|
88
|
+
$createSecurity.SetAccessRuleProtection($true, $false)
|
|
89
|
+
$createSecurity.SetOwner($sid)
|
|
90
|
+
$createSecurity.AddAccessRule($accessRule)
|
|
91
|
+
[void][System.IO.Directory]::CreateDirectory($directory, $createSecurity)
|
|
92
|
+
$created = $true
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
if (([System.IO.File]::GetAttributes($directory) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'reparse point' }
|
|
96
|
+
if ($created) {
|
|
97
|
+
[System.IO.Directory]::SetAccessControl($directory, $daclSecurity)
|
|
98
|
+
$currentOwner = [System.IO.Directory]::GetAccessControl($directory).GetOwner([System.Security.Principal.SecurityIdentifier])
|
|
99
|
+
if ($currentOwner.Value -ne $sid.Value) {
|
|
100
|
+
$ownerSecurity = [System.IO.Directory]::GetAccessControl($directory)
|
|
101
|
+
$ownerSecurity.SetAccessRuleProtection($true, $false)
|
|
102
|
+
$ownerSecurity.SetOwner($sid)
|
|
103
|
+
[System.IO.Directory]::SetAccessControl($directory, $ownerSecurity)
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
$existing = [System.IO.Directory]::GetAccessControl($directory)
|
|
107
|
+
$existingOwner = $existing.GetOwner([System.Security.Principal.SecurityIdentifier])
|
|
108
|
+
$existingRules = $existing.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])
|
|
109
|
+
if ($existingRules.Count -eq 1) {
|
|
110
|
+
$existingRule = $existingRules[0]
|
|
111
|
+
$ownerMatchesToken = ($existingOwner.Value -eq $sid.Value) -or ($null -ne $tokenOwner -and $existingOwner.Value -eq $tokenOwner.Value)
|
|
112
|
+
if ($ownerMatchesToken -and $existingRule.IdentityReference.Value -eq $sid.Value -and -not $existingRule.IsInherited -and $existingRule.AccessControlType -eq 'Allow' -and $existingRule.FileSystemRights -eq 'FullControl' -and $existingRule.InheritanceFlags -eq $inherit -and $existingRule.PropagationFlags -eq 'None') {
|
|
113
|
+
if (-not $existing.AreAccessRulesProtected) {
|
|
114
|
+
[System.IO.Directory]::SetAccessControl($directory, $daclSecurity)
|
|
115
|
+
}
|
|
116
|
+
if ($existingOwner.Value -ne $sid.Value) {
|
|
117
|
+
$ownerSecurity = [System.IO.Directory]::GetAccessControl($directory)
|
|
118
|
+
$ownerSecurity.SetAccessRuleProtection($true, $false)
|
|
119
|
+
$ownerSecurity.SetOwner($sid)
|
|
120
|
+
[System.IO.Directory]::SetAccessControl($directory, $ownerSecurity)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
$actual = [System.IO.Directory]::GetAccessControl($directory)
|
|
126
|
+
if (-not $actual.AreAccessRulesProtected -or $actual.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $sid.Value) { throw 'owner or inheritance' }
|
|
127
|
+
$rules = $actual.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])
|
|
128
|
+
if ($rules.Count -ne 1) { throw 'unexpected access rules' }
|
|
129
|
+
$rule = $rules[0]
|
|
130
|
+
if ($rule.IdentityReference.Value -ne $sid.Value -or $rule.IsInherited -or $rule.AccessControlType -ne 'Allow' -or $rule.FileSystemRights -ne 'FullControl' -or $rule.InheritanceFlags -ne $inherit -or $rule.PropagationFlags -ne 'None') { throw 'non-private rule' }
|
|
131
|
+
} catch {
|
|
132
|
+
if ($created) { try { [System.IO.Directory]::Delete($directory, $false) } catch {} }
|
|
133
|
+
throw
|
|
75
134
|
}
|
|
76
|
-
if (([System.IO.File]::GetAttributes($directory) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'reparse point' }
|
|
77
|
-
$actual = [System.IO.Directory]::GetAccessControl($directory)
|
|
78
|
-
if (-not $actual.AreAccessRulesProtected -or $actual.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $sid.Value) { throw 'owner or inheritance' }
|
|
79
|
-
$rules = $actual.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])
|
|
80
|
-
if ($rules.Count -ne 1) { throw 'unexpected access rules' }
|
|
81
|
-
$rule = $rules[0]
|
|
82
|
-
if ($rule.IdentityReference.Value -ne $sid.Value -or $rule.IsInherited -or $rule.AccessControlType -ne 'Allow' -or $rule.FileSystemRights -ne 'FullControl' -or $rule.InheritanceFlags -ne $inherit -or $rule.PropagationFlags -ne 'None') { throw 'non-private rule' }
|
|
83
135
|
[Console]::Out.Write('JEO_PRIVATE_DIRECTORY_VERIFIED')
|
|
84
136
|
exit 0
|
|
85
137
|
} catch { exit 1 }
|
|
138
|
+
finally {
|
|
139
|
+
if ($locked) { try { $mutex.ReleaseMutex() } catch {} }
|
|
140
|
+
$mutex.Dispose()
|
|
141
|
+
}
|
|
86
142
|
`;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
143
|
+
const executable = path.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
144
|
+
const child = Bun.spawn([executable, "-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], {
|
|
145
|
+
env: { SystemRoot: systemRoot, JEO_SANDBOX_PRIVATE_DIRECTORY: canonical },
|
|
146
|
+
stdin: "ignore", stdout: "pipe", stderr: "ignore",
|
|
147
|
+
});
|
|
148
|
+
const timer = setTimeout(() => child.kill(), 30_000);
|
|
149
|
+
try {
|
|
150
|
+
const [exitCode, output] = await Promise.all([child.exited, new Response(child.stdout).text()]);
|
|
151
|
+
if (exitCode !== 0 || output !== "JEO_PRIVATE_DIRECTORY_VERIFIED") {
|
|
152
|
+
throw new Error("SANDBOX_STATE: private Windows owner-only ACL could not be verified");
|
|
153
|
+
}
|
|
154
|
+
} finally { clearTimeout(timer); }
|
|
155
|
+
} else {
|
|
156
|
+
await fs.mkdir(canonical, { recursive: true, mode: 0o700 });
|
|
157
|
+
}
|
|
158
|
+
const stat = await fs.lstat(canonical);
|
|
159
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() ||
|
|
160
|
+
(process.platform !== "win32" && ((stat.mode & 0o077) !== 0 || stat.uid !== process.getuid?.()))) {
|
|
161
|
+
throw new Error("SANDBOX_STATE: authority state must be a private owner-only directory");
|
|
162
|
+
}
|
|
163
|
+
return canonical;
|
|
164
|
+
})();
|
|
165
|
+
pendingPrivateDirectories.set(key, operation);
|
|
166
|
+
try {
|
|
167
|
+
return await operation;
|
|
168
|
+
} finally {
|
|
169
|
+
if (pendingPrivateDirectories.get(key) === operation) pendingPrivateDirectories.delete(key);
|
|
106
170
|
}
|
|
107
|
-
return canonical;
|
|
108
171
|
}
|
|
109
172
|
|
|
110
173
|
/** All mutable authority remains in this parent closure, outside worker mounts. */
|
|
@@ -19,8 +19,11 @@ export async function prepareHandsBundle(): Promise<string> {
|
|
|
19
19
|
const temporary = path.join(staging, "hands.js");
|
|
20
20
|
try {
|
|
21
21
|
const output = await fs.open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
22
|
-
try {
|
|
23
|
-
|
|
22
|
+
try {
|
|
23
|
+
await output.writeFile(source);
|
|
24
|
+
await output.sync();
|
|
25
|
+
if (process.platform !== "win32") await output.chmod(0o400);
|
|
26
|
+
} finally { await output.close(); }
|
|
24
27
|
try { await fs.link(temporary, file); }
|
|
25
28
|
catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
|
|
26
29
|
} finally { await fs.rm(staging, { recursive: true, force: true }); }
|
|
@@ -76,6 +76,14 @@ export function createDockerBackend(options: DockerBackendOptions = {}): Sandbox
|
|
|
76
76
|
Bun.spawn([...argv], { env: environment, stdin: "pipe", stdout: "pipe", stderr: "pipe" }) as unknown as DockerChild);
|
|
77
77
|
const command = (args: readonly string[]) => run([executable, ...args]);
|
|
78
78
|
|
|
79
|
+
function normalizeMountSource(value: string): string {
|
|
80
|
+
if (platform !== "win32") return value;
|
|
81
|
+
const match = /^\/(?:run\/desktop\/mnt\/host|host_mnt)\/([a-zA-Z])(?:\/(.*))?$/.exec(value.replace(/\\/g, "/"));
|
|
82
|
+
if (!match) return value;
|
|
83
|
+
const drive = `${match[1]!.toUpperCase()}:\\`;
|
|
84
|
+
return match[2] ? path.win32.join(drive, ...match[2].split("/").filter(Boolean)) : drive;
|
|
85
|
+
}
|
|
86
|
+
|
|
79
87
|
async function checked(args: readonly string[], context: string): Promise<string> {
|
|
80
88
|
let result: DockerCommandResult;
|
|
81
89
|
try { result = await command(args); }
|
|
@@ -158,8 +166,9 @@ export function createDockerBackend(options: DockerBackendOptions = {}): Sandbox
|
|
|
158
166
|
if (!labels?.[OWNER] || !labels[SESSION] || !/^\d+$/.test(labels[REVISION] ?? "") || !Array.isArray(record.Mounts)) throw new Error("SANDBOX_UNAVAILABLE: unknown managed ownership or mounts");
|
|
159
167
|
for (const mount of record.Mounts) {
|
|
160
168
|
if (mount.Type === "tmpfs" && mount.Destination === "/tmp") continue;
|
|
161
|
-
|
|
162
|
-
|
|
169
|
+
const normalizedSource = typeof mount.Source === "string" ? normalizeMountSource(mount.Source) : "";
|
|
170
|
+
if (mount.Type !== "bind" || !normalizedSource || !path.isAbsolute(normalizedSource)) throw new Error("SANDBOX_UNAVAILABLE: unknown managed mount metadata");
|
|
171
|
+
const source = await canonicalSandboxPath(normalizedSource);
|
|
163
172
|
if (sources.some(candidate => candidate !== source && (sandboxPathContains(candidate, source) || sandboxPathContains(source, candidate)))) {
|
|
164
173
|
throw new Error("SANDBOX_MOUNT_OVERLAP: managed mount roots must be identical or disjoint");
|
|
165
174
|
}
|
|
@@ -262,7 +271,8 @@ export function createDockerBackend(options: DockerBackendOptions = {}): Sandbox
|
|
|
262
271
|
if (actual.length !== expected.length) throw new Error("SANDBOX_UNAVAILABLE: unexpected container bind mounts");
|
|
263
272
|
for (const mount of expected) {
|
|
264
273
|
const observed = actual.find(candidate => candidate.Destination === mount.guest);
|
|
265
|
-
|
|
274
|
+
const normalizedObserved = observed?.Source ? normalizeMountSource(observed.Source) : "";
|
|
275
|
+
if (!normalizedObserved || await canonicalSandboxPath(normalizedObserved) !== mount.source || observed?.RW !== mount.writable) throw new Error("SANDBOX_UNAVAILABLE: actual container bind configuration changed");
|
|
266
276
|
}
|
|
267
277
|
if (record.State?.Running === true) break;
|
|
268
278
|
if (Date.now() >= deadline) throw new Error("SANDBOX_UNAVAILABLE: container did not start");
|