@opengeni/runtime 0.13.13 → 0.14.1

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.
@@ -170,7 +170,7 @@ import {
170
170
  withToolspaceTokenClient,
171
171
  withToolspaceTokenEnvironment,
172
172
  withToolspaceTokenSession
173
- } from "../chunk-3NUSY22C.js";
173
+ } from "../chunk-HHWIZHQM.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.13.13",
3
+ "version": "0.14.1",
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.8",
54
- "@opengeni/contracts": "^0.20.1",
53
+ "@opengeni/config": "^0.7.11",
54
+ "@opengeni/contracts": "^0.22.0",
55
55
  "@opengeni/network": "^0.1.1",
56
56
  "debug": "4.4.3",
57
57
  "modal": "0.7.6",
@@ -100,11 +100,6 @@ export function isCompactionSummary(item: unknown): boolean {
100
100
  );
101
101
  }
102
102
 
103
- /** Platform-authored system context exists for one inference and is never persisted. */
104
- export function isEphemeralInternalContext(item: unknown): boolean {
105
- return itemType(item) === "message" && itemRole(item) === "system";
106
- }
107
-
108
103
  /**
109
104
  * Conservative tokenizer-independent text estimate used for every local input
110
105
  * budget. Preserve the stable ASCII `chars / 4` heuristic, but never discount
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  approvalIdentifier,
16
16
  CAPABILITY_DESCRIPTORS,
17
17
  DEFAULT_FIRST_PARTY_MCP_PERMISSIONS,
18
+ DEFAULT_FIRST_PARTY_MCP_TOOLS,
18
19
  assertUniqueResourceMountPaths,
19
20
  gitCredentialBindingIdForRepository,
20
21
  gitCredentialProviderForRepository,
@@ -33,6 +34,7 @@ import {
33
34
  type HumanInputResponse,
34
35
  type McpServerConnectionRef,
35
36
  type Permission,
37
+ type FirstPartyMcpToolName,
36
38
  type ReasoningEffort,
37
39
  type ResourceRef,
38
40
  type SessionEventType,
@@ -177,6 +179,7 @@ import {
177
179
  type RunCredentialSessionReady,
178
180
  } from "./sandbox";
179
181
  import { runWithToolCallCorrelation } from "./sandbox/op-correlation";
182
+ import { shellToolspacePath } from "./sandbox/toolspace-token";
180
183
  import {
181
184
  createTurnToolCancellationController,
182
185
  TurnSandboxCommandCancelledError,
@@ -187,6 +190,7 @@ import {
187
190
  } from "./sandbox/turn-tool-cancellation";
188
191
  import { computerUse, type ComputerToolMode } from "./sandbox-computer";
189
192
  import type { RuntimeMetricsHooks } from "./metrics";
193
+ import { workspaceSkills, type WorkspaceSkillSearchPath } from "./workspace-skills";
190
194
 
191
195
  // The Agents SDK's debug namespaces can otherwise serialize complete model
192
196
  // inputs/outputs and tool arguments/results. These getters read process.env on
@@ -295,7 +299,6 @@ export {
295
299
  MAX_COMPACTION_THRESHOLD_RATIO,
296
300
  SUMMARY_BUFFER_TOKENS,
297
301
  SUMMARY_PREFIX,
298
- isEphemeralInternalContext,
299
302
  USER_MESSAGE_TRUNCATION_MARKER,
300
303
  } from "./context-compaction";
301
304
  export type {
@@ -1260,7 +1263,7 @@ export type ToolspaceTokenWriterSession = SandboxSessionLike;
1260
1263
  export type EffectiveSkillSelection = Readonly<{
1261
1264
  id: string;
1262
1265
  name: string;
1263
- source: "bundled" | "library" | "pack";
1266
+ source: "bundled" | "library" | "pack" | "session";
1264
1267
  version: string | null;
1265
1268
  contentSha256: string | null;
1266
1269
  reason: string;
@@ -1430,6 +1433,9 @@ export type BuildAgentOptions = {
1430
1433
  // skills in the sandbox skill index (mounted under .agents/) so
1431
1434
  // skills/<name> references resolve like any other indexed skill.
1432
1435
  packSkills?: PackSkill[];
1436
+ // Inline skills fixed onto this session at creation. They use the SDK's lazy
1437
+ // skill source and are materialized only when the model loads one.
1438
+ sessionSkills?: PackSkill[];
1433
1439
  // Explicitly selected, immutable curated-library skill content. These are
1434
1440
  // separate from packSkills so repository-local/pack compatibility does not
1435
1441
  // turn a curated entry into a mutable override.
@@ -1460,7 +1466,7 @@ export type PackSkillFile = {
1460
1466
 
1461
1467
  export type PackSkill = {
1462
1468
  name: string;
1463
- description?: string | null;
1469
+ description?: string | null | undefined;
1464
1470
  files: PackSkillFile[];
1465
1471
  };
1466
1472
 
@@ -1903,6 +1909,8 @@ export function buildOpenGeniAgent(
1903
1909
  ...(options.skillLibrarySkills?.length
1904
1910
  ? { skillLibrarySkills: options.skillLibrarySkills }
1905
1911
  : {}),
1912
+ ...(options.sessionSkills?.length ? { sessionSkills: options.sessionSkills } : {}),
1913
+ ...repositoryWorkspaceSkillPathsOption(resources),
1906
1914
  ...(options.structuredToolTransport !== undefined
1907
1915
  ? { structuredToolTransport: options.structuredToolTransport }
1908
1916
  : {}),
@@ -1925,6 +1933,7 @@ export function buildOpenGeniAgent(
1925
1933
  options.skillLibrarySelections ?? [],
1926
1934
  options.skillLibrarySkills ?? [],
1927
1935
  options.packSkills ?? [],
1936
+ options.sessionSkills ?? [],
1928
1937
  ).map((selection) => Object.freeze(selection)),
1929
1938
  ),
1930
1939
  );
@@ -2240,6 +2249,8 @@ export function buildAgentCapabilities(
2240
2249
  packSkills: PackSkill[],
2241
2250
  options: {
2242
2251
  skillLibrarySkills?: PackSkill[];
2252
+ sessionSkills?: PackSkill[];
2253
+ workspaceSkillPaths?: readonly WorkspaceSkillSearchPath[];
2243
2254
  structuredToolTransport?: boolean;
2244
2255
  // EXPLICIT computer-use transport (see BuildAgentOptions.computerToolMode). When
2245
2256
  // present, computerUse() is handed the mode directly and its tools() obeys it
@@ -2276,11 +2287,29 @@ export function buildAgentCapabilities(
2276
2287
  filesystemCapability,
2277
2288
  shell({ ...(toolCancellation ? {} : { configureTools: withExecOpCorrelation }) }),
2278
2289
  ];
2290
+ const sessionSkills = sessionSkillsForMaterialization(
2291
+ packSkills,
2292
+ options.skillLibrarySkills ?? [],
2293
+ options.sessionSkills ?? [],
2294
+ );
2279
2295
  caps.push(
2280
2296
  skills({
2281
- lazyFrom: lazySkillSourceWithPackSkills(packSkills, options.skillLibrarySkills ?? []),
2297
+ lazyFrom: lazySkillSourceWithPackSkills(
2298
+ [...packSkills, ...sessionSkills],
2299
+ options.skillLibrarySkills ?? [],
2300
+ ),
2282
2301
  }),
2283
2302
  );
2303
+ if (options.workspaceSkillPaths?.length) {
2304
+ caps.push(
2305
+ workspaceSkills(options.workspaceSkillPaths, [
2306
+ ...bundledSkillDirNames(bundledSkillsDir()),
2307
+ ...(options.skillLibrarySkills ?? []).map((skill) => skill.name),
2308
+ ...packSkills.map((skill) => skill.name),
2309
+ ...sessionSkills.map((skill) => skill.name),
2310
+ ]),
2311
+ );
2312
+ }
2284
2313
  // P4.3 computer-use: the agent drives the SAME :0 humans watch (xdotool/XTEST +
2285
2314
  // scrot), but only when the desktop tier is ON, computer-use is enabled, and the
2286
2315
  // backend is one whose image carries the X stack (descriptorgate — honest about
@@ -2373,10 +2402,16 @@ export type PrepareToolsOptions = {
2373
2402
  executionGeneration?: number;
2374
2403
  subjectId?: string;
2375
2404
  subjectLabel?: string;
2405
+ // Immutable human authority used only for subject-owned connection lookup.
2406
+ // This is intentionally separate from the worker's first-party MCP identity.
2407
+ credentialSubjectId?: string;
2376
2408
  // Overrides the fixed first-party MCP permission set for this session's
2377
2409
  // delegated token (manager-style sessions). The caller is responsible for
2378
2410
  // having validated the set against the session creator's grant.
2379
2411
  firstPartyPermissions?: Permission[];
2412
+ // Exact model-visible catalog selection for the broad first-party server.
2413
+ // Permissions are signed separately and remain the authorization boundary.
2414
+ firstPartyTools?: FirstPartyMcpToolName[];
2380
2415
  resolveCredential?: (
2381
2416
  input: ResolveConnectionCredentialInput,
2382
2417
  ) => Promise<ResolveConnectionCredentialResult>;
@@ -2465,6 +2500,14 @@ export async function prepareAgentTools(
2465
2500
  return { mcpServers: [], close: async () => {}, codexConnectorNamespaces };
2466
2501
  }
2467
2502
  const registry = new Map(settings.mcpServers.map((server) => [server.id, server]));
2503
+ const subjectOwnedTool = tools.find(
2504
+ (tool) => registry.get(tool.id)?.connectionRef?.subjectScope === "subject",
2505
+ );
2506
+ if (subjectOwnedTool && !options.credentialSubjectId) {
2507
+ throw new Error(
2508
+ `subject-owned connection for MCP server ${subjectOwnedTool.id} requires a human turn initiator`,
2509
+ );
2510
+ }
2468
2511
  const aggregateToolBudget = new McpAggregateToolListBudget();
2469
2512
  // npm Undici's dispatcher transport can hang indefinitely under Bun even
2470
2513
  // after an AbortSignal fires. Bun's native fetch is the supported runtime
@@ -2738,7 +2781,7 @@ async function resolveConnectionForRequest(
2738
2781
  destinationUrl,
2739
2782
  forceRefresh,
2740
2783
  ...(toolName ? { toolName } : {}),
2741
- ...(options.subjectId ? { subjectId: options.subjectId } : {}),
2784
+ ...(options.credentialSubjectId ? { subjectId: options.credentialSubjectId } : {}),
2742
2785
  };
2743
2786
  try {
2744
2787
  return await options.resolveCredential(request);
@@ -3125,6 +3168,7 @@ async function signFirstPartyDelegatedBearer(
3125
3168
  subjectId: options.subjectId ?? "worker:first-party-mcp",
3126
3169
  ...(options.subjectLabel ? { subjectLabel: options.subjectLabel } : {}),
3127
3170
  permissions: options.firstPartyPermissions ?? [...DEFAULT_FIRST_PARTY_MCP_PERMISSIONS],
3171
+ firstPartyMcpTools: options.firstPartyTools ?? [...DEFAULT_FIRST_PARTY_MCP_TOOLS],
3128
3172
  ...(options.sessionId ? { sessionId: options.sessionId } : {}),
3129
3173
  ...(options.turnId ? { turnId: options.turnId } : {}),
3130
3174
  ...(options.attemptId ? { attemptId: options.attemptId } : {}),
@@ -5278,6 +5322,32 @@ export function buildManifest(
5278
5322
  });
5279
5323
  }
5280
5324
 
5325
+ export function repositoryWorkspaceSkillPathsOption(resources: readonly ResourceRef[]): {
5326
+ workspaceSkillPaths?: readonly WorkspaceSkillSearchPath[];
5327
+ } {
5328
+ const repositories = resources.filter(
5329
+ (resource): resource is Extract<ResourceRef, { kind: "repository" }> =>
5330
+ resource.kind === "repository",
5331
+ );
5332
+ if (repositories.length === 0) return {};
5333
+
5334
+ const paths = new Map<string, WorkspaceSkillSearchPath>();
5335
+ const add = (path: string, source: string): void => {
5336
+ if (!paths.has(path)) paths.set(path, { path, source });
5337
+ };
5338
+ // A Connected Machine maps /workspace to its configured working directory,
5339
+ // which may itself be the selected repository. Managed sandboxes normally use
5340
+ // the repository mount paths below. Checking both keeps the rule portable.
5341
+ add(".agents/skills", "workspace .agents/skills");
5342
+ add(".claude/skills", "workspace .claude/skills");
5343
+ for (const repository of repositories) {
5344
+ const mountPath = resourceMountPath(repository);
5345
+ add(`${mountPath}/.agents/skills`, `${mountPath}/.agents/skills`);
5346
+ add(`${mountPath}/.claude/skills`, `${mountPath}/.claude/skills`);
5347
+ }
5348
+ return { workspaceSkillPaths: [...paths.values()] };
5349
+ }
5350
+
5281
5351
  function sandboxDownloadDirectory(download: SandboxFileDownload, mountPath: string): any {
5282
5352
  if (download.mountPath !== mountPath) {
5283
5353
  throw new Error(
@@ -6748,10 +6818,10 @@ export function toolspaceTokenSeedCommand(
6748
6818
  ' seed_umask="$(umask)"',
6749
6819
  " umask 077",
6750
6820
  options.tokenFile
6751
- ? ` token_file=${shellQuote(options.tokenFile)}`
6821
+ ? ` token_file=${shellToolspacePath(options.tokenFile)}`
6752
6822
  : ' token_file="${OPENGENI_TOOLSPACE_TOKEN_FILE:-$HOME/.opengeni/toolspace-token}"',
6753
6823
  options.legacyTokenFile
6754
- ? ` legacy_token_file=${shellQuote(options.legacyTokenFile)}`
6824
+ ? ` legacy_token_file=${shellToolspacePath(options.legacyTokenFile)}`
6755
6825
  : ' legacy_token_file=""',
6756
6826
  ' mkdir -p "$(dirname "$token_file")"',
6757
6827
  ' printf \'%s\' "$OPENGENI_TOOLSPACE_TOKEN_SEED" > "$token_file.tmp.$$"',
@@ -7331,6 +7401,7 @@ function effectiveSkillSelections(
7331
7401
  librarySelections: readonly EffectiveSkillSelection[],
7332
7402
  librarySkills: readonly PackSkill[],
7333
7403
  packSkills: readonly PackSkill[],
7404
+ sessionSkills: readonly PackSkill[],
7334
7405
  ): readonly EffectiveSkillSelection[] {
7335
7406
  const defaultSelections = bundledSkillDirNames(bundledSkillsDir()).map((name) => ({
7336
7407
  id: `bundled:${name}`,
@@ -7342,6 +7413,12 @@ function effectiveSkillSelections(
7342
7413
  }));
7343
7414
  const libraryNameKeys = new Set(librarySkills.map((skill) => skill.name.toLowerCase()));
7344
7415
  const packNameKeys = new Set(packSkills.map((skill) => skill.name.toLowerCase()));
7416
+ const effectiveSessionSkills = sessionSkillsForMaterialization(
7417
+ packSkills,
7418
+ librarySkills,
7419
+ sessionSkills,
7420
+ );
7421
+ const sessionNameKeys = new Set(effectiveSessionSkills.map((skill) => skill.name.toLowerCase()));
7345
7422
  const selected = librarySelections.filter((selection) =>
7346
7423
  libraryNameKeys.has(selection.name.toLowerCase()),
7347
7424
  );
@@ -7349,9 +7426,14 @@ function effectiveSkillSelections(
7349
7426
  ...defaultSelections.filter(
7350
7427
  (selection) =>
7351
7428
  !libraryNameKeys.has(selection.name.toLowerCase()) &&
7352
- !packNameKeys.has(selection.name.toLowerCase()),
7429
+ !packNameKeys.has(selection.name.toLowerCase()) &&
7430
+ !sessionNameKeys.has(selection.name.toLowerCase()),
7431
+ ),
7432
+ ...selected.filter(
7433
+ (selection) =>
7434
+ !packNameKeys.has(selection.name.toLowerCase()) &&
7435
+ !sessionNameKeys.has(selection.name.toLowerCase()),
7353
7436
  ),
7354
- ...selected.filter((selection) => !packNameKeys.has(selection.name.toLowerCase())),
7355
7437
  ...packSkills.map((skill) => ({
7356
7438
  id: `pack:${skill.name}`,
7357
7439
  name: skill.name,
@@ -7360,9 +7442,56 @@ function effectiveSkillSelections(
7360
7442
  contentSha256: null,
7361
7443
  reason: "enabled capability pack",
7362
7444
  })),
7445
+ ...effectiveSessionSkills.map((skill) => ({
7446
+ id: `session:${skill.name}`,
7447
+ name: skill.name,
7448
+ source: "session" as const,
7449
+ version: null,
7450
+ contentSha256: null,
7451
+ reason: "attached to session",
7452
+ })),
7363
7453
  ];
7364
7454
  }
7365
7455
 
7456
+ function sessionSkillsForMaterialization(
7457
+ packSkills: readonly PackSkill[],
7458
+ librarySkills: readonly PackSkill[],
7459
+ sessionSkills: readonly PackSkill[],
7460
+ ): PackSkill[] {
7461
+ const configured = new Map<string, PackSkill>();
7462
+ for (const skill of [...librarySkills, ...packSkills]) {
7463
+ configured.set(skill.name.toLowerCase(), skill);
7464
+ }
7465
+ const bundledNames = new Set(
7466
+ bundledSkillDirNames(bundledSkillsDir()).map((name) => name.toLowerCase()),
7467
+ );
7468
+ const selected = new Map<string, PackSkill>();
7469
+ for (const skill of sessionSkills) {
7470
+ assertSafePackSkillName(skill.name);
7471
+ const key = skill.name.toLowerCase();
7472
+ if (bundledNames.has(key)) {
7473
+ throw new Error(`Session skill "${skill.name}" conflicts with a bundled skill`);
7474
+ }
7475
+ const existing = selected.get(key) ?? configured.get(key);
7476
+ if (existing) {
7477
+ if (skillFingerprint(existing) !== skillFingerprint(skill)) {
7478
+ throw new Error(`Conflicting skill definitions for "${skill.name}"`);
7479
+ }
7480
+ continue;
7481
+ }
7482
+ selected.set(key, skill);
7483
+ }
7484
+ return [...selected.values()];
7485
+ }
7486
+
7487
+ function skillFingerprint(skill: PackSkill): string {
7488
+ return JSON.stringify(
7489
+ [...skill.files]
7490
+ .sort((left, right) => left.path.localeCompare(right.path))
7491
+ .map(({ path, content }) => ({ path, content })),
7492
+ );
7493
+ }
7494
+
7366
7495
  function removeSkillChildByNameKey(children: Record<string, Entry>, nameKey: string): void {
7367
7496
  for (const name of Object.keys(children)) {
7368
7497
  if (name.toLowerCase() === nameKey) {
@@ -9,6 +9,7 @@ import type {
9
9
  } from "@openai/agents/sandbox";
10
10
 
11
11
  const MAX_TOOLSPACE_SESSION_ID_BYTES = 512;
12
+ const HOME_RELATIVE_TOOLSPACE_PREFIX = "$HOME/";
12
13
 
13
14
  export class ToolspaceTokenPathError extends Error {
14
15
  constructor(message: string) {
@@ -33,6 +34,36 @@ function assertToolspaceSessionId(sessionId: string): string {
33
34
  return sessionId;
34
35
  }
35
36
 
37
+ function isHomeRelativeToolspacePath(value: string): boolean {
38
+ if (!value.startsWith(HOME_RELATIVE_TOOLSPACE_PREFIX)) return false;
39
+ const relative = value.slice(HOME_RELATIVE_TOOLSPACE_PREFIX.length);
40
+ const segments = relative.split("/");
41
+ return (
42
+ relative.length > 0 &&
43
+ !relative.includes("\0") &&
44
+ segments.every(
45
+ (segment) => segment !== "." && segment !== ".." && /^[A-Za-z0-9._-]+$/.test(segment),
46
+ ) &&
47
+ posixPath.normalize(relative) === relative
48
+ );
49
+ }
50
+
51
+ function assertToolspaceFilePath(value: string, label: string): string {
52
+ if (
53
+ !value ||
54
+ value.includes("\0") ||
55
+ (!posixPath.isAbsolute(value) && !isHomeRelativeToolspacePath(value))
56
+ ) {
57
+ throw new ToolspaceTokenPathError(`${label} must be absolute or use the trusted $HOME/ prefix`);
58
+ }
59
+ return posixPath.isAbsolute(value) ? posixPath.normalize(value) : value;
60
+ }
61
+
62
+ export function shellToolspacePath(value: string): string {
63
+ const validated = assertToolspaceFilePath(value, "Toolspace token file");
64
+ return isHomeRelativeToolspacePath(validated) ? `"${validated}"` : shellQuote(validated);
65
+ }
66
+
36
67
  /**
37
68
  * Derive the per-session token file beside the legacy manifest pointer.
38
69
  *
@@ -44,14 +75,10 @@ function assertToolspaceSessionId(sessionId: string): string {
44
75
  * authority boundary for Toolspace calls.
45
76
  */
