Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,19 @@ bun run docker-git -- browser
DOCKER_GIT_WEB_HOST=127.0.0.1 bun run docker-git -- browser
```

Кнопка `Skiller` в web-терминале по умолчанию открывает внешний Skiller Web:
`https://skiller-web-henna.vercel.app`. Контроллер сохраняет `backendUrl`,
`projectKey` и `sessionId` в коротком launch-wrapper URL вида
`/api/skiller/external-launch/<id>`, чтобы длинный callback URL не оставался в
адресной строке. Если web-версия открыта по локальному HTTP/LAN адресу,
docker-git автоматически запускает или переиспользует Cloudflare Quick Tunnel
и передает в Skiller Web HTTPS `backendUrl` вида `https://*.trycloudflare.com/api`.
Для legacy bundled-режима:

```bash
DOCKER_GIT_SKILLER_WEB_URL= bun run docker-git -- browser
```

## CLI пример

Можно передавать ссылку на репозиторий, ветку (`/tree/...`), issue или PR.
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ services:
DOCKER_GIT_EXCHANGE_AGENT_COMMAND: ${DOCKER_GIT_EXCHANGE_AGENT_COMMAND:-}
DOCKER_GIT_EXCHANGE_AGENT_TIMEOUT_MS: ${DOCKER_GIT_EXCHANGE_AGENT_TIMEOUT_MS:-3600000}
DOCKER_GIT_OUTBOX_POLLING_INTERVAL_MS: ${DOCKER_GIT_OUTBOX_POLLING_INTERVAL_MS:-5000}
DOCKER_GIT_SKILLER_WEB_URL: ${DOCKER_GIT_SKILLER_WEB_URL-https://skiller-web-henna.vercel.app}
DOCKER_GIT_SKILLER_BACKEND_URL: ${DOCKER_GIT_SKILLER_BACKEND_URL:-}
DOCKER_GIT_API_PUBLIC_URL: ${DOCKER_GIT_API_PUBLIC_URL:-}
DOCKER_GIT_SKILLER_ALLOWED_ORIGINS: ${DOCKER_GIT_SKILLER_ALLOWED_ORIGINS:-}
ports:
- "${DOCKER_GIT_API_BIND_HOST:-127.0.0.1}:${DOCKER_GIT_API_PORT:-3334}:${DOCKER_GIT_API_PORT:-3334}"
dns:
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ services:
DOCKER_GIT_EXCHANGE_AGENT_COMMAND: ${DOCKER_GIT_EXCHANGE_AGENT_COMMAND:-}
DOCKER_GIT_EXCHANGE_AGENT_TIMEOUT_MS: ${DOCKER_GIT_EXCHANGE_AGENT_TIMEOUT_MS:-3600000}
DOCKER_GIT_OUTBOX_POLLING_INTERVAL_MS: ${DOCKER_GIT_OUTBOX_POLLING_INTERVAL_MS:-5000}
DOCKER_GIT_SKILLER_WEB_URL: ${DOCKER_GIT_SKILLER_WEB_URL-https://skiller-web-henna.vercel.app}
DOCKER_GIT_SKILLER_BACKEND_URL: ${DOCKER_GIT_SKILLER_BACKEND_URL:-}
DOCKER_GIT_API_PUBLIC_URL: ${DOCKER_GIT_API_PUBLIC_URL:-}
DOCKER_GIT_SKILLER_ALLOWED_ORIGINS: ${DOCKER_GIT_SKILLER_ALLOWED_ORIGINS:-}
ports:
- "${DOCKER_GIT_API_BIND_HOST:-127.0.0.1}:${DOCKER_GIT_API_PORT:-3334}:${DOCKER_GIT_API_PORT:-3334}"
dns:
Expand Down
21 changes: 16 additions & 5 deletions docs/integrations/skiller.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,26 @@ bun run skiller:check

## docker-git Web Launch

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`.
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.

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.
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.

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.
External Skiller Web is controlled by these API/controller environment variables:

- `DOCKER_GIT_SKILLER_WEB_URL` sets the Skiller Web base URL. The compose default is `https://skiller-web-henna.vercel.app`.
- `DOCKER_GIT_SKILLER_BACKEND_URL` overrides the callback base URL sent to Skiller Web and disables automatic tunnel URL selection.
- `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.
- `DOCKER_GIT_SKILLER_ALLOWED_ORIGINS` adds comma-separated trusted Skiller Web origins for CORS.

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.

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.

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.

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`.

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.
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.

Comment on lines +43 to 63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Добавьте формальный Proof of fix блок для нового external launch/tunnel потока.

Сейчас зафиксировано поведение, но не зафиксированы формальные обязательства (инварианты, предусловия, постусловия, сложность), из-за чего нельзя строго сверить фикс по SDD-процессу.

🧩 Минимальный шаблон для добавления в документ
## Proof of fix

- Причина: <корневая причина>
- Решение: <что изменено>
- Доказательство: <тест/прогон, который падал до и проходит после>

## Математические гарантии

- Инвариант: <...>
- Предусловия: <...>
- Постусловия: <...>
- Сложность: O(...)/O(...)

As per coding guidelines: *.md: Document all PR changes with mathematical proof obligations: invariants, preconditions, postconditions, variant functions, and complexity analysis in Markdown format.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/integrations/skiller.md` around lines 43 - 63, Add a formal "Proof of
fix" section and "Математические гарантии" (Mathematical guarantees) subsection
to the Skiller documentation after the environment variables explanation and
before the legacy bundled mode details. The "Proof of fix" block should document
the root cause of the external launch/tunnel flow issue, the solution
implemented, and proof that relevant tests pass. The "Математические гарантии"
section should specify the invariants (properties that must always hold),
preconditions (requirements before the flow executes), postconditions
(guaranteed results after execution), and complexity analysis for the external
Skiller Web URL callback mechanism and automatic Cloudflare tunnel selection
logic. This formalizes the behavioral guarantees for strict verification against
the SDD process requirements.

Source: Coding guidelines

## PR #238 Proof

Expand Down Expand Up @@ -87,4 +98,4 @@ bun run skiller:check

## Integration Boundary

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.
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.
110 changes: 103 additions & 7 deletions packages/api/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as HttpRouter from "@effect/platform/HttpRouter"
import * as HttpServerRequest from "@effect/platform/HttpServerRequest"
import * as HttpServerResponse from "@effect/platform/HttpServerResponse"
import * as HttpServerError from "@effect/platform/HttpServerError"
import { networkInterfaces } from "node:os"
import * as ParseResult from "effect/ParseResult"
import * as Schema from "effect/Schema"
import { renderError, type AppError } from "@effect-template/lib/usecases/errors"
Expand Down Expand Up @@ -168,13 +169,16 @@ import {
openSkiller,
openSkillerForTerminalSession,
parseSkillerRoute,
inspectableSkillerConnectProjects,
proxySkillerTrpc,
readExternalSkillerLaunchHtml,
readSkillerProjectContext,
serveSkillerApp
} from "./services/skiller.js"
import {
isSkillerWebCorsOriginAllowed,
resolveDockerGitSkillerBackendUrl
joinSkillerBackendUrl,
resolveDockerGitSkillerBackendUrlDecision
} from "./services/skiller-core.js"
import {
commitStateFromRequest,
Expand Down Expand Up @@ -244,6 +248,10 @@ const SkillerConnectRequestSchema = Schema.Struct({
sessionId: Schema.optional(Schema.String)
})

const SkillerExternalLaunchParamsSchema = Schema.Struct({
launchId: Schema.String
})

type ApiError =
| ApiAuthRequiredError
| ApiBadRequestError
Expand Down Expand Up @@ -448,6 +456,7 @@ const terminalSessionParams = HttpRouter.schemaParams(TerminalSessionParamsSchem
const terminalSessionByProjectKeyParams = HttpRouter.schemaParams(TerminalSessionByProjectKeyParamsSchema)
const containerTaskParams = HttpRouter.schemaParams(ContainerTaskParamsSchema)
const authTerminalSessionParams = HttpRouter.schemaParams(AuthTerminalSessionParamsSchema)
const skillerExternalLaunchParams = HttpRouter.schemaParams(SkillerExternalLaunchParamsSchema)

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

const resolveSkillerBackendUrl = (request: HttpServerRequest.HttpServerRequest): string =>
resolveDockerGitSkillerBackendUrl(process.env, resolveRequestOrigin(request))
// CHANGE: Preserve the docker-git web `/api` proxy prefix in Skiller Web callbacks.
// WHY: External Skiller Web runs on another origin and must call the browser-reachable API URL.
// QUOTE(ТЗ): "у нас всё равно юзается http://192.168.0.206:4174/api/ssh/session/.../skiller/app/"
// REF: user-message-2026-06-18-skiller-external-module
// SOURCE: n/a
// FORMAT THEOREM: forwardedPrefix = p_safe -> backendUrl = origin + normalize(p_safe)
// PURITY: SHELL
// EFFECT: Reads trusted reverse-proxy request headers.
// INVARIANT: Unsafe or root prefixes do not change the request origin.
// COMPLEXITY: O(n) where n = |x-forwarded-prefix|.
const normalizeForwardedPrefix = (value: string | undefined): string => {
const prefix = firstCommaValue(value)
if (prefix === undefined || prefix.length === 0 || prefix === "/") {
return ""
}
if (!prefix.startsWith("/") || prefix.includes("://")) {
return ""
}
return prefix.replace(/\/+$/u, "")
}

// CHANGE: Give hosted Skiller Web an HTTPS-reachable docker-git backend URL.
// WHY: Chrome blocks an HTTPS Vercel app from fetching a plain HTTP LAN API, even when CORS/PNA preflight succeeds.
// QUOTE(ТЗ): "сделать что бы оно начало работать"
// REF: user-message-2026-06-18-skiller-vercel-failed-fetch
// SOURCE: n/a
const dockerGitApiPort = (env: Record<string, string | undefined>): string => {
const configured = env["DOCKER_GIT_API_PORT"]?.trim()
return configured !== undefined && /^\d{1,5}$/u.test(configured) ? configured : "3334"
}
Comment on lines +653 to +656

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Проверка DOCKER_GIT_API_PORT пропускает недопустимые порты.

На Line 655 регулярка принимает значения до 99999, из-за чего формируется невалидный panelUrl и tunnel-ветка падает при ошибочной конфигурации.

💡 Предлагаемый фикс
 const dockerGitApiPort = (env: Record<string, string | undefined>): string => {
   const configured = env["DOCKER_GIT_API_PORT"]?.trim()
-  return configured !== undefined && /^\d{1,5}$/u.test(configured) ? configured : "3334"
+  if (configured === undefined || !/^\d{1,5}$/u.test(configured)) {
+    return "3334"
+  }
+  const port = Number(configured)
+  return Number.isInteger(port) && port >= 1 && port <= 65_535 ? configured : "3334"
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/api/src/http.ts` around lines 653 - 656, The validation in the
dockerGitApiPort function is too permissive. The regex pattern /^\d{1,5}$/u
accepts port numbers up to 99999, but valid TCP port numbers must be in the
range 0-65535. Fix the validation logic to ensure the configured port number is
both in the correct numeric format and within the valid port range. Convert the
string to a number and check that it falls within the valid port range (0-65535)
before returning it as a valid port.


const firstControllerIpv4 = (): string =>
Object.values(networkInterfaces())
.flatMap((entries) => entries ?? [])
.find((entry) => entry.family === "IPv4" && !entry.internal)?.address ?? "127.0.0.1"

const internalSkillerTunnelPanelUrl = (
env: Record<string, string | undefined>
): string =>
`http://${firstControllerIpv4()}:${dockerGitApiPort(env)}`

// FORMAT THEOREM: externalWebEnabled ∧ requestBackendUrl.protocol != https ∧ noConfiguredBackend -> backendUrl = cloudflare(internalApiUrl) + prefix
// PURITY: SHELL
// EFFECT: Reads controller network interfaces and may start or reuse the panel Cloudflare Quick Tunnel.
// INVARIANT: Explicit backend env values and already-HTTPS request URLs do not start a tunnel.
// COMPLEXITY: O(t) where t is the bounded tunnel startup wait.
const resolveSkillerBackendUrl = (
request: HttpServerRequest.HttpServerRequest
): Effect.Effect<string, ApiBadRequestError | ApiInternalError> => {
const requestOrigin = resolveRequestOrigin(request)
const forwardedPrefix = normalizeForwardedPrefix(readHeader(request, "x-forwarded-prefix"))
const decision = resolveDockerGitSkillerBackendUrlDecision(process.env, requestOrigin, forwardedPrefix)
return Match.value(decision).pipe(
Match.when({ _tag: "Configured" }, ({ backendUrl }) => Effect.succeed(backendUrl)),
Match.when({ _tag: "Request" }, ({ backendUrl }) => Effect.succeed(backendUrl)),
Match.when({ _tag: "Tunnel" }, ({ forwardedPrefix: tunnelPrefix }) =>
startPanelCloudflareTunnel({ panelUrl: internalSkillerTunnelPanelUrl(process.env) }).pipe(
Effect.flatMap((tunnel) =>
tunnel.publicUrl === null
? Effect.fail(new ApiInternalError({
message: "Cloudflare tunnel did not return a public URL for Skiller backend."
}))
: Effect.succeed(joinSkillerBackendUrl(tunnel.publicUrl, tunnelPrefix))
)
)),
Match.exhaustive
)
}

const isPrivateNetworkCorsRequest = (
request: HttpServerRequest.HttpServerRequest
Expand Down Expand Up @@ -916,6 +991,7 @@ const skillerConnectInfoResponse = (
request: HttpServerRequest.HttpServerRequest
) =>
listProjects().pipe(
Effect.flatMap(inspectableSkillerConnectProjects),
Effect.flatMap((projects) => skillerJsonResponse(request, { ok: true, projects }, 200)),
Effect.catchAll((error) => skillerErrorResponse(request, error))
)
Expand All @@ -929,10 +1005,11 @@ const skillerConnectResponse = (
if (projectKey.length === 0) {
return yield* _(Effect.fail(new ApiBadRequestError({ message: "projectKey is required." })))
}
const backendUrl = yield* _(resolveSkillerBackendUrl(request))
const connection = yield* _(connectSkillerWeb(
projectKey,
normalizedOptionalString(body.sessionId),
resolveSkillerBackendUrl(request)
backendUrl
))
return yield* _(skillerJsonResponse(request, { ok: true, ...connection }, 202))
}).pipe(
Expand Down Expand Up @@ -977,11 +1054,28 @@ export const makeRouter = () => {
return yield* _(skillerConnectResponse(request))
})
),
HttpRouter.get(
"/skiller/external-launch/:launchId",
Effect.gen(function*(_) {
const { launchId } = yield* _(skillerExternalLaunchParams)
const html = yield* _(readExternalSkillerLaunchHtml(launchId))
return yield* _(textResponse(html, "text/html; charset=utf-8", 200))
}).pipe(Effect.catchAll(errorResponse))
),
HttpRouter.get(
"/api/skiller/external-launch/:launchId",
Effect.gen(function*(_) {
const { launchId } = yield* _(skillerExternalLaunchParams)
const html = yield* _(readExternalSkillerLaunchHtml(launchId))
return yield* _(textResponse(html, "text/html; charset=utf-8", 200))
}).pipe(Effect.catchAll(errorResponse))
),
HttpRouter.post(
"/skiller/open",
Effect.gen(function*(_) {
const request = yield* _(HttpServerRequest.HttpServerRequest)
const launch = yield* _(openSkiller(undefined, undefined, resolveSkillerBackendUrl(request)))
const backendUrl = yield* _(resolveSkillerBackendUrl(request))
const launch = yield* _(openSkiller(undefined, undefined, backendUrl))
return yield* _(jsonResponse({ ok: true, ...launch }, 202))
}).pipe(Effect.catchAll(errorResponse))
),
Expand All @@ -990,7 +1084,8 @@ export const makeRouter = () => {
Effect.gen(function*(_) {
const request = yield* _(HttpServerRequest.HttpServerRequest)
const { projectKey } = yield* _(projectKeyParams)
const launch = yield* _(openSkiller(projectKey, undefined, resolveSkillerBackendUrl(request)))
const backendUrl = yield* _(resolveSkillerBackendUrl(request))
const launch = yield* _(openSkiller(projectKey, undefined, backendUrl))
return yield* _(jsonResponse({ ok: true, ...launch }, 202))
}).pipe(Effect.catchAll(errorResponse))
),
Expand All @@ -999,7 +1094,8 @@ export const makeRouter = () => {
Effect.gen(function*(_) {
const request = yield* _(HttpServerRequest.HttpServerRequest)
const { projectKey, sessionId } = yield* _(terminalSessionByProjectKeyParams)
const launch = yield* _(openSkillerForTerminalSession(projectKey, sessionId, resolveSkillerBackendUrl(request)))
const backendUrl = yield* _(resolveSkillerBackendUrl(request))
const launch = yield* _(openSkillerForTerminalSession(projectKey, sessionId, backendUrl))
return yield* _(jsonResponse({ ok: true, ...launch }, 202))
}).pipe(Effect.catchAll(errorResponse))
),
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/services/project-browser-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const renderProjectBrowserNoVncPath = (projectId: string): string => {
const projectKey = projectShortKey(projectId)
const params = new URLSearchParams({
autoconnect: "true",
resize: "remote",
resize: "scale",
path: `b/${projectKey}/websockify`
})
return `/b/${projectKey}/vnc.html?${params.toString()}`
Expand Down
Loading
Loading