windows-development-sandbox 1.12.0 → 1.13.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/README.md +1 -0
- package/config.example.json +11 -1
- package/dist/app/features/access/access.def.d.ts +190 -0
- package/dist/app/features/access/access.def.js +273 -0
- package/dist/app/features/access/access.run.d.ts +79 -0
- package/dist/app/features/access/access.run.js +129 -0
- package/dist/app/features/access/index.d.ts +2 -0
- package/dist/app/features/access/index.js +3 -0
- package/dist/app/features/agents/agent-entry.js +5 -0
- package/dist/app/features/agents/agents.def.js +1 -0
- package/dist/app/features/apps/apps.def.js +1 -0
- package/dist/app/features/audit/audit.def.d.ts +11 -2
- package/dist/app/features/audit/audit.def.js +7 -4
- package/dist/app/features/audit/audit.run.d.ts +3 -0
- package/dist/app/features/defs.d.ts +155 -3
- package/dist/app/features/defs.js +12 -1
- package/dist/app/features/files/files.def.js +1 -0
- package/dist/app/features/health/health.def.js +1 -0
- package/dist/app/features/sandbox/sandbox.def.js +3 -0
- package/dist/app/features/sessions/sessions.def.d.ts +36 -0
- package/dist/app/features/sessions/sessions.def.js +77 -9
- package/dist/app/features/sessions/sessions.run.d.ts +25 -2
- package/dist/app/features/sessions/sessions.run.js +71 -3
- package/dist/app/features/settings/settings.def.d.ts +8 -0
- package/dist/app/features/settings/settings.def.js +17 -0
- package/dist/app/features/web/web.def.d.ts +9 -0
- package/dist/app/features/web/web.def.js +26 -1
- package/dist/app/features/web/web.run.d.ts +3 -0
- package/dist/app/features/web/web.run.js +11 -3
- package/dist/app/kernel/access.d.ts +33 -0
- package/dist/app/kernel/access.js +44 -0
- package/dist/app/kernel/define.d.ts +14 -1
- package/dist/app/kernel/index.d.ts +1 -0
- package/dist/app/kernel/index.js +1 -0
- package/dist/app/kernel/rpc.d.ts +5 -2
- package/dist/app/registry.d.ts +4 -1
- package/dist/app/registry.js +24 -4
- package/dist/cli/commands/registry.js +2 -0
- package/dist/cli/commands/remote.d.ts +2 -0
- package/dist/cli/commands/remote.js +49 -0
- package/dist/cli/commands/sessions/reopen.js +3 -0
- package/dist/cli/commands/web.js +66 -0
- package/dist/cli/manifest.d.ts +63 -4
- package/dist/cli/manifest.js +33 -4
- package/dist/cli/menu/io-menu.d.ts +5 -3
- package/dist/cli/menu/io-menu.js +12 -5
- package/dist/cli/menu/remote-menu.d.ts +19 -0
- package/dist/cli/menu/remote-menu.js +108 -0
- package/dist/cli/menu/views.js +2 -0
- package/dist/cli/operations/handler.js +15 -1
- package/dist/cli/services/remote-client.d.ts +16 -0
- package/dist/cli/services/remote-client.js +141 -0
- package/dist/cli/services/sessions-service.d.ts +5 -0
- package/dist/cli/services/sessions-service.js +17 -1
- package/dist/core/config/defaults.js +11 -1
- package/dist/core/local-config/file.js +16 -2
- package/dist/core/local-config/overrides.js +7 -0
- package/dist/core/run/versions.js +9 -8
- package/dist/core/schema/config.d.ts +22 -3
- package/dist/core/schema/config.js +39 -2
- package/dist/core/schema/web.d.ts +51 -0
- package/dist/core/schema/web.js +46 -1
- package/dist/core/util/fs.d.ts +5 -0
- package/dist/core/util/fs.js +23 -0
- package/dist/core/util/index.d.ts +1 -1
- package/dist/core/util/index.js +1 -1
- package/dist/guest/daemon/daemon.js +2 -0
- package/dist/guest/daemon/idle-shutdown.js +2 -1
- package/dist/guest/daemon/sessions.d.ts +16 -2
- package/dist/guest/daemon/sessions.js +126 -20
- package/dist/guest/git/decision.js +7 -0
- package/dist/guest/sessions/cmdline.d.ts +14 -0
- package/dist/guest/sessions/cmdline.js +10 -0
- package/dist/guest/toolchains/setup.js +3 -0
- package/dist/host/audit/log.d.ts +3 -2
- package/dist/host/audit/log.js +3 -3
- package/dist/host/channel/channel.d.ts +5 -1
- package/dist/host/channel/channel.js +12 -0
- package/dist/host/guard/agent-files.js +7 -1
- package/dist/host/guard/commands.js +13 -1
- package/dist/host/guard/git-config.js +27 -7
- package/dist/host/guard/git-dirs.js +78 -3
- package/dist/host/guard/mark-of-the-web.js +3 -1
- package/dist/host/guard/nested-repos.js +7 -2
- package/dist/host/guard/store.js +1 -0
- package/dist/host/guard/types.d.ts +2 -0
- package/dist/host/launch/mappings.js +11 -1
- package/dist/host/launch/project-scan.js +2 -21
- package/dist/host/launch/stage.js +4 -0
- package/dist/host/life/session-ledger.js +2 -1
- package/dist/host/remote/access-store.d.ts +156 -0
- package/dist/host/remote/access-store.js +308 -0
- package/dist/host/remote/cert.d.ts +20 -0
- package/dist/host/remote/cert.js +103 -0
- package/dist/host/remote/door-client.d.ts +13 -0
- package/dist/host/remote/door-client.js +54 -0
- package/dist/host/remote/firewall.d.ts +44 -0
- package/dist/host/remote/firewall.js +118 -0
- package/dist/host/remote/gate.d.ts +125 -0
- package/dist/host/remote/gate.js +391 -0
- package/dist/host/remote/index.d.ts +8 -0
- package/dist/host/remote/index.js +10 -0
- package/dist/host/remote/lan.d.ts +24 -0
- package/dist/host/remote/lan.js +88 -0
- package/dist/host/remote/tls-store.d.ts +22 -0
- package/dist/host/remote/tls-store.js +63 -0
- package/dist/host/remote/windows-helper.d.ts +67 -0
- package/dist/host/remote/windows-helper.js +129 -0
- package/dist/host/web/bridge.d.ts +5 -1
- package/dist/host/web/bridge.js +6 -2
- package/dist/host/web/browser.js +1 -1
- package/dist/host/web/control.d.ts +4 -1
- package/dist/host/web/control.js +28 -4
- package/dist/host/web/rpc.d.ts +16 -2
- package/dist/host/web/rpc.js +37 -4
- package/dist/host/web/runner.d.ts +4 -0
- package/dist/host/web/runner.js +12 -0
- package/dist/host/web/server.d.ts +18 -2
- package/dist/host/web/server.js +97 -24
- package/dist/host/web/state.d.ts +5 -0
- package/dist/host/web/state.js +8 -0
- package/dist/runtime/agent.js +74 -3
- package/dist/runtime/daemon.js +205 -24
- package/dist/runtime/db.js +74 -3
- package/dist/runtime/gitguard.js +6 -0
- package/dist/runtime/main.js +77 -3
- package/dist/runtime/netproxy.js +74 -3
- package/dist/runtime/view.js +76 -3
- package/dist/selftest/app.test.js +41 -6
- package/dist/selftest/channel.test.js +16 -0
- package/dist/selftest/cmdline.test.js +16 -0
- package/dist/selftest/git-access.test.js +8 -0
- package/dist/selftest/hostile-input.test.js +12 -1
- package/dist/selftest/index.js +2 -0
- package/dist/selftest/live-daemon.test.js +25 -0
- package/dist/selftest/local-config.test.js +30 -0
- package/dist/selftest/remote.test.d.ts +2 -0
- package/dist/selftest/remote.test.js +536 -0
- package/dist/selftest/repo-guard.test.js +56 -2
- package/dist/selftest/resume.test.js +2 -1
- package/dist/selftest/sharing.test.js +9 -2
- package/dist/selftest/support/fixtures.d.ts +13 -0
- package/dist/selftest/support/web-preview.js +63 -0
- package/dist/selftest/web.test.js +2 -0
- package/dist/shared/channel/schema.d.ts +23 -0
- package/dist/shared/channel/schema.js +8 -0
- package/dist/shared/pty/protocol.d.ts +2 -0
- package/dist/shared/pty/protocol.js +2 -0
- package/dist/web/index.html +1 -1
- package/dist/web/main-Z2WYAE2Z.js +221 -0
- package/native/csharp/HostTools/Program.cs +17 -1
- package/native/csharp/HostTools/WebHelper.cs +499 -0
- package/native/scripts/get-network-profile.ps1 +19 -0
- package/native/scripts/set-folder-access.ps1 +11 -0
- package/native/scripts/set-web-firewall.ps1 +103 -0
- package/package.json +1 -1
- package/sandbox/csharp/PtyHost.cs +20 -0
- package/schemas/config.schema.json +65 -0
- package/schemas/sandbox-config.schema.json +0 -18
- package/dist/web/main-UD4QCXZR.js +0 -219
|
@@ -54,6 +54,8 @@ export class Daemon {
|
|
|
54
54
|
return execCommand(req);
|
|
55
55
|
case 'restart':
|
|
56
56
|
return sessions.restart(req);
|
|
57
|
+
case 'close':
|
|
58
|
+
return sessions.close(req);
|
|
57
59
|
case 'pressure':
|
|
58
60
|
// No response: the host repeats it every few seconds while its memory stays low.
|
|
59
61
|
return governor.hostPressure(req.seconds, req.reason, req.level ?? 'slow');
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// A headless sandbox with nothing running (or starting) only wastes RAM, so it shuts itself down.
|
|
1
|
+
// A headless sandbox with nothing running (or starting) only wastes RAM, so it shuts itself down. Every open session
|
|
2
|
+
// counts, a plain shell as much as an agent: whether it is busy cannot be told from outside, and closing it says so.
|
|
2
3
|
import { spawnSync } from 'node:child_process';
|
|
3
4
|
import { SYSTEM } from '../../shared/system.js';
|
|
4
5
|
import { saveClaudeMem } from '../setup/claude-mem.js';
|
|
@@ -23,7 +23,10 @@ export declare class SessionManager {
|
|
|
23
23
|
roots(): SessionRoot[];
|
|
24
24
|
private rootStarted;
|
|
25
25
|
private rootEnded;
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* `session` requests: an agent (or a plain shell) in its own ptyhost, in the launch folder's project or a registered
|
|
28
|
+
* one.
|
|
29
|
+
*/
|
|
27
30
|
start(req: SessionRequest): Promise<void>;
|
|
28
31
|
/**
|
|
29
32
|
* `project` requests: provision a registered project without an agent (sandbox start / exec / db from its folder).
|
|
@@ -31,10 +34,21 @@ export declare class SessionManager {
|
|
|
31
34
|
prepare(req: Extract<GuestRequest, {
|
|
32
35
|
kind: 'project';
|
|
33
36
|
}>): Promise<void>;
|
|
34
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* `restart` requests: the matching sessions restart their agent (continuing the conversation where it can). Shells
|
|
39
|
+
* are left alone: restarting one would only throw away what it was doing.
|
|
40
|
+
*/
|
|
35
41
|
restart(req: Extract<GuestRequest, {
|
|
36
42
|
kind: 'restart';
|
|
37
43
|
}>): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* `close` requests: the session's ptyhost ends its program and everything it started (ProcessTree), then exits as
|
|
46
|
+
* if the program had. One that does not end in time has its ptyhost ended instead (closing the pseudo console ends
|
|
47
|
+
* what is attached to it).
|
|
48
|
+
*/
|
|
49
|
+
close(req: Extract<GuestRequest, {
|
|
50
|
+
kind: 'close';
|
|
51
|
+
}>): Promise<void>;
|
|
38
52
|
/**
|
|
39
53
|
* Finds out which of Claude's conversation files this session writes (it appears with the first message) and puts
|
|
40
54
|
* it in the status, so the host can reopen this very conversation. A reopened session starts with the one it was
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
// `session` and `
|
|
2
|
-
// folder's project (the boot environment) or in a project registered later, which is
|
|
3
|
-
// (daemon/project).
|
|
1
|
+
// `session`, `restart` and `close` requests: agent sessions and plain shell sessions, each in its own ptyhost process.
|
|
2
|
+
// A session runs in the launch folder's project (the boot environment) or in a project registered later, which is
|
|
3
|
+
// provisioned first (daemon/project). A shell gets the same environment as an agent, without the agent's own
|
|
4
|
+
// environment (its keys) and without the agent's install, hooks and conversation tracking.
|
|
5
|
+
import fs from 'node:fs';
|
|
4
6
|
import path from 'node:path';
|
|
5
7
|
import crypto from 'node:crypto';
|
|
6
8
|
import os from 'node:os';
|
|
@@ -10,7 +12,8 @@ import { isWithin } from '../../shared/paths/projects.js';
|
|
|
10
12
|
import { now } from '../../shared/channel/run-dir.js';
|
|
11
13
|
import { SYSTEM } from '../../shared/system.js';
|
|
12
14
|
import { buildJobs, cargoJobs, linkSlotsFor, usableCpus } from '../../shared/memory/index.js';
|
|
13
|
-
import { commandLine } from '../sessions/cmdline.js';
|
|
15
|
+
import { commandLine, shellLaunch } from '../sessions/cmdline.js';
|
|
16
|
+
import { pwshDir } from '../toolchains/tools/pwsh.js';
|
|
14
17
|
import { freePort } from '../runtime/ports.js';
|
|
15
18
|
import { primaryProject } from '../boot/project-status.js';
|
|
16
19
|
import { ensurePtyHost, ptyHostLogTail, startPtyHost, waitForPtyHost } from '../sessions/pty-host.js';
|
|
@@ -30,7 +33,9 @@ import { isConversationId } from '../../shared/paths/conversation.js';
|
|
|
30
33
|
import { errMsg } from '../../core/util/errors.js';
|
|
31
34
|
import { provisioner } from './project-provisioner.js';
|
|
32
35
|
import { IdleShutdown } from './idle-shutdown.js';
|
|
33
|
-
import { insidePrimary, respond, safely, saveStatus } from './state.js';
|
|
36
|
+
import { insidePrimary, insideWorkspace, respond, safely, saveStatus } from './state.js';
|
|
37
|
+
/** How long a close waits for the session to end before it ends its ptyhost instead. */
|
|
38
|
+
const CLOSE_WAIT_MS = 8000;
|
|
34
39
|
/** An ended session's ptyhost is remembered this long: its last programs reach the audit up to 15 s later. */
|
|
35
40
|
const ROOTS_KEPT_MS = 10 * 60 * 1000;
|
|
36
41
|
function terminalTab(id, title, cwd) {
|
|
@@ -95,16 +100,45 @@ function writeBudget(cpus, memoryGb, jobs, env) {
|
|
|
95
100
|
warn('budget.json could not be written: ' + errMsg(e));
|
|
96
101
|
}
|
|
97
102
|
}
|
|
98
|
-
/**
|
|
99
|
-
const
|
|
103
|
+
/** The agent, installed if it is not yet; its environment may hold its API keys. */
|
|
104
|
+
const agentProgram = async (req, env, say) => {
|
|
100
105
|
const harness = harnessFor(req);
|
|
101
106
|
// Never refresh here: main already did, and replacing a running agent's executable would fail.
|
|
102
107
|
say('Preparing ' + req.harness);
|
|
103
108
|
await agents.provision(req.harness, harness, env, false);
|
|
104
109
|
const launch = agents.resolveLaunch(req.harness, harness);
|
|
105
110
|
const base = [...launch.args, ...harness.arguments];
|
|
111
|
+
return {
|
|
112
|
+
command: commandLine(launch.file, [...base, ...req.args]),
|
|
113
|
+
restartCommand: commandLine(launch.file, [...base, ...(harness.resumeArguments ?? [])]),
|
|
114
|
+
environment: harness.environment,
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
/** Where the shells are: PowerShell 7 from the toolchains (or an installed copy), Windows' own PowerShell and cmd. */
|
|
118
|
+
function shellPaths() {
|
|
119
|
+
const candidates = [
|
|
120
|
+
path.join(pwshDir(), 'pwsh.exe'),
|
|
121
|
+
path.join(process.env.ProgramFiles ?? 'C:\\Program Files', 'PowerShell', '7', 'pwsh.exe'),
|
|
122
|
+
];
|
|
123
|
+
return { pwsh: candidates.find((f) => fs.existsSync(f)) ?? null, powershell: SYSTEM.powershell(), cmd: SYSTEM.cmd() };
|
|
124
|
+
}
|
|
125
|
+
/** A plain shell: no install, no arguments of the request, nothing of the agent's environment. */
|
|
126
|
+
const shellProgram = (shell) => {
|
|
127
|
+
const line = commandLine(shell.file, shell.args);
|
|
128
|
+
return { command: line, restartCommand: line, environment: {} };
|
|
129
|
+
};
|
|
130
|
+
/** Starts ptyhost for the program and waits until it listens; a failure carries the tail of ptyhost's own log. */
|
|
131
|
+
const launchPtyHost = async (req, program, remote, env, sessions, onSpawn) => {
|
|
106
132
|
const port = await freePort();
|
|
107
133
|
const token = crypto.randomBytes(24).toString('base64url');
|
|
134
|
+
const sessionEnv = {
|
|
135
|
+
...withRegistryPath(env),
|
|
136
|
+
...program.environment,
|
|
137
|
+
...memoryEnv(env, sessions),
|
|
138
|
+
};
|
|
139
|
+
// Each PowerShell builds its own module path; one inherited from another PowerShell breaks its modules.
|
|
140
|
+
if (req.shell)
|
|
141
|
+
delete sessionEnv.PSModulePath;
|
|
108
142
|
const child = startPtyHost({
|
|
109
143
|
exe: ensurePtyHost(),
|
|
110
144
|
port,
|
|
@@ -113,10 +147,10 @@ const launchPtyHost = async (req, remote, env, say, sessions, onSpawn) => {
|
|
|
113
147
|
rows: 32,
|
|
114
148
|
id: req.id,
|
|
115
149
|
cwd: req.cwd,
|
|
116
|
-
env:
|
|
150
|
+
env: sessionEnv,
|
|
117
151
|
remote,
|
|
118
|
-
command:
|
|
119
|
-
restartCommand:
|
|
152
|
+
command: program.command,
|
|
153
|
+
restartCommand: program.restartCommand,
|
|
120
154
|
});
|
|
121
155
|
onSpawn(child);
|
|
122
156
|
try {
|
|
@@ -134,6 +168,16 @@ const launchPtyHost = async (req, remote, env, say, sessions, onSpawn) => {
|
|
|
134
168
|
/** The launch folder's project, or the registered project this session is for (provisioned first if needed). */
|
|
135
169
|
const placeFor = async (req, say) => {
|
|
136
170
|
if (!req.project) {
|
|
171
|
+
// A project set up earlier (the web UI and sandbox shell name it by its folder only): its own environment.
|
|
172
|
+
const known = insidePrimary(req.cwd) ? null : provisioner.provisionedFor(req.cwd);
|
|
173
|
+
if (known && exists(req.cwd))
|
|
174
|
+
return {
|
|
175
|
+
env: known.env,
|
|
176
|
+
hooks: known.spec.hooks ? projectHooks(known.spec.name, known.spec.workspace) : null,
|
|
177
|
+
project: known.spec.name,
|
|
178
|
+
};
|
|
179
|
+
if (!insidePrimary(req.cwd) && insideWorkspace(req.cwd))
|
|
180
|
+
throw new Error('The project in ' + req.cwd + ' is not set up in the sandbox yet: run sandbox start in its folder first.');
|
|
137
181
|
if (!insidePrimary(req.cwd) || !exists(req.cwd))
|
|
138
182
|
throw new Error('Folder is not inside the sandbox workspace ' + workspace + ': ' + req.cwd);
|
|
139
183
|
return { env: process.env, hooks: null, project: primaryProject() };
|
|
@@ -187,16 +231,31 @@ export class SessionManager {
|
|
|
187
231
|
if (r && r.to === undefined)
|
|
188
232
|
this.ptyHosts.set(id, { ...r, to: Date.now() });
|
|
189
233
|
}
|
|
190
|
-
/**
|
|
234
|
+
/**
|
|
235
|
+
* `session` requests: an agent (or a plain shell) in its own ptyhost, in the launch folder's project or a registered
|
|
236
|
+
* one.
|
|
237
|
+
*/
|
|
191
238
|
async start(req) {
|
|
239
|
+
let shell = null;
|
|
240
|
+
let problem = null;
|
|
241
|
+
if (req.shell)
|
|
242
|
+
try {
|
|
243
|
+
shell = shellLaunch(req.shell, shellPaths());
|
|
244
|
+
}
|
|
245
|
+
catch (e) {
|
|
246
|
+
problem = errMsg(e);
|
|
247
|
+
}
|
|
248
|
+
// A shell is listed by its own name (pwsh, powershell, cmd).
|
|
249
|
+
const name = shell?.name ?? (req.shell ? 'shell' : req.harness);
|
|
192
250
|
let status = {
|
|
193
251
|
id: req.id,
|
|
194
252
|
launchId: session.launchId,
|
|
195
|
-
harness:
|
|
253
|
+
harness: name,
|
|
254
|
+
...(req.shell ? { kind: 'shell' } : {}),
|
|
196
255
|
cwd: req.cwd,
|
|
197
256
|
...(req.project ? { project: req.project.name } : {}),
|
|
198
257
|
phase: 'starting',
|
|
199
|
-
message: 'Starting ' +
|
|
258
|
+
message: 'Starting ' + name,
|
|
200
259
|
updatedAt: now(),
|
|
201
260
|
startedAt: now(),
|
|
202
261
|
};
|
|
@@ -209,30 +268,34 @@ export class SessionManager {
|
|
|
209
268
|
this.starting++;
|
|
210
269
|
// Sessions after the first get their own tab right away (it shows the setup progress until the agent starts).
|
|
211
270
|
if (req.id !== session.launchId && process.env.AGENT_NO_TABS !== '1')
|
|
212
|
-
safely('terminal tab', () => terminalTab(req.id,
|
|
271
|
+
safely('terminal tab', () => terminalTab(req.id, name + ' ' + path.basename(req.cwd), req.cwd));
|
|
213
272
|
try {
|
|
273
|
+
if (problem)
|
|
274
|
+
throw new Error(problem);
|
|
214
275
|
const place = await placeFor(req, say);
|
|
276
|
+
const program = shell ? shellProgram(shell) : await agentProgram(req, place.env, say);
|
|
215
277
|
const remote = session.networking && session.attach.enabled;
|
|
216
278
|
const from = Date.now();
|
|
217
|
-
const { child, port, token } = await launchPtyHost(req, remote, place.env,
|
|
218
|
-
const
|
|
279
|
+
const { child, port, token } = await launchPtyHost(req, program, remote, place.env, this.live.size + 1, (c) => this.rootStarted(req.id, c, from));
|
|
280
|
+
const agent = shell === null;
|
|
281
|
+
const resumed = agent && req.harness === 'claude' ? resumedConversation(req.args) : undefined;
|
|
219
282
|
const running = {
|
|
220
283
|
...status,
|
|
221
284
|
...(resumed ? { conversation: resumed } : {}),
|
|
222
285
|
project: place.project,
|
|
223
286
|
phase: 'running',
|
|
224
|
-
message:
|
|
287
|
+
message: name + ' is running',
|
|
225
288
|
attach: { addresses: remote ? guestAddresses() : [], port, token },
|
|
226
289
|
};
|
|
227
290
|
this.live.set(req.id, { status: running, child });
|
|
228
291
|
child.once('exit', (code) => this.onExit(running, place, code));
|
|
229
292
|
saveStatus(running);
|
|
230
|
-
if (req.harness === 'claude')
|
|
293
|
+
if (agent && req.harness === 'claude')
|
|
231
294
|
this.trackConversation(running.id, resumed);
|
|
232
295
|
log('Session ' +
|
|
233
296
|
req.id +
|
|
234
297
|
' started (' +
|
|
235
|
-
|
|
298
|
+
name +
|
|
236
299
|
' in ' +
|
|
237
300
|
req.cwd +
|
|
238
301
|
', project ' +
|
|
@@ -271,15 +334,53 @@ export class SessionManager {
|
|
|
271
334
|
this.idle.check();
|
|
272
335
|
}
|
|
273
336
|
}
|
|
274
|
-
/**
|
|
337
|
+
/**
|
|
338
|
+
* `restart` requests: the matching sessions restart their agent (continuing the conversation where it can). Shells
|
|
339
|
+
* are left alone: restarting one would only throw away what it was doing.
|
|
340
|
+
*/
|
|
275
341
|
async restart(req) {
|
|
276
342
|
const targets = [...this.live.values()].filter((s) => s.status.attach &&
|
|
343
|
+
s.status.kind !== 'shell' &&
|
|
277
344
|
(req.harness === null || s.status.harness === req.harness) &&
|
|
278
345
|
(!req.cwd || isWithin(req.cwd, s.status.cwd)));
|
|
279
346
|
for (const s of targets)
|
|
280
347
|
await sendControl({ addresses: ['127.0.0.1'], port: s.status.attach.port, token: s.status.attach.token }, FrameType.Restart);
|
|
281
348
|
respond(req.id, { ok: true, message: 'restarted ' + targets.length + ' session(s)' });
|
|
282
349
|
}
|
|
350
|
+
/**
|
|
351
|
+
* `close` requests: the session's ptyhost ends its program and everything it started (ProcessTree), then exits as
|
|
352
|
+
* if the program had. One that does not end in time has its ptyhost ended instead (closing the pseudo console ends
|
|
353
|
+
* what is attached to it).
|
|
354
|
+
*/
|
|
355
|
+
async close(req) {
|
|
356
|
+
const entry = this.live.get(req.session);
|
|
357
|
+
if (!entry?.status.attach) {
|
|
358
|
+
respond(req.id, { ok: false, message: 'Session ' + req.session + ' is not running.' });
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const { child, status } = entry;
|
|
362
|
+
const ended = new Promise((resolve) => {
|
|
363
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
364
|
+
return resolve(true);
|
|
365
|
+
const timer = setTimeout(() => resolve(false), CLOSE_WAIT_MS);
|
|
366
|
+
child.once('exit', () => {
|
|
367
|
+
clearTimeout(timer);
|
|
368
|
+
resolve(true);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
try {
|
|
372
|
+
await sendControl({ addresses: ['127.0.0.1'], port: status.attach.port, token: status.attach.token }, FrameType.Close);
|
|
373
|
+
}
|
|
374
|
+
catch (e) {
|
|
375
|
+
warn('Session ' + req.session + ' did not take the close request: ' + errMsg(e));
|
|
376
|
+
}
|
|
377
|
+
if (!(await ended)) {
|
|
378
|
+
warn('Session ' + req.session + ' did not end in time; ending its session host.');
|
|
379
|
+
safely('ending a session host', () => child.kill());
|
|
380
|
+
}
|
|
381
|
+
log('Session ' + req.session + ' closed on request');
|
|
382
|
+
respond(req.id, { ok: true, message: 'Closed ' + status.harness + ' session ' + req.session + '.' });
|
|
383
|
+
}
|
|
283
384
|
/**
|
|
284
385
|
* Finds out which of Claude's conversation files this session writes (it appears with the first message) and puts
|
|
285
386
|
* it in the status, so the host can reopen this very conversation. A reopened session starts with the one it was
|
|
@@ -324,6 +425,11 @@ export class SessionManager {
|
|
|
324
425
|
attach: undefined,
|
|
325
426
|
});
|
|
326
427
|
log('Session ' + running.id + ' ended (exit ' + code + ')');
|
|
428
|
+
// A shell ran no agent: no post-agent hooks, nothing of an agent's to save.
|
|
429
|
+
if (running.kind === 'shell') {
|
|
430
|
+
this.idle.check();
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
327
433
|
if (place.hooks)
|
|
328
434
|
void runHooksAsync('post-agent', place.env, place.hooks).catch((e) => warn('post-agent hooks failed: ' + errMsg(e)));
|
|
329
435
|
else
|
|
@@ -57,6 +57,8 @@ const LISTING = {
|
|
|
57
57
|
/** Global options that take a value (`git -C dir status`). Config overrides (-c, --config-env) are refused outright. */
|
|
58
58
|
const WITH_VALUE = new Set(['-C', '--namespace']);
|
|
59
59
|
const FORBIDDEN_GLOBAL = /^(-c|--config-env|--exec-path|--git-dir|--work-tree|--super-prefix)(=|$)/;
|
|
60
|
+
/** Options of reading commands that run a program or write a file (grep -O<cmd>, ls-remote --upload-pack, --output). */
|
|
61
|
+
const RUNS_OR_WRITES = /^(--open-files-in-pager|--upload-pack|--output|--ext-diff)(=|$)/;
|
|
60
62
|
export function gitDecision(args, access) {
|
|
61
63
|
if (access === 'full')
|
|
62
64
|
return { allow: true };
|
|
@@ -72,6 +74,11 @@ export function gitDecision(args, access) {
|
|
|
72
74
|
i += WITH_VALUE.has(args[i]) ? 2 : 1;
|
|
73
75
|
}
|
|
74
76
|
const [command, ...rest] = args.slice(i);
|
|
77
|
+
const runsOrWrites = rest.find((a) => RUNS_OR_WRITES.test(a) ||
|
|
78
|
+
(command === 'ls-remote' && /^-u(.|$)/.test(a)) ||
|
|
79
|
+
(command === 'grep' && /^-O/.test(a)));
|
|
80
|
+
if (runsOrWrites)
|
|
81
|
+
return { allow: false, reason: 'git ' + runsOrWrites + ' is not allowed: git is read-only in this sandbox.' };
|
|
75
82
|
if (command === undefined || command === '--version' || READ_ONLY.has(command) || (LISTING[command]?.(rest) ?? false))
|
|
76
83
|
return { allow: true };
|
|
77
84
|
return {
|
|
@@ -10,3 +10,17 @@ export declare function quoteArg(arg: string): string;
|
|
|
10
10
|
export declare function cmdArgs(args: string[]): string;
|
|
11
11
|
/** The command line ptyhost starts; when the program is cmd.exe its arguments are quoted for cmd (cmdArgs). */
|
|
12
12
|
export declare function commandLine(file: string, args: string[]): string;
|
|
13
|
+
/** A plain shell session's program: which shell, its absolute path and its arguments. */
|
|
14
|
+
export interface ShellLaunch {
|
|
15
|
+
readonly name: 'pwsh' | 'powershell' | 'cmd';
|
|
16
|
+
readonly file: string;
|
|
17
|
+
readonly args: string[];
|
|
18
|
+
}
|
|
19
|
+
/** Where the shells are in this sandbox (pwsh: null when PowerShell 7 is not installed). */
|
|
20
|
+
export interface ShellPaths {
|
|
21
|
+
readonly pwsh: string | null;
|
|
22
|
+
readonly powershell: string;
|
|
23
|
+
readonly cmd: string;
|
|
24
|
+
}
|
|
25
|
+
/** The shell a session asked for runs as (auto: PowerShell 7 when present, else Windows PowerShell). Pure. */
|
|
26
|
+
export declare function shellLaunch(asked: 'auto' | ShellLaunch['name'], paths: ShellPaths): ShellLaunch;
|
|
@@ -31,3 +31,13 @@ export function commandLine(file, args) {
|
|
|
31
31
|
? [quoteArg(file), cmdArgs(args)].filter(Boolean).join(' ')
|
|
32
32
|
: [file, ...args].map(quoteArg).join(' ');
|
|
33
33
|
}
|
|
34
|
+
/** The shell a session asked for runs as (auto: PowerShell 7 when present, else Windows PowerShell). Pure. */
|
|
35
|
+
export function shellLaunch(asked, paths) {
|
|
36
|
+
if (asked === 'cmd')
|
|
37
|
+
return { name: 'cmd', file: paths.cmd, args: [] };
|
|
38
|
+
if (asked === 'powershell' || (asked === 'auto' && !paths.pwsh))
|
|
39
|
+
return { name: 'powershell', file: paths.powershell, args: ['-NoLogo'] };
|
|
40
|
+
if (!paths.pwsh)
|
|
41
|
+
throw new Error('PowerShell 7 is not installed in this sandbox (toolchains.pwsh). Open powershell or cmd instead, or turn pwsh on.');
|
|
42
|
+
return { name: 'pwsh', file: paths.pwsh, args: ['-NoLogo'] };
|
|
43
|
+
}
|
|
@@ -17,6 +17,9 @@ export async function setupToolchains(config, versions, options, run = {}) {
|
|
|
17
17
|
env.summary.push(...part.summary);
|
|
18
18
|
}
|
|
19
19
|
Object.assign(env.vars, config.environment);
|
|
20
|
+
// The git guard's variables (AGENT_GIT_ACCESS, GIT_DIR, GIT_CONFIG_*, BASH_ENV) always win over
|
|
21
|
+
// toolchains.environment.
|
|
22
|
+
Object.assign(env.vars, envs.get('Git')?.vars);
|
|
20
23
|
log('Set up ' + steps.length + ' toolchains and databases.');
|
|
21
24
|
return env;
|
|
22
25
|
}
|
package/dist/host/audit/log.d.ts
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
export declare const AUDIT_KINDS: readonly ["proc", "file", "commit", "session", "guard", "note"];
|
|
2
|
+
export declare const AUDIT_KINDS: readonly ["proc", "file", "commit", "session", "guard", "note", "remote"];
|
|
3
3
|
export type AuditKind = (typeof AUDIT_KINDS)[number];
|
|
4
4
|
/** A session id as the sandbox's run folder names them. */
|
|
5
5
|
export declare const AuditSessionIdSchema: z.ZodString;
|
|
6
6
|
/**
|
|
7
7
|
* One event. k: proc (a program started), file (changed in a project), commit (a git commit in a project), session,
|
|
8
|
-
* guard (repo guard), note.
|
|
8
|
+
* guard (repo guard), note, remote (what another Windows user or a paired device did in the web UI).
|
|
9
9
|
*/
|
|
10
10
|
export declare const AuditEventSchema: z.ZodObject<{
|
|
11
11
|
t: z.ZodString;
|
|
12
12
|
k: z.ZodEnum<{
|
|
13
13
|
file: "file";
|
|
14
|
+
remote: "remote";
|
|
14
15
|
session: "session";
|
|
15
16
|
note: "note";
|
|
16
17
|
commit: "commit";
|
package/dist/host/audit/log.js
CHANGED
|
@@ -6,12 +6,12 @@ import fs from 'node:fs';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import zlib from 'node:zlib';
|
|
8
8
|
import { z } from 'zod';
|
|
9
|
-
export const AUDIT_KINDS = ['proc', 'file', 'commit', 'session', 'guard', 'note'];
|
|
9
|
+
export const AUDIT_KINDS = ['proc', 'file', 'commit', 'session', 'guard', 'note', 'remote'];
|
|
10
10
|
/** A session id as the sandbox's run folder names them. */
|
|
11
11
|
export const AuditSessionIdSchema = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/);
|
|
12
12
|
/**
|
|
13
13
|
* One event. k: proc (a program started), file (changed in a project), commit (a git commit in a project), session,
|
|
14
|
-
* guard (repo guard), note.
|
|
14
|
+
* guard (repo guard), note, remote (what another Windows user or a paired device did in the web UI).
|
|
15
15
|
*/
|
|
16
16
|
export const AuditEventSchema = z.object({
|
|
17
17
|
/** ISO time. */
|
|
@@ -44,7 +44,7 @@ export const AuditEventSchema = z.object({
|
|
|
44
44
|
.string()
|
|
45
45
|
.regex(/^[0-9a-f]{7,64}$/)
|
|
46
46
|
.optional(),
|
|
47
|
-
/** commit: who git says made it ("Name <email>"). */
|
|
47
|
+
/** commit: who git says made it ("Name <email>"); remote: who did it ("device Josh's phone"). */
|
|
48
48
|
au: z.string().max(256).optional(),
|
|
49
49
|
});
|
|
50
50
|
/** Each text field's limit (the schema's): text is cut to it before writing, so a line is never refused on reading. */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { GuestRequest, GuestResponse, Harness, SandboxStatus, SessionStatus } from '../../core/types.js';
|
|
2
|
-
import type { ProjectSpec } from '../../shared/channel/index.js';
|
|
2
|
+
import type { ProjectSpec, ShellChoice } from '../../shared/channel/index.js';
|
|
3
3
|
/**
|
|
4
4
|
* The boot script writes its first status once Windows has booted the sandbox and logged on (a minute or two, longer on
|
|
5
5
|
* a slow PC or the very first start); none after this long means it never got going.
|
|
@@ -46,6 +46,10 @@ export declare class SandboxChannel {
|
|
|
46
46
|
exec(command: string[], cwd: string, timeoutSeconds: number): Promise<GuestResponse>;
|
|
47
47
|
/** Restarts running sessions in place (they resume via the harness's resumeArguments, e.g. claude --continue). */
|
|
48
48
|
restart(harness: string | null, cwd?: string): Promise<GuestResponse>;
|
|
49
|
+
/** Asks for a plain shell session in a folder of the sandbox; returns the request id (waitForSession follows it). */
|
|
50
|
+
requestShell(shell: ShellChoice, cwd: string): string;
|
|
51
|
+
/** Ends a running session (agent or shell) and everything it started; waits for the sandbox's answer. */
|
|
52
|
+
closeSession(sessionId: string): Promise<GuestResponse>;
|
|
49
53
|
/** The PC is low on memory or CPU: the sandbox slows its builds down for `seconds`. Never waits for an answer. */
|
|
50
54
|
pressure(reason: string, seconds: number, level?: 'slow' | 'severe'): void;
|
|
51
55
|
ping(): Promise<GuestResponse>;
|
|
@@ -179,6 +179,18 @@ export class SandboxChannel {
|
|
|
179
179
|
this.send({ kind: 'restart', id: requestId, harness, ...(cwd ? { cwd } : {}), createdAt: now() });
|
|
180
180
|
return this.waitForResponse(requestId, 30000);
|
|
181
181
|
}
|
|
182
|
+
/** Asks for a plain shell session in a folder of the sandbox; returns the request id (waitForSession follows it). */
|
|
183
|
+
requestShell(shell, cwd) {
|
|
184
|
+
const requestId = newId();
|
|
185
|
+
this.send({ kind: 'session', id: requestId, harness: 'shell', args: [], cwd, shell, createdAt: now() });
|
|
186
|
+
return requestId;
|
|
187
|
+
}
|
|
188
|
+
/** Ends a running session (agent or shell) and everything it started; waits for the sandbox's answer. */
|
|
189
|
+
async closeSession(sessionId) {
|
|
190
|
+
const requestId = newId();
|
|
191
|
+
this.send({ kind: 'close', id: requestId, session: sessionId, createdAt: now() });
|
|
192
|
+
return this.waitForResponse(requestId, 30000);
|
|
193
|
+
}
|
|
182
194
|
/** The PC is low on memory or CPU: the sandbox slows its builds down for `seconds`. Never waits for an answer. */
|
|
183
195
|
pressure(reason, seconds, level = 'slow') {
|
|
184
196
|
this.send({ kind: 'pressure', id: newId(), reason, seconds, level, createdAt: now() });
|
|
@@ -17,8 +17,11 @@ const CLAUDE_RISKY_KEYS = [
|
|
|
17
17
|
'enableAllProjectMcpServers',
|
|
18
18
|
'enabledMcpjsonServers',
|
|
19
19
|
'disableAllHooks',
|
|
20
|
+
// Plugins bring their own hooks and MCP servers.
|
|
21
|
+
'enabledPlugins',
|
|
22
|
+
'extraKnownMarketplaces',
|
|
20
23
|
];
|
|
21
|
-
const VSCODE_RISKY_KEY = /path|executable|command|interpreter|shell|profile|args|env|exec|program|server|launch|runtime|binary/i;
|
|
24
|
+
const VSCODE_RISKY_KEY = /path|executable|command|interpreter|shell|profile|args|env|exec|program|server|launch|runtime|binary|alternatetools|home|goroot|tsdk|sdk/i;
|
|
22
25
|
const MAX_AGENT_FILE_BYTES = 1024 * 1024;
|
|
23
26
|
function claudeRisk(d) {
|
|
24
27
|
const s = (_.isPlainObject(d) ? d : {});
|
|
@@ -26,6 +29,9 @@ function claudeRisk(d) {
|
|
|
26
29
|
return {
|
|
27
30
|
..._.pick(s, CLAUDE_RISKY_KEYS),
|
|
28
31
|
permissions: _.pick(permissions, ['defaultMode', 'additionalDirectories']),
|
|
32
|
+
// A whole-tool rule ("Bash", "Bash(*)") lets Claude Code on this PC run any command without asking; narrow rules
|
|
33
|
+
// (Bash(npm test), what "always allow" writes) are ordinary edits.
|
|
34
|
+
broadAllow: (Array.isArray(permissions.allow) ? permissions.allow : []).filter((r) => typeof r !== 'string' || !r.includes('(') || /\(\s*:?\*\s*\)\s*$/.test(r)),
|
|
29
35
|
};
|
|
30
36
|
}
|
|
31
37
|
/**
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { filesIn } from './file-hash.js';
|
|
2
2
|
import { quarantine } from './quarantine.js';
|
|
3
|
+
/** What cmd runs from the current folder: a fixed list plus this PC's PATHEXT (the Python installer adds .PY, .PYW). */
|
|
4
|
+
const EXTENSIONS = [
|
|
5
|
+
...new Set([
|
|
6
|
+
...['exe', 'com', 'bat', 'cmd', 'vbs', 'vbe', 'js', 'jse', 'wsf', 'wsh', 'msc', 'ps1', 'cpl'],
|
|
7
|
+
...(process.env.PATHEXT ?? '')
|
|
8
|
+
.split(';')
|
|
9
|
+
.map((e) => e.replace(/^\./, '').toLowerCase())
|
|
10
|
+
.filter((e) => /^[a-z0-9]{1,8}$/.test(e)),
|
|
11
|
+
]),
|
|
12
|
+
];
|
|
3
13
|
/** Names you are likely to run in a project folder; with any of these extensions Windows would pick the local copy. */
|
|
4
14
|
export const COMMANDS = new RegExp('^(' +
|
|
5
15
|
[
|
|
@@ -108,7 +118,9 @@ export const COMMANDS = new RegExp('^(' +
|
|
|
108
118
|
'ssh',
|
|
109
119
|
'scp',
|
|
110
120
|
].join('|') +
|
|
111
|
-
')\\.(
|
|
121
|
+
')\\.(' +
|
|
122
|
+
EXTENSIONS.join('|') +
|
|
123
|
+
')$', 'i');
|
|
112
124
|
/** Fingerprints of the files in the project root named like one of COMMANDS. */
|
|
113
125
|
export function plantedCommands(workspace) {
|
|
114
126
|
return filesIn(workspace, (n) => COMMANDS.test(n));
|
|
@@ -5,13 +5,28 @@ import _ from 'lodash';
|
|
|
5
5
|
import { writeFileAtomic } from '../../shared/io/index.js';
|
|
6
6
|
import { fileExists, isLink } from '../../core/util/index.js';
|
|
7
7
|
/** Config keys that make git run a program, or load more config. */
|
|
8
|
-
const DANGEROUS_KEY = /^(fsmonitor|hookspath|sshcommand|pager|editor|askpass|program|gpgprogram|textconv|command|cmd|clean|smudge|process|driver|helper|uploadpack|receivepack|path|attributesfile|excludesfile|worktree|worktreeconfig|gitproxy|proxycommand|sendemailvalidate|tool|external|difffilter|defaultkeycommand|alternaterefscommand)\s
|
|
9
|
-
|
|
8
|
+
const DANGEROUS_KEY = /^(fsmonitor|hookspath|sshcommand|pager|editor|askpass|program|gpgprogram|textconv|command|cmd|tocmd|cccmd|smtpserver|httpd|vcs|packobjectshook|clean|smudge|process|driver|helper|uploadpack|receivepack|path|attributesfile|excludesfile|worktree|worktreeconfig|gitproxy|proxycommand|sendemailvalidate|tool|external|difffilter|defaultkeycommand|alternaterefscommand)\s*(=|$)/i;
|
|
9
|
+
/**
|
|
10
|
+
* A section header as git reads it: a name (the old [section.sub] form included), then an optional quoted subsection
|
|
11
|
+
* in which `]` and escaped quotes are allowed ([filter "a]b"] must not hide the key after it).
|
|
12
|
+
*/
|
|
13
|
+
const SECTION = /^\s*\[\s*([A-Za-z0-9.-]+)(\s+"(?:[^"\\]|\\.)*")?\s*\]\s*/;
|
|
14
|
+
/** Sections where any key is risky: [protocol] turns on transports that run commands (ext::), [url] rewrites URLs. */
|
|
15
|
+
const RISKY_SECTIONS = new Set(['pager', 'protocol', 'url']);
|
|
10
16
|
const MARK = '# [agent-sandbox] disabled, added inside the sandbox: ';
|
|
11
17
|
const MAX_CONFIG_BYTES = 4 * 1024 * 1024;
|
|
12
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Sections where any key runs a program ([pager] log = x), where a "!" value does ([alias] st = !x, also quoted or on a
|
|
20
|
+
* continued line: every new alias line with a "!" counts), or a "!" submodule update command.
|
|
21
|
+
*/
|
|
13
22
|
function dangerousInSection(section, keyPart) {
|
|
14
|
-
|
|
23
|
+
if (keyPart === '' || /^[#;]/.test(keyPart))
|
|
24
|
+
return false;
|
|
25
|
+
if (RISKY_SECTIONS.has(section))
|
|
26
|
+
return true;
|
|
27
|
+
if (section === 'alias')
|
|
28
|
+
return keyPart.includes('!');
|
|
29
|
+
return section === 'submodule' && /^update\s*=/i.test(keyPart) && keyPart.includes('!');
|
|
15
30
|
}
|
|
16
31
|
/** The lines of <dir>\config (none for a link or a file over 4 MB). */
|
|
17
32
|
export function configLines(dir) {
|
|
@@ -30,16 +45,21 @@ export function disableConfig(dir, before) {
|
|
|
30
45
|
let section = '';
|
|
31
46
|
const fixed = lines.map((line) => {
|
|
32
47
|
const header = SECTION.exec(line);
|
|
48
|
+
// A line that starts like a header but is not one git would accept: treat it as unknown (risky) below.
|
|
49
|
+
const badHeader = !header && /^\s*\[/.test(line);
|
|
33
50
|
if (header) {
|
|
34
|
-
section = header[1].
|
|
35
|
-
inInclude =
|
|
51
|
+
section = header[1].toLowerCase().split('.')[0];
|
|
52
|
+
inInclude = section === 'include' || section === 'includeif';
|
|
36
53
|
}
|
|
37
54
|
seen[line] = (seen[line] ?? 0) + 1;
|
|
38
55
|
if (line.startsWith(MARK) || seen[line] <= (known[line] ?? 0))
|
|
39
56
|
return line;
|
|
40
57
|
// git also accepts "[core] fsmonitor = x" on one line, so look past a leading section header.
|
|
41
58
|
const keyPart = line.replace(SECTION, '').trim();
|
|
42
|
-
if (!(
|
|
59
|
+
if (!(badHeader ||
|
|
60
|
+
DANGEROUS_KEY.test(keyPart) ||
|
|
61
|
+
(inInclude && keyPart.includes('=')) ||
|
|
62
|
+
dangerousInSection(section, keyPart)))
|
|
43
63
|
return line;
|
|
44
64
|
findings.push({ kind: 'config', detail: line.trim() });
|
|
45
65
|
return MARK + line.trim();
|