46
77
  export function toolspaceTokenFileForSession(manifestTokenFile: string, sessionId: string): string {
47
- if (
48
- !manifestTokenFile ||
49
- manifestTokenFile.includes("\0") ||
50
- !posixPath.isAbsolute(manifestTokenFile)
51
- ) {
52
- throw new ToolspaceTokenPathError("Toolspace manifest token file must be absolute");
53
- }
54
- const normalizedManifestFile = posixPath.normalize(manifestTokenFile);
78
+ const normalizedManifestFile = assertToolspaceFilePath(
79
+ manifestTokenFile,
80
+ "Toolspace manifest token file",
81
+ );
55
82
  const digest = createHash("sha256").update(assertToolspaceSessionId(sessionId)).digest("hex");
56
83
  return posixPath.join(posixPath.dirname(normalizedManifestFile), "toolspace-tokens", digest);
57
84
  }
@@ -68,10 +95,7 @@ export function toolspaceTokenFileFromEnvironment(
68
95
 
69
96
  /** Prefix one sandbox command with its session-specific Toolspace pointer. */
70
97
  export function withToolspaceTokenEnvironment(cmd: string, tokenFile: string): string {
71
- if (!tokenFile || tokenFile.includes("\0") || !posixPath.isAbsolute(tokenFile)) {
72
- throw new ToolspaceTokenPathError("Toolspace token file must be absolute");
73
- }
74
- return [`export OPENGENI_TOOLSPACE_TOKEN_FILE=${shellQuote(tokenFile)}`, cmd].join("\n");
98
+ return [`export OPENGENI_TOOLSPACE_TOKEN_FILE=${shellToolspacePath(tokenFile)}`, cmd].join("\n");
75
99
  }
76
100
 
77
101
  /** Preserve provider identity/capabilities while decorating command creation. */
@@ -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
+ }