Skip to content

Commit 6447b70

Browse files
committed
fix(api): stabilize skiller and browser launch
- Route external Skiller launches through short backend records and active project filtering. - Repair browser sidecar startup so noVNC scales cleanly and Chromium opens about:blank without first-run/restore screens. - Proof of fix: packages/api browser/skiller tests passed, packages/app browser action tests passed, API build/lint passed, and live noVNC/CDP flow was verified.
1 parent 8c976d1 commit 6447b70

21 files changed

Lines changed: 971 additions & 49 deletions

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,19 @@ bun run docker-git -- browser
6161
DOCKER_GIT_WEB_HOST=127.0.0.1 bun run docker-git -- browser
6262
```
6363

64+
Кнопка `Skiller` в web-терминале по умолчанию открывает внешний Skiller Web:
65+
`https://skiller-web-henna.vercel.app`. Контроллер сохраняет `backendUrl`,
66+
`projectKey` и `sessionId` в коротком launch-wrapper URL вида
67+
`/api/skiller/external-launch/<id>`, чтобы длинный callback URL не оставался в
68+
адресной строке. Если web-версия открыта по локальному HTTP/LAN адресу,
69+
docker-git автоматически запускает или переиспользует Cloudflare Quick Tunnel
70+
и передает в Skiller Web HTTPS `backendUrl` вида `https://*.trycloudflare.com/api`.
71+
Для legacy bundled-режима:
72+
73+
```bash
74+
DOCKER_GIT_SKILLER_WEB_URL= bun run docker-git -- browser
75+
```
76+
6477
## CLI пример
6578

6679
Можно передавать ссылку на репозиторий, ветку (`/tree/...`), issue или PR.

