@opengeni/runtime 2.5.3-canary.0 → 2.5.3-canary.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.
@@ -31,6 +31,11 @@ import {
31
31
  type StreamChannel,
32
32
  } from "@opengeni/agent-proto";
33
33
  import { DESKTOP_STREAM_PORT } from "@opengeni/contracts";
34
+ import {
35
+ SELFHOSTED_FILE_CHUNK_BYTES,
36
+ fileContentDigest,
37
+ transferEditorFile,
38
+ } from "./file-transfer";
34
39
  // `Manifest` from the ALLOWED sandbox-leaf entrypoint (`@openai/agents/sandbox`
35
40
  // re-exports `@openai/agents-core/sandbox`, which exports the Manifest class) —
36
41
  // NOT the agent-loop `@openai/agents` root the sandbox leaf forbids. The live
@@ -262,6 +267,7 @@ export interface SelfhostedBackgroundCommandAdoption {
262
267
  /** Exact command-admission snapshot. A worker resolves this immediately before
263
268
  * every exec/Git admission; retries and already-started operations retain it. */
264
269
  export interface SelfhostedOperationAdmission {
270
+ transactionalFsWriteSupported?: boolean;
265
271
  /** Workspace segment of the physical agent/relay route. Personal machines may
266
272
  * originate in a different same-organization workspace than the session. */
267
273
  workspaceId?: string;
@@ -284,6 +290,8 @@ export interface SelfhostedOperationAdmission {
284
290
  }
285
291
 
286
292
  export interface SelfhostedSessionDeps {
293
+ /** Exact live daemon capability; absent preserves legacy small-file writes. */
294
+ transactionalFsWriteSupported?: boolean;
287
295
  /** Authorization/session workspace. */
288
296
  workspaceId: string;
289
297
  /** Physical control-plane workspace. Defaults to workspaceId for legacy and
@@ -488,6 +496,7 @@ export class SelfhostedSession {
488
496
  private readonly resourcePolicy: OperationResourcePolicy | undefined;
489
497
  private readonly resourcePolicySupported: boolean;
490
498
  private readonly operationCpuQuotaSupported: boolean;
499
+ private readonly transactionalFsWriteSupported: boolean;
491
500
  private readonly defaultOpStream: SelfhostedOpStreamDeps | undefined;
492
501
  private readonly connectionInstanceId: string;
493
502
  private readonly resolveOperationAdmission:
@@ -560,6 +569,7 @@ export class SelfhostedSession {
560
569
  this.resourcePolicy = normalizeOperationResourcePolicy(deps.operationResourcePolicy);
561
570
  this.resourcePolicySupported = deps.operationResourcePolicySupported === true;
562
571
  this.operationCpuQuotaSupported = deps.operationCpuQuotaSupported === true;
572
+ this.transactionalFsWriteSupported = deps.transactionalFsWriteSupported === true;
563
573
  this.defaultOpStream = deps.opStream;
564
574
  this.connectionInstanceId = deps.connectionInstanceId;
565
575
  this.resolveOperationAdmission = deps.resolveOperationAdmission;
@@ -577,6 +587,7 @@ export class SelfhostedSession {
577
587
  subject: this.subject,
578
588
  resourcePolicy: this.resourcePolicy,
579
589
  opStream: deps.opStream,
590
+ transactionalFsWriteSupported: this.transactionalFsWriteSupported,
580
591
  })
581
592
  : undefined;
582
593
  // A valid Manifest whose root is the machine's actual effective root. There
@@ -644,6 +655,7 @@ export class SelfhostedSession {
644
655
  }
645
656
 
646
657
  private async admitOperation(commandPolicy: boolean): Promise<{
658
+ transactionalFsWriteSupported: boolean;
647
659
  controlWorkspaceId: string;
648
660
  connectionInstanceId: string;
649
661
  subject: string;
@@ -664,6 +676,7 @@ export class SelfhostedSession {
664
676
  subject: this.subject,
665
677
  resourcePolicy: commandPolicy ? this.resourcePolicy : undefined,
666
678
  opStream: this.defaultOpStream,
679
+ transactionalFsWriteSupported: this.transactionalFsWriteSupported,
667
680
  };
668
681
  }
669
682
 
@@ -698,6 +711,7 @@ export class SelfhostedSession {
698
711
  ),
699
712
  resourcePolicy: commandPolicy ? resourcePolicy : undefined,
700
713
  opStream: resolved.opStream,
714
+ transactionalFsWriteSupported: resolved.transactionalFsWriteSupported === true,
701
715
  };
702
716
  }
703
717
 
@@ -879,7 +893,9 @@ export class SelfhostedSession {
879
893
  machineId: this.agentId,
880
894
  faultClass: selfhostedFaultClass(error),
881
895
  // Known only on a PAYLOAD_TOO_LARGE fault (the agent's encoded_bytes detail).
882
- ...(Number.isFinite(encoded) ? { replyBytes: encoded } : {}),
896
+ ...(Number.isFinite(encoded) && error.detail.direction !== "request"
897
+ ? { replyBytes: encoded }
898
+ : {}),
883
899
  });
884
900
  // Fold the retry count into the DRAINING copy when we actually retried, so
885
901
  // the surfaced message reads "…retried N times first…".
@@ -1324,8 +1340,169 @@ export class SelfhostedSession {
1324
1340
  const pathExists = (path: string): Promise<boolean> => this.pathExists(path, runAs);
1325
1341
  const readText = async (path: string): Promise<string> =>
1326
1342
  decoder.decode(await this.readFile({ path, ...(runAs ? { runAs } : {}) }));
1327
- const writeText = async (path: string, content: string): Promise<void> => {
1328
- await this.writeFile({ path, content, createParents: true });
1343
+ const writeText = async (
1344
+ path: string,
1345
+ content: string,
1346
+ base?: string | (() => Promise<string | undefined>),
1347
+ ): Promise<((source: string, sourceBase: string) => Promise<void>) | undefined> => {
1348
+ const bytes = encoder.encode(content);
1349
+ if (bytes.byteLength <= SELFHOSTED_FILE_CHUNK_BYTES) {
1350
+ await this.writeFile({ path, content, createParents: true });
1351
+ return;
1352
+ }
1353
+ const initial = await this.admitOperation(false);
1354
+ if (!initial.transactionalFsWriteSupported) {
1355
+ // Older agents may have a larger negotiated message budget. Preserve
1356
+ // their existing writes; a real size rejection remains a typed fault.
1357
+ await this.writeFile({ path, content, createParents: true });
1358
+ return;
1359
+ }
1360
+ if (runAs) {
1361
+ throw new SelfhostedControlError({
1362
+ code: ErrorCode.ERROR_CODE_UNSUPPORTED,
1363
+ message:
1364
+ "Transactional file editing does not support runAs impersonation; no file was written.",
1365
+ reason: null,
1366
+ retryable: false,
1367
+ });
1368
+ }
1369
+ const requestTransfer = async (
1370
+ requestId: string,
1371
+ op: NonNullable<ControlRequest["op"]>,
1372
+ ): Promise<ControlResponse> => {
1373
+ const startedAt = Date.now();
1374
+ try {
1375
+ const current = await this.admitOperation(false);
1376
+ if (
1377
+ !current.transactionalFsWriteSupported ||
1378
+ current.subject !== initial.subject ||
1379
+ current.connectionInstanceId !== initial.connectionInstanceId
1380
+ ) {
1381
+ throw new Error(
1382
+ "The authorized machine connection changed during the file transfer; the operation was not replayed.",
1383
+ );
1384
+ }
1385
+ const response = await this.controlRpc.request(
1386
+ current.subject,
1387
+ {
1388
+ requestId,
1389
+ epoch: this.epoch,
1390
+ op,
1391
+ resourcePolicy: undefined,
1392
+ },
1393
+ { timeoutMs: this.timeoutMs },
1394
+ );
1395
+ if (response.requestId !== requestId)
1396
+ throw new Error("The machine returned a mismatched file-transfer response identity.");
1397
+ const error = response.error
1398
+ ? agentErrorToControlError(response.error, requestId)
1399
+ : undefined;
1400
+ this.emitOp({
1401
+ op: op.$case,
1402
+ outcome: error || !response.result ? "failed" : "ok",
1403
+ healed: false,
1404
+ retries: 0,
1405
+ durationMs: Date.now() - startedAt,
1406
+ machineId: this.agentId,
1407
+ ...(error
1408
+ ? { code: error.code, reason: error.reason, faultClass: selfhostedFaultClass(error) }
1409
+ : {}),
1410
+ });
1411
+ return response;
1412
+ } catch (error) {
1413
+ this.emitOp({
1414
+ op: op.$case,
1415
+ outcome: "failed",
1416
+ healed: false,
1417
+ retries: 0,
1418
+ durationMs: Date.now() - startedAt,
1419
+ machineId: this.agentId,
1420
+ ...(error instanceof SelfhostedControlError
1421
+ ? { code: error.code, reason: error.reason, faultClass: selfhostedFaultClass(error) }
1422
+ : {}),
1423
+ });
1424
+ throw error;
1425
+ }
1426
+ };
1427
+ const resolvedBase = typeof base === "function" ? await base() : base;
1428
+ const transferStartedAt = Date.now();
1429
+ try {
1430
+ const result = await transferEditorFile({
1431
+ path: resolveConnectedMachinePath(this.workspaceRoot, path),
1432
+ content: bytes,
1433
+ ...(resolvedBase === undefined ? {} : { baseContent: encoder.encode(resolvedBase) }),
1434
+ request: requestTransfer,
1435
+ });
1436
+ // Unlike physical request observations, this is a verified file outcome.
1437
+ this.emitOp({
1438
+ op: "fsWrite",
1439
+ outcome: "ok",
1440
+ healed: result.recovered,
1441
+ retries: 0,
1442
+ durationMs: Date.now() - transferStartedAt,
1443
+ machineId: this.agentId,
1444
+ });
1445
+ } catch (error) {
1446
+ this.emitOp({
1447
+ op: "fsWrite",
1448
+ outcome: "failed",
1449
+ healed: false,
1450
+ retries: 0,
1451
+ durationMs: Date.now() - transferStartedAt,
1452
+ machineId: this.agentId,
1453
+ });
1454
+ throw error;
1455
+ }
1456
+ return async (source, sourceBase) => {
1457
+ // A move is verified destination replacement followed by source cleanup,
1458
+ // not an atomic rename. Never retarget cleanup after a runner change.
1459
+ // The source check and deletion are not CAS against unrelated writers.
1460
+ try {
1461
+ const sourcePath = resolveConnectedMachinePath(this.workspaceRoot, source);
1462
+ const sourceBytes: Uint8Array[] = [];
1463
+ let offset = 0;
1464
+ let total = Infinity;
1465
+ while (offset < total) {
1466
+ const reply = await requestTransfer(crypto.randomUUID(), {
1467
+ $case: "fsRead",
1468
+ fsRead: {
1469
+ path: sourcePath,
1470
+ offset: String(offset),
1471
+ length: String(SELFHOSTED_FILE_CHUNK_BYTES),
1472
+ },
1473
+ });
1474
+ if (reply.error) throw agentErrorToControlError(reply.error);
1475
+ if (reply.result?.$case !== "fsRead")
1476
+ throw new Error("Source verification response missing");
1477
+ const part = reply.result.fsRead;
1478
+ total = safeWireSize(part.totalSize, "source file size");
1479
+ if (!part.content.length && offset < total)
1480
+ throw new Error("Source verification was incomplete");
1481
+ sourceBytes.push(part.content);
1482
+ offset += part.content.length;
1483
+ }
1484
+ const actual = new Uint8Array(offset);
1485
+ let cursor = 0;
1486
+ for (const chunk of sourceBytes) {
1487
+ actual.set(chunk, cursor);
1488
+ cursor += chunk.length;
1489
+ }
1490
+ if (fileContentDigest(actual) !== fileContentDigest(encoder.encode(sourceBase)))
1491
+ throw new Error("Source changed before move cleanup");
1492
+ const reply = await requestTransfer(crypto.randomUUID(), {
1493
+ $case: "fsRemove",
1494
+ fsRemove: { path: sourcePath, recursive: false },
1495
+ });
1496
+ if (reply.error) throw agentErrorToControlError(reply.error);
1497
+ if (reply.result?.$case !== "fsRemove")
1498
+ throw new Error("Source cleanup response missing");
1499
+ } catch (cause) {
1500
+ throw new Error(
1501
+ "The destination edit was verified, but source cleanup was not verified. The move was not replayed; inspect both paths.",
1502
+ { cause },
1503
+ );
1504
+ }
1505
+ };
1329
1506
  };
1330
1507
  const deletePath = async (path: string): Promise<void> => {
1331
1508
  const result = await this.call({
@@ -1351,9 +1528,15 @@ export class SelfhostedSession {
1351
1528
  const current = await readText(operation.path);
1352
1529
  const next = applyDiff(current, operation.diff);
1353
1530
  const destination = operation.moveTo ?? operation.path;
1354
- await writeText(destination, next);
1531
+ const destinationBase =
1532
+ destination === operation.path
1533
+ ? current
1534
+ : async () =>
1535
+ (await pathExists(destination)) ? await readText(destination) : undefined;
1536
+ const cleanup = await writeText(destination, next, destinationBase);
1355
1537
  if (operation.moveTo && destination !== operation.path) {
1356
- await deletePath(operation.path);
1538
+ if (cleanup) await cleanup(operation.path, current);
1539
+ else await deletePath(operation.path);
1357
1540
  }
1358
1541
  return {};
1359
1542
  },
@@ -1708,6 +1891,7 @@ export class SelfhostedSandboxClient {
1708
1891
  private readonly operationResourcePolicy: SelfhostedOperationResourcePolicy | undefined;
1709
1892
  private readonly operationResourcePolicySupported: boolean | undefined;
1710
1893
  private readonly operationCpuQuotaSupported: boolean | undefined;
1894
+ private readonly transactionalFsWriteSupported: boolean | undefined;
1711
1895
  private readonly resolveOperationAdmission:
1712
1896
  | (() => Promise<SelfhostedOperationAdmission | null>)
1713
1897
  | undefined;
@@ -1748,6 +1932,7 @@ export class SelfhostedSandboxClient {
1748
1932
  operationResourcePolicy?: SelfhostedOperationResourcePolicy;
1749
1933
  operationResourcePolicySupported?: boolean;
1750
1934
  operationCpuQuotaSupported?: boolean;
1935
+ transactionalFsWriteSupported?: boolean;
1751
1936
  resolveOperationAdmission?: () => Promise<SelfhostedOperationAdmission | null>;
1752
1937
  adoptBackgroundCommand?: SelfhostedSessionDeps["adoptBackgroundCommand"];
1753
1938
  settleBackgroundCommand?: SelfhostedSessionDeps["settleBackgroundCommand"];
@@ -1779,6 +1964,7 @@ export class SelfhostedSandboxClient {
1779
1964
  this.operationResourcePolicy = opts.operationResourcePolicy;
1780
1965
  this.operationResourcePolicySupported = opts.operationResourcePolicySupported;
1781
1966
  this.operationCpuQuotaSupported = opts.operationCpuQuotaSupported;
1967
+ this.transactionalFsWriteSupported = opts.transactionalFsWriteSupported;
1782
1968
  this.resolveOperationAdmission = opts.resolveOperationAdmission;
1783
1969
  this.adoptBackgroundCommand = opts.adoptBackgroundCommand;
1784
1970
  this.settleBackgroundCommand = opts.settleBackgroundCommand;
@@ -1822,6 +2008,7 @@ export class SelfhostedSandboxClient {
1822
2008
  ...(this.operationCpuQuotaSupported !== undefined
1823
2009
  ? { operationCpuQuotaSupported: this.operationCpuQuotaSupported }
1824
2010
  : {}),
2011
+ transactionalFsWriteSupported: this.transactionalFsWriteSupported === true,
1825
2012
  ...(this.resolveOperationAdmission !== undefined
1826
2013
  ? { resolveOperationAdmission: this.resolveOperationAdmission }
1827
2014
  : {}),
@@ -1941,6 +2128,7 @@ export interface SelfhostedSessionBuild {
1941
2128
  operationResourcePolicySupported?: boolean;
1942
2129
  /** Exact live CPU enforcement capability paired with the initial snapshot. */
1943
2130
  operationCpuQuotaSupported?: boolean;
2131
+ transactionalFsWriteSupported?: boolean;
1944
2132
  /** Live last-boundary operation admission resolver; see SelfhostedSessionDeps. */
1945
2133
  resolveOperationAdmission?: () => Promise<SelfhostedOperationAdmission | null>;
1946
2134
  adoptBackgroundCommand?: SelfhostedSessionDeps["adoptBackgroundCommand"];
@@ -1996,6 +2184,7 @@ export async function buildSelfhostedBackendSession(
1996
2184
  ...(deps.operationCpuQuotaSupported !== undefined
1997
2185
  ? { operationCpuQuotaSupported: deps.operationCpuQuotaSupported }
1998
2186
  : {}),
2187
+ transactionalFsWriteSupported: deps.transactionalFsWriteSupported === true,
1999
2188
  ...(deps.resolveOperationAdmission !== undefined
2000
2189
  ? { resolveOperationAdmission: deps.resolveOperationAdmission }
2001
2190
  : {}),
@@ -1,4 +1,16 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { tool, type Tool } from "@openai/agents";
3
+ import { parseSkillFrontmatter } from "@opengeni/contracts";
4
+ import { z } from "zod";
5
+ import {
6
+ assertSkillRelativePath,
7
+ listSkillPaths,
8
+ readSkillFiles,
9
+ SkillFileError,
10
+ SKILL_READ_MAX_PATHS,
11
+ SKILL_READ_MAX_OUTPUT_BYTES,
12
+ type SkillTextFile,
13
+ } from "./skill-files";
2
14
 
3
15
  import {
4
16
  Capability,
@@ -64,24 +76,94 @@ export class WorkspaceSkillsCapability extends Capability {
64
76
  super();
65
77
  }
66
78
 
67
- override async instructions(): Promise<string | null> {
79
+ override bind(session: SandboxSessionLike): this {
80
+ if (this._session !== session) delete this.discovery;
81
+ return super.bind(session);
82
+ }
83
+
84
+ private discover(): Promise<readonly WorkspaceSkill[]> {
68
85
  const session = requireWorkspaceSkillSession(this._session);
69
- this.discovery ??= discoverWorkspaceSkills(
86
+ return (this.discovery ??= discoverWorkspaceSkills(
70
87
  session,
71
88
  this.searchPaths,
72
89
  this.reservedNames,
73
90
  this._runAs,
74
91
  this.shadowedNames,
75
- );
76
- const skills = await this.discovery;
92
+ ));
93
+ }
94
+
95
+ override tools(): Tool<unknown>[] {
96
+ return [
97
+ tool({
98
+ name: "repository_skill_read",
99
+ description:
100
+ "Read current repository Skill files from the bound workspace. Use the exact repository: identifier from the Repository skills index. Omit paths for SKILL.md; explicit relative paths read only those files. Set listFiles:true without paths for a bounded file inventory. This reads live files through the sandbox, including Connected Machines.",
101
+ parameters: z.object({
102
+ skill: z.string().min(1),
103
+ paths: z.array(z.string().min(1).max(1024)).min(1).max(SKILL_READ_MAX_PATHS).optional(),
104
+ listFiles: z.boolean().optional(),
105
+ }),
106
+ execute: async (request) => {
107
+ if (request.listFiles && request.paths !== undefined)
108
+ throw new SkillFileError(
109
+ "invalid_request",
110
+ "listFiles:true cannot be combined with paths.",
111
+ );
112
+ const skill = (await this.discover()).find(
113
+ (entry) => repositorySkillId(entry) === request.skill,
114
+ );
115
+ if (!skill)
116
+ throw new SkillFileError(
117
+ "missing_file",
118
+ "Repository Skill is not in this index. Use its exact repository: identifier.",
119
+ );
120
+ const session = requireWorkspaceSkillSession(this._session);
121
+ const root = skill.path.slice(0, -SKILL_FILE.length - 1);
122
+ const result = request.listFiles
123
+ ? listSkillPaths(
124
+ await repositorySkillInventory(session, root, this._runAs),
125
+ MAX_SKILL_ENTRIES,
126
+ )
127
+ : readSkillFiles(
128
+ await repositorySkillFiles(
129
+ session,
130
+ root,
131
+ request.paths ?? [SKILL_FILE],
132
+ this._runAs,
133
+ ),
134
+ request.paths,
135
+ );
136
+ const output = JSON.stringify({
137
+ skillId: request.skill,
138
+ source: "repository",
139
+ root,
140
+ ...result,
141
+ });
142
+ if (new TextEncoder().encode(output).byteLength > SKILL_READ_MAX_OUTPUT_BYTES) {
143
+ throw new SkillFileError(
144
+ "output_too_large",
145
+ "Repository Skill response exceeds the read output limit. Request fewer paths.",
146
+ );
147
+ }
148
+ return output;
149
+ },
150
+ }),
151
+ ];
152
+ }
153
+
154
+ override async instructions(): Promise<string | null> {
155
+ const skills = await this.discover();
77
156
  if (skills.length === 0) return null;
78
157
 
79
158
  const available = skills
80
- .map((skill) => `- ${skill.name}: ${skill.description} (file: ${skill.path})`)
159
+ .map(
160
+ (skill) =>
161
+ `- ${JSON.stringify({ id: repositorySkillId(skill), name: skill.name, description: skill.description, reader: "repository_skill_read", path: skill.path })}`,
162
+ )
81
163
  .join("\n");
82
164
  return `## Repository skills
83
165
 
84
- Repository skills are instructions already present in the live workspace. They do not need to be loaded or copied.
166
+ Repository skills are instructions already present in the live workspace. Read them with repository_skill_read using the exact repository: id below. The reader returns current live files; ordinary filesystem tools execute referenced scripts and read binary assets. Configured Skills use their separate skill_read index.
85
167
 
86
168
  ### Available repository skills
87
169
  ${available}
@@ -165,6 +247,18 @@ async function discoverWorkspaceSkillsUnmeasured(
165
247
  const skillMarkdownPath = joinWorkspacePath(entry.path, SKILL_FILE);
166
248
  let markdown: string;
167
249
  try {
250
+ // Match reader/inventory eligibility: symlink entrypoints are not
251
+ // advertised because the portable filesystem API cannot prove containment.
252
+ const skillEntries = await session.listDir({
253
+ path: entry.path,
254
+ ...(runAs ? { runAs } : {}),
255
+ });
256
+ if (
257
+ !skillEntries.some(
258
+ (candidate) => candidate.name === SKILL_FILE && candidate.type === "file",
259
+ )
260
+ )
261
+ continue;
168
262
  const content = await session.readFile({
169
263
  path: skillMarkdownPath,
170
264
  ...(runAs ? { runAs } : {}),
@@ -182,7 +276,7 @@ async function discoverWorkspaceSkillsUnmeasured(
182
276
  const key = name.toLowerCase();
183
277
  if (shadowedNames.has(key)) continue;
184
278
  const description = frontmatter.description?.trim() || "No description provided.";
185
- if (description.length > 2_048 || /[\r\n]/.test(description)) {
279
+ if (description.length > 2_048) {
186
280
  throw new Error(`Repository skill "${name}" has an invalid description`);
187
281
  }
188
282
  if (reservedNames.has(key)) {
@@ -267,34 +361,80 @@ async function fingerprintWorkspaceDirectory(
267
361
  return hash.digest("hex");
268
362
  }
269
363
 
270
- function parseSkillFrontmatter(markdown: string): {
271
- name?: string;
272
- description?: string;
273
- } {
274
- const lines = markdown.split(/\r?\n/);
275
- if (lines[0]?.trim() !== "---") return {};
276
- const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
277
- if (end === -1) return {};
278
- const values: Record<string, string> = {};
279
- for (const line of lines.slice(1, end)) {
280
- const separator = line.indexOf(":");
281
- if (separator === -1) continue;
282
- const key = line.slice(0, separator).trim();
283
- if (key !== "name" && key !== "description") continue;
284
- values[key] = unquote(line.slice(separator + 1).trim());
364
+ function repositorySkillId(skill: WorkspaceSkill): string {
365
+ return `repository:${skill.path}`;
366
+ }
367
+
368
+ /** Resolve only ordinary directory entries under the discovered Skill root.
369
+ * Never treat a caller identifier as a path or follow a listed symlink. The
370
+ * bound sandbox remains the filesystem authority, including concurrent edits.
371
+ */
372
+ async function repositorySkillFiles(
373
+ session: SandboxSessionLike,
374
+ root: string,
375
+ paths: readonly string[],
376
+ runAs?: string,
377
+ ): Promise<SkillTextFile[]> {
378
+ // Validate the entire request before touching the filesystem.
379
+ readSkillFiles(
380
+ paths.map((path) => ({ path, content: "" })),
381
+ paths,
382
+ );
383
+ const files: SkillTextFile[] = [];
384
+ for (const path of paths) {
385
+ let directory = root;
386
+ const segments = path.split("/");
387
+ for (const [index, name] of segments.entries()) {
388
+ const entries = await session.listDir!({ path: directory, ...(runAs ? { runAs } : {}) });
389
+ const entry = entries.find((candidate) => candidate.name === name);
390
+ const expected = index === segments.length - 1 ? "file" : "dir";
391
+ if (entry?.type !== expected)
392
+ throw new SkillFileError("missing_file", `Repository Skill file is unavailable: ${path}`);
393
+ directory = joinWorkspacePath(directory, name);
394
+ }
395
+ const content = await session.readFile!({ path: directory, ...(runAs ? { runAs } : {}) });
396
+ files.push({
397
+ path,
398
+ content:
399
+ typeof content === "string"
400
+ ? content
401
+ : new TextDecoder("utf-8", { fatal: true }).decode(content),
402
+ });
403
+ // Bound aggregate output as each file arrives; do not truncate accepted text.
404
+ readSkillFiles(
405
+ files,
406
+ files.map((file) => file.path),
407
+ );
285
408
  }
286
- return values;
409
+ return files;
287
410
  }
288
411
 
289
- function unquote(value: string): string {
290
- if (
291
- value.length >= 2 &&
292
- value[0] === value[value.length - 1] &&
293
- (value[0] === '"' || value[0] === "'")
294
- ) {
295
- return value.slice(1, -1);
412
+ async function repositorySkillInventory(
413
+ session: SandboxSessionLike,
414
+ root: string,
415
+ runAs?: string,
416
+ ): Promise<SkillTextFile[]> {
417
+ const files: SkillTextFile[] = [];
418
+ let count = 0;
419
+ async function visit(relative: string): Promise<void> {
420
+ const entries = await session.listDir!({
421
+ path: relative ? joinWorkspacePath(root, relative) : root,
422
+ ...(runAs ? { runAs } : {}),
423
+ });
424
+ for (const entry of entries) {
425
+ if (++count > MAX_SKILL_ENTRIES)
426
+ throw new SkillFileError("output_too_large", "Repository Skill inventory is too large.");
427
+ // Paths derive from validated names, never provider-returned absolute paths.
428
+ assertSkillRelativePath(entry.name);
429
+ if (entry.name.includes("/"))
430
+ throw new SkillFileError("invalid_path", "Invalid repository directory entry.");
431
+ const path = relative ? `${relative}/${entry.name}` : entry.name;
432
+ if (entry.type === "dir") await visit(path);
433
+ else if (entry.type === "file") files.push({ path, content: "" });
434
+ }
296
435
  }
297
- return value;
436
+ await visit("");
437
+ return files;
298
438
  }
299
439
 
300
440
  function joinWorkspacePath(parent: string, child: string): string {