windows-development-sandbox 1.12.0 → 1.13.2

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 (206) hide show
  1. package/README.md +1 -0
  2. package/config.example.json +11 -1
  3. package/dist/app/features/access/access.def.d.ts +190 -0
  4. package/dist/app/features/access/access.def.js +273 -0
  5. package/dist/app/features/access/access.run.d.ts +79 -0
  6. package/dist/app/features/access/access.run.js +129 -0
  7. package/dist/app/features/access/index.d.ts +2 -0
  8. package/dist/app/features/access/index.js +3 -0
  9. package/dist/app/features/accounts/accounts.def.d.ts +1 -0
  10. package/dist/app/features/accounts/accounts.def.js +12 -3
  11. package/dist/app/features/accounts/accounts.run.d.ts +1 -0
  12. package/dist/app/features/accounts/accounts.run.js +20 -11
  13. package/dist/app/features/agents/agent-entry.js +5 -0
  14. package/dist/app/features/agents/agents.def.js +1 -0
  15. package/dist/app/features/apps/apps.def.js +1 -0
  16. package/dist/app/features/audit/audit.def.d.ts +11 -2
  17. package/dist/app/features/audit/audit.def.js +7 -4
  18. package/dist/app/features/audit/audit.run.d.ts +3 -0
  19. package/dist/app/features/defs.d.ts +156 -3
  20. package/dist/app/features/defs.js +12 -1
  21. package/dist/app/features/files/files.def.js +1 -0
  22. package/dist/app/features/health/health.def.js +1 -0
  23. package/dist/app/features/sandbox/sandbox.def.js +3 -0
  24. package/dist/app/features/sessions/sessions.def.d.ts +36 -0
  25. package/dist/app/features/sessions/sessions.def.js +77 -9
  26. package/dist/app/features/sessions/sessions.run.d.ts +25 -2
  27. package/dist/app/features/sessions/sessions.run.js +71 -3
  28. package/dist/app/features/settings/settings-file.d.ts +7 -0
  29. package/dist/app/features/settings/settings-file.js +19 -2
  30. package/dist/app/features/settings/settings-rows.js +9 -1
  31. package/dist/app/features/settings/settings.def.d.ts +8 -0
  32. package/dist/app/features/settings/settings.def.js +17 -0
  33. package/dist/app/features/web/web.def.d.ts +9 -0
  34. package/dist/app/features/web/web.def.js +26 -1
  35. package/dist/app/features/web/web.run.d.ts +3 -0
  36. package/dist/app/features/web/web.run.js +11 -3
  37. package/dist/app/kernel/access.d.ts +33 -0
  38. package/dist/app/kernel/access.js +44 -0
  39. package/dist/app/kernel/define.d.ts +14 -1
  40. package/dist/app/kernel/index.d.ts +1 -0
  41. package/dist/app/kernel/index.js +1 -0
  42. package/dist/app/kernel/rpc.d.ts +5 -2
  43. package/dist/app/registry.d.ts +4 -1
  44. package/dist/app/registry.js +24 -4
  45. package/dist/bin/agent-sandbox.js +7 -1
  46. package/dist/bin/chunk-IL7BXJF2.js +7 -0
  47. package/dist/bin/chunk-K4SU3MHJ.js +10432 -0
  48. package/dist/bin/chunk-VENWSGX2.js +78437 -0
  49. package/dist/bin/claude-host.js +10 -4
  50. package/dist/bin/claude-sbx.js +8 -2
  51. package/dist/bin/codex-sbx.js +8 -2
  52. package/dist/bin/gemini-sbx.js +8 -2
  53. package/dist/bin/sandbox.js +7 -2
  54. package/dist/bin/sbx-hide.js +8 -2
  55. package/dist/bin/sbx-show.js +8 -2
  56. package/dist/cli/commands/launch/prepare.js +24 -7
  57. package/dist/cli/commands/launch/running.js +4 -0
  58. package/dist/cli/commands/registry.js +2 -0
  59. package/dist/cli/commands/remote.d.ts +2 -0
  60. package/dist/cli/commands/remote.js +49 -0
  61. package/dist/cli/commands/sessions/reopen.js +3 -0
  62. package/dist/cli/commands/storage/offer.js +1 -1
  63. package/dist/cli/commands/web.js +66 -0
  64. package/dist/cli/manifest.d.ts +63 -4
  65. package/dist/cli/manifest.js +33 -4
  66. package/dist/cli/menu/io-menu.d.ts +5 -3
  67. package/dist/cli/menu/io-menu.js +12 -5
  68. package/dist/cli/menu/live.d.ts +29 -0
  69. package/dist/cli/menu/live.js +92 -21
  70. package/dist/cli/menu/menu.d.ts +2 -1
  71. package/dist/cli/menu/menu.js +16 -2
  72. package/dist/cli/menu/pages/list-page.d.ts +5 -0
  73. package/dist/cli/menu/pages/list-page.js +19 -0
  74. package/dist/cli/menu/remote-menu.d.ts +19 -0
  75. package/dist/cli/menu/remote-menu.js +108 -0
  76. package/dist/cli/menu/shell.d.ts +2 -0
  77. package/dist/cli/menu/shell.js +1 -1
  78. package/dist/cli/menu/views.js +2 -0
  79. package/dist/cli/operations/handler.js +15 -1
  80. package/dist/cli/services/accounts-service.d.ts +7 -0
  81. package/dist/cli/services/accounts-service.js +20 -0
  82. package/dist/cli/services/remote-client.d.ts +16 -0
  83. package/dist/cli/services/remote-client.js +141 -0
  84. package/dist/cli/services/sessions-service.d.ts +5 -0
  85. package/dist/cli/services/sessions-service.js +17 -1
  86. package/dist/cli/tui/app.d.ts +4 -0
  87. package/dist/cli/tui/app.js +5 -0
  88. package/dist/cli/tui/terminal.d.ts +6 -0
  89. package/dist/cli/tui/terminal.js +8 -1
  90. package/dist/cli/validate.js +8 -4
  91. package/dist/core/config/defaults.js +11 -1
  92. package/dist/core/local-config/file.js +16 -2
  93. package/dist/core/local-config/overrides.js +7 -0
  94. package/dist/core/package-info.js +1 -1
  95. package/dist/core/run/versions.js +9 -8
  96. package/dist/core/schema/config.d.ts +22 -3
  97. package/dist/core/schema/config.js +39 -2
  98. package/dist/core/schema/web.d.ts +51 -0
  99. package/dist/core/schema/web.js +46 -1
  100. package/dist/core/util/fs.d.ts +5 -0
  101. package/dist/core/util/fs.js +23 -0
  102. package/dist/core/util/index.d.ts +1 -1
  103. package/dist/core/util/index.js +1 -1
  104. package/dist/guest/daemon/daemon.js +2 -0
  105. package/dist/guest/daemon/idle-shutdown.js +2 -1
  106. package/dist/guest/daemon/sessions.d.ts +16 -2
  107. package/dist/guest/daemon/sessions.js +126 -20
  108. package/dist/guest/git/decision.js +7 -0
  109. package/dist/guest/sessions/cmdline.d.ts +14 -0
  110. package/dist/guest/sessions/cmdline.js +10 -0
  111. package/dist/guest/toolchains/setup.js +3 -0
  112. package/dist/host/audit/log.d.ts +3 -2
  113. package/dist/host/audit/log.js +3 -3
  114. package/dist/host/channel/channel.d.ts +5 -1
  115. package/dist/host/channel/channel.js +12 -0
  116. package/dist/host/guard/agent-files.js +7 -1
  117. package/dist/host/guard/commands.js +13 -1
  118. package/dist/host/guard/git-config.js +27 -7
  119. package/dist/host/guard/git-dirs.js +78 -3
  120. package/dist/host/guard/mark-of-the-web.js +3 -1
  121. package/dist/host/guard/nested-repos.js +7 -2
  122. package/dist/host/guard/store.js +1 -0
  123. package/dist/host/guard/types.d.ts +2 -0
  124. package/dist/host/launch/mappings.js +11 -1
  125. package/dist/host/launch/project-scan.js +2 -21
  126. package/dist/host/launch/stage.js +4 -0
  127. package/dist/host/life/session-ledger.js +2 -1
  128. package/dist/host/remote/access-store.d.ts +156 -0
  129. package/dist/host/remote/access-store.js +308 -0
  130. package/dist/host/remote/cert.d.ts +20 -0
  131. package/dist/host/remote/cert.js +103 -0
  132. package/dist/host/remote/door-client.d.ts +13 -0
  133. package/dist/host/remote/door-client.js +54 -0
  134. package/dist/host/remote/firewall.d.ts +44 -0
  135. package/dist/host/remote/firewall.js +118 -0
  136. package/dist/host/remote/gate.d.ts +125 -0
  137. package/dist/host/remote/gate.js +391 -0
  138. package/dist/host/remote/index.d.ts +8 -0
  139. package/dist/host/remote/index.js +10 -0
  140. package/dist/host/remote/lan.d.ts +24 -0
  141. package/dist/host/remote/lan.js +88 -0
  142. package/dist/host/remote/tls-store.d.ts +22 -0
  143. package/dist/host/remote/tls-store.js +63 -0
  144. package/dist/host/remote/windows-helper.d.ts +67 -0
  145. package/dist/host/remote/windows-helper.js +129 -0
  146. package/dist/host/sandbox/platform.d.ts +2 -0
  147. package/dist/host/sandbox/platform.js +19 -4
  148. package/dist/host/storage/contained/state.d.ts +3 -2
  149. package/dist/host/storage/contained/state.js +5 -2
  150. package/dist/host/web/bridge.d.ts +5 -1
  151. package/dist/host/web/bridge.js +6 -2
  152. package/dist/host/web/browser.js +1 -1
  153. package/dist/host/web/control.d.ts +4 -1
  154. package/dist/host/web/control.js +28 -4
  155. package/dist/host/web/rpc.d.ts +16 -2
  156. package/dist/host/web/rpc.js +37 -4
  157. package/dist/host/web/runner.d.ts +4 -0
  158. package/dist/host/web/runner.js +12 -0
  159. package/dist/host/web/server.d.ts +18 -2
  160. package/dist/host/web/server.js +97 -24
  161. package/dist/host/web/state.d.ts +5 -0
  162. package/dist/host/web/state.js +8 -0
  163. package/dist/runtime/agent.js +74 -3
  164. package/dist/runtime/daemon.js +205 -24
  165. package/dist/runtime/db.js +74 -3
  166. package/dist/runtime/gitguard.js +6 -0
  167. package/dist/runtime/main.js +77 -3
  168. package/dist/runtime/netproxy.js +74 -3
  169. package/dist/runtime/view.js +76 -3
  170. package/dist/selftest/app.test.js +41 -6
  171. package/dist/selftest/channel.test.js +16 -0
  172. package/dist/selftest/cmdline.test.js +16 -0
  173. package/dist/selftest/git-access.test.js +8 -0
  174. package/dist/selftest/hostile-input.test.js +12 -1
  175. package/dist/selftest/index.js +2 -0
  176. package/dist/selftest/layout.test.js +10 -1
  177. package/dist/selftest/live-daemon.test.js +25 -0
  178. package/dist/selftest/local-config.test.js +30 -0
  179. package/dist/selftest/menu-perf.test.js +198 -12
  180. package/dist/selftest/menu.test.js +4 -0
  181. package/dist/selftest/remote.test.d.ts +2 -0
  182. package/dist/selftest/remote.test.js +536 -0
  183. package/dist/selftest/repo-guard.test.js +56 -2
  184. package/dist/selftest/resume.test.js +2 -1
  185. package/dist/selftest/sharing.test.js +9 -2
  186. package/dist/selftest/support/fixtures.d.ts +13 -0
  187. package/dist/selftest/support/web-preview.js +63 -0
  188. package/dist/selftest/web.test.js +2 -0
  189. package/dist/shared/channel/schema.d.ts +23 -0
  190. package/dist/shared/channel/schema.js +8 -0
  191. package/dist/shared/pty/protocol.d.ts +2 -0
  192. package/dist/shared/pty/protocol.js +2 -0
  193. package/dist/web/index.html +1 -1
  194. package/dist/web/main-NBQ3LF4E.js +221 -0
  195. package/native/csharp/HcsHost/AssemblyInfo.cs +3 -3
  196. package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
  197. package/native/csharp/HostTools/Program.cs +17 -1
  198. package/native/csharp/HostTools/WebHelper.cs +499 -0
  199. package/native/scripts/get-network-profile.ps1 +19 -0
  200. package/native/scripts/set-folder-access.ps1 +11 -0
  201. package/native/scripts/set-web-firewall.ps1 +103 -0
  202. package/package.json +1 -1
  203. package/sandbox/csharp/PtyHost.cs +20 -0
  204. package/schemas/config.schema.json +65 -0
  205. package/schemas/sandbox-config.schema.json +0 -18
  206. package/dist/web/main-UD4QCXZR.js +0 -219
