@opengeni/runtime 0.13.12 → 0.14.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.
- package/dist/{chunk-FLRCDJRI.js → chunk-3NUSY22C.js} +6 -6
- package/dist/chunk-3NUSY22C.js.map +1 -0
- package/dist/index.d.ts +15 -3
- package/dist/index.js +282 -9
- package/dist/index.js.map +1 -1
- package/dist/sandbox/index.js +1 -1
- package/package.json +3 -3
- package/src/index.ts +129 -6
- package/src/sandbox/index.ts +5 -5
- package/src/workspace-skills.ts +235 -0
- package/dist/chunk-FLRCDJRI.js.map +0 -1
package/dist/sandbox/index.js
CHANGED
|
@@ -170,7 +170,7 @@ import {
|
|
|
170
170
|
withToolspaceTokenClient,
|
|
171
171
|
withToolspaceTokenEnvironment,
|
|
172
172
|
withToolspaceTokenSession
|
|
173
|
-
} from "../chunk-
|
|
173
|
+
} from "../chunk-3NUSY22C.js";
|
|
174
174
|
import "../chunk-4YJ5Z52Z.js";
|
|
175
175
|
import "../chunk-K4V4QYCL.js";
|
|
176
176
|
import "../chunk-PZ5AY32C.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"@openai/agents-extensions": "0.13.3",
|
|
51
51
|
"@opengeni/agent-proto": "^0.3.0",
|
|
52
52
|
"@opengeni/codex": "^0.2.7",
|
|
53
|
-
"@opengeni/config": "^0.7.
|
|
54
|
-
"@opengeni/contracts": "^0.
|
|
53
|
+
"@opengeni/config": "^0.7.10",
|
|
54
|
+
"@opengeni/contracts": "^0.21.0",
|
|
55
55
|
"@opengeni/network": "^0.1.1",
|
|
56
56
|
"debug": "4.4.3",
|
|
57
57
|
"modal": "0.7.6",
|
package/src/index.ts
CHANGED
|
@@ -187,6 +187,7 @@ import {
|
|
|
187
187
|
} from "./sandbox/turn-tool-cancellation";
|
|
188
188
|
import { computerUse, type ComputerToolMode } from "./sandbox-computer";
|
|
189
189
|
import type { RuntimeMetricsHooks } from "./metrics";
|
|
190
|
+
import { workspaceSkills, type WorkspaceSkillSearchPath } from "./workspace-skills";
|
|
190
191
|
|
|
191
192
|
// The Agents SDK's debug namespaces can otherwise serialize complete model
|
|
192
193
|
// inputs/outputs and tool arguments/results. These getters read process.env on
|
|
@@ -1260,7 +1261,7 @@ export type ToolspaceTokenWriterSession = SandboxSessionLike;
|
|
|
1260
1261
|
export type EffectiveSkillSelection = Readonly<{
|
|
1261
1262
|
id: string;
|
|
1262
1263
|
name: string;
|
|
1263
|
-
source: "bundled" | "library" | "pack";
|
|
1264
|
+
source: "bundled" | "library" | "pack" | "session";
|
|
1264
1265
|
version: string | null;
|
|
1265
1266
|
contentSha256: string | null;
|
|
1266
1267
|
reason: string;
|
|
@@ -1430,6 +1431,9 @@ export type BuildAgentOptions = {
|
|
|
1430
1431
|
// skills in the sandbox skill index (mounted under .agents/) so
|
|
1431
1432
|
// skills/<name> references resolve like any other indexed skill.
|
|
1432
1433
|
packSkills?: PackSkill[];
|
|
1434
|
+
// Inline skills fixed onto this session at creation. They use the SDK's lazy
|
|
1435
|
+
// skill source and are materialized only when the model loads one.
|
|
1436
|
+
sessionSkills?: PackSkill[];
|
|
1433
1437
|
// Explicitly selected, immutable curated-library skill content. These are
|
|
1434
1438
|
// separate from packSkills so repository-local/pack compatibility does not
|
|
1435
1439
|
// turn a curated entry into a mutable override.
|
|
@@ -1460,7 +1464,7 @@ export type PackSkillFile = {
|
|
|
1460
1464
|
|
|
1461
1465
|
export type PackSkill = {
|
|
1462
1466
|
name: string;
|
|
1463
|
-
description?: string | null;
|
|
1467
|
+
description?: string | null | undefined;
|
|
1464
1468
|
files: PackSkillFile[];
|
|
1465
1469
|
};
|
|
1466
1470
|
|
|
@@ -1903,6 +1907,8 @@ export function buildOpenGeniAgent(
|
|
|
1903
1907
|
...(options.skillLibrarySkills?.length
|
|
1904
1908
|
? { skillLibrarySkills: options.skillLibrarySkills }
|
|
1905
1909
|
: {}),
|
|
1910
|
+
...(options.sessionSkills?.length ? { sessionSkills: options.sessionSkills } : {}),
|
|
1911
|
+
...repositoryWorkspaceSkillPathsOption(resources),
|
|
1906
1912
|
...(options.structuredToolTransport !== undefined
|
|
1907
1913
|
? { structuredToolTransport: options.structuredToolTransport }
|
|
1908
1914
|
: {}),
|
|
@@ -1925,6 +1931,7 @@ export function buildOpenGeniAgent(
|
|
|
1925
1931
|
options.skillLibrarySelections ?? [],
|
|
1926
1932
|
options.skillLibrarySkills ?? [],
|
|
1927
1933
|
options.packSkills ?? [],
|
|
1934
|
+
options.sessionSkills ?? [],
|
|
1928
1935
|
).map((selection) => Object.freeze(selection)),
|
|
1929
1936
|
),
|
|
1930
1937
|
);
|
|
@@ -2240,6 +2247,8 @@ export function buildAgentCapabilities(
|
|
|
2240
2247
|
packSkills: PackSkill[],
|
|
2241
2248
|
options: {
|
|
2242
2249
|
skillLibrarySkills?: PackSkill[];
|
|
2250
|
+
sessionSkills?: PackSkill[];
|
|
2251
|
+
workspaceSkillPaths?: readonly WorkspaceSkillSearchPath[];
|
|
2243
2252
|
structuredToolTransport?: boolean;
|
|
2244
2253
|
// EXPLICIT computer-use transport (see BuildAgentOptions.computerToolMode). When
|
|
2245
2254
|
// present, computerUse() is handed the mode directly and its tools() obeys it
|
|
@@ -2276,11 +2285,29 @@ export function buildAgentCapabilities(
|
|
|
2276
2285
|
filesystemCapability,
|
|
2277
2286
|
shell({ ...(toolCancellation ? {} : { configureTools: withExecOpCorrelation }) }),
|
|
2278
2287
|
];
|
|
2288
|
+
const sessionSkills = sessionSkillsForMaterialization(
|
|
2289
|
+
packSkills,
|
|
2290
|
+
options.skillLibrarySkills ?? [],
|
|
2291
|
+
options.sessionSkills ?? [],
|
|
2292
|
+
);
|
|
2279
2293
|
caps.push(
|
|
2280
2294
|
skills({
|
|
2281
|
-
lazyFrom: lazySkillSourceWithPackSkills(
|
|
2295
|
+
lazyFrom: lazySkillSourceWithPackSkills(
|
|
2296
|
+
[...packSkills, ...sessionSkills],
|
|
2297
|
+
options.skillLibrarySkills ?? [],
|
|
2298
|
+
),
|
|
2282
2299
|
}),
|
|
2283
2300
|
);
|
|
2301
|
+
if (options.workspaceSkillPaths?.length) {
|
|
2302
|
+
caps.push(
|
|
2303
|
+
workspaceSkills(options.workspaceSkillPaths, [
|
|
2304
|
+
...bundledSkillDirNames(bundledSkillsDir()),
|
|
2305
|
+
...(options.skillLibrarySkills ?? []).map((skill) => skill.name),
|
|
2306
|
+
...packSkills.map((skill) => skill.name),
|
|
2307
|
+
...sessionSkills.map((skill) => skill.name),
|
|
2308
|
+
]),
|
|
2309
|
+
);
|
|
2310
|
+
}
|
|
2284
2311
|
// P4.3 computer-use: the agent drives the SAME :0 humans watch (xdotool/XTEST +
|
|
2285
2312
|
// scrot), but only when the desktop tier is ON, computer-use is enabled, and the
|
|
2286
2313
|
// backend is one whose image carries the X stack (descriptorgate — honest about
|
|
@@ -2373,6 +2400,9 @@ export type PrepareToolsOptions = {
|
|
|
2373
2400
|
executionGeneration?: number;
|
|
2374
2401
|
subjectId?: string;
|
|
2375
2402
|
subjectLabel?: string;
|
|
2403
|
+
// Immutable human authority used only for subject-owned connection lookup.
|
|
2404
|
+
// This is intentionally separate from the worker's first-party MCP identity.
|
|
2405
|
+
credentialSubjectId?: string;
|
|
2376
2406
|
// Overrides the fixed first-party MCP permission set for this session's
|
|
2377
2407
|
// delegated token (manager-style sessions). The caller is responsible for
|
|
2378
2408
|
// having validated the set against the session creator's grant.
|
|
@@ -2465,6 +2495,14 @@ export async function prepareAgentTools(
|
|
|
2465
2495
|
return { mcpServers: [], close: async () => {}, codexConnectorNamespaces };
|
|
2466
2496
|
}
|
|
2467
2497
|
const registry = new Map(settings.mcpServers.map((server) => [server.id, server]));
|
|
2498
|
+
const subjectOwnedTool = tools.find(
|
|
2499
|
+
(tool) => registry.get(tool.id)?.connectionRef?.subjectScope === "subject",
|
|
2500
|
+
);
|
|
2501
|
+
if (subjectOwnedTool && !options.credentialSubjectId) {
|
|
2502
|
+
throw new Error(
|
|
2503
|
+
`subject-owned connection for MCP server ${subjectOwnedTool.id} requires a human turn initiator`,
|
|
2504
|
+
);
|
|
2505
|
+
}
|
|
2468
2506
|
const aggregateToolBudget = new McpAggregateToolListBudget();
|
|
2469
2507
|
// npm Undici's dispatcher transport can hang indefinitely under Bun even
|
|
2470
2508
|
// after an AbortSignal fires. Bun's native fetch is the supported runtime
|
|
@@ -2738,7 +2776,7 @@ async function resolveConnectionForRequest(
|
|
|
2738
2776
|
destinationUrl,
|
|
2739
2777
|
forceRefresh,
|
|
2740
2778
|
...(toolName ? { toolName } : {}),
|
|
2741
|
-
...(options.
|
|
2779
|
+
...(options.credentialSubjectId ? { subjectId: options.credentialSubjectId } : {}),
|
|
2742
2780
|
};
|
|
2743
2781
|
try {
|
|
2744
2782
|
return await options.resolveCredential(request);
|
|
@@ -5278,6 +5316,32 @@ export function buildManifest(
|
|
|
5278
5316
|
});
|
|
5279
5317
|
}
|
|
5280
5318
|
|
|
5319
|
+
export function repositoryWorkspaceSkillPathsOption(resources: readonly ResourceRef[]): {
|
|
5320
|
+
workspaceSkillPaths?: readonly WorkspaceSkillSearchPath[];
|
|
5321
|
+
} {
|
|
5322
|
+
const repositories = resources.filter(
|
|
5323
|
+
(resource): resource is Extract<ResourceRef, { kind: "repository" }> =>
|
|
5324
|
+
resource.kind === "repository",
|
|
5325
|
+
);
|
|
5326
|
+
if (repositories.length === 0) return {};
|
|
5327
|
+
|
|
5328
|
+
const paths = new Map<string, WorkspaceSkillSearchPath>();
|
|
5329
|
+
const add = (path: string, source: string): void => {
|
|
5330
|
+
if (!paths.has(path)) paths.set(path, { path, source });
|
|
5331
|
+
};
|
|
5332
|
+
// A Connected Machine maps /workspace to its configured working directory,
|
|
5333
|
+
// which may itself be the selected repository. Managed sandboxes normally use
|
|
5334
|
+
// the repository mount paths below. Checking both keeps the rule portable.
|
|
5335
|
+
add(".agents/skills", "workspace .agents/skills");
|
|
5336
|
+
add(".claude/skills", "workspace .claude/skills");
|
|
5337
|
+
for (const repository of repositories) {
|
|
5338
|
+
const mountPath = resourceMountPath(repository);
|
|
5339
|
+
add(`${mountPath}/.agents/skills`, `${mountPath}/.agents/skills`);
|
|
5340
|
+
add(`${mountPath}/.claude/skills`, `${mountPath}/.claude/skills`);
|
|
5341
|
+
}
|
|
5342
|
+
return { workspaceSkillPaths: [...paths.values()] };
|
|
5343
|
+
}
|
|
5344
|
+
|
|
5281
5345
|
function sandboxDownloadDirectory(download: SandboxFileDownload, mountPath: string): any {
|
|
5282
5346
|
if (download.mountPath !== mountPath) {
|
|
5283
5347
|
throw new Error(
|
|
@@ -7331,6 +7395,7 @@ function effectiveSkillSelections(
|
|
|
7331
7395
|
librarySelections: readonly EffectiveSkillSelection[],
|
|
7332
7396
|
librarySkills: readonly PackSkill[],
|
|
7333
7397
|
packSkills: readonly PackSkill[],
|
|
7398
|
+
sessionSkills: readonly PackSkill[],
|
|
7334
7399
|
): readonly EffectiveSkillSelection[] {
|
|
7335
7400
|
const defaultSelections = bundledSkillDirNames(bundledSkillsDir()).map((name) => ({
|
|
7336
7401
|
id: `bundled:${name}`,
|
|
@@ -7342,6 +7407,12 @@ function effectiveSkillSelections(
|
|
|
7342
7407
|
}));
|
|
7343
7408
|
const libraryNameKeys = new Set(librarySkills.map((skill) => skill.name.toLowerCase()));
|
|
7344
7409
|
const packNameKeys = new Set(packSkills.map((skill) => skill.name.toLowerCase()));
|
|
7410
|
+
const effectiveSessionSkills = sessionSkillsForMaterialization(
|
|
7411
|
+
packSkills,
|
|
7412
|
+
librarySkills,
|
|
7413
|
+
sessionSkills,
|
|
7414
|
+
);
|
|
7415
|
+
const sessionNameKeys = new Set(effectiveSessionSkills.map((skill) => skill.name.toLowerCase()));
|
|
7345
7416
|
const selected = librarySelections.filter((selection) =>
|
|
7346
7417
|
libraryNameKeys.has(selection.name.toLowerCase()),
|
|
7347
7418
|
);
|
|
@@ -7349,9 +7420,14 @@ function effectiveSkillSelections(
|
|
|
7349
7420
|
...defaultSelections.filter(
|
|
7350
7421
|
(selection) =>
|
|
7351
7422
|
!libraryNameKeys.has(selection.name.toLowerCase()) &&
|
|
7352
|
-
!packNameKeys.has(selection.name.toLowerCase())
|
|
7423
|
+
!packNameKeys.has(selection.name.toLowerCase()) &&
|
|
7424
|
+
!sessionNameKeys.has(selection.name.toLowerCase()),
|
|
7425
|
+
),
|
|
7426
|
+
...selected.filter(
|
|
7427
|
+
(selection) =>
|
|
7428
|
+
!packNameKeys.has(selection.name.toLowerCase()) &&
|
|
7429
|
+
!sessionNameKeys.has(selection.name.toLowerCase()),
|
|
7353
7430
|
),
|
|
7354
|
-
...selected.filter((selection) => !packNameKeys.has(selection.name.toLowerCase())),
|
|
7355
7431
|
...packSkills.map((skill) => ({
|
|
7356
7432
|
id: `pack:${skill.name}`,
|
|
7357
7433
|
name: skill.name,
|
|
@@ -7360,9 +7436,56 @@ function effectiveSkillSelections(
|
|
|
7360
7436
|
contentSha256: null,
|
|
7361
7437
|
reason: "enabled capability pack",
|
|
7362
7438
|
})),
|
|
7439
|
+
...effectiveSessionSkills.map((skill) => ({
|
|
7440
|
+
id: `session:${skill.name}`,
|
|
7441
|
+
name: skill.name,
|
|
7442
|
+
source: "session" as const,
|
|
7443
|
+
version: null,
|
|
7444
|
+
contentSha256: null,
|
|
7445
|
+
reason: "attached to session",
|
|
7446
|
+
})),
|
|
7363
7447
|
];
|
|
7364
7448
|
}
|
|
7365
7449
|
|
|
7450
|
+
function sessionSkillsForMaterialization(
|
|
7451
|
+
packSkills: readonly PackSkill[],
|
|
7452
|
+
librarySkills: readonly PackSkill[],
|
|
7453
|
+
sessionSkills: readonly PackSkill[],
|
|
7454
|
+
): PackSkill[] {
|
|
7455
|
+
const configured = new Map<string, PackSkill>();
|
|
7456
|
+
for (const skill of [...librarySkills, ...packSkills]) {
|
|
7457
|
+
configured.set(skill.name.toLowerCase(), skill);
|
|
7458
|
+
}
|
|
7459
|
+
const bundledNames = new Set(
|
|
7460
|
+
bundledSkillDirNames(bundledSkillsDir()).map((name) => name.toLowerCase()),
|
|
7461
|
+
);
|
|
7462
|
+
const selected = new Map<string, PackSkill>();
|
|
7463
|
+
for (const skill of sessionSkills) {
|
|
7464
|
+
assertSafePackSkillName(skill.name);
|
|
7465
|
+
const key = skill.name.toLowerCase();
|
|
7466
|
+
if (bundledNames.has(key)) {
|
|
7467
|
+
throw new Error(`Session skill "${skill.name}" conflicts with a bundled skill`);
|
|
7468
|
+
}
|
|
7469
|
+
const existing = selected.get(key) ?? configured.get(key);
|
|
7470
|
+
if (existing) {
|
|
7471
|
+
if (skillFingerprint(existing) !== skillFingerprint(skill)) {
|
|
7472
|
+
throw new Error(`Conflicting skill definitions for "${skill.name}"`);
|
|
7473
|
+
}
|
|
7474
|
+
continue;
|
|
7475
|
+
}
|
|
7476
|
+
selected.set(key, skill);
|
|
7477
|
+
}
|
|
7478
|
+
return [...selected.values()];
|
|
7479
|
+
}
|
|
7480
|
+
|
|
7481
|
+
function skillFingerprint(skill: PackSkill): string {
|
|
7482
|
+
return JSON.stringify(
|
|
7483
|
+
[...skill.files]
|
|
7484
|
+
.sort((left, right) => left.path.localeCompare(right.path))
|
|
7485
|
+
.map(({ path, content }) => ({ path, content })),
|
|
7486
|
+
);
|
|
7487
|
+
}
|
|
7488
|
+
|
|
7366
7489
|
function removeSkillChildByNameKey(children: Record<string, Entry>, nameKey: string): void {
|
|
7367
7490
|
for (const name of Object.keys(children)) {
|
|
7368
7491
|
if (name.toLowerCase() === nameKey) {
|
package/src/sandbox/index.ts
CHANGED
|
@@ -536,14 +536,14 @@ function withDockerNetwork(client: SandboxClient, network: string | undefined):
|
|
|
536
536
|
}
|
|
537
537
|
|
|
538
538
|
async function connectDockerNetwork(network: string, containerId: string): Promise<void> {
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
|
|
539
|
+
const { spawnSync } = await import("node:child_process");
|
|
540
|
+
const result = spawnSync("docker", ["network", "connect", network, containerId], {
|
|
541
|
+
encoding: "utf8",
|
|
542
542
|
});
|
|
543
|
-
if (result.
|
|
543
|
+
if (result.status === 0) {
|
|
544
544
|
return;
|
|
545
545
|
}
|
|
546
|
-
const stderr =
|
|
546
|
+
const stderr = result.stderr ?? result.error?.message ?? "";
|
|
547
547
|
if (stderr.includes("already exists")) {
|
|
548
548
|
return;
|
|
549
549
|
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { Capability, type SandboxSessionLike } from "@openai/agents/sandbox";
|
|
4
|
+
|
|
5
|
+
const SKILL_FILE = "SKILL.md";
|
|
6
|
+
const MAX_DISCOVERED_SKILLS = 256;
|
|
7
|
+
const MAX_SKILL_ENTRIES = 1_024;
|
|
8
|
+
const MAX_SKILL_BYTES = 32 * 1024 * 1024;
|
|
9
|
+
const SAFE_SKILL_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
10
|
+
|
|
11
|
+
export type WorkspaceSkillSearchPath = Readonly<{
|
|
12
|
+
path: string;
|
|
13
|
+
source: string;
|
|
14
|
+
}>;
|
|
15
|
+
|
|
16
|
+
type WorkspaceSkill = Readonly<{
|
|
17
|
+
name: string;
|
|
18
|
+
description: string;
|
|
19
|
+
path: string;
|
|
20
|
+
fingerprint: string;
|
|
21
|
+
source: string;
|
|
22
|
+
}>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Index skills that already exist in the live workspace.
|
|
26
|
+
*
|
|
27
|
+
* Unlike the SDK's `skills({ from })` and `skills({ lazyFrom })` sources, this
|
|
28
|
+
* capability deliberately contributes no manifest entries and materializes
|
|
29
|
+
* nothing. Repository resources and Connected Machines already own these
|
|
30
|
+
* files; the bound sandbox session is the one portable way to inspect them.
|
|
31
|
+
*/
|
|
32
|
+
export class WorkspaceSkillsCapability extends Capability {
|
|
33
|
+
readonly type = "workspace-skills";
|
|
34
|
+
private discovery?: Promise<readonly WorkspaceSkill[]>;
|
|
35
|
+
|
|
36
|
+
constructor(
|
|
37
|
+
private readonly searchPaths: readonly WorkspaceSkillSearchPath[],
|
|
38
|
+
private readonly reservedNames: ReadonlySet<string> = new Set(),
|
|
39
|
+
) {
|
|
40
|
+
super();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
override async instructions(): Promise<string | null> {
|
|
44
|
+
const session = requireWorkspaceSkillSession(this._session);
|
|
45
|
+
this.discovery ??= discoverWorkspaceSkills(
|
|
46
|
+
session,
|
|
47
|
+
this.searchPaths,
|
|
48
|
+
this.reservedNames,
|
|
49
|
+
this._runAs,
|
|
50
|
+
);
|
|
51
|
+
const skills = await this.discovery;
|
|
52
|
+
if (skills.length === 0) return null;
|
|
53
|
+
|
|
54
|
+
const available = skills
|
|
55
|
+
.map((skill) => `- ${skill.name}: ${skill.description} (file: ${skill.path})`)
|
|
56
|
+
.join("\n");
|
|
57
|
+
return `## Repository skills
|
|
58
|
+
|
|
59
|
+
Repository skills are instructions already present in the live workspace. They do not need to be loaded or copied.
|
|
60
|
+
|
|
61
|
+
### Available repository skills
|
|
62
|
+
${available}
|
|
63
|
+
|
|
64
|
+
### How to use repository skills
|
|
65
|
+
- If the user names a skill, or the task clearly matches a skill description, read its complete SKILL.md before acting.
|
|
66
|
+
- Resolve referenced scripts, references, assets, and templates relative to that skill directory.
|
|
67
|
+
- Use only the minimum relevant skills for the turn, and say briefly which ones you are using.
|
|
68
|
+
- Do not assume a skill remains applicable on later turns; evaluate the current request again.`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function workspaceSkills(
|
|
73
|
+
searchPaths: readonly WorkspaceSkillSearchPath[],
|
|
74
|
+
reservedNames: Iterable<string> = [],
|
|
75
|
+
): WorkspaceSkillsCapability {
|
|
76
|
+
return new WorkspaceSkillsCapability(
|
|
77
|
+
searchPaths,
|
|
78
|
+
new Set([...reservedNames].map((name) => name.toLowerCase())),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function discoverWorkspaceSkills(
|
|
83
|
+
session: SandboxSessionLike,
|
|
84
|
+
searchPaths: readonly WorkspaceSkillSearchPath[],
|
|
85
|
+
reservedNames: ReadonlySet<string> = new Set(),
|
|
86
|
+
runAs?: string,
|
|
87
|
+
): Promise<readonly WorkspaceSkill[]> {
|
|
88
|
+
if (!session.listDir || !session.readFile) {
|
|
89
|
+
throw new Error("Workspace skill discovery requires sandbox listDir() and readFile() support");
|
|
90
|
+
}
|
|
91
|
+
const discovered = new Map<string, WorkspaceSkill>();
|
|
92
|
+
let candidates = 0;
|
|
93
|
+
for (const searchPath of searchPaths) {
|
|
94
|
+
let entries;
|
|
95
|
+
try {
|
|
96
|
+
entries = await session.listDir({ path: searchPath.path, ...(runAs ? { runAs } : {}) });
|
|
97
|
+
} catch {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
for (const entry of [...entries].sort((left, right) => left.name.localeCompare(right.name))) {
|
|
101
|
+
if (entry.type !== "dir") continue;
|
|
102
|
+
candidates += 1;
|
|
103
|
+
if (candidates > MAX_DISCOVERED_SKILLS) {
|
|
104
|
+
throw new Error(`Repository skill discovery exceeds ${MAX_DISCOVERED_SKILLS} directories`);
|
|
105
|
+
}
|
|
106
|
+
const skillMarkdownPath = joinWorkspacePath(entry.path, SKILL_FILE);
|
|
107
|
+
let markdown: string;
|
|
108
|
+
try {
|
|
109
|
+
const content = await session.readFile({
|
|
110
|
+
path: skillMarkdownPath,
|
|
111
|
+
...(runAs ? { runAs } : {}),
|
|
112
|
+
});
|
|
113
|
+
markdown = typeof content === "string" ? content : new TextDecoder().decode(content);
|
|
114
|
+
} catch {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const frontmatter = parseSkillFrontmatter(markdown);
|
|
118
|
+
const name = frontmatter.name?.trim() || entry.name;
|
|
119
|
+
if (name.length > 64 || !SAFE_SKILL_NAME.test(name)) {
|
|
120
|
+
throw new Error(`Repository skill has an invalid name: ${name.slice(0, 64)}`);
|
|
121
|
+
}
|
|
122
|
+
const description = frontmatter.description?.trim() || "No description provided.";
|
|
123
|
+
if (description.length > 2_048 || /[\r\n]/.test(description)) {
|
|
124
|
+
throw new Error(`Repository skill "${name}" has an invalid description`);
|
|
125
|
+
}
|
|
126
|
+
const key = name.toLowerCase();
|
|
127
|
+
if (reservedNames.has(key)) {
|
|
128
|
+
throw new Error(`Workspace skill "${name}" conflicts with a configured OpenGeni skill`);
|
|
129
|
+
}
|
|
130
|
+
const candidate: WorkspaceSkill = {
|
|
131
|
+
name,
|
|
132
|
+
description,
|
|
133
|
+
path: skillMarkdownPath,
|
|
134
|
+
fingerprint: await fingerprintWorkspaceDirectory(session, entry.path, runAs),
|
|
135
|
+
source: searchPath.source,
|
|
136
|
+
};
|
|
137
|
+
const existing = discovered.get(key);
|
|
138
|
+
if (!existing) {
|
|
139
|
+
discovered.set(key, candidate);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (existing.fingerprint !== candidate.fingerprint) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`Workspace skill "${name}" has conflicting definitions in ${existing.source} and ${candidate.source}`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return [...discovered.values()].sort((left, right) => left.name.localeCompare(right.name));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function fingerprintWorkspaceDirectory(
|
|
153
|
+
session: SandboxSessionLike,
|
|
154
|
+
root: string,
|
|
155
|
+
runAs?: string,
|
|
156
|
+
): Promise<string> {
|
|
157
|
+
const hash = createHash("sha256");
|
|
158
|
+
let entryCount = 0;
|
|
159
|
+
let byteCount = 0;
|
|
160
|
+
|
|
161
|
+
async function visit(path: string, relativePath: string): Promise<void> {
|
|
162
|
+
const entries = await session.listDir!({ path, ...(runAs ? { runAs } : {}) });
|
|
163
|
+
for (const entry of [...entries].sort((left, right) => left.name.localeCompare(right.name))) {
|
|
164
|
+
entryCount += 1;
|
|
165
|
+
if (entryCount > MAX_SKILL_ENTRIES) {
|
|
166
|
+
throw new Error(`Workspace skill exceeds ${MAX_SKILL_ENTRIES} filesystem entries`);
|
|
167
|
+
}
|
|
168
|
+
const childRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
169
|
+
hash.update(entry.type);
|
|
170
|
+
hash.update("\0");
|
|
171
|
+
hash.update(childRelativePath);
|
|
172
|
+
hash.update("\0");
|
|
173
|
+
if (entry.type === "dir") {
|
|
174
|
+
await visit(entry.path, childRelativePath);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (entry.type !== "file") continue;
|
|
178
|
+
const content = await session.readFile!({
|
|
179
|
+
path: entry.path,
|
|
180
|
+
...(runAs ? { runAs } : {}),
|
|
181
|
+
});
|
|
182
|
+
const bytes = typeof content === "string" ? new TextEncoder().encode(content) : content;
|
|
183
|
+
byteCount += bytes.byteLength;
|
|
184
|
+
if (byteCount > MAX_SKILL_BYTES) {
|
|
185
|
+
throw new Error(`Workspace skill exceeds ${MAX_SKILL_BYTES} bytes`);
|
|
186
|
+
}
|
|
187
|
+
hash.update(bytes);
|
|
188
|
+
hash.update("\0");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
await visit(root, "");
|
|
193
|
+
return hash.digest("hex");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function parseSkillFrontmatter(markdown: string): {
|
|
197
|
+
name?: string;
|
|
198
|
+
description?: string;
|
|
199
|
+
} {
|
|
200
|
+
const lines = markdown.split(/\r?\n/);
|
|
201
|
+
if (lines[0]?.trim() !== "---") return {};
|
|
202
|
+
const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
|
|
203
|
+
if (end === -1) return {};
|
|
204
|
+
const values: Record<string, string> = {};
|
|
205
|
+
for (const line of lines.slice(1, end)) {
|
|
206
|
+
const separator = line.indexOf(":");
|
|
207
|
+
if (separator === -1) continue;
|
|
208
|
+
const key = line.slice(0, separator).trim();
|
|
209
|
+
if (key !== "name" && key !== "description") continue;
|
|
210
|
+
values[key] = unquote(line.slice(separator + 1).trim());
|
|
211
|
+
}
|
|
212
|
+
return values;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function unquote(value: string): string {
|
|
216
|
+
if (
|
|
217
|
+
value.length >= 2 &&
|
|
218
|
+
value[0] === value[value.length - 1] &&
|
|
219
|
+
(value[0] === '"' || value[0] === "'")
|
|
220
|
+
) {
|
|
221
|
+
return value.slice(1, -1);
|
|
222
|
+
}
|
|
223
|
+
return value;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function joinWorkspacePath(parent: string, child: string): string {
|
|
227
|
+
return `${parent.replace(/\/+$/, "")}/${child}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function requireWorkspaceSkillSession(session: SandboxSessionLike | undefined): SandboxSessionLike {
|
|
231
|
+
if (!session) {
|
|
232
|
+
throw new Error('capability "workspace-skills" used before bind(session)');
|
|
233
|
+
}
|
|
234
|
+
return session;
|
|
235
|
+
}
|