@opengeni/worker-bundle 0.27.0-canary.0 → 0.27.2-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/activities/agent-turn/agent-build.d.ts +3 -4
  2. package/dist/activities/agent-turn/governance-model.d.ts +2 -3
  3. package/dist/activities/agent-turn/skill-checkout.d.ts +21 -0
  4. package/dist/activities/agent-turn/skill-install.d.ts +12 -0
  5. package/dist/activities/agent-turn/skill-read.d.ts +15 -0
  6. package/dist/activities/agent-turn/skill-save.d.ts +26 -0
  7. package/dist/activities/agent-turn/skill-search.d.ts +16 -0
  8. package/dist/activities/agent-turn/skill-selection.d.ts +17 -0
  9. package/dist/activities/agent-turn/skill-tools.d.ts +20 -0
  10. package/dist/activities/agent-turn/skill-transfer.d.ts +26 -0
  11. package/dist/activities/agent-turn/tool-environment.d.ts +7 -0
  12. package/dist/activities/parent-wake.d.ts +12 -2
  13. package/dist/activities/run-input.d.ts +11 -5
  14. package/dist/activities/sandbox-lease.d.ts +5 -4
  15. package/dist/activities/types.d.ts +4 -2
  16. package/dist/activities/workflow-wake.d.ts +2 -4
  17. package/dist/{activities-control-VMA3XMHE.js → activities-control-WZRFEIJH.js} +103 -26
  18. package/dist/activities-control-WZRFEIJH.js.map +1 -0
  19. package/dist/{activities-turn-57AOUNYN.js → activities-turn-V7J34ZOF.js} +945 -532
  20. package/dist/activities-turn-V7J34ZOF.js.map +1 -0
  21. package/dist/{chunk-D4ZEFOYH.js → chunk-BARTLVBK.js} +1 -1
  22. package/dist/chunk-BARTLVBK.js.map +1 -0
  23. package/dist/{chunk-7LS42PHP.js → chunk-C54VDT6I.js} +590 -34
  24. package/dist/chunk-C54VDT6I.js.map +1 -0
  25. package/dist/index.js +6 -4
  26. package/dist/index.js.map +1 -1
  27. package/dist/model-context-contributions.d.ts +2 -0
  28. package/dist/observability-metrics.d.ts +1 -1
  29. package/dist/workflow-bundle.js +1 -1
  30. package/package.json +17 -17
  31. package/src/activities/agent-turn/agent-build.ts +8 -34
  32. package/src/activities/agent-turn/errors.ts +4 -0
  33. package/src/activities/agent-turn/governance-model.ts +15 -19
  34. package/src/activities/agent-turn/run.ts +4 -6
  35. package/src/activities/agent-turn/sandbox-provision.ts +9 -0
  36. package/src/activities/agent-turn/skill-checkout.ts +124 -0
  37. package/src/activities/agent-turn/skill-install.ts +57 -0
  38. package/src/activities/agent-turn/skill-read.ts +117 -0
  39. package/src/activities/agent-turn/skill-save.ts +124 -0
  40. package/src/activities/agent-turn/skill-search.ts +110 -0
  41. package/src/activities/agent-turn/skill-selection.ts +53 -0
  42. package/src/activities/agent-turn/skill-tools.ts +268 -0
  43. package/src/activities/agent-turn/skill-transfer.ts +137 -0
  44. package/src/activities/agent-turn/tool-environment.ts +93 -1
  45. package/src/activities/automations.ts +3 -1
  46. package/src/activities/context-compaction.ts +26 -2
  47. package/src/activities/parent-wake.ts +66 -5
  48. package/src/activities/run-input.ts +88 -12
  49. package/src/activities/sandbox-lease.ts +77 -20
  50. package/src/activities/scheduled-tasks.ts +2 -0
  51. package/src/activities/types.ts +4 -2
  52. package/src/activities/workflow-wake.ts +42 -5
  53. package/src/index.ts +3 -1
  54. package/src/model-context-contributions.ts +18 -2
  55. package/src/observability-metrics.ts +2 -0
  56. package/src/sandbox-routing.ts +42 -1
  57. package/dist/activities-control-VMA3XMHE.js.map +0 -1
  58. package/dist/activities-turn-57AOUNYN.js.map +0 -1
  59. package/dist/chunk-7LS42PHP.js.map +0 -1
  60. package/dist/chunk-D4ZEFOYH.js.map +0 -1
