Skip to content

Commit 89c04cb

Browse files
committed
fix: expose selected container Codex skills to Skiller
1 parent e655e57 commit 89c04cb

7 files changed

Lines changed: 63 additions & 10 deletions

File tree

docs/integrations/skiller.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ docker-git serves Skiller's built renderer from the submodule and proxies `/api/
4646

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

49+
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`.
50+
4951
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.
5052

5153
## PR #238 Proof
@@ -54,6 +56,7 @@ The latest Playwright proof screenshots are checked in under `docs/screenshots/i
5456

5557
- `pr238-proof-27-terminal-skiller-same-session.png` shows the attached terminal with the `Skiller` button.
5658
- `pr238-proof-28-skiller-session-scoped-ui.png` shows the real Skiller UI opened from that button.
59+
- `pr238-proof-29-skiller-codex-container-skill.png` shows a Codex skill discovered from the selected container's `/home/dev/.codex/skills` tree.
5760

5861
## Updating the Pin
5962

63.7 KB
Loading

packages/api/src/services/skiller-core.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ export type DockerContainerMount = {
77
}
88

99
export type SkillerContainerScope = {
10+
readonly containerCodexSkillsPath: string
1011
readonly containerHomePath: string
1112
readonly containerName: string
1213
readonly containerProjectPath: string
14+
readonly hostCodexSkillsPath: string
1315
readonly hostHomePath: string
1416
readonly hostProjectPath: string
1517
readonly projectId: string
@@ -41,6 +43,9 @@ export const normalizeContainerPath = (path: string): string => {
4143
return posix.normalize(absolute)
4244
}
4345

46+
export const containerCodexSkillsPath = (containerHomePath: string): string =>
47+
posix.join(normalizeContainerPath(containerHomePath), ".codex", "skills")
48+
4449
const isPathInside = (basePath: string, targetPath: string): boolean =>
4550
targetPath === basePath || targetPath.startsWith(`${basePath}/`)
4651

@@ -82,6 +87,7 @@ export const sameSkillerScope = (
8287
}
8388
return left.projectKey === right.projectKey &&
8489
left.containerName === right.containerName &&
90+
left.hostCodexSkillsPath === right.hostCodexSkillsPath &&
8591
left.hostHomePath === right.hostHomePath &&
8692
left.hostProjectPath === right.hostProjectPath
8793
}

packages/api/src/services/skiller.ts

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import * as Stream from "effect/Stream"
1717

