@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.
- package/dist/{chunk-3NUSY22C.js → chunk-HHWIZHQM.js} +26 -9
- package/dist/chunk-HHWIZHQM.js.map +1 -0
- package/dist/index.d.ts +17 -6
- package/dist/index.js +287 -15
- package/dist/index.js.map +1 -1
- package/dist/sandbox/index.js +1 -1
- package/package.json +3 -3
- package/src/context-compaction.ts +0 -5
- package/src/index.ts +138 -9
- package/src/sandbox/toolspace-token.ts +36 -12
- package/src/workspace-skills.ts +235 -0
- package/dist/chunk-3NUSY22C.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-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.
|
|
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.
|
|
54
|
-
"@opengeni/contracts": "^0.
|
|
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(
|
|
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.
|
|
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=${
|
|
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=${
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
+
}
|