Skip to content

Commit 68090c6

Browse files
committed
fix: keep Skiller session URLs scoped
1 parent 354dcd2 commit 68090c6

6 files changed

Lines changed: 79 additions & 20 deletions

File tree

docs/integrations/skiller.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ 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 opens `/api/ssh/session/:sessionId/skiller/app/` immediately, using the same terminal session id that is present in `/ssh/session/:sessionId`. It also calls `POST /projects/by-key/:projectKey/terminal-sessions/:sessionId/skiller/open`, which launches the pinned submodule Electron app as a separate process and writes launcher output to `~/.docker-git/logs/skiller.log`.
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`.
4444

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

@@ -60,6 +60,7 @@ The latest Playwright proof screenshots are checked in under `docs/screenshots/i
6060
- `pr238-proof-30-skiller-add-project-folder-browser-picker.png` shows the browser-visible project folder picker opened from `Add project folder...` with selected-container paths.
6161
- `pr238-proof-31-skiller-project-scoped-folder-picker-working.png` shows `/api/skiller/app/` opened from a project-scoped Skiller process with `Add project folder...` resolving to the selected container paths.
6262
- `pr238-proof-32-docker-git-browser-live-command.png` shows the live `docker-git browser` frontend served through the same controller used for the proof.
63+
- `pr238-proof-33-skiller-session-folder-picker-mcp.png` shows the terminal session-scoped Skiller URL opened from the real `Skiller` button and the `Add project folder...` picker resolving to `dg-skiller-button-demo:/home/dev/app`.
6364

6465
## Updating the Pin
6566

99.6 KB
Loading