@@ -1,14 +1,42 @@
1
1
  // What the menu shows about the running sandbox (the status line), kept fresh in the background. Drawing and keys only
2
- // read the last copy: the process list is fetched without blocking (every few seconds, and after an action), and the
3
- // screen is told when what it shows has changed. While it runs, the host's own "is the sandbox running?" questions are
4
- // answered from the same copy, so no page waits for a child process.
2
+ // read the last copy, and the screen is told when what it shows has changed. While it runs, the host's own "is the
3
+ // sandbox running?" questions are answered from the same copy, so no page waits for a child process.
4
+ //
5
+ // The process list (tasklist) is the expensive part: on a busy PC one run costs a large share of a second of CPU. So it
6
+ // is not fetched on a timer: while a sandbox runs, its processes are checked by id (no program started) and the list is
7
+ // fetched again only when one of them has ended; while none runs, the list is fetched less and less often while nothing
8
+ // changes (and again soon after a key press, someone is looking, or when the data folder's run folder changes, a start
9
+ // writes there), and after every action.
5
10
  import { sandboxStatus } from '../../app/features/sandbox/index.js';
6
- import { readTasklistAsync, useTasklistCache } from '../../host/sandbox/index.js';
11
+ import { insideSandbox, readTasklistAsync, sandboxPids, useTasklistCache } from '../../host/sandbox/index.js';
7
12
  /** The same rule as the web's live status: the sandbox status query's interval. */