@@ -0,0 +1,124 @@
1
+ import type { AttemptToolDefinition } from "@opengeni/codemode";
2
+ import {
3
+ applySkillFileChanges,
4
+ buildPortableSkillArtifact,
5
+ PORTABLE_SKILL_MAX_FILES,
6
+ type SkillTextFile,
7
+ } from "@opengeni/runtime/skill-library";
8
+
9
+ export type SkillSaveRequest = {
10
+ operationId: string;
11
+ skillId: string;
12
+ expectedRevisionId: string | null;
13
+ expectedScopeVersion: number;
14
+ files: readonly SkillTextFile[];
15
+ reason: string;
16
+ };
17
+
18
+ export type SkillSaveReceipt = {
19
+ operationId: string;
20
+ skillId: string;
21
+ revisionId: string;
22
+ outcome: "applied" | "pending" | "preserved";
23
+ replayed: boolean;
24
+ };
25
+
26
+ /** Persistence owns mode enforcement, replay, and the final compare-and-swap. */
27
+ export function createSkillSaveAttemptToolDefinition(input: {
28
+ authorize: () => Promise<void>;
29
+ load: (
30
+ skillId: string,
31
+ revisionId: string,
32
+ ) => Promise<{
33
+ revisionId: string;
34
+ files: readonly SkillTextFile[];
35
+ }>;
36
+ save: (request: SkillSaveRequest) => Promise<SkillSaveReceipt>;
37
+ }): AttemptToolDefinition {
38
+ return {
39
+ identity: { serverId: "opengeni", toolName: "skill_save" },
40
+ modelName: "skill_save",
41
+ codemodePath: ["opengeni", "skill_save"],
42
+ title: "Save Skill text",
43
+ description:
44
+ "Create or edit a workspace Skill without a sandbox. Supply only changed UTF-8 text files and explicit deletions; omitted files are preserved. Use the revision and scope version returned by Skill discovery for stale-write protection. For creation choose a new UUID, set expectedRevisionId to null and expectedScopeVersion to 1. Learning mode determines whether the change is live, pending approval, or refused. If humanInput is returned, call request_human_input verbatim, Save activates the exact reviewed revision as the answer is accepted. Do not ask for a second review or call a separate activation tool.",
45
+ inputSchema: {
46
+ type: "object",
47
+ properties: {
48
+ operationId: { type: "string", format: "uuid" },
49
+ skillId: { type: "string", format: "uuid" },
50
+ expectedRevisionId: { type: ["string", "null"], format: "uuid" },
51
+ expectedScopeVersion: { type: "integer", minimum: 0 },
52
+ files: {
53
+ type: "array",
54
+ maxItems: PORTABLE_SKILL_MAX_FILES,
55
+ items: {
56
+ type: "object",
57
+ properties: {
58
+ path: { type: "string", minLength: 1, maxLength: 1024 },
59
+ content: { type: "string" },
60
+ },
61
+ required: ["path", "content"],
62
+ additionalProperties: false,
63
+ },
64
+ },
65
+ deletions: {
66
+ type: "array",
67
+ maxItems: PORTABLE_SKILL_MAX_FILES,
68
+ uniqueItems: true,
69
+ items: { type: "string", minLength: 1, maxLength: 1024 },
70
+ },
71
+ reason: { type: "string", minLength: 1, maxLength: 2000 },
72
+ },
73
+ required: [
74
+ "operationId",
75
+ "skillId",
76
+ "expectedRevisionId",
77
+ "expectedScopeVersion",
78
+ "files",
79
+ "reason",
80
+ ],
81
+ additionalProperties: false,
82
+ },
83
+ annotations: {
84
+ title: "Save Skill text",
85
+ readOnlyHint: false,
86
+ destructiveHint: false,
87
+ idempotentHint: true,
88
+ openWorldHint: false,
89
+ },
90
+ source: "opengeni",
91
+ approval: "none",
92
+ execute: async (args) => {
93
+ await input.authorize();
94
+ const skillId = args.skillId as string;
95
+ const expectedRevisionId = args.expectedRevisionId as string | null;
96
+ // Expand the edit against its immutable base, not today's head. The
97
+ // lifecycle can then replay an already committed operation before CAS.
98
+ const current =
99
+ expectedRevisionId === null ? null : await input.load(skillId, expectedRevisionId);
100
+ if (current && current.revisionId !== expectedRevisionId) {
101
+ throw new Error("Skill edit base did not match the requested revision.");
102
+ }
103
+ const files = applySkillFileChanges(
104
+ current?.files ?? [],
105
+ args.files as SkillTextFile[],
106
+ (args.deletions ?? []) as string[],
107
+ );
108
+ const artifact = buildPortableSkillArtifact(files);
109
+ const output = await input.save({
110
+ operationId: args.operationId as string,
111
+ skillId,
112
+ expectedRevisionId,
113
+ expectedScopeVersion: args.expectedScopeVersion as number,
114
+ files: artifact.files,
115
+ reason: args.reason as string,
116
+ });
117
+ return {
118
+ isError: false,
119
+ content: [{ type: "text", text: JSON.stringify(output) }],
120
+ structuredContent: output,
121
+ };
122
+ },
123
+ };
124
+ }
@@ -0,0 +1,110 @@
1
+ import type { AttemptToolDefinition } from "@opengeni/codemode";
2
+ import { PublicSkillSearchError, type PublicSkillSearchClient } from "@opengeni/core";
3
+ import { listSkillLibraryEntries } from "@opengeni/runtime/skill-library";
4
+
5
+ export type WorkspaceSkillSearchEntry = Readonly<{
6
+ id: string;
7
+ name: string;
8
+ description: string;
9
+ revisionId?: string;
10
+ scopeVersion?: number;
11
+ installationVersion?: number;
12
+ source?: "workspace" | "builtin" | "session" | "pack";
13
+ }>;
14
+
15
+ export function createSkillSearchAttemptToolDefinition(input: {
16
+ authorize: () => Promise<void>;
17
+ listWorkspace: () => Promise<readonly WorkspaceSkillSearchEntry[]>;
18
+ publicSearch: PublicSkillSearchClient;
19
+ }): AttemptToolDefinition {
20
+ return {
21
+ identity: { serverId: "opengeni", toolName: "skill_search" },
22
+ modelName: "skill_search",
23
+ codemodePath: ["opengeni", "skill_search"],
24
+ title: "Search Skills",
25
+ description:
26
+ "Find installed workspace Skills or available curated and public Skills. Returns identifiers and install sources, not file contents. Search never installs or starts a sandbox. Scope defaults to all; use installed to avoid external search.",
27
+ inputSchema: {
28
+ type: "object",
29
+ properties: {
30
+ query: { type: "string", minLength: 2, maxLength: 200 },
31
+ scope: { type: "string", enum: ["installed", "catalog", "all"] },
32
+ limit: { type: "integer", minimum: 1, maximum: 20 },
33
+ },
34
+ required: ["query"],
35
+ additionalProperties: false,
36
+ },
37
+ annotations: {
38
+ title: "Search Skills",
39
+ readOnlyHint: true,
40
+ destructiveHint: false,
41
+ idempotentHint: true,
42
+ openWorldHint: true,
43
+ },
44
+ source: "opengeni",
45
+ approval: "none",
46
+ execute: async (args) => {
47
+ await input.authorize();
48
+ const query = String(args.query).trim();
49
+ const scope = args.scope ?? "all";
50
+ const limit = typeof args.limit === "number" ? args.limit : 20;
51
+ const searchText = query.toLowerCase();
52
+ const matches = (entry: { name: string; description: string }) =>
53
+ `${entry.name}\n${entry.description}`.toLowerCase().includes(searchText);
54
+ const installed = scope === "catalog" ? [] : await input.listWorkspace();
55
+ const workspaceHits = installed
56
+ .filter(matches)
57
+ .slice(0, limit)
58
+ .map((entry) => ({
59
+ ...entry,
60
+ source: entry.source ?? "workspace",
61
+ installed: !entry.source || entry.source === "workspace",
62
+ }));
63
+ const libraryHits =
64
+ scope === "installed"
65
+ ? []
66
+ : listSkillLibraryEntries()
67
+ .filter(matches)
68
+ .slice(0, limit)
69
+ .map((entry) => ({
70
+ id: `library:${entry.id}`,
71
+ name: entry.name,
72
+ description: entry.description,
73
+ source: "library" as const,
74
+ libraryId: entry.id,
75
+ }));
76
+ let publicResult: Awaited<ReturnType<PublicSkillSearchClient["search"]>> | null = null;
77
+ let publicError: {
78
+ source: "skills_sh";
79
+ code: string;
80
+ retryAfterSeconds: number | null;
81
+ } | null = null;
82
+ if (scope !== "installed") {
83
+ try {
84
+ publicResult = await input.publicSearch.search({ query, limit });
85
+ } catch (error) {
86
+ if (!(error instanceof PublicSkillSearchError)) throw error;
87
+ publicError = {
88
+ source: "skills_sh",
89
+ code: error.code,
90
+ retryAfterSeconds: error.retryAfterSeconds,
91
+ };
92
+ }
93
+ }
94
+ const output = {
95
+ workspace: workspaceHits,
96
+ library: libraryHits,
97
+ public: publicResult?.items.map((entry) => ({ ...entry })) ?? [],
98
+ // A provider outage is not an empty search. Keep local results useful
99
+ // and expose partial failure explicitly, without leaking network details.
100
+ partial: publicError !== null,
101
+ errors: publicError ? [publicError] : [],
102
+ };
103
+ return {
104
+ isError: false,
105
+ content: [{ type: "text", text: JSON.stringify(output) }],
106
+ structuredContent: output,
107
+ };
108
+ },
109
+ };
110
+ }
@@ -0,0 +1,53 @@
1
+ import { loadNativeToolSkillArtifacts } from "@opengeni/runtime";
2
+ import { loadSkillManagementSkill } from "@opengeni/runtime/skill-library";
3
+ import type { BundledSkillId } from "@opengeni/contracts";
4
+
5
+ export type BundledSkillConfiguration = {
6
+ /** Already resolved names, not discovered schemas or an attempt catalog. */
7
+ firstPartyTools: readonly string[];
8
+ videoGenerationEnabled: boolean;
9
+ bundledSkillIds?: readonly BundledSkillId[] | undefined;
10
+ };
11
+
12
+ /** Individual ordinary-code defaults, evaluated without async tool preparation. */
13
+ export function configuredBundledSkillNames(context: BundledSkillConfiguration): string[] {
14
+ const tools = new Set(context.firstPartyTools);
15
+ const artifacts = () => tools.has("editable_artifact_list") && tools.has("editable_artifact_get");
16
+ const definitions = [
17
+ { name: "opengeni-skills", include: () => true },
18
+ { name: "opengeni-projects", include: () => true },
19
+ { name: "opengeni-documents", include: artifacts },
20
+ { name: "opengeni-spreadsheets", include: artifacts },
21
+ { name: "opengeni-presentations", include: artifacts },
22
+ {
23
+ name: "opengeni-sites",
24
+ include: () => tools.has("artifacts_create") && tools.has("artifacts_publish"),
25
+ },
26
+ { name: "opengeni-video-generation", include: () => context.videoGenerationEnabled },
27
+ ];
28
+ return definitions
29
+ .filter(
30
+ (definition) =>
31
+ definition.include() &&
32
+ (context.bundledSkillIds === undefined ||
33
+ context.bundledSkillIds.includes(`builtin:${definition.name}` as BundledSkillId)),
34
+ )
35
+ .map((definition) => definition.name);
36
+ }
37
+
38
+ export function loadConfiguredBundledSkills(context: BundledSkillConfiguration) {
39
+ const names = new Set(configuredBundledSkillNames(context));
40
+ const artifacts = loadNativeToolSkillArtifacts({
41
+ projects: names.has("opengeni-projects"),
42
+ editableArtifacts: [
43
+ "opengeni-documents",
44
+ "opengeni-spreadsheets",
45
+ "opengeni-presentations",
46
+ ].some((name) => names.has(name)),
47
+ sites: names.has("opengeni-sites"),
48
+ videoGeneration: names.has("opengeni-video-generation"),
49
+ });
50
+ return [...artifacts, ...(names.has("opengeni-skills") ? [loadSkillManagementSkill()] : [])]
51
+ .filter((artifact) => names.has(artifact.name))
52
+ .map((artifact) => ({ id: `builtin:${artifact.name}`, artifact }));
53
+ }
@@ -0,0 +1,268 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { Settings } from "@opengeni/config";
3
+ import {
4
+ skillReviewHumanInput,
5
+ type SkillActor,
6
+ type SkillWriteReceipt,
7
+ } from "@opengeni/contracts";
8
+ import {
9
+ assertSkillReadAttempt,
10
+ skillReviewResolution,
11
+ installPortableSkill,
12
+ replayPortableSkillInstall,
13
+ listSkillDescriptors,
14
+ type Database,
15
+ type InstallPortableSkillInput,
16
+ } from "@opengeni/db";
17
+ import {
18
+ createGitHubSkillSourceClient,
19
+ createPublicSkillSearchClient,
20
+ portableSkillCapabilityId,
21
+ portableSkillPluginKey,
22
+ readSkill,
23
+ resolveSkillImport,
24
+ saveSkill,
25
+ } from "@opengeni/core";
26
+ import {
27
+ buildPortableSkillArtifact,
28
+ loadSkillLibrarySkill,
29
+ skillLibraryRepositoryUrl,
30
+ type SkillTextFile,
31
+ } from "@opengeni/runtime/skill-library";
32
+ import type { RuntimeSkillArtifact } from "@opengeni/runtime";
33
+ import type { SandboxChannelAService } from "@opengeni/runtime/sandbox";
34
+ import { createSkillReadAttemptToolDefinition, type SkillReadContent } from "./skill-read";
35
+ import { createSkillSearchAttemptToolDefinition } from "./skill-search";
36
+ import { createSkillSaveAttemptToolDefinition, type SkillSaveRequest } from "./skill-save";
37
+ import { createSkillInstallAttemptToolDefinition } from "./skill-install";
38
+ import {
39
+ createSkillCheckoutAttemptToolDefinition,
40
+ createSkillPublishAttemptToolDefinition,
41
+ } from "./skill-checkout";
42
+
43
+ export function createWorkspaceSkillTools(input: {
44
+ db: Database;
45
+ settings: Settings;
46
+ accountId: string;
47
+ workspaceId: string;
48
+ subjectId?: string;
49
+ actor: Extract<SkillActor, { kind: "agent" }>;
50
+ selected: readonly { id: string; artifact: RuntimeSkillArtifact }[];
51
+ filesystem: () => Promise<
52
+ Pick<SandboxChannelAService, "fsList" | "fsRead" | "fsWrite" | "fsMkdir">
53
+ >;
54
+ }) {
55
+ const context = {
56
+ accountId: input.accountId,
57
+ workspaceId: input.workspaceId,
58
+ ...(input.subjectId ? { subjectId: input.subjectId } : {}),
59
+ };
60
+ const authorize = () => assertSkillReadAttempt(input.db, { ...context, actor: input.actor });
61
+ const selected = new Map(input.selected.map((entry) => [entry.id, entry.artifact]));
62
+ const list = async () =>
63
+ (await listSkillDescriptors(input.db, context)).filter(
64
+ (entry) => entry.activationMode === "workspace_managed",
65
+ );
66
+ // Both text reads and inventory resolve through this same authorized source.
67
+ // Selected artifacts have no ledger revision identity; never synthesize one.
68
+ const load = async (identifier: string): Promise<readonly SkillTextFile[] | SkillReadContent> => {
69
+ const exact = selected.get(identifier);
70
+ if (exact) return exact.files;
71
+ const descriptors = await list();
72
+ const exactWorkspace = descriptors.find((entry) => entry.id === identifier);
73
+ const matches = exactWorkspace
74
+ ? [exactWorkspace]
75
+ : descriptors.filter((entry) => entry.title === identifier || entry.stableKey === identifier);
76
+ const selectedMatches = [...selected.entries()].filter(
77
+ ([, artifact]) => artifact.name === identifier,
78
+ );
79
+ if (!exactWorkspace && matches.length + selectedMatches.length > 1)
80
+ throw new Error(
81
+ "Multiple Skills match that name. Use the Skill id from the index or search.",
82
+ );
83
+ if (!matches.length && selectedMatches.length === 1) return selectedMatches[0]![1].files;
84
+ const match = matches[0];
85
+ if (!match) throw new Error("Skill is not available in this session.");
86
+ const record = await readSkill(input.db, context, match.id);
87
+ if (
88
+ !record ||
89
+ record.status !== "active" ||
90
+ !record.activeRevisionId ||
91
+ record.revisionId !== record.activeRevisionId
92
+ )
93
+ throw new Error("Skill is no longer active. Search again for current Skills.");
94
+ return {
95
+ skillId: record.id,
96
+ revisionId: record.activeRevisionId,
97
+ scopeVersion: record.scopeVersion,
98
+ ...(match.installationVersion !== null
99
+ ? { installationVersion: match.installationVersion }
100
+ : {}),
101
+ files: record.files,
102
+ };
103
+ };
104
+ const withConfirmation = async (receipt: SkillWriteReceipt) => {
105
+ const reviewResolution = receipt.skillReview
106
+ ? await skillReviewResolution(input.db, context, receipt.skillReview)
107
+ : undefined;
108
+ return {
109
+ ...receipt,
110
+ ...(reviewResolution ? { reviewResolution } : {}),
111
+ ...(receipt.skillReview && reviewResolution === "pending"
112
+ ? { humanInput: skillReviewHumanInput(receipt.skillReview) }
113
+ : {}),
114
+ };
115
+ };
116
+ const save = async (request: SkillSaveRequest) => {
117
+ const artifact = buildPortableSkillArtifact(request.files);
118
+ const base = request.expectedRevisionId
119
+ ? await readSkill(input.db, context, request.skillId, request.expectedRevisionId)
120
+ : null;
121
+ return withConfirmation(
122
+ await saveSkill(input.db, {
123
+ ...context,
124
+ ...request,
125
+ actor: input.actor,
126
+ files: [...artifact.files],
127
+ stableKey: base?.stableKey ?? `authored-${request.skillId.replaceAll("-", "")}`,
128
+ }),
129
+ );
130
+ };
131
+ return [
132
+ createSkillReadAttemptToolDefinition({ authorize, load }),
133
+ createSkillSearchAttemptToolDefinition({
134
+ authorize,
135
+ listWorkspace: async () => [
136
+ ...(await list()).map((entry) => ({
137
+ id: entry.id,
138
+ name: entry.title,
139
+ description: entry.description,
140
+ revisionId: entry.revisionId,
141
+ scopeVersion: entry.scopeVersion,
142
+ ...(entry.installationVersion !== null
143
+ ? { installationVersion: entry.installationVersion }
144
+ : {}),
145
+ })),
146
+ ...input.selected.map((entry) => ({
147
+ id: entry.id,
148
+ name: entry.artifact.name,
149
+ description: entry.artifact.description || entry.artifact.name,
150
+ source: entry.id.startsWith("builtin:")
151
+ ? ("builtin" as const)
152
+ : entry.id.startsWith("session:")
153
+ ? ("session" as const)
154
+ : ("pack" as const),
155
+ })),
156
+ ],
157
+ publicSearch: createPublicSkillSearchClient(input.settings),
158
+ }),
159
+ createSkillSaveAttemptToolDefinition({
160
+ authorize,
161
+ save,
162
+ load: async (skillId, revisionId) => {
163
+ const record = await readSkill(input.db, context, skillId, revisionId);
164
+ if (!record?.revisionId) throw new Error("Skill edit base is unavailable.");
165
+ return { revisionId: record.revisionId, files: record.files };
166
+ },
167
+ }),
168
+ createSkillInstallAttemptToolDefinition({
169
+ authorize,
170
+ install: async (request) => {
171
+ const requestIdentity = {
172
+ operation: "skill_install",
173
+ source: request.source,
174
+ expectedInstallationVersion: request.expectedInstallationVersion ?? null,
175
+ reason: request.reason,
176
+ owner: "direct",
177
+ };
178
+ const replay = await replayPortableSkillInstall(input.db, {
179
+ ...context,
180
+ actor: input.actor,
181
+ operationId: request.operationId,
182
+ requestIdentity,
183
+ });
184
+ if (replay) return withConfirmation(replay.skillReceipt);
185
+ let source: Omit<InstallPortableSkillInput, "accountId" | "workspaceId" | "subjectId">;
186
+ if (request.source.startsWith("library:")) {
187
+ const loaded = loadSkillLibrarySkill(request.source.slice("library:".length));
188
+ source = {
189
+ capabilityId: `skill:${loaded.entry.id}`,
190
+ pluginKey: `skill/library/${loaded.entry.id}`,
191
+ source: "library",
192
+ sourceUrl: loaded.entry.sourceUrl,
193
+ repositoryUrl: skillLibraryRepositoryUrl(loaded.entry.sourceUrl),
194
+ version: loaded.entry.version,
195
+ sourceCommit: loaded.entry.sourceCommit,
196
+ sourcePath: loaded.entry.relativePath,
197
+ name: loaded.entry.name,
198
+ description: loaded.entry.description,
199
+ contentSha256: loaded.entry.contentSha256,
200
+ totalBytes: loaded.skill.files.reduce(
201
+ (total, file) => total + Buffer.byteLength(file.content),
202
+ 0,
203
+ ),
204
+ files: fileMetadata(loaded.skill.files),
205
+ provenance: "platform",
206
+ sourceProvenance: loaded.entry.provenance,
207
+ category: loaded.entry.category,
208
+ tags: [...loaded.entry.tags],
209
+ license: loaded.entry.license,
210
+ };
211
+ } else {
212
+ const resolved = await resolveSkillImport(
213
+ request.source,
214
+ createGitHubSkillSourceClient(input.settings),
215
+ );
216
+ source = {
217
+ capabilityId: portableSkillCapabilityId(resolved.preview),
218
+ pluginKey: portableSkillPluginKey(resolved.preview),
219
+ source: resolved.preview.source,
220
+ sourceUrl: resolved.preview.sourceUrl,
221
+ repositoryUrl: resolved.preview.repositoryUrl,
222
+ version: resolved.preview.sourceCommit,
223
+ sourceCommit: resolved.preview.sourceCommit,
224
+ sourcePath: resolved.preview.sourcePath,
225
+ name: resolved.preview.name,
226
+ description: resolved.preview.description,
227
+ contentSha256: resolved.preview.contentSha256,
228
+ totalBytes: resolved.preview.totalBytes,
229
+ files: fileMetadata(resolved.files),
230
+ };
231
+ }
232
+ const installed = await installPortableSkill(input.db, {
233
+ ...context,
234
+ ...source,
235
+ subjectId: `service:skill-attempt:${input.actor.attemptId}`,
236
+ skillActor: input.actor,
237
+ skillOperationId: request.operationId,
238
+ skillRequestIdentity: requestIdentity,
239
+ ...(request.expectedInstallationVersion !== undefined
240
+ ? {
241
+ expectedInstallationVersion: request.expectedInstallationVersion,
242
+ }
243
+ : {}),
244
+ });
245
+ return withConfirmation(installed.skillReceipt);
246
+ },
247
+ }),
248
+ createSkillCheckoutAttemptToolDefinition({
249
+ authorize,
250
+ filesystem: input.filesystem,
251
+ load: async (skill) => {
252
+ const content = await load(skill);
253
+ if (!("files" in content))
254
+ return { skillId: skill, revisionId: null, scopeVersion: null, files: content };
255
+ return content;
256
+ },
257
+ }),
258
+ createSkillPublishAttemptToolDefinition({ authorize, filesystem: input.filesystem, save }),
259
+ ];
260
+ }
261
+
262
+ function fileMetadata(files: readonly SkillTextFile[]) {
263
+ return files.map((file) => ({
264
+ ...file,
265
+ byteSize: Buffer.byteLength(file.content),
266
+ contentSha256: createHash("sha256").update(file.content, "utf8").digest("hex"),
267
+ }));
268
+ }
@@ -0,0 +1,137 @@
1
+ import { posix } from "node:path";
2
+ import type { SandboxChannelAService } from "@opengeni/runtime/sandbox";
3
+ import {
4
+ assertSkillRelativePath,
5
+ buildPortableSkillArtifact,
6
+ PORTABLE_SKILL_MAX_FILES,
7
+ PORTABLE_SKILL_MAX_FILE_BYTES,
8
+ PORTABLE_SKILL_MAX_TOTAL_BYTES,
9
+ type SkillTextFile,
10
+ } from "@opengeni/runtime/skill-library";
11
+
12
+ type SkillFileSystem = Pick<SandboxChannelAService, "fsList" | "fsRead" | "fsWrite" | "fsMkdir">;
13
+ const maxTraversalEntries = 1024;
14
+
15
+ /** Reuse the host's cancellation and workspace-mutation authority for each filesystem operation. */
16
+ export function guardSkillFilesystem(
17
+ fs: SkillFileSystem,
18
+ authority: {
19
+ assertActive: () => void;
20
+ runMutation: <T>(operation: () => Promise<T>) => Promise<T>;
21
+ },
22
+ ): SkillFileSystem {
23
+ const read = <T>(operation: () => Promise<T>): Promise<T> => {
24
+ authority.assertActive();
25
+ return operation();
26
+ };
27
+ const write = <T>(operation: () => Promise<T>): Promise<T> => {
28
+ authority.assertActive();
29
+ return authority.runMutation(() => read(operation));
30
+ };
31
+ return {
32
+ fsList: (request) => read(() => fs.fsList(request)),
33
+ fsRead: (request) => read(() => fs.fsRead(request)),
34
+ fsWrite: (request) => write(() => fs.fsWrite(request)),
35
+ fsMkdir: (request) => write(() => fs.fsMkdir(request)),
36
+ };
37
+ }
38
+
39
+ /** Caller supplies an authorized live filesystem and a fresh workspace-relative target. */
40
+ export async function checkoutSkillDirectory(
41
+ fs: SkillFileSystem,
42
+ directory: string,
43
+ files: readonly SkillTextFile[],
44
+ ): Promise<{ directory: string; fileCount: number }> {
45
+ assertSkillRelativePath(directory);
46
+ const artifact = buildPortableSkillArtifact(files);
47
+ const parent = posix.dirname(directory);
48
+ if (parent !== ".") await fs.fsMkdir({ path: parent, recursive: true });
49
+ // Never overwrite an earlier checkout (which may now contain the user's edits).
50
+ await fs.fsMkdir({ path: directory, recursive: false });
51
+ for (const file of artifact.files) {
52
+ const result = await fs.fsWrite({
53
+ path: `${directory}/${file.path}`,
54
+ content: file.content,
55
+ encoding: "utf8",
56
+ overwrite: false,
57
+ createParents: true,
58
+ });
59
+ if (result.sizeBytes !== new TextEncoder().encode(file.content).byteLength) {
60
+ throw new Error(`Skill checkout wrote an unexpected byte count: ${file.path}`);
61
+ }
62
+ }
63
+ return { directory, fileCount: artifact.files.length };
64
+ }
65
+
66
+ /**
67
+ * Read a folder through existing structured filesystem services, never via model
68
+ * arguments. The caller owns workspace confinement and the final governed save.
69
+ * This operation does not execute scripts or activate the resulting artifact.
70
+ */
71
+ export async function readSkillDirectory(fs: SkillFileSystem, directory: string) {
72
+ assertSkillRelativePath(directory);
73
+ const pending = [directory];
74
+ const seen = new Set<string>(pending);
75
+ const files: SkillTextFile[] = [];
76
+ let entries = 0;
77
+ let totalBytes = 0;
78
+ while (pending.length) {
79
+ const current = pending.shift()!;
80
+ const listing = await fs.fsList({
81
+ path: current,
82
+ depth: 1,
83
+ maxEntries: maxTraversalEntries + 1,
84
+ includeHidden: true,
85
+ });
86
+ if (listing.root.type !== "dir" || listing.root.path !== current) {
87
+ throw new Error(`Expected a real Skill directory: ${current}`);
88
+ }
89
+ if (listing.truncated || listing.root.truncated || !listing.root.children) {
90
+ throw new Error(`Skill directory listing is incomplete: ${current}`);
91
+ }
92
+ for (const entry of listing.root.children) {
93
+ assertSkillRelativePath(entry.path);
94
+ if (posix.dirname(entry.path) !== current || posix.basename(entry.path) !== entry.name) {
95
+ throw new Error("Skill directory listing returned a path outside the requested directory.");
96
+ }
97
+ if (seen.has(entry.path)) throw new Error(`Duplicate Skill directory entry: ${entry.path}`);
98
+ seen.add(entry.path);
99
+ if (++entries > maxTraversalEntries) throw new Error("Skill directory has too many entries.");
100
+ if (entry.type === "dir") {
101
+ pending.push(entry.path);
102
+ continue;
103
+ }
104
+ if (entry.type !== "file") throw new Error(`Unsupported Skill file type: ${entry.path}`);
105
+ if (files.length >= PORTABLE_SKILL_MAX_FILES) throw new Error("Skill has too many files.");
106
+ if (entry.sizeBytes !== null && entry.sizeBytes > PORTABLE_SKILL_MAX_FILE_BYTES) {
107
+ throw new Error(`Skill file exceeds the size limit: ${entry.path}`);
108
+ }
109
+ const read = await fs.fsRead({
110
+ path: entry.path,
111
+ encoding: "base64",
112
+ maxBytes: PORTABLE_SKILL_MAX_FILE_BYTES + 1,
113
+ });
114
+ if (read.truncated || read.sizeBytes > PORTABLE_SKILL_MAX_FILE_BYTES) {
115
+ throw new Error(`Skill file exceeds the size limit: ${entry.path}`);
116
+ }
117
+ if (read.encoding !== "base64" || read.path !== entry.path) {
118
+ throw new Error(`Invalid Skill file read response: ${entry.path}`);
119
+ }
120
+ const bytes = Buffer.from(read.content, "base64");
121
+ if (bytes.byteLength !== read.sizeBytes) {
122
+ throw new Error(`Invalid Skill file byte count: ${entry.path}`);
123
+ }
124
+ totalBytes += bytes.byteLength;
125
+ if (totalBytes > PORTABLE_SKILL_MAX_TOTAL_BYTES)
126
+ throw new Error("Skill exceeds the total size limit.");
127
+ let content: string;
128
+ try {
129
+ content = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
130
+ } catch {
131
+ throw new Error(`Skill file is not valid UTF-8 text: ${entry.path}`);
132
+ }
133
+ files.push({ path: entry.path.slice(directory.length + 1), content });
134
+ }
135
+ }
136
+ return buildPortableSkillArtifact(files);
137
+ }