docker-compose.api.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ services:
2727
DOCKER_GIT_EXCHANGE_AGENT_COMMAND: ${DOCKER_GIT_EXCHANGE_AGENT_COMMAND:-}
2828
DOCKER_GIT_EXCHANGE_AGENT_TIMEOUT_MS: ${DOCKER_GIT_EXCHANGE_AGENT_TIMEOUT_MS:-3600000}
2929
DOCKER_GIT_OUTBOX_POLLING_INTERVAL_MS: ${DOCKER_GIT_OUTBOX_POLLING_INTERVAL_MS:-5000}
30+
DOCKER_GIT_SKILLER_WEB_URL: ${DOCKER_GIT_SKILLER_WEB_URL-https://skiller-web-henna.vercel.app}
31+
DOCKER_GIT_SKILLER_BACKEND_URL: ${DOCKER_GIT_SKILLER_BACKEND_URL:-}
32+
DOCKER_GIT_API_PUBLIC_URL: ${DOCKER_GIT_API_PUBLIC_URL:-}
33+
DOCKER_GIT_SKILLER_ALLOWED_ORIGINS: ${DOCKER_GIT_SKILLER_ALLOWED_ORIGINS:-}
3034
ports:
3135
- "${DOCKER_GIT_API_BIND_HOST:-127.0.0.1}:${DOCKER_GIT_API_PORT:-3334}:${DOCKER_GIT_API_PORT:-3334}"
3236
dns:

docker-compose.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ services:
2727
DOCKER_GIT_EXCHANGE_AGENT_COMMAND: ${DOCKER_GIT_EXCHANGE_AGENT_COMMAND:-}
2828
DOCKER_GIT_EXCHANGE_AGENT_TIMEOUT_MS: ${DOCKER_GIT_EXCHANGE_AGENT_TIMEOUT_MS:-3600000}
2929
DOCKER_GIT_OUTBOX_POLLING_INTERVAL_MS: ${DOCKER_GIT_OUTBOX_POLLING_INTERVAL_MS:-5000}
30+
DOCKER_GIT_SKILLER_WEB_URL: ${DOCKER_GIT_SKILLER_WEB_URL-https://skiller-web-henna.vercel.app}
31+
DOCKER_GIT_SKILLER_BACKEND_URL: ${DOCKER_GIT_SKILLER_BACKEND_URL:-}
32+
DOCKER_GIT_API_PUBLIC_URL: ${DOCKER_GIT_API_PUBLIC_URL:-}
33+
DOCKER_GIT_SKILLER_ALLOWED_ORIGINS: ${DOCKER_GIT_SKILLER_ALLOWED_ORIGINS:-}
3034
ports:
3135
- "${DOCKER_GIT_API_BIND_HOST:-127.0.0.1}:${DOCKER_GIT_API_PORT:-3334}:${DOCKER_GIT_API_PORT:-3334}"
3236
dns:

docs/integrations/skiller.md

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,26 @@ bun run skiller:check
4040

4141
## docker-git Web Launch
4242

43-
The docker-git web terminal header includes a `Skiller` button next to `Open browser`. In a project terminal the button calls `POST /projects/by-key/:projectKey/terminal-sessions/:sessionId/skiller/open` first, which launches the pinned submodule Electron app as a separate process, registers the terminal session filesystem scope, and writes launcher output to `~/.docker-git/logs/skiller.log`. After that response succeeds, the browser opens the returned `/api/ssh/session/:sessionId/skiller/app/` URL using the same terminal session id that is present in `/ssh/session/:sessionId`.
43+
The docker-git web terminal header includes a `Skiller` button next to `Open browser`. In a project terminal the button calls `POST /projects/by-key/:projectKey/terminal-sessions/:sessionId/skiller/open` first. By default the controller stores the external Skiller Web launch URL for `https://skiller-web-henna.vercel.app/launch` and returns a short docker-git wrapper path such as `/api/skiller/external-launch/<id>`. The saved target carries `backendUrl`, `projectKey`, and `sessionId` parameters, while the browser address bar keeps the short wrapper URL. When docker-git web is served through the `/api` proxy, `backendUrl` includes that forwarded prefix so Skiller Web can call back to the same browser-reachable docker-git endpoint.
4444

45-
docker-git serves Skiller's built renderer from the submodule and proxies `/api/ssh/session/:sessionId/skiller/trpc/*` to the running Skiller tRPC backend, so the user sees the actual Skiller UI instead of an invisible background desktop process. The session id is part of the URL so a Skiller tab can be tied back to the terminal container that opened it.
45+
Hosted Skiller Web is served over HTTPS, so browser fetches to a plain `http://192.168.x.x:4174/api` backend can be blocked before they reach docker-git. If external Skiller Web is enabled and no explicit backend override is configured, docker-git automatically starts or reuses the panel Cloudflare Quick Tunnel for local/private HTTP origins and sends Skiller Web an HTTPS callback URL such as `https://<name>.trycloudflare.com/api`. Follow-up API calls through that tunnel preserve the HTTPS forwarded protocol so the controller does not try to create a nested tunnel.
4646

47-
For project terminals, docker-git scopes Skiller to the active project container filesystem. The API inspects the selected project container mounts, maps `/home/<sshUser>` and the project `targetDir` to the controller-visible Docker volume path, launches Skiller with `HOME` set to that mapped home directory, and registers the mapped project directory in Skiller. This makes global skill operations target the selected container home and project skill operations target the selected container project directory. If the controller cannot access the Docker volume path, the endpoint fails instead of opening Skiller against the wrong filesystem.
47+
External Skiller Web is controlled by these API/controller environment variables:
48+
49+
- `DOCKER_GIT_SKILLER_WEB_URL` sets the Skiller Web base URL. The compose default is `https://skiller-web-henna.vercel.app`.
50+
- `DOCKER_GIT_SKILLER_BACKEND_URL` overrides the callback base URL sent to Skiller Web and disables automatic tunnel URL selection.
51+
- `DOCKER_GIT_API_PUBLIC_URL` is the secondary callback override when `DOCKER_GIT_SKILLER_BACKEND_URL` is unset and also disables automatic tunnel URL selection.
52+
- `DOCKER_GIT_SKILLER_ALLOWED_ORIGINS` adds comma-separated trusted Skiller Web origins for CORS.
53+
54+
Set `DOCKER_GIT_SKILLER_WEB_URL=` to force the legacy bundled renderer flow. `DOCKER_GIT_CONTROLLER_BUILD_SKILLER=0` only controls whether the controller image bundles the Skiller submodule; it does not enable external Web mode by itself.
55+
56+
In the legacy bundled mode, docker-git launches the pinned submodule Electron app as a separate process, registers the terminal session filesystem scope, writes launcher output to `~/.docker-git/logs/skiller.log`, serves Skiller's built renderer from the submodule, and proxies `/api/ssh/session/:sessionId/skiller/trpc/*` to the running Skiller tRPC backend. The session id is part of the URL so a Skiller tab can be tied back to the terminal container that opened it.
57+
58+
For project terminals, docker-git scopes Skiller to the active project container filesystem. The API inspects the selected project container mounts, maps `/home/<sshUser>` and the project `targetDir` to the controller-visible Docker volume path, launches or reuses the local Skiller runtime with `HOME` set to that mapped home directory, and registers the mapped project directory in Skiller. This makes global skill operations target the selected container home and project skill operations target the selected container project directory. If the controller cannot access the Docker volume path, the endpoint fails instead of opening Skiller against the wrong filesystem.
4859

4960
For Codex, Skiller resolves `~/.codex/skills` against the selected container home volume. For example, `/home/dev/.codex/skills` inside the selected container is exposed to the controller as the mapped Docker volume path and is the only Codex global skill tree used for that session. docker-git does not fall back to the controller's own `~/.codex/skills`.
5061

51-
When the API process has no `$DISPLAY`, the launcher uses `xvfb-run` if it is available so Skiller can still start in a headless controller environment.
62+
When the API process has no `$DISPLAY`, the legacy bundled launcher uses `xvfb-run` if it is available so Skiller can still start in a headless controller environment.
5263

5364
## PR #238 Proof
5465

@@ -87,4 +98,4 @@ bun run skiller:check
8798

8899
## Integration Boundary
89100

90-
This integration makes Skiller part of the docker-git checkout and developer workflow. docker-git keeps Skiller as an isolated submodule and does not import Skiller source into the docker-git web bundle. The visible browser view is served from Skiller's own built renderer and backed by Skiller's own tRPC process.
101+
This integration keeps the Skiller runtime and filesystem scope owned by docker-git while the default browser UI is served by external Skiller Web. The pinned submodule remains available for legacy bundled rendering and local development, but docker-git does not import Skiller source into the docker-git web bundle.

packages/api/src/http.ts

Lines changed: 103 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as HttpRouter from "@effect/platform/HttpRouter"
66
import * as HttpServerRequest from "@effect/platform/HttpServerRequest"
77
import * as HttpServerResponse from "@effect/platform/HttpServerResponse"
88
import * as HttpServerError from "@effect/platform/HttpServerError"
9+
import { networkInterfaces } from "node:os"
910
import * as ParseResult from "effect/ParseResult"
1011
import * as Schema from "effect/Schema"
1112
import { renderError, type AppError } from "@effect-template/lib/usecases/errors"
@@ -168,13 +169,16 @@ import {
168169
openSkiller,
169170
openSkillerForTerminalSession,
170171
parseSkillerRoute,
172+
inspectableSkillerConnectProjects,
171173
proxySkillerTrpc,
174+
readExternalSkillerLaunchHtml,
172175
readSkillerProjectContext,
173176
serveSkillerApp
174177
} from "./services/skiller.js"
175178
import {
176179
isSkillerWebCorsOriginAllowed,
177-
resolveDockerGitSkillerBackendUrl
180+
joinSkillerBackendUrl,
181+
resolveDockerGitSkillerBackendUrlDecision
178182
} from "./services/skiller-core.js"
179183
import {
180184
commitStateFromRequest,
@@ -244,6 +248,10 @@ const SkillerConnectRequestSchema = Schema.Struct({
244248
sessionId: Schema.optional(Schema.String)
245249
})
246250

251+
const SkillerExternalLaunchParamsSchema = Schema.Struct({
252+
launchId: Schema.String
253+
})
254+
247255
type ApiError =
248256
| ApiAuthRequiredError
249257
| ApiBadRequestError
@@ -448,6 +456,7 @@ const terminalSessionParams = HttpRouter.schemaParams(TerminalSessionParamsSchem
448456
const terminalSessionByProjectKeyParams = HttpRouter.schemaParams(TerminalSessionByProjectKeyParamsSchema)
449457
const containerTaskParams = HttpRouter.schemaParams(ContainerTaskParamsSchema)
450458
const authTerminalSessionParams = HttpRouter.schemaParams(AuthTerminalSessionParamsSchema)
459+
const skillerExternalLaunchParams = HttpRouter.schemaParams(SkillerExternalLaunchParamsSchema)
451460

452461
const readCreateProjectRequest = () => HttpServerRequest.schemaBodyJson(CreateProjectRequestSchema)
453462
const readCreateFollowRequest = () => HttpServerRequest.schemaBodyJson(CreateFollowRequestSchema)
@@ -615,8 +624,74 @@ const resolveRequestOrigin = (request: HttpServerRequest.HttpServerRequest): str
615624
return `${proto}://${host}`
616625
}
617626

618-
const resolveSkillerBackendUrl = (request: HttpServerRequest.HttpServerRequest): string =>
619-
resolveDockerGitSkillerBackendUrl(process.env, resolveRequestOrigin(request))
627+
// CHANGE: Preserve the docker-git web `/api` proxy prefix in Skiller Web callbacks.
628+
// WHY: External Skiller Web runs on another origin and must call the browser-reachable API URL.
629+
// QUOTE(ТЗ): "у нас всё равно юзается http://192.168.0.206:4174/api/ssh/session/.../skiller/app/"
630+
// REF: user-message-2026-06-18-skiller-external-module
631+
// SOURCE: n/a
632+
// FORMAT THEOREM: forwardedPrefix = p_safe -> backendUrl = origin + normalize(p_safe)
633+
// PURITY: SHELL
634+
// EFFECT: Reads trusted reverse-proxy request headers.
635+
// INVARIANT: Unsafe or root prefixes do not change the request origin.
636+
// COMPLEXITY: O(n) where n = |x-forwarded-prefix|.
637+
const normalizeForwardedPrefix = (value: string | undefined): string => {
638+
const prefix = firstCommaValue(value)
639+
if (prefix === undefined || prefix.length === 0 || prefix === "/") {
640+
return ""
641+
}
642+
if (!prefix.startsWith("/") || prefix.includes("://")) {
643+
return ""
644+
}
645+
return prefix.replace(/\/+$/u, "")
646+
}
647+
648+
// CHANGE: Give hosted Skiller Web an HTTPS-reachable docker-git backend URL.
649+
// WHY: Chrome blocks an HTTPS Vercel app from fetching a plain HTTP LAN API, even when CORS/PNA preflight succeeds.
650+
// QUOTE(ТЗ): "сделать что бы оно начало работать"
651+
// REF: user-message-2026-06-18-skiller-vercel-failed-fetch
652+
// SOURCE: n/a
653+
const dockerGitApiPort = (env: Record<string, string | undefined>): string => {
654+
const configured = env["DOCKER_GIT_API_PORT"]?.trim()
655+
return configured !== undefined && /^\d{1,5}$/u.test(configured) ? configured : "3334"
656+
}
657+
658+
const firstControllerIpv4 = (): string =>
659+
Object.values(networkInterfaces())
660+
.flatMap((entries) => entries ?? [])
661+
.find((entry) => entry.family === "IPv4" && !entry.internal)?.address ?? "127.0.0.1"
662+
663+
const internalSkillerTunnelPanelUrl = (
664+
env: Record<string, string | undefined>
665+
): string =>
666+
`http://${firstControllerIpv4()}:${dockerGitApiPort(env)}`
667+
668+
// FORMAT THEOREM: externalWebEnabled ∧ requestBackendUrl.protocol != https ∧ noConfiguredBackend -> backendUrl = cloudflare(internalApiUrl) + prefix
669+
// PURITY: SHELL
670+
// EFFECT: Reads controller network interfaces and may start or reuse the panel Cloudflare Quick Tunnel.
671+
// INVARIANT: Explicit backend env values and already-HTTPS request URLs do not start a tunnel.
672+
// COMPLEXITY: O(t) where t is the bounded tunnel startup wait.
673+
const resolveSkillerBackendUrl = (
674+
request: HttpServerRequest.HttpServerRequest
675+
): Effect.Effect<string, ApiBadRequestError | ApiInternalError> => {
676+
const requestOrigin = resolveRequestOrigin(request)
677+
const forwardedPrefix = normalizeForwardedPrefix(readHeader(request, "x-forwarded-prefix"))
678+
const decision = resolveDockerGitSkillerBackendUrlDecision(process.env, requestOrigin, forwardedPrefix)
679+
return Match.value(decision).pipe(
680+
Match.when({ _tag: "Configured" }, ({ backendUrl }) => Effect.succeed(backendUrl)),
681+
Match.when({ _tag: "Request" }, ({ backendUrl }) => Effect.succeed(backendUrl)),
682+
Match.when({ _tag: "Tunnel" }, ({ forwardedPrefix: tunnelPrefix }) =>
683+
startPanelCloudflareTunnel({ panelUrl: internalSkillerTunnelPanelUrl(process.env) }).pipe(
684+
Effect.flatMap((tunnel) =>
685+
tunnel.publicUrl === null
686+
? Effect.fail(new ApiInternalError({
687+
message: "Cloudflare tunnel did not return a public URL for Skiller backend."
688+
}))
689+
: Effect.succeed(joinSkillerBackendUrl(tunnel.publicUrl, tunnelPrefix))
690+
)
691+
)),
692+
Match.exhaustive
693+
)
694+
}
620695

621696
const isPrivateNetworkCorsRequest = (
622697
request: HttpServerRequest.HttpServerRequest
@@ -916,6 +991,7 @@ const skillerConnectInfoResponse = (
916991
request: HttpServerRequest.HttpServerRequest
917992
) =>
918993
listProjects().pipe(
994+
Effect.flatMap(inspectableSkillerConnectProjects),
919995
Effect.flatMap((projects) => skillerJsonResponse(request, { ok: true, projects }, 200)),
920996
Effect.catchAll((error) => skillerErrorResponse(request, error))
921997
)
@@ -929,10 +1005,11 @@ const skillerConnectResponse = (
9291005
if (projectKey.length === 0) {
9301006
return yield* _(Effect.fail(new ApiBadRequestError({ message: "projectKey is required." })))
9311007
}
1008+
const backendUrl = yield* _(resolveSkillerBackendUrl(request))
9321009
const connection = yield* _(connectSkillerWeb(
9331010
projectKey,
9341011
normalizedOptionalString(body.sessionId),
935-
resolveSkillerBackendUrl(request)
1012+
backendUrl
9361013
))
9371014
return yield* _(skillerJsonResponse(request, { ok: true, ...connection }, 202))
9381015
}).pipe(
@@ -977,11 +1054,28 @@ export const makeRouter = () => {
9771054
return yield* _(skillerConnectResponse(request))
9781055
})
9791056
),
1057+
HttpRouter.get(
1058+
"/skiller/external-launch/:launchId",
1059+
Effect.gen(function*(_) {
1060+
const { launchId } = yield* _(skillerExternalLaunchParams)
1061+
const html = yield* _(readExternalSkillerLaunchHtml(launchId))
1062+
return yield* _(textResponse(html, "text/html; charset=utf-8", 200))
1063+
}).pipe(Effect.catchAll(errorResponse))
1064+
),
1065+
HttpRouter.get(
1066+
"/api/skiller/external-launch/:launchId",
1067+
Effect.gen(function*(_) {
1068+
const { launchId } = yield* _(skillerExternalLaunchParams)
1069+
const html = yield* _(readExternalSkillerLaunchHtml(launchId))
1070+
return yield* _(textResponse(html, "text/html; charset=utf-8", 200))
1071+
}).pipe(Effect.catchAll(errorResponse))
1072+
),
9801073
HttpRouter.post(
9811074
"/skiller/open",
9821075
Effect.gen(function*(_) {
9831076
const request = yield* _(HttpServerRequest.HttpServerRequest)
984-
const launch = yield* _(openSkiller(undefined, undefined, resolveSkillerBackendUrl(request)))
1077+
const backendUrl = yield* _(resolveSkillerBackendUrl(request))
1078+
const launch = yield* _(openSkiller(undefined, undefined, backendUrl))
9851079
return yield* _(jsonResponse({ ok: true, ...launch }, 202))
9861080
}).pipe(Effect.catchAll(errorResponse))
9871081
),
@@ -990,7 +1084,8 @@ export const makeRouter = () => {
9901084
Effect.gen(function*(_) {
9911085
const request = yield* _(HttpServerRequest.HttpServerRequest)
9921086
const { projectKey } = yield* _(projectKeyParams)
993-
const launch = yield* _(openSkiller(projectKey, undefined, resolveSkillerBackendUrl(request)))
1087+
const backendUrl = yield* _(resolveSkillerBackendUrl(request))
1088+
const launch = yield* _(openSkiller(projectKey, undefined, backendUrl))
9941089
return yield* _(jsonResponse({ ok: true, ...launch }, 202))
9951090
}).pipe(Effect.catchAll(errorResponse))
9961091
),
@@ -999,7 +1094,8 @@ export const makeRouter = () => {
9991094
Effect.gen(function*(_) {
10001095
const request = yield* _(HttpServerRequest.HttpServerRequest)
10011096
const { projectKey, sessionId } = yield* _(terminalSessionByProjectKeyParams)
1002-
const launch = yield* _(openSkillerForTerminalSession(projectKey, sessionId, resolveSkillerBackendUrl(request)))
1097+
const backendUrl = yield* _(resolveSkillerBackendUrl(request))
1098+
const launch = yield* _(openSkillerForTerminalSession(projectKey, sessionId, backendUrl))
10031099
return yield* _(jsonResponse({ ok: true, ...launch }, 202))
10041100
}).pipe(Effect.catchAll(errorResponse))
10051101
),

packages/api/src/services/project-browser-core.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const renderProjectBrowserNoVncPath = (projectId: string): string => {
2525
const projectKey = projectShortKey(projectId)
2626
const params = new URLSearchParams({
2727
autoconnect: "true",
28-
resize: "remote",
28+
resize: "scale",
2929
path: `b/${projectKey}/websockify`
3030
})
3131
return `/b/${projectKey}/vnc.html?${params.toString()}`

0 commit comments

Comments
 (0)