packages/api/src/services/skiller.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -595,16 +595,26 @@ export const resolveSkillerBrowserScopeSelection = (
595595
route: Extract<SkillerRoute, { readonly _tag: "App" }>,
596596
currentScope: SkillerContainerScope | null,
597597
sessionScopeForId: (sessionId: string) => SkillerContainerScope | null | undefined
598+
): SkillerBrowserScopeSelection | null =>
599+
resolveSkillerRouteScopeSelection(route.sessionId, currentScope, sessionScopeForId)
600+
601+
export const resolveSkillerRouteScopeSelection = (
602+
sessionId: string | null,
603+
currentScope: SkillerContainerScope | null,
604+
sessionScopeForId: (sessionId: string) => SkillerContainerScope | null | undefined
598605
): SkillerBrowserScopeSelection | null => {
599-
if (route.sessionId === null) {
606+
if (sessionId === null) {
600607
return currentScope === null
601608
? null
602609
: { scope: currentScope, sessionId: null }
603610
}
604-
const sessionScope = sessionScopeForId(route.sessionId)
605-
return sessionScope === undefined || sessionScope === null
611+
const sessionScope = sessionScopeForId(sessionId)
612+
if (sessionScope !== undefined && sessionScope !== null) {
613+
return { scope: sessionScope, sessionId }
614+
}
615+
return currentScope === null
606616
? null
607-
: { scope: sessionScope, sessionId: route.sessionId }
617+
: { scope: currentScope, sessionId }
608618
}
609619

610620
const browserDockerGitScopeBootstrap = (
@@ -719,13 +729,20 @@ const verifySkillerRouteScope = (
719729
if (route.sessionId === null) {
720730
return Effect.void
721731
}
722-
const scope = sessionScopes.get(route.sessionId)
723-
if (scope === undefined) {
732+
const currentScope = currentProcess !== null && isRunning(currentProcess.process)
733+
? currentProcess.scope
734+
: null
735+
const selection = resolveSkillerRouteScopeSelection(
736+
route.sessionId,
737+
currentScope,
738+
(sessionId) => sessionScopes.get(sessionId)
739+
)
740+
if (selection === null) {
724741
return Effect.fail(new ApiConflictError({
725742
message: `Skiller session is not registered: ${route.sessionId}. Click the terminal Skiller button again.`
726743
}))
727744
}
728-
if (currentProcess === null || !sameSkillerScope(currentProcess.scope, scope)) {
745+
if (currentProcess === null || !sameSkillerScope(currentProcess.scope, selection.scope)) {
729746
return Effect.fail(new ApiConflictError({
730747
message: `Skiller is not running for terminal session ${route.sessionId}. Click the terminal Skiller button again.`
731748
}))

packages/api/tests/skiller-routes.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, expect, it } from "@effect/vitest"
33
import {
44
parseSkillerRoute,
55
resolveSkillerBrowserScopeSelection,
6+
resolveSkillerRouteScopeSelection,
67
type SkillerRoute
78
} from "../src/services/skiller.js"
89
import type { SkillerContainerScope } from "../src/services/skiller-core.js"
@@ -74,6 +75,32 @@ describe("skiller routes", () => {
7475
})
7576
})
7677

78+
it("falls back to the current project scope for stale session app routes", () => {
79+
const currentScope = scope("current-scope")
80+
81+
expect(resolveSkillerBrowserScopeSelection(
82+
appRoute("/api/ssh/session/stale-session/skiller/app/"),
83+
currentScope,
84+
() => undefined
85+
)).toEqual({
86+
scope: currentScope,
87+
sessionId: "stale-session"
88+
})
89+
})
90+
91+
it("falls back to the current project scope for stale session trpc routes", () => {
92+
const currentScope = scope("current-scope")
93+
94+
expect(resolveSkillerRouteScopeSelection(
95+
"stale-session",
96+
currentScope,
97+
() => undefined
98+
)).toEqual({
99+
scope: currentScope,
100+
sessionId: "stale-session"
101+
})
102+
})
103+
77104
it("does not inject a browser scope for unscoped Skiller app routes", () => {
78105
expect(resolveSkillerBrowserScopeSelection(
79106
appRoute("/api/skiller/app/"),

packages/app/src/web/actions-skiller.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,6 @@ type SkillerLaunch = {
1414
readonly trpcBasePath: string
1515
}
1616

17-
const skillerAppPathForSession = (sessionId: string): string =>
18-
`/api/ssh/session/${encodeURIComponent(sessionId)}/skiller/app/`
19-
2017
const skillerLaunchMessage = (launch: SkillerLaunch, openedPath: string, opened: boolean): string => {
2118
const pid = launch.pid === null ? "unknown pid" : `pid ${launch.pid}`
2219
const state = launch.alreadyRunning
@@ -36,15 +33,13 @@ export const openSkillerApp = (
3633
sessionId?: string
3734
): void => {
3835
const resolvedProjectKey = projectKey ?? undefined
39-
const immediateAppPath = sessionId === undefined ? null : skillerAppPathForSession(sessionId)
40-
const immediateOpenResult = immediateAppPath === null ? null : openUrl(immediateAppPath)
4136
withBusy({
4237
context,
4338
effect: openSkiller(resolvedProjectKey, sessionId),
4439
label: "Opening Skiller",
4540
onSuccess: (launch) => {
46-
const openedPath = immediateAppPath ?? launch.appPath
47-
const opened = immediateOpenResult ?? openUrl(openedPath)
41+
const openedPath = launch.appPath
42+
const opened = openUrl(openedPath)
4843
context.setMessage(skillerLaunchMessage(launch, openedPath, opened))
4944
}
5045
})

packages/app/tests/docker-git/actions-skiller.test.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const proofScope = {
2424
const skillerLaunch = (
2525
overrides: {
2626
readonly alreadyRunning?: boolean
27+
readonly appPath?: string
2728
readonly scope?: null | {
2829
readonly containerCodexSkillsPath: string
2930
readonly containerHomePath: string
@@ -36,16 +37,17 @@ const skillerLaunch = (
3637
readonly projectKey: string
3738
readonly sshUser: string
3839
}
40+
readonly trpcBasePath?: string
3941
} = {}
4042
) => ({
4143
alreadyRunning: overrides.alreadyRunning ?? false,
42-
appPath: "/api/skiller/app/",
44+
appPath: overrides.appPath ?? "/api/skiller/app/",
4345
logPath: "/home/dev/.docker-git/logs/skiller.log",
4446
ok: true,
4547
pid: 1234,
4648
scope: overrides.scope ?? null,
4749
startedAtIso: "2026-05-09T17:30:00.000Z",
48-
trpcBasePath: "/api/skiller",
50+
trpcBasePath: overrides.trpcBasePath ?? "/api/skiller",
4951
trpcPort: 17_888
5052
})
5153

@@ -110,18 +112,35 @@ describe("web Skiller actions", () => {
110112
}))
111113
}))
112114

113-
it.effect("opens the session-scoped Skiller URL immediately from a terminal session", () =>
115+
it.effect("opens the session-scoped Skiller URL after the backend registers the scope", () =>
114116
Effect.gen(function*(_) {
115-
mockScopedSkillerLaunch()
117+
let completeLaunch = (_launch: ReturnType<typeof skillerLaunch>): void => {
118+
throw new Error("Expected Skiller launch effect to be subscribed.")
119+
}
120+
openUrlMock.mockReturnValue(true)
121+
openSkillerMock.mockImplementation(() =>
122+
Effect.async<ReturnType<typeof skillerLaunch>>((resume) => {
123+
completeLaunch = (launch) => {
124+
resume(Effect.succeed(launch))
125+
}
126+
})
127+
)
116128
const { context, setMessage } = makeBrowserActionContext()
117129

118130
openSkillerApp(context, "abc123", "terminal-proof")
119131

120-
expect(openUrlMock).toHaveBeenCalledWith("/api/ssh/session/terminal-proof/skiller/app/")
132+
expect(openUrlMock).not.toHaveBeenCalled()
121133
yield* _(waitForAssertion(() => {
122134
expect(openSkillerMock).toHaveBeenCalledWith("abc123", "terminal-proof")
123135
}))
136+
completeLaunch(skillerLaunch({
137+
alreadyRunning: true,
138+
appPath: "/api/ssh/session/terminal-proof/skiller/app/",
139+
scope: proofScope,
140+
trpcBasePath: "/api/ssh/session/terminal-proof/skiller"
141+
}))
124142
yield* _(waitForAssertion(() => {
143+
expect(openUrlMock).toHaveBeenCalledWith("/api/ssh/session/terminal-proof/skiller/app/")
125144
expect(setMessage).toHaveBeenCalledWith(
126145
"Skiller is already running (pid 1234). Log: /home/dev/.docker-git/logs/skiller.log. Container FS: dg-project:/home/dev/app. Opened /api/ssh/session/terminal-proof/skiller/app/."
127146
)

0 commit comments

Comments
 (0)