1818
import { ApiConflictError, ApiInternalError, ApiNotFoundError } from "../api/errors.js"
1919
import {
20+
containerCodexSkillsPath,
2021
parseDockerMountLines,
2122
remapContainerPathToMountedHost,
2223
sameSkillerScope,
@@ -203,13 +204,20 @@ const resolveSkillerScope = (
203204
Effect.gen(function*(_) {
204205
const mounts = yield* _(inspectContainerMounts(project.containerName))
205206
const containerHome = containerHomePath(project.sshUser)
207+
const containerCodexSkills = containerCodexSkillsPath(containerHome)
206208
const hostHomePath = remapContainerPathToMountedHost(mounts, containerHome)
209+
const hostCodexSkillsPath = remapContainerPathToMountedHost(mounts, containerCodexSkills)
207210
const hostProjectPath = remapContainerPathToMountedHost(mounts, project.targetDir)
208211
if (hostHomePath === null) {
209212
return yield* _(Effect.fail(new ApiConflictError({
210213
message: `Skiller cannot find a writable Docker mount for ${containerHome} in ${project.containerName}.`
211214
})))
212215
}
216+
if (hostCodexSkillsPath === null) {
217+
return yield* _(Effect.fail(new ApiConflictError({
218+
message: `Skiller cannot find a writable Docker mount for ${containerCodexSkills} in ${project.containerName}.`
219+
})))
220+
}
213221
if (hostProjectPath === null) {
214222
return yield* _(Effect.fail(new ApiConflictError({
215223
message: `Skiller cannot find a writable Docker mount for ${project.targetDir} in ${project.containerName}.`
@@ -218,9 +226,11 @@ const resolveSkillerScope = (
218226
yield* _(requireAccessibleDirectory(hostHomePath, "home volume"))
219227
yield* _(requireAccessibleDirectory(hostProjectPath, "project directory"))
220228
return {
229+
containerCodexSkillsPath: containerCodexSkills,
221230
containerHomePath: containerHome,
222231
containerName: project.containerName,
223232
containerProjectPath: project.targetDir,
233+
hostCodexSkillsPath,
224234
hostHomePath,
225235
hostProjectPath,
226236
projectId: project.projectDir,
@@ -318,6 +328,24 @@ const chownIfExists = (path: string, user: SkillerProcessUser): void => {
318328
}
319329
}
320330

331+
const prepareSkillerScopeHome = (scope: SkillerContainerScope | null): SkillerProcessUser | null => {
332+
const processUser = scopedProcessUser(scope)
333+
if (scope === null || processUser === null) {
334+
return null
335+
}
336+
ensureOwnedDirectory(join(scope.hostHomePath, ".agents"), processUser)
337+
ensureOwnedDirectory(join(scope.hostHomePath, ".agents", "skills"), processUser)
338+
ensureOwnedDirectory(join(scope.hostHomePath, ".codex"), processUser)
339+
ensureOwnedDirectory(scope.hostCodexSkillsPath, processUser)
340+
ensureOwnedDirectory(join(scope.hostHomePath, ".config"), processUser)
341+
ensureOwnedDirectory(join(scope.hostHomePath, ".cache"), processUser)
342+
ensureOwnedDirectory(join(scope.hostHomePath, ".local", "share"), processUser)
343+
ensureOwnedDirectory(join(scope.hostHomePath, ".skiller"), processUser)
344+
chownIfExists(join(scope.hostHomePath, ".codex", "config.toml"), processUser)
345+
chownIfExists(join(scope.hostHomePath, ".skiller", "config.toml"), processUser)
346+
return processUser
347+
}
348+
321349
const skillerLaunchCommand = (
322350
user: SkillerProcessUser | null
323351
): readonly [string, ReadonlyArray<string>] =>
@@ -377,16 +405,7 @@ const launchSkillerProcess = (
377405
scope: SkillerContainerScope | null
378406
): SkillerLaunch => {
379407
mkdirSync(dirname(launchLogPath), { recursive: true })
380-
const processUser = scopedProcessUser(scope)
381-
if (scope !== null && processUser !== null) {
382-
ensureOwnedDirectory(join(scope.hostHomePath, ".agents"), processUser)
383-
ensureOwnedDirectory(join(scope.hostHomePath, ".agents", "skills"), processUser)
384-
ensureOwnedDirectory(join(scope.hostHomePath, ".config"), processUser)
385-
ensureOwnedDirectory(join(scope.hostHomePath, ".cache"), processUser)
386-
ensureOwnedDirectory(join(scope.hostHomePath, ".local", "share"), processUser)
387-
ensureOwnedDirectory(join(scope.hostHomePath, ".skiller"), processUser)
388-
chownIfExists(join(scope.hostHomePath, ".skiller", "config.toml"), processUser)
389-
}
408+
const processUser = prepareSkillerScopeHome(scope)
390409
const logFd = openSync(launchLogPath, "a")
391410
try {
392411
const [command, args] = skillerLaunchCommand(processUser)
@@ -442,6 +461,15 @@ export const openSkiller = (
442461
rememberSessionScope(sessionId, scope)
443462
if (currentProcess !== null && isRunning(currentProcess.process)) {
444463
if (sameSkillerScope(currentProcess.scope, scope)) {
464+
yield* _(Effect.try({
465+
catch: (cause) => new ApiInternalError({
466+
message: "Failed to prepare selected container home for Skiller.",
467+
cause
468+
}),
469+
try: () => {
470+
prepareSkillerScopeHome(scope)
471+
}
472+
}))
445473
yield* _(waitForSkillerReady(currentProcess.trpcPort))
446474
yield* _(registerSkillerProject(currentProcess.trpcPort, scope))
447475
return toLaunch(currentProcess, true, sessionId)

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from "@effect/vitest"
22

33
import {
4+
containerCodexSkillsPath,
45
parseDockerMountLines,
56
remapContainerPathToMountedHost,
67
sameSkillerScope
@@ -17,6 +18,9 @@ describe("skiller container filesystem mapping", () => {
1718
expect(remapContainerPathToMountedHost(mounts, "/home/dev/app")).toBe(
1819
"/var/lib/docker/volumes/project-home/_data/app"
1920
)
21+
expect(remapContainerPathToMountedHost(mounts, containerCodexSkillsPath("/home/dev"))).toBe(
22+
"/var/lib/docker/volumes/project-home/_data/.codex/skills"
23+
)
2024
expect(remapContainerPathToMountedHost(mounts, "/home/dev/.docker-git/.cache/bun")).toBe(
2125
"/var/lib/docker/volumes/project-cache/_data/bun"
2226
)
@@ -25,9 +29,11 @@ describe("skiller container filesystem mapping", () => {
2529

2630
it("treats identical Skiller scopes as reusable and different scopes as isolated", () => {
2731
const scope = {
32+
containerCodexSkillsPath: "/home/dev/.codex/skills",
2833
containerHomePath: "/home/dev",
2934
containerName: "dg-project",
3035
containerProjectPath: "/home/dev/app",
36+
hostCodexSkillsPath: "/var/lib/docker/volumes/project-home/_data/.codex/skills",
3137
hostHomePath: "/var/lib/docker/volumes/project-home/_data",
3238
hostProjectPath: "/var/lib/docker/volumes/project-home/_data/app",
3339
projectId: "/home/dev/.docker-git/project",
@@ -37,6 +43,10 @@ describe("skiller container filesystem mapping", () => {
3743

3844
expect(sameSkillerScope(scope, scope)).toBe(true)
3945
expect(sameSkillerScope(scope, { ...scope, projectKey: "def456" })).toBe(false)
46+
expect(sameSkillerScope(scope, {
47+
...scope,
48+
hostCodexSkillsPath: "/var/lib/docker/volumes/other-home/_data/.codex/skills"
49+
})).toBe(false)
4050
expect(sameSkillerScope(scope, null)).toBe(false)
4151
expect(sameSkillerScope(null, null)).toBe(true)
4252
})

packages/app/src/web/api-skiller-schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import * as Schema from "@effect/schema/Schema"
22

33
export const SkillerScopeResponseSchema = Schema.Struct({
4+
containerCodexSkillsPath: Schema.String,
45
containerHomePath: Schema.String,
56
containerName: Schema.String,
67
containerProjectPath: Schema.String,
8+
hostCodexSkillsPath: Schema.String,
79
hostHomePath: Schema.String,
810
hostProjectPath: Schema.String,
911
projectId: Schema.String,

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ const openSkillerMock = vi.hoisted(() => vi.fn())
99
const openUrlMock = vi.hoisted(() => vi.fn())
1010

1111
const proofScope = {
12+
containerCodexSkillsPath: "/home/dev/.codex/skills",
1213
containerHomePath: "/home/dev",
1314
containerName: "dg-project",
1415
containerProjectPath: "/home/dev/app",
16+
hostCodexSkillsPath: "/var/lib/docker/volumes/dg-project-home/_data/.codex/skills",
1517
hostHomePath: "/var/lib/docker/volumes/dg-project-home/_data",
1618
hostProjectPath: "/var/lib/docker/volumes/dg-project-home/_data/app",
1719
projectId: "/home/dev/.docker-git/project",
@@ -23,9 +25,11 @@ const skillerLaunch = (
2325
overrides: {
2426
readonly alreadyRunning?: boolean
2527
readonly scope?: null | {
28+
readonly containerCodexSkillsPath: string
2629
readonly containerHomePath: string
2730
readonly containerName: string
2831
readonly containerProjectPath: string
32+
readonly hostCodexSkillsPath: string
2933
readonly hostHomePath: string
3034
readonly hostProjectPath: string
3135
readonly projectId: string

0 commit comments

Comments
 (0)