8
13
  const DEFAULT_INTERVAL_MS = sandboxStatus.live?.everyMs ?? 2500;
14
+ /** While no sandbox runs, nobody presses a key and the data folder is quiet, the list is fetched this seldom. */
15
+ const DEFAULT_IDLE_MAX_MS = 30000;
16
+ /** The gap before the next process list after `unchanged` lists in a row found the same: doubling, up to `max`. */
17
+ export function listGap(unchanged, base, max) {
18
+ return Math.min(max, base * 2 ** Math.max(0, Math.min(unchanged, 16)));
19
+ }
20
+ /** A process with this id exists (EPERM: it does, but belongs to someone else, such as the sandbox's own service). */
21
+ function processAlive(pid) {
22
+ try {
23
+ process.kill(pid, 0);
24
+ return true;
25
+ }
26
+ catch (e) {
27
+ return e.code === 'EPERM';
28
+ }
29
+ }
9
30
  export class LiveStatus {
10
31
  o;
11
32
  tasklist;
33
+ /** The sandbox's processes in the last list (none: no sandbox was running). */
34
+ pids = [];
35
+ fetchedAt = 0;
36
+ /** Lists in a row that found the same answer (the gap to the next one grows with it). */
37
+ unchanged = 0;
38
+ /** The signal when the list was last fetched. */
39
+ signalled = '';
12
40
  text = '';
13
41
  timer;
14
42
  pending;
@@ -16,30 +44,72 @@ export class LiveStatus {
16
44
  constructor(o) {
17
45
  this.o = o;
18
46
  }
47
+ get interval() {
48
+ return this.o.intervalMs ?? DEFAULT_INTERVAL_MS;
49
+ }
19
50
  /** The status line as of the last refresh. */
20
51
  line() {
21
52
  return this.text;
22
53
  }
23
54
  /** Reads the process list now and derives the line (one fetch at a time; a caller during one waits for it). */
24
55
  refresh() {
25
- this.pending ??= (async () => {
26
- try {
27
- const list = await (this.o.fetch ?? readTasklistAsync)();
28
- // Stopped meanwhile: a command may be running, and nothing here may touch the host's facts any more.
29
- if (!this.running)
30
- return;
31
- this.tasklist = list;
32
- this.derive();
33
- }
34
- catch {
35
- // The line keeps what it showed; the next refresh tries again.
36
- }
37
- finally {
38
- this.pending = undefined;
39
- }
40
- })();
56
+ // The next gap starts again from the shortest one.
57
+ this.unchanged = -1;
58
+ return this.fetch();
59
+ }
60
+ fetch() {
61
+ // Cleared in a callback, so also after a fetch that finished without waiting for anything.
62
+ this.pending ??= this.load().finally(() => (this.pending = undefined));
41
63
  return this.pending;
42
64
  }
65
+ async load() {
66
+ try {
67
+ const needed = (this.o.needsList ?? (() => !insideSandbox()))();
68
+ this.signalled = this.o.signal?.() ?? '';
69
+ // Not needed: an empty copy, so nothing runs tasklist itself either.
70
+ const list = needed ? await (this.o.fetch ?? readTasklistAsync)() : '';
71
+ // Stopped meanwhile: a command may be running, and nothing here may touch the host's facts any more.
72
+ if (!this.running)
73
+ return;
74
+ const pids = sandboxPids(list);
75
+ const same = this.tasklist !== undefined && pids.length > 0 === this.pids.length > 0;
76
+ this.unchanged = same ? this.unchanged + 1 : 0;
77
+ this.tasklist = list;
78
+ this.pids = pids;
79
+ this.fetchedAt = Date.now();
80
+ this.derive();
81
+ }
82
+ catch {
83
+ // The line keeps what it showed; the next refresh tries again.
84
+ }
85
+ }
86
+ /** Whether the process list may have changed since it was fetched (checked on every tick, cheaply). */
87
+ listDue(now) {
88
+ if (this.tasklist === undefined)
89
+ return true;
90
+ if (this.pids.length)
91
+ return !this.pids.every((pid) => (this.o.alive ?? processAlive)(pid));
92
+ if (this.o.signal && this.o.signal() !== this.signalled)
93
+ return true;
94
+ return now - this.fetchedAt >= listGap(this.unchanged, this.interval, this.o.idleMaxMs ?? DEFAULT_IDLE_MAX_MS);
95
+ }
96
+ tick() {
97
+ if (this.pending)
98
+ return;
99
+ if (this.listDue(Date.now()))
100
+ void this.fetch();
101
+ else
102
+ this.derive();
103
+ }
104
+ /** A key was pressed: someone is looking, so a list older than one interval is fetched again (after the draw). */
105
+ touch() {
106
+ if (!this.running || this.pending || this.pids.length || Date.now() - this.fetchedAt < this.interval)
107
+ return;
108
+ setImmediate(() => {
109
+ if (this.running)
110
+ void this.refresh();
111
+ });
112
+ }
43
113
  derive() {
44
114
  const next = this.o.derive();
45
115
  if (next === this.text)
@@ -54,7 +124,7 @@ export class LiveStatus {
54
124
  return;
55
125
  this.running = true;
56
126
  useTasklistCache(() => this.tasklist);
57
- this.timer = setInterval(() => void this.refresh(), this.o.intervalMs ?? DEFAULT_INTERVAL_MS);
127
+ this.timer = setInterval(() => this.tick(), this.interval);
58
128
  this.timer.unref();
59
129
  await this.refresh();
60
130
  }
@@ -65,5 +135,6 @@ export class LiveStatus {
65
135
  this.timer = undefined;
66
136
  useTasklistCache(null);
67
137
  this.tasklist = undefined;
138
+ this.pids = [];
68
139
  }
69
140
  }
@@ -1,10 +1,11 @@
1
1
  import type { Context } from '../commands/context.js';
2
2
  import { Terminal } from '../tui/index.js';
3
- /** What a test may replace: the terminal, the process list fetch and how often it is fetched. */
3
+ /** What a test may replace: the terminal, the process list fetch, how often it is fetched and the process check. */
4
4
  export interface MenuOptions {
5
5
  readonly terminal?: Terminal;
6
6
  readonly fetchTasklist?: () => Promise<string>;
7
7
  readonly refreshMs?: number;
8
+ readonly processAlive?: (pid: number) => boolean;
8
9
  }
9
10
  export declare class Menu {
10
11
  private readonly ctx;
@@ -6,6 +6,7 @@ import path from 'node:path';
6
6
  import { loadConfig } from '../../core/config/index.js';
7
7
  import { errMsg } from '../../core/util/index.js';
8
8
  import { SandboxChannel } from '../../host/channel/index.js';
9
+ import { RunDir } from '../../shared/channel/index.js';
9
10
  import { isSandboxRunning } from '../../host/sandbox/index.js';
10
11
  import { lookAtSandbox } from '../commands/shared-use.js';
11
12
  import { commandLine } from '../history.js';
@@ -26,6 +27,15 @@ import { readMenuView, saveMenuView } from './memory.js';
26
27
  import { SECTION_LABELS, Shell } from './shell.js';
27
28
  /** Where the menu was last left: it opens there again (also in a later run, from menu-state.json). */
28
29
  let lastView;
30
+ /** When the data folder's run folder last changed (a start writes there): a stat, never a read of what is in it. */
31
+ function runFolderStamp(dataPath) {
32
+ try {
33
+ return String(fs.lstatSync(new RunDir(dataPath).root).mtimeMs);
34
+ }
35
+ catch {
36
+ return '';
37
+ }
38
+ }
29
39
  export class Menu {
30
40
  ctx;
31
41
  runArgv;
@@ -40,9 +50,12 @@ export class Menu {
40
50
  derive: () => this.status(),
41
51
  onChange: () => this.app.requestRedraw(),
42
52
  intervalMs: options.refreshMs,
53
+ alive: options.processAlive,
54
+ signal: () => runFolderStamp(ctx.dataPath),
43
55
  });
44
56
  this.app.status = () => this.live.line();
45
57
  this.app.afterAction = () => this.live.refresh();
58
+ this.app.onKey = () => this.live.touch();
46
59
  try {
47
60
  Frame.version =
48
61
  JSON.parse(fs.readFileSync(path.join(ctx.packageRoot, 'package.json'), 'utf8'))
@@ -95,7 +108,8 @@ export class Menu {
95
108
  }
96
109
  try {
97
110
  const shell = this.shell();
98
- await this.live.start();
111
+ // The first screen does not wait for the process list: the pages show "Loading…" until it is there.
112
+ this.app.ready = this.live.start();
99
113
  await shell.restore(lastView ?? readMenuView(SECTION_LABELS) ?? { section: 0, screens: {} });
100
114
  for (;;) {
101
115
  const exit = await this.app.show(shell);
@@ -112,7 +126,7 @@ export class Menu {
112
126
  if (code !== undefined)
113
127
  return code;
114
128
  }
115
- await this.live.start();
129
+ this.app.ready = this.live.start();
116
130
  await shell.reload();
117
131
  }
118
132
  }
@@ -57,6 +57,11 @@ export declare abstract class ListPage<T> implements Page {
57
57
  protected ticked(): T[];
58
58
  /** Loads the data again; a slow load does not hold up the keys (the tab keeps what it showed, then updates). */
59
59
  open(): Promise<void>;
60
+ /**
61
+ * The section came on screen again: it shows what it had at once and loads the data again after that screen is
62
+ * drawn (the list is rebuilt on the entry and search the user is on). The first time, it is open().
63
+ */
64
+ show(): Promise<void>;
60
65
  /**
61
66
  * Loads the data and waits for it at most QUICK_LOAD_MS. In time, the list is built here (`build`) or by the caller;
62
67
  * later, the list is built when the data arrives, on the entry and search the user has moved to meanwhile.
@@ -97,6 +97,23 @@ export class ListPage {
97
97
  async open() {
98
98
  await this.reload(true);
99
99
  }
100
+ /**
101
+ * The section came on screen again: it shows what it had at once and loads the data again after that screen is
102
+ * drawn (the list is rebuilt on the entry and search the user is on). The first time, it is open().
103
+ */
104
+ async show() {
105
+ if (!this.loaded)
106
+ return this.open();
107
+ const mine = ++this.generation;
108
+ setImmediate(() => {
109
+ if (mine !== this.generation)
110
+ return;
111
+ void this.loadData().then(() => {
112
+ if (mine === this.generation)
113
+ this.rebuildLate();
114
+ });
115
+ });
116
+ }
100
117
  /**
101
118
  * Loads the data and waits for it at most QUICK_LOAD_MS. In time, the list is built here (`build`) or by the caller;
102
119
  * later, the list is built when the data arrives, on the entry and search the user has moved to meanwhile.
@@ -125,6 +142,8 @@ export class ListPage {
125
142
  }
126
143
  async loadData() {
127
144
  try {
145
+ // The first screen is drawn before the menu's facts (the process list) are known; loads wait for them.
146
+ await this.app.ready;
128
147
  await this.load();
129
148
  }
130
149
  catch (e) {
@@ -0,0 +1,19 @@
1
+ import { type AnyView, type Caller } from '../../app/kernel/index.js';
2
+ import type { Context } from '../commands/context.js';
3
+ import type { RemoteClient } from '../services/remote-client.js';
4
+ import { type App } from '../tui/index.js';
5
+ /** The views of the owner's program this role may open (remote access itself is the owner's). */
6
+ export declare function remoteViews(role: Caller): AnyView[];
7
+ export declare class RemoteMenu {
8
+ private readonly app;
9
+ private readonly client;
10
+ private readonly owner;
11
+ private readonly caller;
12
+ private readonly operations;
13
+ constructor(app: App, ctx: Context, client: RemoteClient, owner: string, caller: Caller);
14
+ private title;
15
+ /** The list of areas, until Esc. */
16
+ start(): Promise<void>;
17
+ private page;
18
+ private showView;
19
+ }
@@ -0,0 +1,108 @@
1
+ // `sandbox remote`: the menu drawn over another Windows user's program (docs/design/remote-access.md). Every view the
2
+ // web UI has is drawn by the menu's own renderer (menuView) from the shared core's pure definitions; its queries and
3
+ // operations run on the owner's web UI over the RPC socket, which checks the role this user was given and writes each
4
+ // action to the owner's audit log. Nothing here runs on this user's own settings or data.
5
+ import { NAV, navViews, viewById } from '../../app/features/defs.js';
6
+ import { NO_QUESTIONS, allows, viewInputs, } from '../../app/kernel/index.js';
7
+ import { errMsg, safeText } from '../../core/util/index.js';
8
+ import { Theme } from '../tui/index.js';
9
+ import { MenuOperations } from './io-menu.js';
10
+ import { menuView } from './render-view.js';
11
+ /** The views of the owner's program this role may open (remote access itself is the owner's). */
12
+ export function remoteViews(role) {
13
+ return navViews(NAV)
14
+ .map((id) => viewById(id))
15
+ .filter((v) => v !== undefined)
16
+ .filter((v) => Object.values(v.queries).every((q) => allows(role, q.access ?? 'view')));
17
+ }
18
+ export class RemoteMenu {
19
+ app;
20
+ client;
21
+ owner;
22
+ caller;
23
+ operations;
24
+ constructor(app, ctx, client, owner, caller) {
25
+ this.app = app;
26
+ this.client = client;
27
+ this.owner = owner;
28
+ this.caller = caller;
29
+ this.operations = new MenuOperations(app, ctx, (id, input, io) => client.run(id, input, io));
30
+ }
31
+ title() {
32
+ return safeText(this.owner) + "'s sandbox";
33
+ }
34
+ /** The list of areas, until Esc. */
35
+ async start() {
36
+ const views = remoteViews(this.caller);
37
+ const role = this.caller.kind === 'owner' ? 'owner' : this.caller.role;
38
+ let at;
39
+ for (;;) {
40
+ const picked = await this.app.list({
41
+ path: [this.title()],
42
+ intro: [
43
+ 'You are using ' + safeText(this.owner) + "'s program with the role " + role + '.',
44
+ 'What you do runs as ' + safeText(this.owner) + ' and is written to their audit log.',
45
+ ],
46
+ items: views.map((v) => ({ value: v.id, label: v.title, description: v.summary })),
47
+ startAt: at,
48
+ escapeLabel: 'quit',
49
+ enterLabel: 'open',
50
+ });
51
+ if (picked.kind !== 'pick')
52
+ return;
53
+ at = picked.value;
54
+ const view = views.find((v) => v.id === picked.value);
55
+ if (view)
56
+ await this.showView(view);
57
+ }
58
+ }
59
+ async page(view) {
60
+ const inputs = viewInputs(view, {});
61
+ const data = {};
62
+ for (const [name, query] of Object.entries(view.queries))
63
+ data[name] = await this.client.run(query.id, inputs[name], NO_QUESTIONS);
64
+ return view.build(data, {});
65
+ }
66
+ async showView(view) {
67
+ let notice;
68
+ for (;;) {
69
+ let page;
70
+ try {
71
+ page = await this.page(view);
72
+ }
73
+ catch (e) {
74
+ await this.app.message({ path: [this.title(), view.title], lines: [safeText(errMsg(e))], tone: 'error' });
75
+ return;
76
+ }
77
+ const mv = menuView(page);
78
+ const result = await this.app.list({
79
+ path: [this.title(), view.title],
80
+ intro: page.notice ? [safeText(page.notice.text)] : undefined,
81
+ items: mv.items,
82
+ actions: mv.actions,
83
+ enterLabel: mv.enterLabel,
84
+ notice,
85
+ empty: page.empty?.text,
86
+ });
87
+ if (result.kind === 'back')
88
+ return;
89
+ const chosen = mv.resolve(result);
90
+ notice = undefined;
91
+ if (!chosen || chosen.kind === 'refresh')
92
+ continue;
93
+ const action = chosen.action;
94
+ if (action.kind === 'link') {
95
+ notice =
96
+ action.target.kind === 'terminal'
97
+ ? Theme.muted('Attach to it from your own terminal: sandbox attach ' + safeText(action.target.session))
98
+ : Theme.muted('That opens on ' + safeText(this.owner) + "'s side only.");
99
+ continue;
100
+ }
101
+ const done = await this.operations.perform(action, [this.title(), view.title], (text) => {
102
+ notice = text;
103
+ this.app.redraw();
104
+ });
105
+ notice = done.notice;
106
+ }
107
+ }
108
+ }
@@ -47,6 +47,8 @@ export interface Page {
47
47
  setFocus(list: boolean): void;
48
48
  /** The section was opened (or the shell came back from a command): load what it shows again. */
49
49
  open(): Promise<void>;
50
+ /** The section came on screen again: show what it had at once and load it again in the background (default open). */
51
+ show?(): Promise<void>;
50
52
  render(size: Size): FrameParts;
51
53
  /** A `quit` from the page means "Esc with nothing left to close": the shell moves to the navigation. */
52
54
  handle(key: Key): Promise<Step<ShellExit>> | Step<ShellExit>;
@@ -76,7 +76,7 @@ export class Shell {
76
76
  this.remember();
77
77
  this.active = Math.max(0, Math.min(this.pages.length - 1, index));
78
78
  this.page.setFocus(this.focus === 'list');
79
- await this.page.open();
79
+ await (this.page.show ?? this.page.open).call(this.page);
80
80
  if (this.restored.has(this.active))
81
81
  return;
82
82
  this.restored.add(this.active);
@@ -18,6 +18,7 @@ import { updatesView } from '../../app/features/updates/index.js';
18
18
  import { webView } from '../../app/features/web/index.js';
19
19
  import { vpnView } from '../../app/features/vpn/index.js';
20
20
  import { githubView } from '../../app/features/github/index.js';
21
+ import { accessView } from '../../app/features/access/index.js';
21
22
  export const MENU_VIEWS = [
22
23
  { view: agentsView, section: 'Agents' },
23
24
  { view: sessionsView, section: 'Sessions' },
@@ -34,6 +35,7 @@ export const MENU_VIEWS = [
34
35
  { view: updatesView, section: 'Maintenance' },
35
36
  { view: engineView, section: 'Maintenance' },
36
37
  { view: webView, section: 'Maintenance' },
38
+ { view: accessView, section: 'Maintenance' },
37
39
  // The Settings page draws the VPN row itself (its login is staged with the settings); the view lists its operations.
38
40
  { view: vpnView, section: 'Settings' },
39
41
  // The same for the GitHub token row.
@@ -1,6 +1,7 @@
1
1
  // Runs a definition as a command: its input from the command line, its questions on the terminal, and its output as
2
2
  // --json, as the definition's one-line text, or as the page of the view that shows it. A declined question prints
3
- // "Nothing changed." and exits 1, as the commands always have.
3
+ // "Nothing changed." and exits 1, as the commands always have. An operation that opens a session's terminal (a new
4
+ // shell) attaches this terminal to it afterwards.
4
5
  import { VIEWS } from '../../app/features/defs.js';
5
6
  import { runDefinition } from '../../app/registry.js';
6
7
  import { Cancelled } from '../../app/kernel/index.js';
@@ -33,6 +34,16 @@ function printOutput(def, output, input) {
33
34
  else
34
35
  console.log(JSON.stringify(output, null, 2));
35
36
  }
37
+ /** Attaches this terminal to the session an operation opened (in a script: says how to). */
38
+ async function attachOpened(ctx, session) {
39
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
40
+ nextStep('sandbox attach ' + session);
41
+ return 0;
42
+ }
43
+ // Loaded when needed: attach reaches the launch commands, which reach this handler (no import cycle).
44
+ const { attach } = await import('../commands/sessions/attach.js');
45
+ return attach({ ...ctx, options: { ...ctx.options, positionals: [session] } });
46
+ }
36
47
  /** The command for a definition. */
37
48
  export function definitionHandler(def) {
38
49
  return async (ctx) => {
@@ -47,6 +58,9 @@ export function definitionHandler(def) {
47
58
  return code;
48
59
  }
49
60
  printOutput(def, output, def.input.parse(input));
61
+ const opened = def.kind === 'operation' ? def.opens?.(output) : undefined;
62
+ if (opened?.kind === 'terminal')
63
+ return attachOpened(ctx, opened.session);
50
64
  return code;
51
65
  }
52
66
  catch (e) {
@@ -31,6 +31,13 @@ export declare class AccountsService {
31
31
  save(name: string, fromHost: boolean, agent?: string): AccountRef;
32
32
  /** Makes the sandbox use a saved login. Running sessions keep the old one until restart(). */
33
33
  activate(name: string, agent?: string): AccountRef;
34
+ /** The agent's default login, if it keeps logins and one is set. */
35
+ defaultName(agent?: string): string | null;
36
+ /**
37
+ * Makes the sandbox use `wanted`, else the agent's default, when it uses another login now (a launch, or a session
38
+ * started with --account). Returns the login it then uses; null when the agent keeps no login or none is saved.
39
+ */
40
+ ensure(agent: string, wanted: string | null): string | null;
34
41
  /** Whether a session of this agent is running that would pick up a new login on restart. */
35
42
  hasRunningSessions(agent?: string): boolean;
36
43
  /** Restarts the agent's running sessions (conversations continue); returns the sandbox's own message. */
@@ -74,6 +74,26 @@ export class AccountsService {
74
74
  store.activate(this.ctx.dataPath, name, stateDir);
75
75
  return { name, email: null };
76
76
  }
77
+ /** The agent's default login, if it keeps logins and one is set. */
78
+ defaultName(agent = CLAUDE) {
79
+ return agent === CLAUDE ? this.claude.defaultName() : this.other(agent).store.defaultName();
80
+ }
81
+ /**
82
+ * Makes the sandbox use `wanted`, else the agent's default, when it uses another login now (a launch, or a session
83
+ * started with --account). Returns the login it then uses; null when the agent keeps no login or none is saved.
84
+ */
85
+ ensure(agent, wanted) {
86
+ if (!this.agents().some((a) => a.name === agent && a.kept))
87
+ return null;
88
+ const name = wanted ?? this.defaultName(agent);
89
+ if (!name)
90
+ return this.activeName(agent);
91
+ if (name !== this.activeName(agent))
92
+ this.activate(name, agent);
93
+ else if (agent === CLAUDE)
94
+ this.claude.syncPlugins(this.ctx.dataPath);
95
+ return name;
96
+ }
77
97
  /** Whether a session of this agent is running that would pick up a new login on restart. */
78
98
  hasRunningSessions(agent = CLAUDE) {
79
99
  return (isSandboxRunning() && new SandboxChannel(this.ctx.dataPath).runningSessions().some((s) => s.harness === agent));
@@ -0,0 +1,16 @@
1
+ import { type OperationIO } from '../../app/kernel/index.js';
2
+ export declare class RemoteClient {
3
+ private readonly socket;
4
+ private readonly pending;
5
+ private next;
6
+ private constructor();
7
+ /** Trades the one-time token for a sign-in cookie, then opens the RPC socket. */
8
+ static connect(port: number, token: string): Promise<RemoteClient>;
9
+ private receive;
10
+ private send;
11
+ /** Runs a definition on the owner's side (a query or an operation) and returns its output. */
12
+ run(id: string, input: unknown, io: OperationIO): Promise<unknown>;
13
+ close(): void;
14
+ }
15
+ /** Connects, or fails with the reason and what to do. */
16
+ export declare function remoteClient(port: number, token: string): Promise<RemoteClient>;