@agimon-ai/doompi-web 0.0.1-alpha.0 → 0.0.1-alpha.3
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 +210 -28
- package/dist/bin/serve.cjs +1 -1
- package/dist/bin/serve.cjs.map +1 -1
- package/dist/bin/serve.mjs +1 -1
- package/dist/bin/serve.mjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.mts +2 -2
- package/dist/src/adapters/cockpitContainer.cjs +2 -0
- package/dist/src/adapters/cockpitContainer.cjs.map +1 -0
- package/dist/src/adapters/cockpitContainer.mjs +2 -0
- package/dist/src/adapters/cockpitContainer.mjs.map +1 -0
- package/dist/src/adapters/cockpitHandoff.cjs +2 -0
- package/dist/src/adapters/cockpitHandoff.cjs.map +1 -0
- package/dist/src/adapters/cockpitHandoff.mjs +2 -0
- package/dist/src/adapters/cockpitHandoff.mjs.map +1 -0
- package/dist/src/adapters/deviceAuth.cjs +2 -0
- package/dist/src/adapters/deviceAuth.cjs.map +1 -0
- package/dist/src/adapters/deviceAuth.mjs +2 -0
- package/dist/src/adapters/deviceAuth.mjs.map +1 -0
- package/dist/src/adapters/directoryListing.cjs +1 -1
- package/dist/src/adapters/directoryListing.cjs.map +1 -1
- package/dist/src/adapters/directoryListing.mjs +1 -1
- package/dist/src/adapters/directoryListing.mjs.map +1 -1
- package/dist/src/adapters/httpServer.cjs +1 -1
- package/dist/src/adapters/httpServer.cjs.map +1 -1
- package/dist/src/adapters/httpServer.d.cts +2 -0
- package/dist/src/adapters/httpServer.d.cts.map +1 -1
- package/dist/src/adapters/httpServer.d.mts +2 -0
- package/dist/src/adapters/httpServer.d.mts.map +1 -1
- package/dist/src/adapters/httpServer.mjs +1 -1
- package/dist/src/adapters/httpServer.mjs.map +1 -1
- package/dist/src/adapters/packageApiProxy.cjs +1 -1
- package/dist/src/adapters/packageApiProxy.cjs.map +1 -1
- package/dist/src/adapters/packageApiProxy.mjs +1 -1
- package/dist/src/adapters/packageApiProxy.mjs.map +1 -1
- package/dist/src/adapters/providerAuth.cjs +1 -1
- package/dist/src/adapters/providerAuth.cjs.map +1 -1
- package/dist/src/adapters/providerAuth.mjs +1 -1
- package/dist/src/adapters/providerAuth.mjs.map +1 -1
- package/dist/src/adapters/registryWatcher.d.cts +1 -0
- package/dist/src/adapters/registryWatcher.d.mts +1 -0
- package/dist/src/adapters/remoteAccess.cjs +2 -0
- package/dist/src/adapters/remoteAccess.cjs.map +1 -0
- package/dist/src/adapters/remoteAccess.mjs +2 -0
- package/dist/src/adapters/remoteAccess.mjs.map +1 -0
- package/dist/src/adapters/remoteAccessStore.cjs +2 -0
- package/dist/src/adapters/remoteAccessStore.cjs.map +1 -0
- package/dist/src/adapters/remoteAccessStore.mjs +2 -0
- package/dist/src/adapters/remoteAccessStore.mjs.map +1 -0
- package/dist/src/adapters/remoteGuard.cjs +2 -0
- package/dist/src/adapters/remoteGuard.cjs.map +1 -0
- package/dist/src/adapters/remoteGuard.mjs +2 -0
- package/dist/src/adapters/remoteGuard.mjs.map +1 -0
- package/dist/src/adapters/remoteRoutes.cjs +2 -0
- package/dist/src/adapters/remoteRoutes.cjs.map +1 -0
- package/dist/src/adapters/remoteRoutes.mjs +2 -0
- package/dist/src/adapters/remoteRoutes.mjs.map +1 -0
- package/dist/src/adapters/serverSpawner.cjs +1 -1
- package/dist/src/adapters/serverSpawner.cjs.map +1 -1
- package/dist/src/adapters/serverSpawner.d.cts +1 -0
- package/dist/src/adapters/serverSpawner.d.mts +1 -0
- package/dist/src/adapters/serverSpawner.mjs +1 -1
- package/dist/src/adapters/serverSpawner.mjs.map +1 -1
- package/dist/src/adapters/sessionHub.cjs +1 -1
- package/dist/src/adapters/sessionHub.cjs.map +1 -1
- package/dist/src/adapters/sessionHub.d.cts +6 -0
- package/dist/src/adapters/sessionHub.d.mts +6 -0
- package/dist/src/adapters/sessionHub.mjs +1 -1
- package/dist/src/adapters/sessionHub.mjs.map +1 -1
- package/dist/src/adapters/sessionRelaunch.cjs +2 -0
- package/dist/src/adapters/sessionRelaunch.cjs.map +1 -0
- package/dist/src/adapters/sessionRelaunch.mjs +2 -0
- package/dist/src/adapters/sessionRelaunch.mjs.map +1 -0
- package/dist/src/adapters/sessionSocketClient.cjs +1 -1
- package/dist/src/adapters/sessionSocketClient.cjs.map +1 -1
- package/dist/src/adapters/sessionSocketClient.mjs +1 -1
- package/dist/src/adapters/sessionSocketClient.mjs.map +1 -1
- package/dist/src/adapters/settingsRoutes.cjs +2 -0
- package/dist/src/adapters/settingsRoutes.cjs.map +1 -0
- package/dist/src/adapters/settingsRoutes.mjs +2 -0
- package/dist/src/adapters/settingsRoutes.mjs.map +1 -0
- package/dist/src/adapters/syncGuard.cjs +1 -1
- package/dist/src/adapters/syncGuard.cjs.map +1 -1
- package/dist/src/adapters/syncGuard.mjs +1 -1
- package/dist/src/adapters/syncGuard.mjs.map +1 -1
- package/dist/src/adapters/tunnelProcess.cjs +2 -0
- package/dist/src/adapters/tunnelProcess.cjs.map +1 -0
- package/dist/src/adapters/tunnelProcess.mjs +2 -0
- package/dist/src/adapters/tunnelProcess.mjs.map +1 -0
- package/dist/src/adapters/webBundler.cjs +1 -1
- package/dist/src/adapters/webBundler.cjs.map +1 -1
- package/dist/src/adapters/webBundler.d.cts.map +1 -1
- package/dist/src/adapters/webBundler.d.mts.map +1 -1
- package/dist/src/adapters/webBundler.mjs +1 -1
- package/dist/src/adapters/webBundler.mjs.map +1 -1
- package/dist/src/adapters/webTelemetry.cjs +2 -0
- package/dist/src/adapters/webTelemetry.cjs.map +1 -0
- package/dist/src/adapters/webTelemetry.mjs +2 -0
- package/dist/src/adapters/webTelemetry.mjs.map +1 -0
- package/dist/src/adapters/webauthn.cjs +2 -0
- package/dist/src/adapters/webauthn.cjs.map +1 -0
- package/dist/src/adapters/webauthn.mjs +2 -0
- package/dist/src/adapters/webauthn.mjs.map +1 -0
- package/dist/src/services/deviceSessions.cjs +2 -0
- package/dist/src/services/deviceSessions.cjs.map +1 -0
- package/dist/src/services/deviceSessions.mjs +2 -0
- package/dist/src/services/deviceSessions.mjs.map +1 -0
- package/dist/src/services/pairingFlow.cjs +2 -0
- package/dist/src/services/pairingFlow.cjs.map +1 -0
- package/dist/src/services/pairingFlow.mjs +2 -0
- package/dist/src/services/pairingFlow.mjs.map +1 -0
- package/dist/src/services/pairingPage.cjs +247 -0
- package/dist/src/services/pairingPage.cjs.map +1 -0
- package/dist/src/services/pairingPage.mjs +247 -0
- package/dist/src/services/pairingPage.mjs.map +1 -0
- package/dist/src/services/pathScope.cjs +2 -0
- package/dist/src/services/pathScope.cjs.map +1 -0
- package/dist/src/services/pathScope.mjs +2 -0
- package/dist/src/services/pathScope.mjs.map +1 -0
- package/dist/src/services/remoteAccessSettings.cjs +2 -0
- package/dist/src/services/remoteAccessSettings.cjs.map +1 -0
- package/dist/src/services/remoteAccessSettings.mjs +2 -0
- package/dist/src/services/remoteAccessSettings.mjs.map +1 -0
- package/dist/src/services/remoteGuardPolicy.cjs +2 -0
- package/dist/src/services/remoteGuardPolicy.cjs.map +1 -0
- package/dist/src/services/remoteGuardPolicy.mjs +2 -0
- package/dist/src/services/remoteGuardPolicy.mjs.map +1 -0
- package/dist/src/services/serveOptions.cjs +1 -1
- package/dist/src/services/serveOptions.cjs.map +1 -1
- package/dist/src/services/serveOptions.d.cts +4 -0
- package/dist/src/services/serveOptions.d.cts.map +1 -1
- package/dist/src/services/serveOptions.d.mts +4 -0
- package/dist/src/services/serveOptions.d.mts.map +1 -1
- package/dist/src/services/serveOptions.mjs +1 -1
- package/dist/src/services/serveOptions.mjs.map +1 -1
- package/dist/src/services/sessionMigration.cjs +2 -0
- package/dist/src/services/sessionMigration.cjs.map +1 -0
- package/dist/src/services/sessionMigration.mjs +2 -0
- package/dist/src/services/sessionMigration.mjs.map +1 -0
- package/dist/src/services/staticAssets.cjs +1 -1
- package/dist/src/services/staticAssets.cjs.map +1 -1
- package/dist/src/services/staticAssets.mjs +1 -1
- package/dist/src/services/staticAssets.mjs.map +1 -1
- package/dist/src/services/tunnelOutput.cjs +2 -0
- package/dist/src/services/tunnelOutput.cjs.map +1 -0
- package/dist/src/services/tunnelOutput.mjs +2 -0
- package/dist/src/services/tunnelOutput.mjs.map +1 -0
- package/dist/src/services/webauthnPolicy.cjs +2 -0
- package/dist/src/services/webauthnPolicy.cjs.map +1 -0
- package/dist/src/services/webauthnPolicy.mjs +2 -0
- package/dist/src/services/webauthnPolicy.mjs.map +1 -0
- package/dist/src/types/auth.cjs.map +1 -1
- package/dist/src/types/auth.d.cts +6 -0
- package/dist/src/types/auth.d.cts.map +1 -1
- package/dist/src/types/auth.d.mts +6 -0
- package/dist/src/types/auth.d.mts.map +1 -1
- package/dist/src/types/auth.mjs.map +1 -1
- package/dist/src/types/bridge.d.cts +29 -0
- package/dist/src/types/bridge.d.cts.map +1 -1
- package/dist/src/types/bridge.d.mts +29 -0
- package/dist/src/types/bridge.d.mts.map +1 -1
- package/dist/src/types/notification.cjs +2 -0
- package/dist/src/types/notification.cjs.map +1 -0
- package/dist/src/types/notification.mjs +2 -0
- package/dist/src/types/notification.mjs.map +1 -0
- package/dist/src/types/remoteAccess.cjs +2 -0
- package/dist/src/types/remoteAccess.cjs.map +1 -0
- package/dist/src/types/remoteAccess.d.cts +88 -0
- package/dist/src/types/remoteAccess.d.cts.map +1 -0
- package/dist/src/types/remoteAccess.d.mts +88 -0
- package/dist/src/types/remoteAccess.d.mts.map +1 -0
- package/dist/src/types/remoteAccess.mjs +2 -0
- package/dist/src/types/remoteAccess.mjs.map +1 -0
- package/dist/src/types/settings.cjs +2 -0
- package/dist/src/types/settings.cjs.map +1 -0
- package/dist/src/types/settings.d.cts +1 -0
- package/dist/src/types/settings.d.mts +1 -0
- package/dist/src/types/settings.mjs +2 -0
- package/dist/src/types/settings.mjs.map +1 -0
- package/dist/web/assets/CodeEditorView-CP55qjoR.js +15 -0
- package/dist/web/assets/CodeEditorView-CP55qjoR.js.map +1 -0
- package/dist/web/assets/dist-B0inTUnL.js +2 -0
- package/dist/web/assets/dist-B0inTUnL.js.map +1 -0
- package/dist/web/assets/dist-Bqd73gXk.js +24 -0
- package/dist/web/assets/dist-Bqd73gXk.js.map +1 -0
- package/dist/web/assets/dist-C7QLpOTK.js +10 -0
- package/dist/web/assets/dist-C7QLpOTK.js.map +1 -0
- package/dist/web/assets/dist-CElJaC8s.js +2 -0
- package/dist/web/assets/dist-CElJaC8s.js.map +1 -0
- package/dist/web/assets/dist-Cwzviw_U.js +7 -0
- package/dist/web/assets/dist-Cwzviw_U.js.map +1 -0
- package/dist/web/assets/dist-DgVFo7X3.js +2 -0
- package/dist/web/assets/dist-DgVFo7X3.js.map +1 -0
- package/dist/web/assets/dist-GONoz8to.js +2 -0
- package/dist/web/assets/dist-GONoz8to.js.map +1 -0
- package/dist/web/assets/dist-Gdc8iniF.js +2 -0
- package/dist/web/assets/dist-Gdc8iniF.js.map +1 -0
- package/dist/web/assets/dist-TyTwU387.js +2 -0
- package/dist/web/assets/dist-TyTwU387.js.map +1 -0
- package/dist/web/assets/dist-eZCftY6T.js +1 -0
- package/dist/web/assets/dockerfile-BDePf5PZ.js +2 -0
- package/dist/web/assets/dockerfile-BDePf5PZ.js.map +1 -0
- package/dist/web/assets/index-BqJnbtrI.css +2 -0
- package/dist/web/assets/index-Ck8t7U8J.js +98 -0
- package/dist/web/assets/index-Ck8t7U8J.js.map +1 -0
- package/dist/web/assets/rolldown-runtime-hePW80VL.js +1 -0
- package/dist/web/assets/shell-DwuoZtxw.js +2 -0
- package/dist/web/assets/shell-DwuoZtxw.js.map +1 -0
- package/dist/web/assets/toml-BPTmHmyx.js +2 -0
- package/dist/web/assets/toml-BPTmHmyx.js.map +1 -0
- package/dist/web/index.html +4 -3
- package/llms.txt +6 -1
- package/package.json +17 -11
- package/src/adapters/cockpitContainer.ts +209 -0
- package/src/adapters/cockpitHandoff.ts +66 -0
- package/src/adapters/deviceAuth.ts +171 -0
- package/src/adapters/directoryListing.ts +25 -8
- package/src/adapters/httpServer.ts +689 -83
- package/src/adapters/packageApiProxy.ts +14 -3
- package/src/adapters/providerAuth.ts +20 -0
- package/src/adapters/remoteAccess.ts +510 -0
- package/src/adapters/remoteAccessStore.ts +161 -0
- package/src/adapters/remoteGuard.ts +179 -0
- package/src/adapters/remoteRoutes.ts +481 -0
- package/src/adapters/serverSpawner.ts +67 -6
- package/src/adapters/sessionHub.ts +251 -90
- package/src/adapters/sessionRelaunch.ts +67 -0
- package/src/adapters/sessionSocketClient.ts +3 -1
- package/src/adapters/settingsRoutes.ts +383 -0
- package/src/adapters/syncGuard.ts +27 -7
- package/src/adapters/tunnelProcess.ts +377 -0
- package/src/adapters/webBundler.ts +6 -0
- package/src/adapters/webTelemetry.ts +131 -0
- package/src/adapters/webauthn.ts +297 -0
- package/src/bin/serve.ts +106 -5
- package/src/services/deviceSessions.ts +135 -0
- package/src/services/pairingFlow.ts +193 -0
- package/src/services/pairingPage.ts +320 -0
- package/src/services/pathScope.ts +23 -0
- package/src/services/remoteAccessSettings.ts +185 -0
- package/src/services/remoteGuardPolicy.ts +209 -0
- package/src/services/serveOptions.ts +20 -1
- package/src/services/sessionMigration.ts +48 -0
- package/src/services/staticAssets.ts +1 -0
- package/src/services/tunnelOutput.ts +74 -0
- package/src/services/webauthnPolicy.ts +134 -0
- package/src/types/auth.ts +3 -0
- package/src/types/bridge.ts +27 -0
- package/src/types/notification.ts +30 -0
- package/src/types/remoteAccess.ts +193 -0
- package/src/types/settings.ts +120 -0
- package/src/web/app/Providers.tsx +28 -5
- package/src/web/app/protocolRuntime.ts +12 -2
- package/src/web/app/sessionRuntime.ts +31 -1
- package/src/web/components/QrCode.tsx +53 -0
- package/src/web/components/RemoteAccessButton.tsx +46 -0
- package/src/web/features/activity/ActivityDock.tsx +77 -57
- package/src/web/features/dialogs/DialogOverlay.tsx +13 -5
- package/src/web/features/leader/CommandPalette.tsx +6 -3
- package/src/web/features/remote/HandoverProgress.tsx +25 -0
- package/src/web/features/remote/PairedDeviceList.tsx +39 -0
- package/src/web/features/remote/PairingApprovalDialog.tsx +58 -0
- package/src/web/features/remote/RemoteAccessDialog.tsx +113 -0
- package/src/web/features/remote/RemoteAccessOptions.tsx +124 -0
- package/src/web/features/remote/RemoteAccessPairing.tsx +53 -0
- package/src/web/features/remote/RemoteBanner.tsx +51 -0
- package/src/web/features/remote/SandboxWorkspaces.tsx +84 -0
- package/src/web/features/selection/SelectionBar.tsx +14 -11
- package/src/web/features/session/Composer.tsx +317 -20
- package/src/web/features/session/ComposerPrompt.tsx +50 -0
- package/src/web/features/session/Timeline.tsx +26 -51
- package/src/web/features/sessions/NewSessionDialog.tsx +18 -6
- package/src/web/features/sessions/SessionRail.tsx +97 -13
- package/src/web/features/sessions/WelcomePanel.tsx +32 -0
- package/src/web/features/settings/AppearanceSettings.tsx +1 -1
- package/src/web/features/settings/ContributedSettings.tsx +408 -0
- package/src/web/features/settings/NotificationSettings.tsx +49 -0
- package/src/web/features/settings/PluginSettings.tsx +6 -2
- package/src/web/features/settings/ProviderSettings.tsx +11 -4
- package/src/web/features/settings/RemoteControlSettings.tsx +40 -0
- package/src/web/features/settings/RepositorySettings.tsx +365 -0
- package/src/web/features/settings/RepositoryWorkspace.tsx +150 -0
- package/src/web/features/settings/SettingsMenu.tsx +97 -26
- package/src/web/features/settings/TunnelSettings.tsx +145 -0
- package/src/web/features/status/TopBar.tsx +72 -25
- package/src/web/index.html +1 -1
- package/src/web/lib/authApi.ts +4 -1
- package/src/web/lib/browserNotifications.ts +47 -0
- package/src/web/lib/browserTelemetry.ts +95 -0
- package/src/web/lib/commands.ts +12 -6
- package/src/web/lib/composition.ts +15 -3
- package/src/web/lib/hubApi.ts +41 -4
- package/src/web/lib/installStaleChunkRecovery.ts +43 -0
- package/src/web/lib/piTransport.ts +18 -7
- package/src/web/lib/pluginRegistry.ts +42 -1
- package/src/web/lib/protocolTimeline.ts +3 -1
- package/src/web/lib/qrMatrix.ts +33 -0
- package/src/web/lib/remoteApi.ts +103 -0
- package/src/web/lib/sealedSession.ts +80 -0
- package/src/web/lib/sessionModel.ts +89 -21
- package/src/web/lib/settingsApi.ts +158 -0
- package/src/web/lib/settingsDraft.ts +58 -0
- package/src/web/lib/settingsSections.ts +78 -6
- package/src/web/lib/stepUp.ts +43 -0
- package/src/web/lib/toolPrompt.ts +68 -0
- package/src/web/lib/transport.ts +12 -0
- package/src/web/lib/webauthnClient.ts +107 -0
- package/src/web/lib/wsClient.ts +43 -11
- package/src/web/main.tsx +3 -0
- package/src/web/routes/CockpitPage.tsx +71 -11
- package/src/web/routes/SettingsPage.tsx +97 -18
- package/src/web/stores/newSessionStore.ts +29 -0
- package/src/web/stores/remoteAccessStore.ts +181 -0
- package/src/web/stores/sessionStore.ts +68 -21
- package/src/web/stores/sessionsStore.ts +17 -0
- package/src/web/stores/toolPromptStore.ts +28 -0
- package/src/web/stores/useToolPrompt.ts +32 -0
- package/dist/web/assets/index-Cg52ngKv.css +0 -2
- package/dist/web/assets/index-DQO7NTIP.js +0 -90
- package/dist/web/assets/index-DQO7NTIP.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,22 +1,18 @@
|
|
|
1
1
|
# @agimon-ai/doompi-web
|
|
2
2
|
|
|
3
|
-
Web cockpit for DoomPi
|
|
4
|
-
running [doompi-server](https://www.npmjs.com/package/@agimon-ai/doompi-server) through the session
|
|
5
|
-
registry, and multiplexes their authenticated sockets behind one page connection
|
|
3
|
+
Web cockpit for DoomPi. It discovers running [doompi-server](https://www.npmjs.com/package/@agimon-ai/doompi-server) sessions, multiplexes their authenticated sockets, and serves them through one browser connection.
|
|
6
4
|
|
|
7
5
|
This package is a composable [DoomPi](https://www.npmjs.com/package/@agimon-ai/doompi) subsystem.
|
|
8
6
|
It registers no Pi extension and adds nothing to an interactive session.
|
|
9
7
|
|
|
10
|
-
##
|
|
8
|
+
## How it runs
|
|
11
9
|
|
|
12
|
-
|
|
13
|
-
to add to `.doom/modes.yaml`. It is a standalone executable you run yourself, as shown under Run
|
|
14
|
-
below, or the process `doompi-server --web` starts for you.
|
|
10
|
+
`doompi-web` is a standalone process, not a Pi extension. Do not add it to `.doom/modes.yaml`. Run it directly, or start it through `doompi-server --web`.
|
|
15
11
|
|
|
16
12
|
## Requirements
|
|
17
13
|
|
|
18
14
|
- Node.js 22.19.0 or newer
|
|
19
|
-
-
|
|
15
|
+
- `doompi-server` sessions to attach to. The cockpit can start them itself.
|
|
20
16
|
|
|
21
17
|
## Install
|
|
22
18
|
|
|
@@ -30,25 +26,17 @@ npm install -g @agimon-ai/doompi-web
|
|
|
30
26
|
doompi-web
|
|
31
27
|
```
|
|
32
28
|
|
|
33
|
-
|
|
34
|
-
below is an override. `doompi-web` is the hub. It watches the session registry (`~/.doompi/run` by
|
|
35
|
-
default), attaches to every registered session, shows them all in the rail, and can start new ones
|
|
36
|
-
from the page. Sessions it starts run the `doompi-server` and the agent from this installation, so
|
|
37
|
-
the cockpit and the sessions it creates are always the same build.
|
|
29
|
+
Open `http://127.0.0.1:7433`. With no flags, the hub watches `~/.doompi/run`, attaches to registered sessions, and can start new sessions from the page. Sessions started by the cockpit use the server and agent from the same installation.
|
|
38
30
|
|
|
39
|
-
|
|
40
|
-
exits, because one hub already serves every session.
|
|
31
|
+
If a hub already answers on the port, a second `doompi-web` process prints its URL and exits.
|
|
41
32
|
|
|
42
|
-
|
|
33
|
+
To run this repository's build:
|
|
43
34
|
|
|
44
35
|
```bash
|
|
45
36
|
pnpm cockpit
|
|
46
37
|
```
|
|
47
38
|
|
|
48
|
-
|
|
49
|
-
this checkout's builds, which matters: a session on some other `doompi` is a different composition,
|
|
50
|
-
and its extensions, commands, and minor modes are not the ones being edited here, so a change looks
|
|
51
|
-
like it did nothing.
|
|
39
|
+
The command builds the required packages and starts the hub on port 7433. Sessions created from the page use this checkout's builds.
|
|
52
40
|
|
|
53
41
|
| Flag | Default | Meaning |
|
|
54
42
|
| ----------------- | --------------- | ------------------------------------------------ |
|
|
@@ -57,10 +45,10 @@ like it did nothing.
|
|
|
57
45
|
| `--port` | `7433` | HTTP port |
|
|
58
46
|
| `--host` | `127.0.0.1` | Bind address |
|
|
59
47
|
| `--assets` | bundled | Override the built SPA directory |
|
|
48
|
+
| `--state-dir` | `~/.doompi/web` | Remote-access settings and the tunnel pid file |
|
|
49
|
+
| `--cloudflared` | from `PATH` | Tunnel binary (also `DOOMPI_CLOUDFLARED`) |
|
|
60
50
|
|
|
61
|
-
|
|
62
|
-
yourself, install [doompi-server](https://www.npmjs.com/package/@agimon-ai/doompi-server); the hub
|
|
63
|
-
does not need it on PATH to create sessions.
|
|
51
|
+
Install [doompi-server](https://www.npmjs.com/package/@agimon-ai/doompi-server) when you also want to run a session server directly. The hub does not require `doompi-server` on `PATH` to create sessions.
|
|
64
52
|
|
|
65
53
|
## Security
|
|
66
54
|
|
|
@@ -69,6 +57,24 @@ browser. The server binds loopback by default, so reaching the cockpit already m
|
|
|
69
57
|
account on the machine. Remote access means tunnelling the port, for example over SSH; do not bind
|
|
70
58
|
a public address and call it done.
|
|
71
59
|
|
|
60
|
+
Loopback binding is not on its own an authorization boundary, because two request shapes escape it
|
|
61
|
+
and both reach code execution:
|
|
62
|
+
|
|
63
|
+
- **WebSockets are exempt from CORS.** Any page in any browser on this machine can open a socket to
|
|
64
|
+
the cockpit's port, and `session_command` frames carry prompts straight to an agent holding a
|
|
65
|
+
`bash` tool.
|
|
66
|
+
- **A cross-origin POST need not be preflighted.** Sent as `text/plain`, it still parses as JSON
|
|
67
|
+
server-side, so a page could spawn a session in any directory without ever reading a response.
|
|
68
|
+
|
|
69
|
+
Both are refused. Every request and every socket upgrade is checked against an origin and host
|
|
70
|
+
allowlist derived from the port actually bound, plus the vite dev server. A request carrying no
|
|
71
|
+
`Origin` is allowed, because curl, the health probe, and the test harness send none and a browser
|
|
72
|
+
always sends one where it matters; this defends against a hostile web page, not against a hostile
|
|
73
|
+
local program, which can send whatever headers it likes. `DOOMPI_WEB_ALLOW_ORIGIN` takes a
|
|
74
|
+
comma-separated list for setups this package cannot guess. The host check is also the DNS rebinding
|
|
75
|
+
defence, and it is the only check a socket upgrade gets, since the upgrade path never reaches the
|
|
76
|
+
server's own host parsing.
|
|
77
|
+
|
|
72
78
|
## What the cockpit shows
|
|
73
79
|
|
|
74
80
|
Everything comes from Pi's RPC protocol, so a bare Pi session and a DoomPi session both work and
|
|
@@ -85,13 +91,17 @@ neither is shown facts it did not report:
|
|
|
85
91
|
usage, cost, and session identity.
|
|
86
92
|
- **Dialogs:** the extension UI sub-protocol (`select`, `confirm`, `input`, `editor`), which is how
|
|
87
93
|
a permission prompt reaches the browser. Cancelling answers the agent rather than stranding it.
|
|
94
|
+
- **Notifications:** live `doom-notification` entries from every attached session, including sessions
|
|
95
|
+
that are not focused. Browser permission is requested only after the user clicks **allow notifications**
|
|
96
|
+
in Settings. Replayed backlog and historical pages update the timeline but never notify.
|
|
88
97
|
- **Leader Space:** `ctrl+k`, `ctrl+space`, or space in an empty composer. Keys walk the tree the
|
|
89
98
|
installed web plugins declared (`SPC w r` opens the workflows tab, `SPC g e` toggles goal mode),
|
|
90
99
|
backspace climbs, and `/` searches whatever `get_commands` reported to run it as a slash prompt.
|
|
91
|
-
- **Settings:** the gear at the foot of the rail opens the settings pages.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
100
|
+
- **Settings:** the gear at the foot of the rail opens the settings pages. Notifications shows this
|
|
101
|
+
browser's permission and provides the user-click permission action. Providers lists every model
|
|
102
|
+
provider Pi knows with whether it is authenticated, signs in with an API key or OAuth through the
|
|
103
|
+
hub, and signs out. The hub uses the same `auth.json` as the sessions, so a login here is a login for
|
|
104
|
+
every session on the machine.
|
|
95
105
|
|
|
96
106
|
## Attach and reattach
|
|
97
107
|
|
|
@@ -110,6 +120,177 @@ perform the handshake itself: `attach` frames sent from the browser are refused.
|
|
|
110
120
|
them.
|
|
111
121
|
- Subagent, workflow, and runner surfaces report summaries only; per-run detail has no RPC source
|
|
112
122
|
to render from yet.
|
|
123
|
+
- Browser notifications require a live, connected page. There is no service worker, Web Push, replay,
|
|
124
|
+
or durable queue, so notifications emitted while every page is closed are not delivered later.
|
|
125
|
+
|
|
126
|
+
## Remote access
|
|
127
|
+
|
|
128
|
+
Working from a phone means putting the cockpit on the internet, and the cockpit's socket carries
|
|
129
|
+
prompts to an agent holding a `bash` tool. Reaching it is therefore equivalent to a shell on this
|
|
130
|
+
machine, and the whole design follows from that.
|
|
131
|
+
|
|
132
|
+
Switching it on binds a **second loopback listener** on an ephemeral port and points `cloudflared`
|
|
133
|
+
at it. Port 7433 is untouched, so working at the keyboard is exactly as before, while everything
|
|
134
|
+
arriving on the tunnel listener has to prove it holds a paired session. The two listeners are the
|
|
135
|
+
mechanism, not an implementation detail: a tunnel connects from `127.0.0.1` like every local client,
|
|
136
|
+
so the socket a connection arrived on is the only thing about it that cannot be forged.
|
|
137
|
+
|
|
138
|
+
Pairing takes **two** things, not one:
|
|
139
|
+
|
|
140
|
+
1. The host shows a QR encoding `https://<tunnel-host>/pair#c=<code>`. The code rides in the URL
|
|
141
|
+
fragment, which no browser sends to any server, so it never reaches Cloudflare's logs, this
|
|
142
|
+
process's log, or a `Referer`.
|
|
143
|
+
2. Scanning it raises an approve or deny prompt **on the host**. Nothing is paired until someone at
|
|
144
|
+
the machine answers. A screen is visible over a shoulder and in a screenshare, so the code alone
|
|
145
|
+
is one factor for a credential that ends in shell access.
|
|
146
|
+
|
|
147
|
+
A paired device then has the same powers as the person at the keyboard, with one exception: it
|
|
148
|
+
cannot mint pairing codes, approve devices, or change these settings. A device able to approve
|
|
149
|
+
devices could make its own access permanent, which is precisely what host confirmation prevents.
|
|
150
|
+
Turning remote access off and revoking a device are allowed remotely, because a panic button is
|
|
151
|
+
worth more from the couch than from the desk.
|
|
152
|
+
|
|
153
|
+
Two optional time limits, both **off by default** and both in the dialog:
|
|
154
|
+
|
|
155
|
+
- Close the tunnel automatically, after 60 minutes.
|
|
156
|
+
- Expire paired sessions, after 30 minutes idle or 12 hours total.
|
|
157
|
+
|
|
158
|
+
Either switch takes effect retroactively in both directions, because expiry is evaluated against the
|
|
159
|
+
setting in force at the moment of the check rather than stamped onto a session when it was made.
|
|
160
|
+
Turning expiry on therefore drops sessions that are already idle, and turning it off brings them
|
|
161
|
+
back.
|
|
162
|
+
|
|
163
|
+
With both off, the only expiry is you: a tunnel stays up until it is closed or the hub restarts, and
|
|
164
|
+
a paired device keeps its session until it is revoked or remote access is switched off. Switching
|
|
165
|
+
remote access off always revokes every paired session, and that is not a toggle.
|
|
166
|
+
|
|
167
|
+
Before reporting success, the tunnel is **self-tested through its own public URL**: the pairing page
|
|
168
|
+
must answer 200 and `/api/health` must answer 401. Anything else means the agent is on the public
|
|
169
|
+
internet unguarded, so the tunnel is killed immediately rather than warned about.
|
|
170
|
+
|
|
171
|
+
Requires `cloudflared` on `PATH` (`brew install cloudflared`). A quick tunnel needs no account but
|
|
172
|
+
gets a new hostname on every start, so every restart means scanning again, and Cloudflare does not
|
|
173
|
+
support SSE on quick tunnels, which breaks the workflow and runner-log plugin surfaces. A named
|
|
174
|
+
tunnel on your own domain has neither problem and is the mode to use if you do this daily.
|
|
175
|
+
|
|
176
|
+
### Passkeys and step-up
|
|
177
|
+
|
|
178
|
+
On a named tunnel, a device can enrol a passkey once and then sign in with a gesture instead of
|
|
179
|
+
another QR. Two actions need a fresh gesture even from a live session: writing or clearing a model
|
|
180
|
+
provider's credential, and spawning a session in a directory of the caller's choosing. Both are
|
|
181
|
+
escalation paths out of "drive the agent" and into "own the machine". Ordinary prompting and tool
|
|
182
|
+
approval are deliberately ungated, because a biometric check in that loop gets trained away in a day.
|
|
183
|
+
|
|
184
|
+
A quick tunnel cannot carry a passkey: `rpID` is bound to the hostname and a quick tunnel's rotates
|
|
185
|
+
on every start, so a credential enrolled today would silently stop working tomorrow. The cockpit says
|
|
186
|
+
so rather than letting the ceremony fail in the browser.
|
|
187
|
+
|
|
188
|
+
### Tunnel-provider trust and payload privacy
|
|
189
|
+
|
|
190
|
+
Cloudflare is a trusted delivery boundary. It terminates TLS and delivers both the pairing page and the
|
|
191
|
+
cockpit SPA. A browser cannot independently verify its first executable page against the same edge that
|
|
192
|
+
served it: a malicious replacement could omit any in-page verifier, read the QR fragment, and control a
|
|
193
|
+
paired agent. DoomPi therefore does not claim that browser-side bundle signing protects against a
|
|
194
|
+
compromised Cloudflare edge.
|
|
195
|
+
|
|
196
|
+
Within that trust model, **the payload is sealed**. A QR carries an ephemeral P-256 public key alongside the
|
|
197
|
+
pairing code, in the URL fragment, which no browser sends to any server. A returning device that proves a
|
|
198
|
+
passkey receives the current public key in the successful authentication response instead. Both paths then
|
|
199
|
+
complete ECDH and derive AES-256-GCM keys, separate per direction. Socket frames and API bodies travel as
|
|
200
|
+
ciphertext. This limits routine relay visibility, but it still depends on Cloudflare delivering the intended
|
|
201
|
+
pairing page and SPA.
|
|
202
|
+
|
|
203
|
+
The channel implementation lives in `@agimon-ai/doompi-web-security`, which ships the envelope contract,
|
|
204
|
+
a `node:crypto` half, and a WebCrypto half. **A web plugin that calls `fetch` directly sends plaintext to
|
|
205
|
+
the relay**, so plugins use `sealedTransport.fetch` from that package's `./browser` subpath. The host
|
|
206
|
+
cannot enforce this, which is why the import allowlist admits the helper and why it is said here.
|
|
207
|
+
|
|
208
|
+
Cloudflare can still observe the served page and assets, timing, message sizes, connection patterns, and
|
|
209
|
+
tunnel metadata. Do not enable this transport if Cloudflare is outside your trust boundary. Protect a
|
|
210
|
+
named-tunnel account with a passkey or hardware MFA.
|
|
211
|
+
|
|
212
|
+
### What a paired device may browse
|
|
213
|
+
|
|
214
|
+
While a tunnel is up, the new-session picker answers a request arriving on it from one subtree only:
|
|
215
|
+
the directory `doompi-web` was started in. Unpinned, that route hands a paired device a map of the
|
|
216
|
+
machine, every project and client name and checkout, which is worth more to an attacker than most of
|
|
217
|
+
what the cockpit shows.
|
|
218
|
+
|
|
219
|
+
Only that request is pinned. The person at the keyboard already has a shell, so pinning them would
|
|
220
|
+
cost the picker its usefulness and buy nothing, and a contained cockpit needs none of it because its
|
|
221
|
+
mounts are already the boundary.
|
|
222
|
+
|
|
223
|
+
Session creation itself is not narrowed by this. `POST /api/sessions` still accepts any absolute path
|
|
224
|
+
a caller already knows, which is why the container below exists: it is the boundary, this is only the
|
|
225
|
+
end of casual enumeration.
|
|
226
|
+
|
|
227
|
+
### Running the cockpit in a container
|
|
228
|
+
|
|
229
|
+
Off by default. A second switch in the same dialog moves the whole cockpit into a container when
|
|
230
|
+
remote access is turned on: the hub, every session server it spawns, every agent, and `cloudflared`
|
|
231
|
+
itself. This is the answer to the blunt fact that a paired device drives an agent holding a shell,
|
|
232
|
+
which every layer above narrows access to but none of them contains.
|
|
233
|
+
|
|
234
|
+
The list of workspaces is the boundary, and it is the only thing on the host the container can see.
|
|
235
|
+
A hub inside the container cannot create a session in a path that is not mounted, so spawning in an
|
|
236
|
+
arbitrary directory is closed by construction rather than by a check. Not mounted, and deliberately
|
|
237
|
+
so: your home directory, `~/.ssh`, `~/.gitconfig`, the container socket, and every repository you
|
|
238
|
+
did not list.
|
|
239
|
+
|
|
240
|
+
What happens when you turn it on:
|
|
241
|
+
|
|
242
|
+
1. The image is built if this DoomPi version has not built it before, which takes minutes the first
|
|
243
|
+
time. Progress is printed in the terminal that started the cockpit.
|
|
244
|
+
2. The host cockpit answers the request, then stops serving and releases its port.
|
|
245
|
+
3. The container starts, publishing the same port on loopback, and becomes the cockpit. Your browser
|
|
246
|
+
reconnects on its own; there is no new address to remember.
|
|
247
|
+
4. Sessions working inside a mounted workspace are stopped on the host and recreated inside. Sessions
|
|
248
|
+
working anywhere else are named in the terminal and stay on the host, where the contained cockpit
|
|
249
|
+
cannot see them.
|
|
250
|
+
|
|
251
|
+
If the container fails to start, the host cockpit comes back and its sessions are recreated there, so
|
|
252
|
+
a failed move never leaves you with nothing listening. `Ctrl-C` stops the container. A cockpit killed
|
|
253
|
+
outright leaves the container running; the next start finds it through `~/.doompi/web/cockpit-container.json`
|
|
254
|
+
and stops it.
|
|
255
|
+
|
|
256
|
+
Two things carry across the boundary and nothing else does. Provider credentials go through the
|
|
257
|
+
existing broker, which hands the container a per-session token rather than your keys. Git identity is
|
|
258
|
+
passed as `GIT_AUTHOR_*` and `GIT_COMMITTER_*`, so an agent can commit but cannot push, because no
|
|
259
|
+
key crosses.
|
|
260
|
+
|
|
261
|
+
What this does not defend against, stated plainly: anyone who can talk to the container daemon can
|
|
262
|
+
escape any container it runs, so the engine is part of the trusted base. Sessions share one container
|
|
263
|
+
and can read each other's mounted workspaces. A mounted workspace is fully writable, so the agent can
|
|
264
|
+
still destroy the repository you gave it. Network access is unrestricted, exactly as it already is for
|
|
265
|
+
`doompi --sandbox`. And plugin tabs fall back to the built-in set until a bundle is synced inside.
|
|
266
|
+
|
|
267
|
+
Requires `@agimon-ai/doompi-sandbox` in the composition and a container engine on the host.
|
|
268
|
+
|
|
269
|
+
## Planned: WebRTC transport
|
|
270
|
+
|
|
271
|
+
Today the page reaches the hub over two WebSockets, and any remote access means putting a tunnel in
|
|
272
|
+
front of them, which puts the tunnel provider in the path of everything. A WebRTC data channel would
|
|
273
|
+
replace that, with the tunnel demoted to carrying signaling only. Two independent reasons want it,
|
|
274
|
+
and the first is the stronger.
|
|
275
|
+
|
|
276
|
+
**Realtime voice.** `@agimon-ai/doompi-voice` now captures on the selected browser client, sends
|
|
277
|
+
mono PCM16 through its authenticated session media transport, keeps VAD and Whisper on the agent
|
|
278
|
+
host, and plays narration with browser speech synthesis. Voice activation is when the page asks for
|
|
279
|
+
microphone permission, never on load. A future WebRTC implementation can replace the HTTP media
|
|
280
|
+
adapter with Opus tracks while preserving the same client device and transport contracts.
|
|
281
|
+
|
|
282
|
+
**Confidentiality.** A QR shown on the host can carry the DTLS fingerprint, which authenticates the
|
|
283
|
+
peer connection out-of-band. The data path is then peer-to-peer DTLS: the tunnel provider usually
|
|
284
|
+
never carries the traffic and can never read it. That is close to VPN-grade confidentiality without
|
|
285
|
+
asking anyone to install a VPN.
|
|
286
|
+
|
|
287
|
+
The cost is real and worth stating: a WebRTC stack on the Node side (`node-datachannel` or
|
|
288
|
+
`werift`), a second client transport alongside `wsClient.ts` and `piTransport.ts`, ICE and NAT
|
|
289
|
+
traversal debugging, and a TURN relay fallback for the connections that cannot go direct. TURN
|
|
290
|
+
reintroduces a relay, though still one that cannot decrypt. The WebSocket transport stays as the
|
|
291
|
+
fallback for when a data channel cannot be established.
|
|
292
|
+
|
|
293
|
+
No date. This is a design note, not a commitment.
|
|
113
294
|
|
|
114
295
|
## Public API
|
|
115
296
|
|
|
@@ -180,7 +361,8 @@ Plugins are independent. A manifest names no other plugin and `registrationOrder
|
|
|
180
361
|
optional tiebreak (default 1000, then plugin id): every relation between two plugins resolves by
|
|
181
362
|
name once all of them are installed. A plugin opens slots named `<pluginId>.<name>` and renders
|
|
182
363
|
them with the `renderSlot` and `slotData` props its components receive; any plugin fills them, and
|
|
183
|
-
the host's `overlay`, `rail`, `selection-bar`, `
|
|
364
|
+
the host's `overlay`, `rail`, `selection-bar`, `composer-actions`, `activity`, and
|
|
365
|
+
`activity.<group>` slots, without
|
|
184
366
|
knowing whether the owner is installed. A fill into a slot nobody declares, or two plugins wanting
|
|
185
367
|
one tab id, tool name, group name, or Leader Space leaf, never fails the page: the install resolves
|
|
186
368
|
it (the first plugin keeps a shared name, a later binding takes a leaf over as the TUI documents)
|
package/dist/bin/serve.cjs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
const e=require("../_virtual/_rolldown/runtime.cjs"),t=require("../src/services/registryStore.cjs"),n=require("../src/adapters/
|
|
2
|
+
const e=require("../_virtual/_rolldown/runtime.cjs"),t=require("../src/services/registryStore.cjs"),n=require("../src/adapters/remoteAccessStore.cjs"),r=require("../src/adapters/httpServer.cjs"),i=require("../src/services/serveOptions.cjs"),a=require("../src/adapters/cockpitContainer.cjs"),o=require("../src/adapters/cockpitHandoff.cjs"),s=require("../src/adapters/hubProbe.cjs"),c=require("../src/adapters/sessionRelaunch.cjs");let l=require("node:os");l=e.__toESM(l,1);function u(e){process.stderr.write(`[doompi-web] ${e}\n`)}async function d(){let e=i.parseServeOptions(process.argv.slice(2)),o=`http://${e.host}:${String(e.port)}`,c=e.stateDir??n.defaultRemoteStateDir(),d=a.createCockpitContainer({stateDir:c,onNotice:u});if(await d.reapStale(),i.isLoopbackHost(e.host)||(u(`WARNING: --host ${e.host} is not loopback, so the cockpit is reachable without pairing.`),u(`WARNING: turn on remote access in settings instead, which requires a paired device.`)),await s.hubAnswers(e.host,e.port)){u(`cockpit already running at ${o}`);return}let p=async()=>{},m=async()=>{let n=await r.serveWeb({port:e.port,host:e.host,assetsDir:e.assetsDir,registryDir:t.resolveRegistryDir({flagValue:e.registryDir,envValue:process.env[t.REGISTRY_DIR_ENV],homeDir:l.default.homedir()}),spawnCommand:e.spawnCommand,remoteStateDir:c,cloudflaredPath:e.cloudflaredPath,onNotice:u,onHandover:t=>{f({serving:n,restart:m,container:d,handover:t,port:e.port,host:e.host,adopt:e=>{p=e}})}});return p=async()=>{await n.close()},n};try{await m()}catch(t){if(t.code!==`EADDRINUSE`)throw t;if(await s.hubAnswers(e.host,e.port)){u(`cockpit already running at ${o}`);return}throw Error(`Port ${String(e.port)} is taken by something that is not a DoomPi cockpit; pass --port.`)}let h=()=>{p().then(()=>{process.exit(0)})};process.on(`SIGINT`,h),process.on(`SIGTERM`,h)}async function f(e){await e.serving.close();let t=await e.container.start({workspaces:e.handover.settings.sandbox.workspaces.map(e=>({path:e})),port:e.port,onProgress:u});if(!t.ok){u(`the cockpit could not move into a container: ${t.error}`),u(`serving from the host again`),await e.restart(),await c.relaunchSessions({port:e.port,host:e.host,sessions:e.handover.sessions,onNotice:u});return}e.adopt(async()=>{await t.container.stop()}),await o.handOffRemoteAccess({port:e.port,settings:e.handover.settings,onNotice:u}),await c.relaunchSessions({port:e.port,sessions:e.handover.sessions,onNotice:u}),u(`supervising the cockpit container; Ctrl-C stops it`),await t.container.supervise()}d().catch(e=>{u(e instanceof Error?e.message:String(e)),process.exitCode=1});
|
|
3
3
|
//# sourceMappingURL=serve.cjs.map
|
package/dist/bin/serve.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"serve.cjs","names":["parseServeOptions","hubAnswers","serveWeb","resolveRegistryDir","REGISTRY_DIR_ENV","os"],"sources":["../../src/bin/serve.ts"],"sourcesContent":["#!/usr/bin/env node\n\nimport os from 'node:os';\nimport { hubAnswers } from '../adapters/hubProbe.ts';\nimport { serveWeb } from '../adapters/httpServer.ts';\nimport { REGISTRY_DIR_ENV, resolveRegistryDir } from '../services/registryStore.ts';\nimport { parseServeOptions } from '../services/serveOptions.ts';\nimport type { WebServer } from '../types/bridge.ts';\n\nconst ADDRESS_IN_USE = 'EADDRINUSE';\n\nfunction notice(message: string): void {\n process.stderr.write(`[doompi-web] ${message}\\n`);\n}\n\nasync function main(): Promise<void> {\n const options = parseServeOptions(process.argv.slice(2));\n const url = `http://${options.host}:${String(options.port)}`;\n\n // One hub serves every session, so a second start is a request to use the\n // running one, not an error worth a stack trace.\n if (await hubAnswers(options.host, options.port)) {\n notice(`cockpit already running at ${url}`);\n return;\n }\n\n let
|
|
1
|
+
{"version":3,"file":"serve.cjs","names":["parseServeOptions","defaultRemoteStateDir","createCockpitContainer","isLoopbackHost","hubAnswers","serveWeb","resolveRegistryDir","REGISTRY_DIR_ENV","os","relaunchSessions","handOffRemoteAccess"],"sources":["../../src/bin/serve.ts"],"sourcesContent":["#!/usr/bin/env node\n\nimport os from 'node:os';\nimport { createCockpitContainer } from '../adapters/cockpitContainer.ts';\nimport { handOffRemoteAccess } from '../adapters/cockpitHandoff.ts';\nimport { hubAnswers } from '../adapters/hubProbe.ts';\nimport { serveWeb } from '../adapters/httpServer.ts';\nimport { defaultRemoteStateDir } from '../adapters/remoteAccessStore.ts';\nimport { relaunchSessions } from '../adapters/sessionRelaunch.ts';\nimport { REGISTRY_DIR_ENV, resolveRegistryDir } from '../services/registryStore.ts';\nimport { isLoopbackHost, parseServeOptions } from '../services/serveOptions.ts';\nimport type { WebServer } from '../types/bridge.ts';\nimport type { MigratingSession, RemoteAccessSettings } from '../types/remoteAccess.ts';\n\nconst ADDRESS_IN_USE = 'EADDRINUSE';\n\nfunction notice(message: string): void {\n process.stderr.write(`[doompi-web] ${message}\\n`);\n}\n\nasync function main(): Promise<void> {\n const options = parseServeOptions(process.argv.slice(2));\n const url = `http://${options.host}:${String(options.port)}`;\n const stateDir = options.stateDir ?? defaultRemoteStateDir();\n const container = createCockpitContainer({ stateDir, onNotice: notice });\n // A cockpit that was killed rather than closed leaves its container holding\n // the published port, so the next start would fail to bind for a reason\n // nothing explains.\n await container.reapStale();\n\n // Binding off loopback publishes an unauthenticated cockpit to whoever can\n // reach the address. Remote access exists so nobody has to do this; a warning\n // rather than a refusal, because an existing setup should not break today.\n if (!isLoopbackHost(options.host)) {\n notice(`WARNING: --host ${options.host} is not loopback, so the cockpit is reachable without pairing.`);\n notice('WARNING: turn on remote access in settings instead, which requires a paired device.');\n }\n\n // One hub serves every session, so a second start is a request to use the\n // running one, not an error worth a stack trace.\n if (await hubAnswers(options.host, options.port)) {\n notice(`cockpit already running at ${url}`);\n return;\n }\n\n /**\n * What Ctrl-C stops, which is not the same thing throughout the run.\n *\n * A single indirection rather than swapping signal handlers, so the handover\n * never has to reach for `removeAllListeners` and take out whatever else this\n * process has registered.\n */\n let shutDown: () => Promise<void> = async () => {};\n\n const start = async (): Promise<WebServer> => {\n const server = await serveWeb({\n port: options.port,\n host: options.host,\n assetsDir: options.assetsDir,\n registryDir: resolveRegistryDir({\n flagValue: options.registryDir,\n envValue: process.env[REGISTRY_DIR_ENV],\n homeDir: os.homedir(),\n }),\n spawnCommand: options.spawnCommand,\n remoteStateDir: stateDir,\n cloudflaredPath: options.cloudflaredPath,\n onNotice: notice,\n // Already deferred past its own response by the route that asked, so by\n // the time this runs the caller has its answer and the server can go.\n onHandover: (handover) => {\n void handOver({\n serving: server,\n restart: start,\n container,\n handover,\n port: options.port,\n host: options.host,\n adopt: (next) => {\n shutDown = next;\n },\n });\n },\n });\n shutDown = async () => {\n await server.close();\n };\n return server;\n };\n\n try {\n await start();\n } catch (error) {\n // Two cockpits starting together can both find the port free; the loser\n // settles for the winner rather than reporting a clash nobody caused.\n if ((error as NodeJS.ErrnoException).code !== ADDRESS_IN_USE) throw error;\n if (await hubAnswers(options.host, options.port)) {\n notice(`cockpit already running at ${url}`);\n return;\n }\n throw new Error(`Port ${String(options.port)} is taken by something that is not a DoomPi cockpit; pass --port.`);\n }\n\n const stop = (): void => {\n void shutDown().then(() => {\n process.exit(0);\n });\n };\n process.on('SIGINT', stop);\n process.on('SIGTERM', stop);\n}\n\n/**\n * Hands this cockpit over to one running in a container.\n *\n * The port has to be free before the container can publish onto it, so the\n * order is: stop serving, start the container, and put the host cockpit back if\n * the container does not come up. Leaving the user with nothing listening would\n * be the worst outcome of the three, which is why the rollback also recreates\n * the sessions that were stopped in preparation for a move that did not happen.\n */\nasync function handOver(input: {\n serving: WebServer;\n restart: () => Promise<WebServer>;\n container: ReturnType<typeof createCockpitContainer>;\n handover: { settings: RemoteAccessSettings; sessions: readonly MigratingSession[] };\n port: number;\n host: string;\n adopt: (shutDown: () => Promise<void>) => void;\n}): Promise<void> {\n await input.serving.close();\n const started = await input.container.start({\n workspaces: input.handover.settings.sandbox.workspaces.map((workspace) => ({ path: workspace })),\n port: input.port,\n onProgress: notice,\n });\n if (!started.ok) {\n notice(`the cockpit could not move into a container: ${started.error}`);\n notice('serving from the host again');\n await input.restart();\n await relaunchSessions({\n port: input.port,\n host: input.host,\n sessions: input.handover.sessions,\n onNotice: notice,\n });\n return;\n }\n input.adopt(async () => {\n await started.container.stop();\n });\n // The container answers its own health probe before `start` resolves, so its\n // hub is already there to take the settings and the sessions.\n await handOffRemoteAccess({ port: input.port, settings: input.handover.settings, onNotice: notice });\n await relaunchSessions({ port: input.port, sessions: input.handover.sessions, onNotice: notice });\n // Nothing else holds this process open now that its server is closed, and a\n // supervisor that exits leaves a container nobody will ever stop.\n notice('supervising the cockpit container; Ctrl-C stops it');\n await started.container.supervise();\n}\n\nmain().catch((error: unknown) => {\n notice(error instanceof Error ? error.message : String(error));\n process.exitCode = 1;\n});\n"],"mappings":"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"}
|
package/dist/bin/serve.mjs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import{REGISTRY_DIR_ENV as e,resolveRegistryDir as t}from"../src/services/registryStore.mjs";import{
|
|
2
|
+
import{REGISTRY_DIR_ENV as e,resolveRegistryDir as t}from"../src/services/registryStore.mjs";import{defaultRemoteStateDir as n}from"../src/adapters/remoteAccessStore.mjs";import{serveWeb as r}from"../src/adapters/httpServer.mjs";import{isLoopbackHost as i,parseServeOptions as a}from"../src/services/serveOptions.mjs";import{createCockpitContainer as o}from"../src/adapters/cockpitContainer.mjs";import{handOffRemoteAccess as s}from"../src/adapters/cockpitHandoff.mjs";import{hubAnswers as c}from"../src/adapters/hubProbe.mjs";import{relaunchSessions as l}from"../src/adapters/sessionRelaunch.mjs";import u from"node:os";function d(e){process.stderr.write(`[doompi-web] ${e}\n`)}async function f(){let s=a(process.argv.slice(2)),l=`http://${s.host}:${String(s.port)}`,f=s.stateDir??n(),m=o({stateDir:f,onNotice:d});if(await m.reapStale(),i(s.host)||(d(`WARNING: --host ${s.host} is not loopback, so the cockpit is reachable without pairing.`),d(`WARNING: turn on remote access in settings instead, which requires a paired device.`)),await c(s.host,s.port)){d(`cockpit already running at ${l}`);return}let h=async()=>{},g=async()=>{let n=await r({port:s.port,host:s.host,assetsDir:s.assetsDir,registryDir:t({flagValue:s.registryDir,envValue:process.env[e],homeDir:u.homedir()}),spawnCommand:s.spawnCommand,remoteStateDir:f,cloudflaredPath:s.cloudflaredPath,onNotice:d,onHandover:e=>{p({serving:n,restart:g,container:m,handover:e,port:s.port,host:s.host,adopt:e=>{h=e}})}});return h=async()=>{await n.close()},n};try{await g()}catch(e){if(e.code!==`EADDRINUSE`)throw e;if(await c(s.host,s.port)){d(`cockpit already running at ${l}`);return}throw Error(`Port ${String(s.port)} is taken by something that is not a DoomPi cockpit; pass --port.`)}let _=()=>{h().then(()=>{process.exit(0)})};process.on(`SIGINT`,_),process.on(`SIGTERM`,_)}async function p(e){await e.serving.close();let t=await e.container.start({workspaces:e.handover.settings.sandbox.workspaces.map(e=>({path:e})),port:e.port,onProgress:d});if(!t.ok){d(`the cockpit could not move into a container: ${t.error}`),d(`serving from the host again`),await e.restart(),await l({port:e.port,host:e.host,sessions:e.handover.sessions,onNotice:d});return}e.adopt(async()=>{await t.container.stop()}),await s({port:e.port,settings:e.handover.settings,onNotice:d}),await l({port:e.port,sessions:e.handover.sessions,onNotice:d}),d(`supervising the cockpit container; Ctrl-C stops it`),await t.container.supervise()}f().catch(e=>{d(e instanceof Error?e.message:String(e)),process.exitCode=1});export{};
|
|
3
3
|
//# sourceMappingURL=serve.mjs.map
|
package/dist/bin/serve.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"serve.mjs","names":[],"sources":["../../src/bin/serve.ts"],"sourcesContent":["#!/usr/bin/env node\n\nimport os from 'node:os';\nimport { hubAnswers } from '../adapters/hubProbe.ts';\nimport { serveWeb } from '../adapters/httpServer.ts';\nimport { REGISTRY_DIR_ENV, resolveRegistryDir } from '../services/registryStore.ts';\nimport { parseServeOptions } from '../services/serveOptions.ts';\nimport type { WebServer } from '../types/bridge.ts';\n\nconst ADDRESS_IN_USE = 'EADDRINUSE';\n\nfunction notice(message: string): void {\n process.stderr.write(`[doompi-web] ${message}\\n`);\n}\n\nasync function main(): Promise<void> {\n const options = parseServeOptions(process.argv.slice(2));\n const url = `http://${options.host}:${String(options.port)}`;\n\n // One hub serves every session, so a second start is a request to use the\n // running one, not an error worth a stack trace.\n if (await hubAnswers(options.host, options.port)) {\n notice(`cockpit already running at ${url}`);\n return;\n }\n\n let
|
|
1
|
+
{"version":3,"file":"serve.mjs","names":[],"sources":["../../src/bin/serve.ts"],"sourcesContent":["#!/usr/bin/env node\n\nimport os from 'node:os';\nimport { createCockpitContainer } from '../adapters/cockpitContainer.ts';\nimport { handOffRemoteAccess } from '../adapters/cockpitHandoff.ts';\nimport { hubAnswers } from '../adapters/hubProbe.ts';\nimport { serveWeb } from '../adapters/httpServer.ts';\nimport { defaultRemoteStateDir } from '../adapters/remoteAccessStore.ts';\nimport { relaunchSessions } from '../adapters/sessionRelaunch.ts';\nimport { REGISTRY_DIR_ENV, resolveRegistryDir } from '../services/registryStore.ts';\nimport { isLoopbackHost, parseServeOptions } from '../services/serveOptions.ts';\nimport type { WebServer } from '../types/bridge.ts';\nimport type { MigratingSession, RemoteAccessSettings } from '../types/remoteAccess.ts';\n\nconst ADDRESS_IN_USE = 'EADDRINUSE';\n\nfunction notice(message: string): void {\n process.stderr.write(`[doompi-web] ${message}\\n`);\n}\n\nasync function main(): Promise<void> {\n const options = parseServeOptions(process.argv.slice(2));\n const url = `http://${options.host}:${String(options.port)}`;\n const stateDir = options.stateDir ?? defaultRemoteStateDir();\n const container = createCockpitContainer({ stateDir, onNotice: notice });\n // A cockpit that was killed rather than closed leaves its container holding\n // the published port, so the next start would fail to bind for a reason\n // nothing explains.\n await container.reapStale();\n\n // Binding off loopback publishes an unauthenticated cockpit to whoever can\n // reach the address. Remote access exists so nobody has to do this; a warning\n // rather than a refusal, because an existing setup should not break today.\n if (!isLoopbackHost(options.host)) {\n notice(`WARNING: --host ${options.host} is not loopback, so the cockpit is reachable without pairing.`);\n notice('WARNING: turn on remote access in settings instead, which requires a paired device.');\n }\n\n // One hub serves every session, so a second start is a request to use the\n // running one, not an error worth a stack trace.\n if (await hubAnswers(options.host, options.port)) {\n notice(`cockpit already running at ${url}`);\n return;\n }\n\n /**\n * What Ctrl-C stops, which is not the same thing throughout the run.\n *\n * A single indirection rather than swapping signal handlers, so the handover\n * never has to reach for `removeAllListeners` and take out whatever else this\n * process has registered.\n */\n let shutDown: () => Promise<void> = async () => {};\n\n const start = async (): Promise<WebServer> => {\n const server = await serveWeb({\n port: options.port,\n host: options.host,\n assetsDir: options.assetsDir,\n registryDir: resolveRegistryDir({\n flagValue: options.registryDir,\n envValue: process.env[REGISTRY_DIR_ENV],\n homeDir: os.homedir(),\n }),\n spawnCommand: options.spawnCommand,\n remoteStateDir: stateDir,\n cloudflaredPath: options.cloudflaredPath,\n onNotice: notice,\n // Already deferred past its own response by the route that asked, so by\n // the time this runs the caller has its answer and the server can go.\n onHandover: (handover) => {\n void handOver({\n serving: server,\n restart: start,\n container,\n handover,\n port: options.port,\n host: options.host,\n adopt: (next) => {\n shutDown = next;\n },\n });\n },\n });\n shutDown = async () => {\n await server.close();\n };\n return server;\n };\n\n try {\n await start();\n } catch (error) {\n // Two cockpits starting together can both find the port free; the loser\n // settles for the winner rather than reporting a clash nobody caused.\n if ((error as NodeJS.ErrnoException).code !== ADDRESS_IN_USE) throw error;\n if (await hubAnswers(options.host, options.port)) {\n notice(`cockpit already running at ${url}`);\n return;\n }\n throw new Error(`Port ${String(options.port)} is taken by something that is not a DoomPi cockpit; pass --port.`);\n }\n\n const stop = (): void => {\n void shutDown().then(() => {\n process.exit(0);\n });\n };\n process.on('SIGINT', stop);\n process.on('SIGTERM', stop);\n}\n\n/**\n * Hands this cockpit over to one running in a container.\n *\n * The port has to be free before the container can publish onto it, so the\n * order is: stop serving, start the container, and put the host cockpit back if\n * the container does not come up. Leaving the user with nothing listening would\n * be the worst outcome of the three, which is why the rollback also recreates\n * the sessions that were stopped in preparation for a move that did not happen.\n */\nasync function handOver(input: {\n serving: WebServer;\n restart: () => Promise<WebServer>;\n container: ReturnType<typeof createCockpitContainer>;\n handover: { settings: RemoteAccessSettings; sessions: readonly MigratingSession[] };\n port: number;\n host: string;\n adopt: (shutDown: () => Promise<void>) => void;\n}): Promise<void> {\n await input.serving.close();\n const started = await input.container.start({\n workspaces: input.handover.settings.sandbox.workspaces.map((workspace) => ({ path: workspace })),\n port: input.port,\n onProgress: notice,\n });\n if (!started.ok) {\n notice(`the cockpit could not move into a container: ${started.error}`);\n notice('serving from the host again');\n await input.restart();\n await relaunchSessions({\n port: input.port,\n host: input.host,\n sessions: input.handover.sessions,\n onNotice: notice,\n });\n return;\n }\n input.adopt(async () => {\n await started.container.stop();\n });\n // The container answers its own health probe before `start` resolves, so its\n // hub is already there to take the settings and the sessions.\n await handOffRemoteAccess({ port: input.port, settings: input.handover.settings, onNotice: notice });\n await relaunchSessions({ port: input.port, sessions: input.handover.sessions, onNotice: notice });\n // Nothing else holds this process open now that its server is closed, and a\n // supervisor that exits leaves a container nobody will ever stop.\n notice('supervising the cockpit container; Ctrl-C stops it');\n await started.container.supervise();\n}\n\nmain().catch((error: unknown) => {\n notice(error instanceof Error ? error.message : String(error));\n process.exitCode = 1;\n});\n"],"mappings":";6mBAgBA,SAAS,EAAO,EAAuB,CACrC,QAAQ,OAAO,MAAM,gBAAgB,EAAQ,GAAG,CAClD,CAEA,eAAe,GAAsB,CACnC,IAAM,EAAU,EAAkB,QAAQ,KAAK,MAAM,CAAC,CAAC,EACjD,EAAM,UAAU,EAAQ,KAAK,GAAG,OAAO,EAAQ,IAAI,IACnD,EAAW,EAAQ,UAAY,EAAsB,EACrD,EAAY,EAAuB,CAAE,WAAU,SAAU,CAAO,CAAC,EAgBvE,GAZA,MAAM,EAAU,UAAU,EAKrB,EAAe,EAAQ,IAAI,IAC9B,EAAO,mBAAmB,EAAQ,KAAK,+DAA+D,EACtG,EAAO,qFAAqF,GAK1F,MAAM,EAAW,EAAQ,KAAM,EAAQ,IAAI,EAAG,CAChD,EAAO,8BAA8B,GAAK,EAC1C,MACF,CASA,IAAI,EAAgC,SAAY,CAAC,EAE3C,EAAQ,SAAgC,CAC5C,IAAM,EAAS,MAAM,EAAS,CAC5B,KAAM,EAAQ,KACd,KAAM,EAAQ,KACd,UAAW,EAAQ,UACnB,YAAa,EAAmB,CAC9B,UAAW,EAAQ,YACnB,SAAU,QAAQ,IAAI,GACtB,QAAS,EAAG,QAAQ,CACtB,CAAC,EACD,aAAc,EAAQ,aACtB,eAAgB,EAChB,gBAAiB,EAAQ,gBACzB,SAAU,EAGV,WAAa,GAAa,CACxB,EAAc,CACZ,QAAS,EACT,QAAS,EACT,YACA,WACA,KAAM,EAAQ,KACd,KAAM,EAAQ,KACd,MAAQ,GAAS,CACf,EAAW,CACb,CACF,CAAC,CACH,CACF,CAAC,EAID,MAHA,GAAW,SAAY,CACrB,MAAM,EAAO,MAAM,CACrB,EACO,CACT,EAEA,GAAI,CACF,MAAM,EAAM,CACd,OAAS,EAAO,CAGd,GAAK,EAAgC,OAAS,aAAgB,MAAM,EACpE,GAAI,MAAM,EAAW,EAAQ,KAAM,EAAQ,IAAI,EAAG,CAChD,EAAO,8BAA8B,GAAK,EAC1C,MACF,CACA,MAAU,MAAM,QAAQ,OAAO,EAAQ,IAAI,EAAE,kEAAkE,CACjH,CAEA,IAAM,MAAmB,CACvB,EAAc,CAAC,CAAC,SAAW,CACzB,QAAQ,KAAK,CAAC,CAChB,CAAC,CACH,EACA,QAAQ,GAAG,SAAU,CAAI,EACzB,QAAQ,GAAG,UAAW,CAAI,CAC5B,CAWA,eAAe,EAAS,EAQN,CAChB,MAAM,EAAM,QAAQ,MAAM,EAC1B,IAAM,EAAU,MAAM,EAAM,UAAU,MAAM,CAC1C,WAAY,EAAM,SAAS,SAAS,QAAQ,WAAW,IAAK,IAAe,CAAE,KAAM,CAAU,EAAE,EAC/F,KAAM,EAAM,KACZ,WAAY,CACd,CAAC,EACD,GAAI,CAAC,EAAQ,GAAI,CACf,EAAO,gDAAgD,EAAQ,OAAO,EACtE,EAAO,6BAA6B,EACpC,MAAM,EAAM,QAAQ,EACpB,MAAM,EAAiB,CACrB,KAAM,EAAM,KACZ,KAAM,EAAM,KACZ,SAAU,EAAM,SAAS,SACzB,SAAU,CACZ,CAAC,EACD,MACF,CACA,EAAM,MAAM,SAAY,CACtB,MAAM,EAAQ,UAAU,KAAK,CAC/B,CAAC,EAGD,MAAM,EAAoB,CAAE,KAAM,EAAM,KAAM,SAAU,EAAM,SAAS,SAAU,SAAU,CAAO,CAAC,EACnG,MAAM,EAAiB,CAAE,KAAM,EAAM,KAAM,SAAU,EAAM,SAAS,SAAU,SAAU,CAAO,CAAC,EAGhG,EAAO,oDAAoD,EAC3D,MAAM,EAAQ,UAAU,UAAU,CACpC,CAEA,EAAK,CAAC,CAAC,MAAO,GAAmB,CAC/B,EAAO,aAAiB,MAAQ,EAAM,QAAU,OAAO,CAAK,CAAC,EAC7D,QAAQ,SAAW,CACrB,CAAC"}
|
package/dist/index.d.cts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { BridgeState, BridgeStatusFrame, SessionFrame } from "./src/types/session.cjs";
|
|
2
2
|
import { SessionAttachment, WebServer, WebServerOptions } from "./src/types/bridge.cjs";
|
|
3
|
-
import { serveWeb } from "./src/adapters/httpServer.cjs";
|
|
4
|
-
import { ServeOptions, parseServeOptions } from "./src/services/serveOptions.cjs";
|
|
5
3
|
import { HUB_PROTOCOL_VERSION, HUB_ROLE, SESSIONS_API_ROUTE, SessionPhase, SessionSummary } from "./src/types/hub.cjs";
|
|
6
4
|
import { SESSION_RECORD_VERSION, SessionRecord } from "./src/types/registry.cjs";
|
|
5
|
+
import { serveWeb } from "./src/adapters/httpServer.cjs";
|
|
6
|
+
import { ServeOptions, parseServeOptions } from "./src/services/serveOptions.cjs";
|
|
7
7
|
export { type BridgeState, type BridgeStatusFrame, HUB_PROTOCOL_VERSION, HUB_ROLE, SESSIONS_API_ROUTE, SESSION_RECORD_VERSION, type ServeOptions, type SessionAttachment, type SessionFrame, type SessionPhase, type SessionRecord, type SessionSummary, type WebServer, type WebServerOptions, parseServeOptions, serveWeb };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { BridgeState, BridgeStatusFrame, SessionFrame } from "./src/types/session.mjs";
|
|
2
2
|
import { SessionAttachment, WebServer, WebServerOptions } from "./src/types/bridge.mjs";
|
|
3
|
-
import { serveWeb } from "./src/adapters/httpServer.mjs";
|
|
4
|
-
import { ServeOptions, parseServeOptions } from "./src/services/serveOptions.mjs";
|
|
5
3
|
import { HUB_PROTOCOL_VERSION, HUB_ROLE, SESSIONS_API_ROUTE, SessionPhase, SessionSummary } from "./src/types/hub.mjs";
|
|
6
4
|
import { SESSION_RECORD_VERSION, SessionRecord } from "./src/types/registry.mjs";
|
|
5
|
+
import { serveWeb } from "./src/adapters/httpServer.mjs";
|
|
6
|
+
import { ServeOptions, parseServeOptions } from "./src/services/serveOptions.mjs";
|
|
7
7
|
export { type BridgeState, type BridgeStatusFrame, HUB_PROTOCOL_VERSION, HUB_ROLE, SESSIONS_API_ROUTE, SESSION_RECORD_VERSION, type ServeOptions, type SessionAttachment, type SessionFrame, type SessionPhase, type SessionRecord, type SessionSummary, type WebServer, type WebServerOptions, parseServeOptions, serveWeb };
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
const e=require("../../_virtual/_rolldown/runtime.cjs");let t=require("node:fs");t=e.__toESM(t,1);let n=require("node:path");n=e.__toESM(n,1);let r=require("node:module"),i=require("@agimon-ai/doompi-extension-contracts/cockpit-container");const a=[`@agimon-ai/doompi-sandbox`];async function o(e){let t=(0,r.createRequire)(require("url").pathToFileURL(__filename).href);for(let n of a){let r;try{r=t.resolve(`${n}/${i.COCKPIT_HARNESS_EXPORT_SUBPATH.replace(`./`,``)}`)}catch{continue}let a=await import(r);if((0,i.isCockpitContainerHarnessModule)(a))return a;e(`${n} exports a cockpit harness this build does not understand; ignoring it`)}}function s(e){let r=e.onNotice??(()=>{}),i=e.now??(()=>Date.now()),a=n.default.join(e.stateDir,`cockpit-container.json`),s=e.resolveHarness??(async()=>await o(r)),l=n=>{try{t.default.mkdirSync(e.stateDir,{recursive:!0,mode:448}),t.default.writeFileSync(a,JSON.stringify({containerId:n,startedAt:i()}),{mode:384})}catch{}};return{async reapStale(){let e;try{e=JSON.parse(t.default.readFileSync(a,`utf8`))}catch{return}t.default.rmSync(a,{force:!0});let n=typeof e.containerId==`string`?e.containerId:void 0,o=typeof e.startedAt==`number`?e.startedAt:0;if(n===void 0||i()-o>6048e5)return;let c=await s();c!==void 0&&await c.reapCockpitContainer(n)&&r(`stopped a cockpit container left running by a previous process (${n})`)},async start(t){let n=await s();if(n===void 0)return{ok:!1,error:`No installed layer can host the cockpit in a container. Add @agimon-ai/doompi-sandbox.`};let i=await n.startCockpitContainer({workspaces:t.workspaces,port:t.port,environment:process.env,...t.onProgress===void 0?{}:{onProgress:t.onProgress}});return i.ok?(l(i.handle.containerId),r(`cockpit container ${i.handle.containerId} is serving on port ${String(t.port)}`),{ok:!0,container:c(i.handle,a,r,e.supervisePollMs??2e3)}):{ok:!1,error:i.error}}}}function c(e,n,r,i){let a=!1,o=()=>{try{t.default.rmSync(n,{force:!0})}catch{}};process.once(`exit`,o);let s=()=>{process.off(`exit`,o),o()};return{containerId:e.containerId,stop:async()=>{a||(a=!0,s(),await e.stop().catch(e=>{r(`the cockpit container did not stop cleanly: ${e instanceof Error?e.message:String(e)}`)}))},supervise:async()=>{for(;!a;){if(await new Promise(e=>setTimeout(e,i)),a)return;if(!await e.alive()){a=!0,s(),r(`the cockpit container ${e.containerId} stopped on its own`);return}}}}}exports.SUPERVISE_POLL_MS=2e3,exports.createCockpitContainer=s;
|
|
2
|
+
//# sourceMappingURL=cockpitContainer.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cockpitContainer.cjs","names":["require","createRequire","COCKPIT_HARNESS_EXPORT_SUBPATH","isCockpitContainerHarnessModule","path","fs"],"sources":["../../../src/adapters/cockpitContainer.ts"],"sourcesContent":["import { createRequire } from 'node:module';\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport {\n COCKPIT_HARNESS_EXPORT_SUBPATH,\n isCockpitContainerHarnessModule,\n type CockpitContainerHandle,\n type CockpitContainerHarnessModule,\n type CockpitWorkspace,\n} from '@agimon-ai/doompi-extension-contracts/cockpit-container';\n\n/**\n * Owns the container the cockpit hands over to.\n *\n * The harness is resolved from the composition rather than imported, the same\n * way core resolves the interactive sandbox, so this package never depends on a\n * container technology. What lives here is the part the harness cannot own: the\n * container's lifetime relative to *this* process, and the record that lets a\n * later start clean up after a process that did not get to.\n */\n\nconst ID_FILE = 'cockpit-container.json';\nconst FILE_MODE = 0o600;\nconst DIRECTORY_MODE = 0o700;\n/** How often the supervisor checks the container it is holding open for. */\nexport const SUPERVISE_POLL_MS = 2000;\n/** Past this the recorded id is more likely to name something else than the container. */\nconst ID_FILE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;\n/** Layer packages that may host the cockpit, in the order they are tried. */\nconst HARNESS_PACKAGES = ['@agimon-ai/doompi-sandbox'];\n\nexport interface CockpitContainerOptions {\n stateDir: string;\n onNotice?: (message: string) => void;\n /** Test seam: stands in for looking a harness up in the composition. */\n resolveHarness?: () => Promise<CockpitContainerHarnessModule | undefined>;\n now?: () => number;\n /** Test seam: how often the supervisor asks whether the container is still there. */\n supervisePollMs?: number;\n}\n\nexport interface StartedCockpitContainer {\n containerId: string;\n stop: () => Promise<void>;\n /**\n * Keeps this process alive for as long as the container is, and no longer.\n *\n * A cockpit that has handed over has closed its server, so nothing else holds\n * its event loop open: without this it would exit immediately and leave a\n * container running with nobody to stop it. Resolves when the container is\n * gone, whether that was asked for or not.\n */\n supervise: () => Promise<void>;\n}\n\nexport type CockpitContainerOutcome = { ok: true; container: StartedCockpitContainer } | { ok: false; error: string };\n\n/**\n * Finds a layer that can host the cockpit.\n *\n * Dynamic on purpose: a composition without a sandbox layer simply cannot\n * offer this, and saying so is better than a build-time dependency that makes\n * every install carry container code it will never run.\n */\nasync function resolveHarness(onNotice: (message: string) => void): Promise<CockpitContainerHarnessModule | undefined> {\n const require = createRequire(import.meta.url);\n for (const name of HARNESS_PACKAGES) {\n let entry: string;\n try {\n entry = require.resolve(`${name}/${COCKPIT_HARNESS_EXPORT_SUBPATH.replace('./', '')}`);\n } catch {\n continue; // Not installed, or too old to export the subpath.\n }\n const loaded: unknown = await import(entry);\n if (isCockpitContainerHarnessModule(loaded)) return loaded;\n onNotice(`${name} exports a cockpit harness this build does not understand; ignoring it`);\n }\n return undefined;\n}\n\nexport function createCockpitContainer(options: CockpitContainerOptions) {\n const notice = options.onNotice ?? ((): void => {});\n const now = options.now ?? ((): number => Date.now());\n const idPath = path.join(options.stateDir, ID_FILE);\n const findHarness =\n options.resolveHarness ??\n (async (): Promise<CockpitContainerHarnessModule | undefined> => await resolveHarness(notice));\n\n const record = (containerId: string): void => {\n try {\n fs.mkdirSync(options.stateDir, { recursive: true, mode: DIRECTORY_MODE });\n fs.writeFileSync(idPath, JSON.stringify({ containerId, startedAt: now() }), { mode: FILE_MODE });\n } catch {\n // The reaper is a safety net, not a requirement; losing it does not stop\n // the container from coming up.\n }\n };\n\n return {\n /**\n * Stops a container an earlier process left running.\n *\n * A cockpit that was killed rather than closed cannot clean up after\n * itself, and the container it left behind still holds the published port,\n * so the next start would fail to bind for a reason nothing explains.\n */\n async reapStale(): Promise<void> {\n let parsed: { containerId?: unknown; startedAt?: unknown };\n try {\n parsed = JSON.parse(fs.readFileSync(idPath, 'utf8')) as typeof parsed;\n } catch {\n return; // No record is the normal case.\n }\n fs.rmSync(idPath, { force: true });\n const containerId = typeof parsed.containerId === 'string' ? parsed.containerId : undefined;\n const startedAt = typeof parsed.startedAt === 'number' ? parsed.startedAt : 0;\n if (containerId === undefined || now() - startedAt > ID_FILE_MAX_AGE_MS) return;\n const harness = await findHarness();\n if (harness === undefined) return;\n if (await harness.reapCockpitContainer(containerId)) {\n notice(`stopped a cockpit container left running by a previous process (${containerId})`);\n }\n },\n\n async start(input: {\n workspaces: readonly CockpitWorkspace[];\n port: number;\n onProgress?: (message: string) => void;\n }): Promise<CockpitContainerOutcome> {\n const harness = await findHarness();\n if (harness === undefined) {\n return {\n ok: false,\n error: 'No installed layer can host the cockpit in a container. Add @agimon-ai/doompi-sandbox.',\n };\n }\n const started = await harness.startCockpitContainer({\n workspaces: input.workspaces,\n port: input.port,\n environment: process.env,\n ...(input.onProgress === undefined ? {} : { onProgress: input.onProgress }),\n });\n if (!started.ok) return { ok: false, error: started.error };\n record(started.handle.containerId);\n notice(`cockpit container ${started.handle.containerId} is serving on port ${String(input.port)}`);\n return {\n ok: true,\n container: hold(started.handle, idPath, notice, options.supervisePollMs ?? SUPERVISE_POLL_MS),\n };\n },\n };\n}\n\n/**\n * Binds the container's lifetime to this process.\n *\n * The same shape as the tunnel: signals stop it, and a synchronous `exit`\n * handler is the net under `process.exit`, an uncaught exception, and an\n * unhandled rejection, none of which reach a graceful path. A `SIGKILL` still\n * escapes, which is what the id file and `reapStale` are for.\n */\nfunction hold(\n handle: CockpitContainerHandle,\n idPath: string,\n notice: (message: string) => void,\n pollMs: number,\n): StartedCockpitContainer {\n let stopped = false;\n const onExit = (): void => {\n // Synchronous: async work in an exit handler never runs. Best effort, and\n // the id file covers what this cannot.\n try {\n fs.rmSync(idPath, { force: true });\n } catch {\n // Nothing further to do at exit.\n }\n };\n process.once('exit', onExit);\n\n const forget = (): void => {\n process.off('exit', onExit);\n onExit();\n };\n\n return {\n containerId: handle.containerId,\n stop: async () => {\n if (stopped) return;\n stopped = true;\n forget();\n await handle.stop().catch((error: unknown) => {\n notice(`the cockpit container did not stop cleanly: ${error instanceof Error ? error.message : String(error)}`);\n });\n },\n supervise: async () => {\n while (!stopped) {\n await new Promise((resolve) => setTimeout(resolve, pollMs));\n if (stopped) return;\n if (await handle.alive()) continue;\n // Not a clean stop: the container went away on its own, so the record\n // is cleared and the caller is told rather than left waiting.\n stopped = true;\n forget();\n notice(`the cockpit container ${handle.containerId} stopped on its own`);\n return;\n }\n },\n };\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{createRequire as e}from"node:module";import t from"node:fs";import n from"node:path";import{COCKPIT_HARNESS_EXPORT_SUBPATH as r,isCockpitContainerHarnessModule as i}from"@agimon-ai/doompi-extension-contracts/cockpit-container";const a=2e3,o=[`@agimon-ai/doompi-sandbox`];async function s(t){let n=e(import.meta.url);for(let e of o){let a;try{a=n.resolve(`${e}/${r.replace(`./`,``)}`)}catch{continue}let o=await import(a);if(i(o))return o;t(`${e} exports a cockpit harness this build does not understand; ignoring it`)}}function c(e){let r=e.onNotice??(()=>{}),i=e.now??(()=>Date.now()),a=n.join(e.stateDir,`cockpit-container.json`),o=e.resolveHarness??(async()=>await s(r)),c=n=>{try{t.mkdirSync(e.stateDir,{recursive:!0,mode:448}),t.writeFileSync(a,JSON.stringify({containerId:n,startedAt:i()}),{mode:384})}catch{}};return{async reapStale(){let e;try{e=JSON.parse(t.readFileSync(a,`utf8`))}catch{return}t.rmSync(a,{force:!0});let n=typeof e.containerId==`string`?e.containerId:void 0,s=typeof e.startedAt==`number`?e.startedAt:0;if(n===void 0||i()-s>6048e5)return;let c=await o();c!==void 0&&await c.reapCockpitContainer(n)&&r(`stopped a cockpit container left running by a previous process (${n})`)},async start(t){let n=await o();if(n===void 0)return{ok:!1,error:`No installed layer can host the cockpit in a container. Add @agimon-ai/doompi-sandbox.`};let i=await n.startCockpitContainer({workspaces:t.workspaces,port:t.port,environment:process.env,...t.onProgress===void 0?{}:{onProgress:t.onProgress}});return i.ok?(c(i.handle.containerId),r(`cockpit container ${i.handle.containerId} is serving on port ${String(t.port)}`),{ok:!0,container:l(i.handle,a,r,e.supervisePollMs??2e3)}):{ok:!1,error:i.error}}}}function l(e,n,r,i){let a=!1,o=()=>{try{t.rmSync(n,{force:!0})}catch{}};process.once(`exit`,o);let s=()=>{process.off(`exit`,o),o()};return{containerId:e.containerId,stop:async()=>{a||(a=!0,s(),await e.stop().catch(e=>{r(`the cockpit container did not stop cleanly: ${e instanceof Error?e.message:String(e)}`)}))},supervise:async()=>{for(;!a;){if(await new Promise(e=>setTimeout(e,i)),a)return;if(!await e.alive()){a=!0,s(),r(`the cockpit container ${e.containerId} stopped on its own`);return}}}}}export{a as SUPERVISE_POLL_MS,c as createCockpitContainer};
|
|
2
|
+
//# sourceMappingURL=cockpitContainer.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cockpitContainer.mjs","names":[],"sources":["../../../src/adapters/cockpitContainer.ts"],"sourcesContent":["import { createRequire } from 'node:module';\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport {\n COCKPIT_HARNESS_EXPORT_SUBPATH,\n isCockpitContainerHarnessModule,\n type CockpitContainerHandle,\n type CockpitContainerHarnessModule,\n type CockpitWorkspace,\n} from '@agimon-ai/doompi-extension-contracts/cockpit-container';\n\n/**\n * Owns the container the cockpit hands over to.\n *\n * The harness is resolved from the composition rather than imported, the same\n * way core resolves the interactive sandbox, so this package never depends on a\n * container technology. What lives here is the part the harness cannot own: the\n * container's lifetime relative to *this* process, and the record that lets a\n * later start clean up after a process that did not get to.\n */\n\nconst ID_FILE = 'cockpit-container.json';\nconst FILE_MODE = 0o600;\nconst DIRECTORY_MODE = 0o700;\n/** How often the supervisor checks the container it is holding open for. */\nexport const SUPERVISE_POLL_MS = 2000;\n/** Past this the recorded id is more likely to name something else than the container. */\nconst ID_FILE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;\n/** Layer packages that may host the cockpit, in the order they are tried. */\nconst HARNESS_PACKAGES = ['@agimon-ai/doompi-sandbox'];\n\nexport interface CockpitContainerOptions {\n stateDir: string;\n onNotice?: (message: string) => void;\n /** Test seam: stands in for looking a harness up in the composition. */\n resolveHarness?: () => Promise<CockpitContainerHarnessModule | undefined>;\n now?: () => number;\n /** Test seam: how often the supervisor asks whether the container is still there. */\n supervisePollMs?: number;\n}\n\nexport interface StartedCockpitContainer {\n containerId: string;\n stop: () => Promise<void>;\n /**\n * Keeps this process alive for as long as the container is, and no longer.\n *\n * A cockpit that has handed over has closed its server, so nothing else holds\n * its event loop open: without this it would exit immediately and leave a\n * container running with nobody to stop it. Resolves when the container is\n * gone, whether that was asked for or not.\n */\n supervise: () => Promise<void>;\n}\n\nexport type CockpitContainerOutcome = { ok: true; container: StartedCockpitContainer } | { ok: false; error: string };\n\n/**\n * Finds a layer that can host the cockpit.\n *\n * Dynamic on purpose: a composition without a sandbox layer simply cannot\n * offer this, and saying so is better than a build-time dependency that makes\n * every install carry container code it will never run.\n */\nasync function resolveHarness(onNotice: (message: string) => void): Promise<CockpitContainerHarnessModule | undefined> {\n const require = createRequire(import.meta.url);\n for (const name of HARNESS_PACKAGES) {\n let entry: string;\n try {\n entry = require.resolve(`${name}/${COCKPIT_HARNESS_EXPORT_SUBPATH.replace('./', '')}`);\n } catch {\n continue; // Not installed, or too old to export the subpath.\n }\n const loaded: unknown = await import(entry);\n if (isCockpitContainerHarnessModule(loaded)) return loaded;\n onNotice(`${name} exports a cockpit harness this build does not understand; ignoring it`);\n }\n return undefined;\n}\n\nexport function createCockpitContainer(options: CockpitContainerOptions) {\n const notice = options.onNotice ?? ((): void => {});\n const now = options.now ?? ((): number => Date.now());\n const idPath = path.join(options.stateDir, ID_FILE);\n const findHarness =\n options.resolveHarness ??\n (async (): Promise<CockpitContainerHarnessModule | undefined> => await resolveHarness(notice));\n\n const record = (containerId: string): void => {\n try {\n fs.mkdirSync(options.stateDir, { recursive: true, mode: DIRECTORY_MODE });\n fs.writeFileSync(idPath, JSON.stringify({ containerId, startedAt: now() }), { mode: FILE_MODE });\n } catch {\n // The reaper is a safety net, not a requirement; losing it does not stop\n // the container from coming up.\n }\n };\n\n return {\n /**\n * Stops a container an earlier process left running.\n *\n * A cockpit that was killed rather than closed cannot clean up after\n * itself, and the container it left behind still holds the published port,\n * so the next start would fail to bind for a reason nothing explains.\n */\n async reapStale(): Promise<void> {\n let parsed: { containerId?: unknown; startedAt?: unknown };\n try {\n parsed = JSON.parse(fs.readFileSync(idPath, 'utf8')) as typeof parsed;\n } catch {\n return; // No record is the normal case.\n }\n fs.rmSync(idPath, { force: true });\n const containerId = typeof parsed.containerId === 'string' ? parsed.containerId : undefined;\n const startedAt = typeof parsed.startedAt === 'number' ? parsed.startedAt : 0;\n if (containerId === undefined || now() - startedAt > ID_FILE_MAX_AGE_MS) return;\n const harness = await findHarness();\n if (harness === undefined) return;\n if (await harness.reapCockpitContainer(containerId)) {\n notice(`stopped a cockpit container left running by a previous process (${containerId})`);\n }\n },\n\n async start(input: {\n workspaces: readonly CockpitWorkspace[];\n port: number;\n onProgress?: (message: string) => void;\n }): Promise<CockpitContainerOutcome> {\n const harness = await findHarness();\n if (harness === undefined) {\n return {\n ok: false,\n error: 'No installed layer can host the cockpit in a container. Add @agimon-ai/doompi-sandbox.',\n };\n }\n const started = await harness.startCockpitContainer({\n workspaces: input.workspaces,\n port: input.port,\n environment: process.env,\n ...(input.onProgress === undefined ? {} : { onProgress: input.onProgress }),\n });\n if (!started.ok) return { ok: false, error: started.error };\n record(started.handle.containerId);\n notice(`cockpit container ${started.handle.containerId} is serving on port ${String(input.port)}`);\n return {\n ok: true,\n container: hold(started.handle, idPath, notice, options.supervisePollMs ?? SUPERVISE_POLL_MS),\n };\n },\n };\n}\n\n/**\n * Binds the container's lifetime to this process.\n *\n * The same shape as the tunnel: signals stop it, and a synchronous `exit`\n * handler is the net under `process.exit`, an uncaught exception, and an\n * unhandled rejection, none of which reach a graceful path. A `SIGKILL` still\n * escapes, which is what the id file and `reapStale` are for.\n */\nfunction hold(\n handle: CockpitContainerHandle,\n idPath: string,\n notice: (message: string) => void,\n pollMs: number,\n): StartedCockpitContainer {\n let stopped = false;\n const onExit = (): void => {\n // Synchronous: async work in an exit handler never runs. Best effort, and\n // the id file covers what this cannot.\n try {\n fs.rmSync(idPath, { force: true });\n } catch {\n // Nothing further to do at exit.\n }\n };\n process.once('exit', onExit);\n\n const forget = (): void => {\n process.off('exit', onExit);\n onExit();\n };\n\n return {\n containerId: handle.containerId,\n stop: async () => {\n if (stopped) return;\n stopped = true;\n forget();\n await handle.stop().catch((error: unknown) => {\n notice(`the cockpit container did not stop cleanly: ${error instanceof Error ? error.message : String(error)}`);\n });\n },\n supervise: async () => {\n while (!stopped) {\n await new Promise((resolve) => setTimeout(resolve, pollMs));\n if (stopped) return;\n if (await handle.alive()) continue;\n // Not a clean stop: the container went away on its own, so the record\n // is cleared and the caller is told rather than left waiting.\n stopped = true;\n forget();\n notice(`the cockpit container ${handle.containerId} stopped on its own`);\n return;\n }\n },\n };\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
const e=require("../types/remoteAccess.cjs");async function t(e,t,n){let r=await fetch(e,{method:t,headers:{"content-type":`application/json`},body:n,signal:AbortSignal.timeout(6e4)});return{status:r.status,text:await r.text()}}async function n(n){let r=n.onNotice??(()=>{}),i=n.send??t,a=`http://${n.host??`127.0.0.1`}:${String(n.port)}${e.REMOTE_API_ROUTE}`;try{let e=await i(`${a}/settings`,`PUT`,JSON.stringify(n.settings));if(e.status!==200)return r(`the container would not take the remote-access settings: ${e.text}`),!1;let t=await i(`${a}/enable`,`POST`,`{}`);return t.status!==200&&t.status!==202?(r(`the container could not turn remote access on: ${t.text}`),!1):!0}catch(e){return r(`the container could not turn remote access on: ${e instanceof Error?e.message:String(e)}`),!1}}exports.handOffRemoteAccess=n;
|
|
2
|
+
//# sourceMappingURL=cockpitHandoff.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cockpitHandoff.cjs","names":["REMOTE_API_ROUTE"],"sources":["../../../src/adapters/cockpitHandoff.ts"],"sourcesContent":["import { REMOTE_API_ROUTE, type RemoteAccessSettings } from '../types/remoteAccess.ts';\n\n/**\n * Telling the cockpit that took over what it took over.\n *\n * The container has its own home volume, so it starts from its own settings and\n * knows nothing about the toggle that was just flipped on the host. Two calls\n * over the hub's own control plane close that gap: adopt the settings, then\n * turn remote access on, which is what the user asked for in the first place.\n *\n * Both routes are local-only, and a request to the published loopback port\n * arrives on the container's main listener, which is local by definition. The\n * tunnel listener is a different socket and is not reachable from here.\n */\n\nconst OK = 200;\nconst ACCEPTED = 202;\nconst LOOPBACK = '127.0.0.1';\n/** Enabling starts a tunnel, which waits on cloudflared reaching the edge. */\nconst HANDOFF_TIMEOUT_MS = 60_000;\n\nexport interface CockpitHandoffOptions {\n port: number;\n host?: string;\n settings: RemoteAccessSettings;\n onNotice?: (message: string) => void;\n /** Test seam, so a suite never opens a socket. */\n send?: (url: string, method: string, body: string) => Promise<{ status: number; text: string }>;\n}\n\nasync function sendJson(url: string, method: string, body: string): Promise<{ status: number; text: string }> {\n const response = await fetch(url, {\n method,\n headers: { 'content-type': 'application/json' },\n body,\n signal: AbortSignal.timeout(HANDOFF_TIMEOUT_MS),\n });\n return { status: response.status, text: await response.text() };\n}\n\nexport async function handOffRemoteAccess(options: CockpitHandoffOptions): Promise<boolean> {\n const notice = options.onNotice ?? ((): void => {});\n const send = options.send ?? sendJson;\n const base = `http://${options.host ?? LOOPBACK}:${String(options.port)}${REMOTE_API_ROUTE}`;\n try {\n // Sent whole, sandbox flag included: the contained hub reads its own\n // environment to know it is already contained, so the setting stays true\n // and the dialog keeps telling the truth about how it is running.\n const stored = await send(`${base}/settings`, 'PUT', JSON.stringify(options.settings));\n if (stored.status !== OK) {\n notice(`the container would not take the remote-access settings: ${stored.text}`);\n return false;\n }\n const enabled = await send(`${base}/enable`, 'POST', '{}');\n if (enabled.status !== OK && enabled.status !== ACCEPTED) {\n notice(`the container could not turn remote access on: ${enabled.text}`);\n return false;\n }\n return true;\n } catch (error) {\n // Reported rather than retried: the host is no longer serving, so the user\n // needs to know the tunnel is not up rather than watch a silent loop.\n notice(`the container could not turn remote access on: ${error instanceof Error ? error.message : String(error)}`);\n return false;\n }\n}\n"],"mappings":"6CA8BA,eAAe,EAAS,EAAa,EAAgB,EAAyD,CAC5G,IAAM,EAAW,MAAM,MAAM,EAAK,CAChC,SACA,QAAS,CAAE,eAAgB,kBAAmB,EAC9C,OACA,OAAQ,YAAY,QAAQ,GAAkB,CAChD,CAAC,EACD,MAAO,CAAE,OAAQ,EAAS,OAAQ,KAAM,MAAM,EAAS,KAAK,CAAE,CAChE,CAEA,eAAsB,EAAoB,EAAkD,CAC1F,IAAM,EAAS,EAAQ,eAAyB,CAAC,GAC3C,EAAO,EAAQ,MAAQ,EACvB,EAAO,UAAU,EAAQ,MAAQ,YAAS,GAAG,OAAO,EAAQ,IAAI,IAAIA,EAAAA,mBAC1E,GAAI,CAIF,IAAM,EAAS,MAAM,EAAK,GAAG,EAAK,WAAY,MAAO,KAAK,UAAU,EAAQ,QAAQ,CAAC,EACrF,GAAI,EAAO,SAAW,IAEpB,OADA,EAAO,4DAA4D,EAAO,MAAM,EACzE,GAET,IAAM,EAAU,MAAM,EAAK,GAAG,EAAK,SAAU,OAAQ,IAAI,EAKzD,OAJI,EAAQ,SAAW,KAAM,EAAQ,SAAW,KAC9C,EAAO,kDAAkD,EAAQ,MAAM,EAChE,IAEF,EACT,OAAS,EAAO,CAId,OADA,EAAO,kDAAkD,aAAiB,MAAQ,EAAM,QAAU,OAAO,CAAK,GAAG,EAC1G,EACT,CACF"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{REMOTE_API_ROUTE as e}from"../types/remoteAccess.mjs";async function t(e,t,n){let r=await fetch(e,{method:t,headers:{"content-type":`application/json`},body:n,signal:AbortSignal.timeout(6e4)});return{status:r.status,text:await r.text()}}async function n(n){let r=n.onNotice??(()=>{}),i=n.send??t,a=`http://${n.host??`127.0.0.1`}:${String(n.port)}${e}`;try{let e=await i(`${a}/settings`,`PUT`,JSON.stringify(n.settings));if(e.status!==200)return r(`the container would not take the remote-access settings: ${e.text}`),!1;let t=await i(`${a}/enable`,`POST`,`{}`);return t.status!==200&&t.status!==202?(r(`the container could not turn remote access on: ${t.text}`),!1):!0}catch(e){return r(`the container could not turn remote access on: ${e instanceof Error?e.message:String(e)}`),!1}}export{n as handOffRemoteAccess};
|
|
2
|
+
//# sourceMappingURL=cockpitHandoff.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cockpitHandoff.mjs","names":[],"sources":["../../../src/adapters/cockpitHandoff.ts"],"sourcesContent":["import { REMOTE_API_ROUTE, type RemoteAccessSettings } from '../types/remoteAccess.ts';\n\n/**\n * Telling the cockpit that took over what it took over.\n *\n * The container has its own home volume, so it starts from its own settings and\n * knows nothing about the toggle that was just flipped on the host. Two calls\n * over the hub's own control plane close that gap: adopt the settings, then\n * turn remote access on, which is what the user asked for in the first place.\n *\n * Both routes are local-only, and a request to the published loopback port\n * arrives on the container's main listener, which is local by definition. The\n * tunnel listener is a different socket and is not reachable from here.\n */\n\nconst OK = 200;\nconst ACCEPTED = 202;\nconst LOOPBACK = '127.0.0.1';\n/** Enabling starts a tunnel, which waits on cloudflared reaching the edge. */\nconst HANDOFF_TIMEOUT_MS = 60_000;\n\nexport interface CockpitHandoffOptions {\n port: number;\n host?: string;\n settings: RemoteAccessSettings;\n onNotice?: (message: string) => void;\n /** Test seam, so a suite never opens a socket. */\n send?: (url: string, method: string, body: string) => Promise<{ status: number; text: string }>;\n}\n\nasync function sendJson(url: string, method: string, body: string): Promise<{ status: number; text: string }> {\n const response = await fetch(url, {\n method,\n headers: { 'content-type': 'application/json' },\n body,\n signal: AbortSignal.timeout(HANDOFF_TIMEOUT_MS),\n });\n return { status: response.status, text: await response.text() };\n}\n\nexport async function handOffRemoteAccess(options: CockpitHandoffOptions): Promise<boolean> {\n const notice = options.onNotice ?? ((): void => {});\n const send = options.send ?? sendJson;\n const base = `http://${options.host ?? LOOPBACK}:${String(options.port)}${REMOTE_API_ROUTE}`;\n try {\n // Sent whole, sandbox flag included: the contained hub reads its own\n // environment to know it is already contained, so the setting stays true\n // and the dialog keeps telling the truth about how it is running.\n const stored = await send(`${base}/settings`, 'PUT', JSON.stringify(options.settings));\n if (stored.status !== OK) {\n notice(`the container would not take the remote-access settings: ${stored.text}`);\n return false;\n }\n const enabled = await send(`${base}/enable`, 'POST', '{}');\n if (enabled.status !== OK && enabled.status !== ACCEPTED) {\n notice(`the container could not turn remote access on: ${enabled.text}`);\n return false;\n }\n return true;\n } catch (error) {\n // Reported rather than retried: the host is no longer serving, so the user\n // needs to know the tunnel is not up rather than watch a silent loop.\n notice(`the container could not turn remote access on: ${error instanceof Error ? error.message : String(error)}`);\n return false;\n }\n}\n"],"mappings":"6DA8BA,eAAe,EAAS,EAAa,EAAgB,EAAyD,CAC5G,IAAM,EAAW,MAAM,MAAM,EAAK,CAChC,SACA,QAAS,CAAE,eAAgB,kBAAmB,EAC9C,OACA,OAAQ,YAAY,QAAQ,GAAkB,CAChD,CAAC,EACD,MAAO,CAAE,OAAQ,EAAS,OAAQ,KAAM,MAAM,EAAS,KAAK,CAAE,CAChE,CAEA,eAAsB,EAAoB,EAAkD,CAC1F,IAAM,EAAS,EAAQ,eAAyB,CAAC,GAC3C,EAAO,EAAQ,MAAQ,EACvB,EAAO,UAAU,EAAQ,MAAQ,YAAS,GAAG,OAAO,EAAQ,IAAI,IAAI,IAC1E,GAAI,CAIF,IAAM,EAAS,MAAM,EAAK,GAAG,EAAK,WAAY,MAAO,KAAK,UAAU,EAAQ,QAAQ,CAAC,EACrF,GAAI,EAAO,SAAW,IAEpB,OADA,EAAO,4DAA4D,EAAO,MAAM,EACzE,GAET,IAAM,EAAU,MAAM,EAAK,GAAG,EAAK,SAAU,OAAQ,IAAI,EAKzD,OAJI,EAAQ,SAAW,KAAM,EAAQ,SAAW,KAC9C,EAAO,kDAAkD,EAAQ,MAAM,EAChE,IAEF,EACT,OAAS,EAAO,CAId,OADA,EAAO,kDAAkD,aAAiB,MAAQ,EAAM,QAAU,OAAO,CAAK,GAAG,EAC1G,EACT,CACF"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
const e=require("../services/deviceSessions.cjs");let t=require("node:crypto");function n(e){return(0,t.createHash)(`sha256`).update(e).digest(`hex`)}function r(r){let i=r.now??(()=>Date.now()),a=r.onNotice??(()=>{}),o=new Map,s=new Map,c=e=>{let t=s.get(e);t!==void 0&&clearTimeout(t),s.delete(e)},l=(e,t)=>{let n=o.get(e);if(n!==void 0)return o.delete(e),c(e),r.onDrop?.(n,t),n},u=(t,n)=>{c(t);let a=e.deviceExpiryAt(n,r.settings());if(a===void 0)return;let d=a-i();if(d<=0){l(t,`expired`);return}let f=setTimeout(()=>{let n=o.get(t);n!==void 0&&(e.evaluateDevice(n,i(),r.settings()).ok?u(t,n):l(t,`expired`))},Math.min(d,2147483647));f.unref(),s.set(t,f)};return{enrol(r){let s=(0,t.randomBytes)(32).toString(`base64url`),c=e.sanitizeUserAgent(r.userAgent),l=i(),d={id:(0,t.randomBytes)(8).toString(`hex`),tokenHash:n(s),label:e.deviceLabelFor(c),userAgent:c,createdAt:l,lastSeenAt:l};return o.set(d.tokenHash,d),u(d.tokenHash,d),a(`paired ${d.label} (${d.id})`),{token:s,record:d}},verify(t){if(t===void 0||t===``)return;let a=n(t),s=i(),c=e.evaluateDevice(o.get(a),s,r.settings());if(!c.ok){c.reason!==`unknown`&&l(a,`expired`);return}let d=e.touchDevice(c.record,s);return o.set(a,d),u(a,d),d},revoke(e){for(let[t,n]of o)if(n.id===e)return l(t,`revoked`),a(`revoked ${n.label} (${n.id})`),!0;return!1},revokeAll(){let e=[...o.values()];for(let[e]of o)l(e,`revoked`);return e.length>0&&a(`revoked ${String(e.length)} paired device(s)`),e.length},list:()=>[...o.values()],reschedule(){for(let[e,t]of o)u(e,t)},sweep(){let t=i(),n=r.settings(),a=0;for(let[r,i]of o)e.evaluateDevice(i,t,n).ok||(l(r,`expired`),a+=1);return a}}}exports.createDeviceAuth=r;
|
|
2
|
+
//# sourceMappingURL=deviceAuth.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deviceAuth.cjs","names":["createHash","deviceExpiryAt","evaluateDevice","randomBytes","sanitizeUserAgent","deviceLabelFor","touchDevice"],"sources":["../../../src/adapters/deviceAuth.ts"],"sourcesContent":["import { createHash, randomBytes } from 'node:crypto';\nimport {\n type DeviceRecord,\n deviceExpiryAt,\n deviceLabelFor,\n evaluateDevice,\n sanitizeUserAgent,\n touchDevice,\n} from '../services/deviceSessions.ts';\nimport type { RemoteAccessSettings } from '../types/remoteAccess.ts';\n\n/** 256 bits. The token is scanned or set by the server, never typed, so there is no reason to economise. */\nconst TOKEN_BYTES = 32;\nconst ID_BYTES = 8;\n\nexport interface DeviceAuthOptions {\n /** Read fresh on every check, so a changed toggle takes effect on the next request. */\n settings: () => RemoteAccessSettings;\n now?: () => number;\n onNotice?: (message: string) => void;\n onDrop?: (record: DeviceRecord, reason: 'revoked' | 'expired') => void;\n}\n\nexport interface EnrolledDevice {\n /** Returned exactly once, to be sent as the cookie value. Only its digest is retained. */\n token: string;\n record: DeviceRecord;\n}\n\nexport interface DeviceAuth {\n enrol(input: { userAgent: string | undefined }): EnrolledDevice;\n /** The device this cookie names, or undefined. Bumps last-seen as a side effect. */\n verify(token: string | undefined): DeviceRecord | undefined;\n revoke(id: string): boolean;\n revokeAll(): number;\n list(): readonly DeviceRecord[];\n /** Re-arms exact deadlines after expiry settings change. */\n reschedule(): void;\n /** Drops what has expired under the current settings. Returns how many went. */\n sweep(): number;\n}\n\nfunction digest(token: string): string {\n // No salt and no KDF, deliberately. The input is 256 bits of uniform\n // randomness, so there is no dictionary to attack and a slow hash would only\n // add latency to every request. Do not \"fix\" this into bcrypt.\n return createHash('sha256').update(token).digest('hex');\n}\n\n/**\n * Paired sessions, held in memory only.\n *\n * Keyed by digest rather than scanned, so verification is a map lookup and\n * there is no comparison loop for a timing side channel to live in. That is why\n * `timingSafeEqual` does not appear here: there is nothing to compare. If a\n * future change introduces a scan, it will need one.\n */\nexport function createDeviceAuth(options: DeviceAuthOptions): DeviceAuth {\n const now = options.now ?? ((): number => Date.now());\n const notice = options.onNotice ?? ((): void => {});\n /** Digest to record. The raw token exists only in flight. */\n const devices = new Map<string, DeviceRecord>();\n const timers = new Map<string, ReturnType<typeof setTimeout>>();\n\n const clearDeadline = (hash: string): void => {\n const timer = timers.get(hash);\n if (timer !== undefined) clearTimeout(timer);\n timers.delete(hash);\n };\n\n const drop = (hash: string, reason: 'revoked' | 'expired'): DeviceRecord | undefined => {\n const record = devices.get(hash);\n if (record === undefined) return undefined;\n devices.delete(hash);\n clearDeadline(hash);\n options.onDrop?.(record, reason);\n return record;\n };\n\n const schedule = (hash: string, record: DeviceRecord): void => {\n clearDeadline(hash);\n const deadline = deviceExpiryAt(record, options.settings());\n if (deadline === undefined) return;\n const remaining = deadline - now();\n if (remaining <= 0) {\n drop(hash, 'expired');\n return;\n }\n const timer = setTimeout(\n () => {\n const current = devices.get(hash);\n if (current === undefined) return;\n if (evaluateDevice(current, now(), options.settings()).ok) schedule(hash, current);\n else drop(hash, 'expired');\n },\n Math.min(remaining, 2_147_483_647),\n );\n timer.unref();\n timers.set(hash, timer);\n };\n return {\n enrol(input) {\n const token = randomBytes(TOKEN_BYTES).toString('base64url');\n const userAgent = sanitizeUserAgent(input.userAgent);\n const at = now();\n const record: DeviceRecord = {\n id: randomBytes(ID_BYTES).toString('hex'),\n tokenHash: digest(token),\n label: deviceLabelFor(userAgent),\n userAgent,\n createdAt: at,\n lastSeenAt: at,\n };\n devices.set(record.tokenHash, record);\n schedule(record.tokenHash, record);\n notice(`paired ${record.label} (${record.id})`);\n return { token, record };\n },\n\n verify(token) {\n if (token === undefined || token === '') return undefined;\n const hash = digest(token);\n const at = now();\n const verdict = evaluateDevice(devices.get(hash), at, options.settings());\n if (!verdict.ok) {\n // An expired record is dropped on the way past rather than left to the\n // sweeper, so a revoked-by-time device stops appearing in the list.\n if (verdict.reason !== 'unknown') drop(hash, 'expired');\n return undefined;\n }\n const seen = touchDevice(verdict.record, at);\n devices.set(hash, seen);\n schedule(hash, seen);\n return seen;\n },\n\n revoke(id) {\n for (const [hash, record] of devices) {\n if (record.id !== id) continue;\n drop(hash, 'revoked');\n notice(`revoked ${record.label} (${record.id})`);\n return true;\n }\n return false;\n },\n\n revokeAll() {\n const records = [...devices.values()];\n for (const [hash] of devices) drop(hash, 'revoked');\n if (records.length > 0) notice(`revoked ${String(records.length)} paired device(s)`);\n return records.length;\n },\n\n list: () => [...devices.values()],\n\n reschedule() {\n for (const [hash, record] of devices) schedule(hash, record);\n },\n sweep() {\n const at = now();\n const settings = options.settings();\n let dropped = 0;\n for (const [hash, record] of devices) {\n if (evaluateDevice(record, at, settings).ok) continue;\n drop(hash, 'expired');\n dropped += 1;\n }\n return dropped;\n },\n };\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{deviceExpiryAt as e,deviceLabelFor as t,evaluateDevice as n,sanitizeUserAgent as r,touchDevice as i}from"../services/deviceSessions.mjs";import{createHash as a,randomBytes as o}from"node:crypto";function s(e){return a(`sha256`).update(e).digest(`hex`)}function c(a){let c=a.now??(()=>Date.now()),l=a.onNotice??(()=>{}),u=new Map,d=new Map,f=e=>{let t=d.get(e);t!==void 0&&clearTimeout(t),d.delete(e)},p=(e,t)=>{let n=u.get(e);if(n!==void 0)return u.delete(e),f(e),a.onDrop?.(n,t),n},m=(t,r)=>{f(t);let i=e(r,a.settings());if(i===void 0)return;let o=i-c();if(o<=0){p(t,`expired`);return}let s=setTimeout(()=>{let e=u.get(t);e!==void 0&&(n(e,c(),a.settings()).ok?m(t,e):p(t,`expired`))},Math.min(o,2147483647));s.unref(),d.set(t,s)};return{enrol(e){let n=o(32).toString(`base64url`),i=r(e.userAgent),a=c(),d={id:o(8).toString(`hex`),tokenHash:s(n),label:t(i),userAgent:i,createdAt:a,lastSeenAt:a};return u.set(d.tokenHash,d),m(d.tokenHash,d),l(`paired ${d.label} (${d.id})`),{token:n,record:d}},verify(e){if(e===void 0||e===``)return;let t=s(e),r=c(),o=n(u.get(t),r,a.settings());if(!o.ok){o.reason!==`unknown`&&p(t,`expired`);return}let l=i(o.record,r);return u.set(t,l),m(t,l),l},revoke(e){for(let[t,n]of u)if(n.id===e)return p(t,`revoked`),l(`revoked ${n.label} (${n.id})`),!0;return!1},revokeAll(){let e=[...u.values()];for(let[e]of u)p(e,`revoked`);return e.length>0&&l(`revoked ${String(e.length)} paired device(s)`),e.length},list:()=>[...u.values()],reschedule(){for(let[e,t]of u)m(e,t)},sweep(){let e=c(),t=a.settings(),r=0;for(let[i,a]of u)n(a,e,t).ok||(p(i,`expired`),r+=1);return r}}}export{c as createDeviceAuth};
|
|
2
|
+
//# sourceMappingURL=deviceAuth.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deviceAuth.mjs","names":[],"sources":["../../../src/adapters/deviceAuth.ts"],"sourcesContent":["import { createHash, randomBytes } from 'node:crypto';\nimport {\n type DeviceRecord,\n deviceExpiryAt,\n deviceLabelFor,\n evaluateDevice,\n sanitizeUserAgent,\n touchDevice,\n} from '../services/deviceSessions.ts';\nimport type { RemoteAccessSettings } from '../types/remoteAccess.ts';\n\n/** 256 bits. The token is scanned or set by the server, never typed, so there is no reason to economise. */\nconst TOKEN_BYTES = 32;\nconst ID_BYTES = 8;\n\nexport interface DeviceAuthOptions {\n /** Read fresh on every check, so a changed toggle takes effect on the next request. */\n settings: () => RemoteAccessSettings;\n now?: () => number;\n onNotice?: (message: string) => void;\n onDrop?: (record: DeviceRecord, reason: 'revoked' | 'expired') => void;\n}\n\nexport interface EnrolledDevice {\n /** Returned exactly once, to be sent as the cookie value. Only its digest is retained. */\n token: string;\n record: DeviceRecord;\n}\n\nexport interface DeviceAuth {\n enrol(input: { userAgent: string | undefined }): EnrolledDevice;\n /** The device this cookie names, or undefined. Bumps last-seen as a side effect. */\n verify(token: string | undefined): DeviceRecord | undefined;\n revoke(id: string): boolean;\n revokeAll(): number;\n list(): readonly DeviceRecord[];\n /** Re-arms exact deadlines after expiry settings change. */\n reschedule(): void;\n /** Drops what has expired under the current settings. Returns how many went. */\n sweep(): number;\n}\n\nfunction digest(token: string): string {\n // No salt and no KDF, deliberately. The input is 256 bits of uniform\n // randomness, so there is no dictionary to attack and a slow hash would only\n // add latency to every request. Do not \"fix\" this into bcrypt.\n return createHash('sha256').update(token).digest('hex');\n}\n\n/**\n * Paired sessions, held in memory only.\n *\n * Keyed by digest rather than scanned, so verification is a map lookup and\n * there is no comparison loop for a timing side channel to live in. That is why\n * `timingSafeEqual` does not appear here: there is nothing to compare. If a\n * future change introduces a scan, it will need one.\n */\nexport function createDeviceAuth(options: DeviceAuthOptions): DeviceAuth {\n const now = options.now ?? ((): number => Date.now());\n const notice = options.onNotice ?? ((): void => {});\n /** Digest to record. The raw token exists only in flight. */\n const devices = new Map<string, DeviceRecord>();\n const timers = new Map<string, ReturnType<typeof setTimeout>>();\n\n const clearDeadline = (hash: string): void => {\n const timer = timers.get(hash);\n if (timer !== undefined) clearTimeout(timer);\n timers.delete(hash);\n };\n\n const drop = (hash: string, reason: 'revoked' | 'expired'): DeviceRecord | undefined => {\n const record = devices.get(hash);\n if (record === undefined) return undefined;\n devices.delete(hash);\n clearDeadline(hash);\n options.onDrop?.(record, reason);\n return record;\n };\n\n const schedule = (hash: string, record: DeviceRecord): void => {\n clearDeadline(hash);\n const deadline = deviceExpiryAt(record, options.settings());\n if (deadline === undefined) return;\n const remaining = deadline - now();\n if (remaining <= 0) {\n drop(hash, 'expired');\n return;\n }\n const timer = setTimeout(\n () => {\n const current = devices.get(hash);\n if (current === undefined) return;\n if (evaluateDevice(current, now(), options.settings()).ok) schedule(hash, current);\n else drop(hash, 'expired');\n },\n Math.min(remaining, 2_147_483_647),\n );\n timer.unref();\n timers.set(hash, timer);\n };\n return {\n enrol(input) {\n const token = randomBytes(TOKEN_BYTES).toString('base64url');\n const userAgent = sanitizeUserAgent(input.userAgent);\n const at = now();\n const record: DeviceRecord = {\n id: randomBytes(ID_BYTES).toString('hex'),\n tokenHash: digest(token),\n label: deviceLabelFor(userAgent),\n userAgent,\n createdAt: at,\n lastSeenAt: at,\n };\n devices.set(record.tokenHash, record);\n schedule(record.tokenHash, record);\n notice(`paired ${record.label} (${record.id})`);\n return { token, record };\n },\n\n verify(token) {\n if (token === undefined || token === '') return undefined;\n const hash = digest(token);\n const at = now();\n const verdict = evaluateDevice(devices.get(hash), at, options.settings());\n if (!verdict.ok) {\n // An expired record is dropped on the way past rather than left to the\n // sweeper, so a revoked-by-time device stops appearing in the list.\n if (verdict.reason !== 'unknown') drop(hash, 'expired');\n return undefined;\n }\n const seen = touchDevice(verdict.record, at);\n devices.set(hash, seen);\n schedule(hash, seen);\n return seen;\n },\n\n revoke(id) {\n for (const [hash, record] of devices) {\n if (record.id !== id) continue;\n drop(hash, 'revoked');\n notice(`revoked ${record.label} (${record.id})`);\n return true;\n }\n return false;\n },\n\n revokeAll() {\n const records = [...devices.values()];\n for (const [hash] of devices) drop(hash, 'revoked');\n if (records.length > 0) notice(`revoked ${String(records.length)} paired device(s)`);\n return records.length;\n },\n\n list: () => [...devices.values()],\n\n reschedule() {\n for (const [hash, record] of devices) schedule(hash, record);\n },\n sweep() {\n const at = now();\n const settings = options.settings();\n let dropped = 0;\n for (const [hash, record] of devices) {\n if (evaluateDevice(record, at, settings).ok) continue;\n drop(hash, 'expired');\n dropped += 1;\n }\n return dropped;\n },\n };\n}\n"],"mappings":"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"}
|