Skip to content
Merged
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
29 changes: 29 additions & 0 deletions docker/platformclaw-runtime/OPERATIONS.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -705,4 +705,33 @@ DOCKER_HOST="unix:///run/user/$(id -u)/docker.sock" docker ps -a
| rootless socket 연결 실패 | `/run/user/<uid>/docker.sock`, user systemd 상태 |
| VM 인증 실패 | host-key 승인, 할당, AD 비밀번호, SafeConnect 연결 검사 |

### Knox 그룹방 스킬 복구 확인

배포 후 `/skillhub installed`에서 설치 기록 미완료나 방 정책으로 비활성화된 항목을 확인한다.
Control DB에서 다음 읽기 전용 쿼리로 아직 확인이 필요한 서버 설치 기록을 찾는다.

```sql
select agent_id, slug
from skill_hub_catalog_installations
where agent_id like 'group-%'
and destination = 'platform_server'
and (state != 'completed' or installed_revision is null);
```

모든 미완료 행의 설치본이 검증되면 결과는 0건이다. 파일이 없거나 원본과 일치하지 않거나
방 정책이 설치를 허용하지 않는 행은 계속 제외된다. 이 경우 Control 로그에서 해당 행의
복구 제외 사유를 확인한다.

방의 활성 작업이 끝난 뒤 메시지를 보내고, 서비스 계정의 rootless Docker에서 샌드박스의
마운트를 확인한다. `sandbox`는 확인할 컨테이너 이름이다.

```bash
DOCKER_HOST="unix:///run/user/$(id -u)/docker.sock" \
docker inspect -f '{{range .Mounts}}{{println .Destination .Source}}{{end}}' "$sandbox"
```

복구된 `/workspace/skills/<slug>`에는 `.excluded-skill-empty` 소스가 없어야 하고,
해당 경로의 `SKILL.md`를 읽을 수 있어야 한다. 방 정책으로 차단된 다른 스킬의 제외
마운트는 남을 수 있다.

로그를 공유할 때 AD 비밀번호, API key, token, 직원정보를 제거한다.
5 changes: 5 additions & 0 deletions docs/platformclaw/skill-hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,11 @@ Deleting an installed copy does not delete its Hub original. Deletion checks the
current physical directory and revision; it cannot remove another workspace,
follow an arbitrary path, or select a sibling by a frontmatter alias.

In a room, `/skillhub installed` identifies copies disabled by an incomplete
installation record or room policy. If a skill mount changes while the room is
busy, wait for the current task to finish and retry the message. The next idle
dispatch can recreate the room sandbox with the current skill mounts.

## Natural language in Knox

Ask the Agent to find a skill for a task. It searches the available catalog,
Expand Down
5 changes: 5 additions & 0 deletions docs/plugins/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,11 @@ Use the phase-specific hooks for new plugins:
run check rejects permissions that need a mask missing from the prepared view.
Workspace masks are confined to the executing Agent's `skills/` and
`.agents/skills/` source roots. They do not delete or rewrite installed content.
A managed policy owner can return `reconcileSandboxSkillMounts: true` to allow
a restricted Docker dispatch to refresh an idle, writable agent or session
sandbox. Docker verifies that only the managed skill mounts changed before
replacing the container. Competing work keeps its sandbox and the new dispatch
returns a retryable error.
Return `{ excludedPaths: [...] }` to exclude exact candidate paths from this
run's skill prompt, environment requirements, Code Mode readers, and native
CLI skill materialization. Multiple handlers' exclusions are combined; a
Expand Down
33 changes: 32 additions & 1 deletion extensions/knox/src/inbound-replies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
type ChannelInboundTurnPlan,
} from "openclaw/plugin-sdk/channel-inbound";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { closeOpenClawStateDatabaseForTest } from "openclaw/plugin-sdk/plugin-state-test-runtime";
import {
getReplyPayloadNoVisibleReplyReason,
type NoVisibleReplyReason,
Expand All @@ -29,6 +28,10 @@ import {
setReplyPayloadMetadata,
} from "openclaw/plugin-sdk/reply-payload-testing";
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
import {
closeOpenClawAgentDatabasesForTest,
closeOpenClawStateDatabaseForTest,
} from "openclaw/plugin-sdk/sqlite-runtime-testing";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { dispatchKnoxInbound } from "./inbound.js";
import { setKnoxRuntime } from "./runtime.js";
Expand Down Expand Up @@ -207,6 +210,7 @@ afterEach(async () => {
}
vi.useRealTimers();
resetAgentEventsForTest();
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
Expand Down Expand Up @@ -823,6 +827,33 @@ describe("Knox reply delivery through the core dispatch boundary", () => {
},
);

it.each(["thrown", "payload"] as const)(
"delivers a safe Korean retry notice for a %s sandbox skill refresh conflict",
async (kind) => {
const error = Object.assign(
new Error("Recreate first: openclaw sandbox recreate --agent test"),
{
code: "sandbox-skill-refresh-busy",
},
);
await runReplyBoundary(async () => {
if (kind === "thrown") {
throw new Error("Sandbox provisioning failed", { cause: error });
}
return buildKnownAgentRunFailureReplyPayload({
err: error,
sessionCtx: { ChatType: "group", Provider: "knox", Surface: "knox" },
resolvedVerboseLevel: "off",
});
});
expect(sent).toHaveLength(1);
expect(sent[0]).toMatchObject({ status: "error", final: true });
expect(sent[0]?.text).toContain("스킬 설정을 반영 중입니다. 잠시 후 다시 시도해 주세요.");
expect(sent[0]?.text).toContain("확인 코드: sandbox-skill-refresh-busy");
expect(sent[0]?.text).not.toContain("openclaw sandbox recreate");
},
);

it.each(["room", "dm"] as const)(
"explains an empty result in %s with one Korean terminal notice",
async (conversationType) => {
Expand Down
27 changes: 23 additions & 4 deletions extensions/knox/src/inbound.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { createHash } from "node:crypto";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { findAgentRunTerminalOutcome, formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
collectErrorGraphCandidates,
extractErrorCode,
findAgentRunTerminalOutcome,
formatErrorMessage,
} from "openclaw/plugin-sdk/error-runtime";
import { redactSensitiveText } from "openclaw/plugin-sdk/logging-core";
import {
getReplyPayloadAgentRunTerminalOutcome,
Expand Down Expand Up @@ -43,10 +48,24 @@ function describeSourceFailure(
return reason === "runtime-error" ? (knownReason ?? reason) : reason;
}

function runtimeFailureReason(error: unknown): "timeout" | "cancelled" | "runtime-error" {
function runtimeFailureReason(error: unknown): KnoxReplyFailureReason {
const terminal = findAgentRunTerminalOutcome(error);
if (terminal) {
return terminalFailureReason(terminal);
const terminalReason = terminal ? terminalFailureReason(terminal) : undefined;
if (terminalReason && terminalReason !== "runtime-error") {
return terminalReason;
}
const skillRefreshBusy = "sandbox-skill-refresh-busy";
if (
collectErrorGraphCandidates(error, (candidate) => [
candidate.cause,
candidate.error,
...(Array.isArray(candidate.errors) ? candidate.errors : []),
]).some((candidate) => extractErrorCode(candidate) === skillRefreshBusy)
) {
return skillRefreshBusy;
}
if (terminalReason) {
return terminalReason;
}
let candidate = error;
const seen = new Set<object>();
Expand Down
1 change: 1 addition & 0 deletions extensions/knox/src/reply-notice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const REPLY_FAILURE_TEXT: Record<KnoxReplyFailureReason, string> = {
cancelled: "완료 전에 작업이 취소되었습니다. 계속하려면 남은 작업을 다시 요청해 주세요.",
"runtime-error":
"작업 실행 중 오류가 발생했습니다. 운영자에게 아래 확인 코드로 실행 기록 확인을 요청해 주세요.",
"sandbox-skill-refresh-busy": "스킬 설정을 반영 중입니다. 잠시 후 다시 시도해 주세요.",
"unrenderable-result":
"응답에 본문·선택지·첨부 설명이 없어 Knox 메시지로 변환하지 못했습니다. 운영자에게 응답 형식 확인을 요청해 주세요.",
};
Expand Down
20 changes: 19 additions & 1 deletion extensions/platformclaw-execution/src/backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,21 +565,39 @@ describe("PlatformClaw execution backend", () => {
expect(dependencies.createPlatformServerHandle).not.toHaveBeenCalled();
});

it("materializes Basic workspace skills before creating the Docker handle", async () => {
it("materializes Basic workspace skills and forwards the mount refresh policy", async () => {
const dependencies = createDependencies(async () => ({
kind: "platform_server",
agentId: "person_one",
revision: 1,
targetId: "server-default",
}));
const params = createParams("person_one");
const mounts = [
{ hostPath: "/gateway/managed-skills", containerPath: "/opt/platformclaw/skills" },
];
vi.mocked(params.materializeSkills!).mockResolvedValueOnce({
catalog: { revision: "gateway:test", files: [] },
mounts,
policyApplied: true,
reconcileSandboxSkillMounts: true,
});

await createPlatformClawExecutionBackendFactory(dependencies)(params);

expect(params.materializeSkills).toHaveBeenCalledOnce();
expect(vi.mocked(params.materializeSkills!).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(dependencies.createPlatformServerHandle).mock.invocationCallOrder[0]!,
);
expect(dependencies.createPlatformServerHandle).toHaveBeenCalledWith(
expect.objectContaining({
createParams: expect.objectContaining({
readOnlySkillMounts: mounts,
requireCurrentConfig: true,
reconcileSandboxSkillMounts: true,
}),
}),
);
});

it("does not create a backend handle when target skill discovery fails", async () => {
Expand Down
3 changes: 3 additions & 0 deletions extensions/platformclaw-execution/src/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,9 @@ export function createPlatformClawExecutionBackendFactory(
requireCurrentConfig: materializedSkills.policyApplied
? true
: (createParams.requireCurrentConfig ?? true),
...(materializedSkills.reconcileSandboxSkillMounts
? { reconcileSandboxSkillMounts: true as const }
: {}),
}
: createParams;
const effectiveSkillCatalog = skillCatalog ?? materializedSkills?.catalog;
Expand Down
21 changes: 15 additions & 6 deletions extensions/platformclaw-skillhub/src/run-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,19 @@ describe("SkillHub managed room skill policy", () => {
await expect(f.run()).rejects.toThrow("permissions could not be verified");
});

it("forwards managed source exclusions independently of discoverable candidates", async () => {
const excludedSourceDirectories = ["/workspace/skills/private"];
await expect(
fixture({ status: 200, body: { excludedPaths: [], excludedSourceDirectories } }).run(),
).resolves.toEqual({ excludedPaths: [], excludedSourceDirectories });
});
it.each([
{ excludedSourceDirectories: [] },
{ excludedSourceDirectories: ["/workspace/skills/private"] },
])(
"opts in to room reconciliation with source exclusions $excludedSourceDirectories",
async ({ excludedSourceDirectories }) => {
await expect(
fixture({ status: 200, body: { excludedPaths: [], excludedSourceDirectories } }).run(),
).resolves.toEqual({
excludedPaths: [],
excludedSourceDirectories,
reconcileSandboxSkillMounts: true,
});
},
);
});
6 changes: 5 additions & 1 deletion extensions/platformclaw-skillhub/src/run-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ export function registerSkillHubRunPolicy(
}
return {
excludedPaths: paths,
...(directories !== undefined ? { excludedSourceDirectories: sourceDirectories } : {}),
// The authenticated control-plane response includes the managed room
// policy even when its current exclusion set is empty.
...(directories !== undefined
? { excludedSourceDirectories: sourceDirectories, reconcileSandboxSkillMounts: true }
: {}),
};
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -93,17 +93,80 @@ async function fixture(enabled = true) {
skillId: f.entry!.id,
version: "1.0.0",
archiveSha256: "a".repeat(64),
installedRevision: "sha256:1111111111111111",
installedAt: 10,
};
const recordPendingOrigin = () =>
f.store.skillHubCatalog.recordInstallation({ ...origin, state: "pending" });
const recordOrigin = async () => {
await f.store.skillHubCatalog.recordInstallation({ ...origin, state: "pending" });
await f.store.skillHubCatalog.completeInstallation({ ...origin, now: 11 });
await recordPendingOrigin();
await f.store.skillHubCatalog.completeInstallation({
...origin,
installedRevision: "sha256:1111111111111111",
now: 11,
});
};
return { ...f, room, request, readJsonBody, candidate, recordOrigin };
return { ...f, room, request, readJsonBody, candidate, recordOrigin, recordPendingOrigin };
}

describe("Skill Hub internal HTTP boundaries", () => {
it("explains incomplete room installations without exposing undiscovered skill identities", async () => {
const f = await fixture();
await f.recordPendingOrigin();
const roomInstallKey = `${f.room.agentId}/platform-server`;
f.installed.set(roomInstallKey, {
slug: "demo-skill",
version: "1.0.0",
revision: "sha256:1111111111111111",
});
const installed = (locale: "en" | "ko", room = true) =>
f.request(
{
accountId: f.actor.user.accountId,
args: "installed",
locale,
context: {
accountId: "default",
conversationType: room ? "room" : "dm",
conversationId: room ? "room-one" : "42",
agentId: room ? f.room.agentId : f.actor.agentId,
sessionKey: `agent:${room ? f.room.agentId : f.actor.agentId}:main`,
},
},
{ path: commandPath },
);
expect(await installed("ko")).toMatchObject({
status: 200,
body: { text: "설치된 스킬\ndemo-skill · 설치 기록 미완료로 비활성" },
});
expect(await installed("en")).toMatchObject({
status: 200,
body: {
text: "Installed skills\ndemo-skill · Inactive because the installation record is incomplete",
},
});
expect(await f.request({ agentId: f.room.agentId, candidates: [f.candidate] })).toMatchObject({
status: 200,
body: {
excludedPaths: [f.candidate.filePath],
excludedSourceDirectories: [path.dirname(f.candidate.filePath)],
},
});
f.installed.set(`${f.actor.agentId}/platform-server`, {
slug: "demo-skill",
version: "1.0.0",
revision: "sha256:1111111111111111",
});
expect(await installed("ko", false)).toMatchObject({
status: 200,
body: { text: "설치된 스킬\ndemo-skill · 1.0.0" },
});
f.installed.delete(roomInstallKey);
expect(await installed("ko")).toMatchObject({
status: 200,
body: { text: "설치된 스킬\n설치 기록이 미완료인 비활성 스킬 1개" },
});
});

it.each(["en", "ko"] as const)(
"preserves a linked-account 401 and its diagnostic in %s",
async (locale) => {
Expand Down Expand Up @@ -162,6 +225,15 @@ describe("Skill Hub internal HTTP boundaries", () => {
);
expect(status.status).toBe(200);
expect(status.body.items).toEqual([expect.not.objectContaining({ hubSkillId: f.entry!.id })]);
expect(
await f.request(
{ accountId: f.actor.user.accountId, args: "installed", locale: "ko", context },
{ path: commandPath },
),
).toMatchObject({
status: 200,
body: { text: "설치된 스킬\ndemo-skill · 공유방 스킬 정책으로 비활성" },
});
expect(await f.request({ agentId: f.room.agentId, candidates: [f.candidate] })).toMatchObject({
status: 200,
body: {
Expand Down
12 changes: 10 additions & 2 deletions packages/platformclaw-control-plane/src/skill-hub-catalog-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export type SkillHubCatalogInstallation = {
state: "pending" | "completed";
};

export type SkillHubCatalogMigration = "sharing-v1" | "legacy-room-installations-v1";

type CatalogMutation = {
skillId: string;
actorUserId: string;
Expand Down Expand Up @@ -109,6 +111,12 @@ export interface SkillHubCatalogStore {
> & { installedRevision?: string; now: number },
): Promise<void>;
listInstallations(agentId: string, targetId: string): Promise<SkillHubCatalogInstallation[]>;
listPendingLegacyRoomInstallations(): Promise<SkillHubCatalogInstallation[]>;
recoverLegacyRoomInstallation(
origin: SkillHubCatalogInstallation,
installedRevision: string,
expectedCatalogRevision: number,
): Promise<boolean>;
roomExcludedSlugs(agentId: string): Promise<string[]>;
removeInstallation(params: {
agentId: string;
Expand Down Expand Up @@ -220,6 +228,6 @@ export interface SkillHubCatalogStore {
now: number;
}): Promise<SkillHubCatalogEntry>;
listLegacyCoordinates(): Promise<Array<{ namespace: string; slug: string }>>;
migrationComplete(): Promise<boolean>;
finishMigration(now: number): Promise<void>;
migrationComplete(id?: SkillHubCatalogMigration): Promise<boolean>;
finishMigration(now: number, id?: SkillHubCatalogMigration): Promise<void>;
}
Loading