@runuai/host 0.9.171 → 0.9.172

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.
@@ -1,3 +1,10 @@
1
+ import { LEASE_PARAMETER_ROOT, BROKER_PARAMETER_ROOT, MAX_CREDENTIAL_ID_CHARS, MAX_PARAMETER_NAME_CHARS, leaseParameterName, brokerParameterName, idPathSegment, parseIdPathSegment, isEngineKind, parseLeaseParameterName, parseBrokerParameterName, sameLocation, isIdValue, isTagValue, type CredentialClass, type CredentialScope, type CredentialLocation } from "./credential-location";
2
+ export * from "./credential-location";
3
+ import { parseCaptureFlowWire, parseCaptureResultWire, type CaptureFlowWire, type CaptureResultWire } from "./capture-wire";
4
+ export * from "./capture-wire";
5
+ import { exactObject, isPositiveInteger, isEpochMs } from "./credential-wire-validation";
6
+ import { parseCustomConnectionMaterial, parseCustomConnectionLease, parseCustomConnectionEnabled, type CustomConnectionMaterial, type CustomConnectionLease, type CustomConnectionCombination } from "./custom-connections";
7
+ export * from "./custom-connections";
1
8
  import { MAX_TASK_SECRETS, TASK_SECRET_NAME, TASK_SECRET_STAGING_ID, type TaskSecretManifestEntry } from "./task-secrets";
2
9
 
3
10
  /**
@@ -55,233 +62,6 @@ export const TASK_MACHINE_CREDENTIALS_V1_ENV = "UAI_TASK_MACHINE_CREDENTIALS_V1"
55
62
  // Parameter layout
56
63
  // ---------------------------------------------------------------------------
57
64
 
58
- /** What a task may receive: a static key, or an OAuth login's brokered copy. */
59
- export const LEASE_PARAMETER_ROOT = "/uai/lease";
60
- /**
61
- * The full OAuth material only the broker reads. Outside `/uai` altogether:
62
- * `GetParametersByPath` returns every descendant of a path its caller may
63
- * read, even one a Deny names, so containment comes from the root alone.
64
- */
65
- export const BROKER_PARAMETER_ROOT = "/uai-broker";
66
-
67
- export type CredentialClass = "engine" | "mcp" | "github" | "ssh";
68
- export type CredentialScope = "org" | "user";
69
-
70
- /** Where one revision of one credential lives. */
71
- export interface CredentialLocation {
72
- class: CredentialClass;
73
- /** `github` and `ssh` are always `user`: one per person. */
74
- scope: CredentialScope;
75
- /** The org id for `org` scope, the user id for `user` scope. */
76
- ownerId: string;
77
- /** `engine` only: the engine kind (`codex`, `claude`, …). */
78
- engineKind?: string;
79
- /** `engine` and `mcp` only: the credential's stable id. */
80
- credentialId?: string;
81
- /** One immutable revision of the material. */
82
- revision: string;
83
- }
84
-
85
- /** SSM allows `[A-Za-z0-9_.-]` in a path segment. An id that already fits,
86
- * starts with a letter or digit and has no dot is used as it is; any other
87
- * id is `-` followed by the hex of its UTF-8 bytes. No plain id starts with
88
- * `-`, so the mapping is injective. Legacy workspace ids
89
- * (`personal:<user>`) take the second form. */
90
- const PLAIN_ID_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
91
- const ENCODED_ID_SEGMENT = /^-(?:[0-9a-f]{2})+$/;
92
- const ENGINE_KIND = /^[a-z][a-z0-9-]{0,31}$/;
93
- export const MAX_CREDENTIAL_ID_CHARS = 128;
94
- /** SSM's limit for a parameter name, its ARN included. */
95
- export const MAX_PARAMETER_NAME_CHARS = 1011;
96
-
97
- function isIdValue(value: unknown): value is string {
98
- return (
99
- typeof value === "string" &&
100
- value.length > 0 &&
101
- value.length <= MAX_CREDENTIAL_ID_CHARS &&
102
- !/[\u0000-\u001f\u007f]/.test(value)
103
- );
104
- }
105
-
106
- function utf8Hex(value: string): string {
107
- let out = "";
108
- for (const byte of new TextEncoder().encode(value)) {
109
- out += byte.toString(16).padStart(2, "0");
110
- }
111
- return out;
112
- }
113
-
114
- function fromUtf8Hex(hex: string): string | null {
115
- const bytes = new Uint8Array(hex.length / 2);
116
- for (let i = 0; i < bytes.length; i++) {
117
- bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
118
- }
119
- try {
120
- return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
121
- } catch {
122
- return null;
123
- }
124
- }
125
-
126
- /** The path segment for an id (see `PLAIN_ID_SEGMENT`). Throws on an id no
127
- * segment may carry: empty, too long, or containing control characters. */
128
- export function idPathSegment(id: string): string {
129
- if (!isIdValue(id)) throw new Error("invalid credential path id");
130
- return PLAIN_ID_SEGMENT.test(id) ? id : `-${utf8Hex(id)}`;
131
- }
132
-
133
- /** Inverse of `idPathSegment`; null for a segment it never produces. */
134
- export function parseIdPathSegment(segment: string): string | null {
135
- if (PLAIN_ID_SEGMENT.test(segment)) {
136
- return segment.length <= MAX_CREDENTIAL_ID_CHARS ? segment : null;
137
- }
138
- if (!ENCODED_ID_SEGMENT.test(segment)) return null;
139
- const id = fromUtf8Hex(segment.slice(1));
140
- if (id === null || !isIdValue(id) || PLAIN_ID_SEGMENT.test(id)) return null;
141
- return id;
142
- }
143
-
144
- export function isEngineKind(value: unknown): value is string {
145
- return typeof value === "string" && ENGINE_KIND.test(value);
146
- }
147
-
148
- function locationSegments(location: CredentialLocation): string[] {
149
- const owner = location.scope === "org" ? "orgs" : "users";
150
- const segments = [owner, idPathSegment(location.ownerId)];
151
- switch (location.class) {
152
- case "engine": {
153
- if (!isEngineKind(location.engineKind)) {
154
- throw new Error("invalid engine kind");
155
- }
156
- if (location.credentialId === undefined) {
157
- throw new Error("an engine credential needs its id");
158
- }
159
- segments.push(
160
- "engines",
161
- location.engineKind,
162
- idPathSegment(location.credentialId),
163
- );
164
- break;
165
- }
166
- case "mcp": {
167
- if (location.engineKind !== undefined) {
168
- throw new Error("an MCP credential has no engine kind");
169
- }
170
- if (location.credentialId === undefined) {
171
- throw new Error("an MCP credential needs its id");
172
- }
173
- segments.push("mcp", idPathSegment(location.credentialId));
174
- break;
175
- }
176
- case "github":
177
- case "ssh": {
178
- if (location.scope !== "user") {
179
- throw new Error(`${location.class} credentials belong to a user`);
180
- }
181
- if (
182
- location.engineKind !== undefined ||
183
- location.credentialId !== undefined
184
- ) {
185
- throw new Error(`${location.class} has one credential per person`);
186
- }
187
- segments.push(location.class);
188
- break;
189
- }
190
- default:
191
- throw new Error("invalid credential class");
192
- }
193
- segments.push(idPathSegment(location.revision));
194
- return segments;
195
- }
196
-
197
- function nameUnder(root: string, location: CredentialLocation): string {
198
- const name = `${root}/${locationSegments(location).join("/")}`;
199
- if (name.length > MAX_PARAMETER_NAME_CHARS) {
200
- throw new Error("credential parameter name too long");
201
- }
202
- return name;
203
- }
204
-
205
- /** The lease-readable parameter of one revision. */
206
- export function leaseParameterName(location: CredentialLocation): string {
207
- return nameUnder(LEASE_PARAMETER_ROOT, location);
208
- }
209
-
210
- /** The broker-only parameter of one revision of an OAuth credential. GitHub
211
- * tokens and SSH keys are static and have none. */
212
- export function brokerParameterName(location: CredentialLocation): string {
213
- if (location.class === "github" || location.class === "ssh") {
214
- throw new Error(`${location.class} credentials have no broker part`);
215
- }
216
- return nameUnder(BROKER_PARAMETER_ROOT, location);
217
- }
218
-
219
- function parseUnder(root: string, name: string): CredentialLocation | null {
220
- if (
221
- typeof name !== "string" ||
222
- name.length > MAX_PARAMETER_NAME_CHARS ||
223
- !name.startsWith(`${root}/`)
224
- ) {
225
- return null;
226
- }
227
- const parts = name.slice(root.length + 1).split("/");
228
- const [ownerKind, ownerSegment] = parts;
229
- const scope: CredentialScope | null =
230
- ownerKind === "orgs" ? "org" : ownerKind === "users" ? "user" : null;
231
- if (scope === null || ownerSegment === undefined) return null;
232
- const ownerId = parseIdPathSegment(ownerSegment);
233
- if (ownerId === null) return null;
234
- const revisionOf = (segment: string | undefined): string | null =>
235
- segment === undefined ? null : parseIdPathSegment(segment);
236
-
237
- if (parts[2] === "engines" && parts.length === 6) {
238
- const credentialId = parseIdPathSegment(parts[4]!);
239
- const revision = revisionOf(parts[5]);
240
- if (!isEngineKind(parts[3]) || credentialId === null || revision === null) {
241
- return null;
242
- }
243
- return {
244
- class: "engine",
245
- scope,
246
- ownerId,
247
- engineKind: parts[3],
248
- credentialId,
249
- revision,
250
- };
251
- }
252
- if (parts[2] === "mcp" && parts.length === 5) {
253
- const credentialId = parseIdPathSegment(parts[3]!);
254
- const revision = revisionOf(parts[4]);
255
- if (credentialId === null || revision === null) return null;
256
- return { class: "mcp", scope, ownerId, credentialId, revision };
257
- }
258
- if (
259
- (parts[2] === "github" || parts[2] === "ssh") &&
260
- parts.length === 4 &&
261
- scope === "user"
262
- ) {
263
- const revision = revisionOf(parts[3]);
264
- if (revision === null) return null;
265
- return { class: parts[2], scope, ownerId, revision };
266
- }
267
- return null;
268
- }
269
-
270
- export function parseLeaseParameterName(
271
- name: string,
272
- ): CredentialLocation | null {
273
- return parseUnder(LEASE_PARAMETER_ROOT, name);
274
- }
275
-
276
- export function parseBrokerParameterName(
277
- name: string,
278
- ): CredentialLocation | null {
279
- const location = parseUnder(BROKER_PARAMETER_ROOT, name);
280
- return location && (location.class === "engine" || location.class === "mcp")
281
- ? location
282
- : null;
283
- }
284
-
285
65
  // ---------------------------------------------------------------------------
286
66
  // Lease-readable values
287
67
  // ---------------------------------------------------------------------------
@@ -340,6 +120,7 @@ interface LeasedValueBase {
340
120
  }
341
121
 
342
122
  export type LeasedValue =
123
+ | (LeasedValueBase & { class: "engine"; authKind: "custom-connection"; connection: CustomConnectionMaterial })
343
124
  | (LeasedValueBase & { class: "engine"; authKind: "env"; env: Record<string, string> })
344
125
  | (LeasedValueBase & {
345
126
  class: "engine";
@@ -374,16 +155,6 @@ const FORBIDDEN_HEADER_NAMES = new Set([
374
155
  "upgrade",
375
156
  ]);
376
157
 
377
- function sameLocation(a: CredentialLocation, b: CredentialLocation): boolean {
378
- return (
379
- a.class === b.class &&
380
- a.scope === b.scope &&
381
- a.ownerId === b.ownerId &&
382
- a.engineKind === b.engineKind &&
383
- a.credentialId === b.credentialId &&
384
- a.revision === b.revision
385
- );
386
- }
387
158
 
388
159
  function parseLocationField(value: unknown): CredentialLocation | null {
389
160
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
@@ -475,6 +246,10 @@ export function parseLeasedValue(
475
246
  switch (location.class) {
476
247
  case "engine": {
477
248
  const kind = location.engineKind!;
249
+ if (o.authKind === "custom-connection") {
250
+ const connection = exactObject(o, ["v", "part", "location", "class", "authKind", "connection"]) ? parseCustomConnectionMaterial(o.connection) : null;
251
+ return connection && connection.engine === kind ? { ...base, class: "engine", authKind: "custom-connection", connection } : null;
252
+ }
478
253
  if (o.authKind === "env") {
479
254
  const env = exactObject(o, ["v", "part", "location", "class", "authKind", "env"])
480
255
  ? parseStringMap(o.env, ENGINE_ENV_CREDENTIAL_KEYS[kind] ?? [], 4096)
@@ -559,104 +334,6 @@ export function parseLeasedValue(
559
334
  /** Advertised by a sign-in box that writes captures as ADR-154 revisions. */
560
335
  export const SIGNIN_BOX_CAPTURE_FLOWS_FEATURE = "signin-box-capture-flows-v1";
561
336
 
562
- /**
563
- * What the cloud tells the sign-in box to write for one capture: the
564
- * revision's parameters (the box never chooses a name) and the ids it tags
565
- * them with. The box checks every field against the others before it writes.
566
- */
567
- export interface CaptureFlowWire {
568
- credentialId: string;
569
- revision: string;
570
- leaseParameter: string;
571
- /** Null for a static credential. */
572
- brokerParameter: string | null;
573
- /** The workspace tag: required except for GitHub and SSH. */
574
- orgId: string | null;
575
- /** The person, for a personal credential. */
576
- userId: string | null;
577
- }
578
-
579
- /** What the box answers once both parts are written: never material. */
580
- export interface CaptureResultWire {
581
- credentialId: string;
582
- revision: string;
583
- /** The lease-readable parameter's Parameter Store version. */
584
- leaseVersion: number;
585
- /** The access token's expiry when the box could read it. */
586
- expiresAt: number | null;
587
- }
588
-
589
- /**
590
- * A capture flow, rebuilt and checked, with the location its parameters name,
591
- * or null. The two parameters must name the same credential and revision under
592
- * their own roots, and the tags must be the parameter's owner: a workspace
593
- * credential's `orgId` is its owner and it has no `userId`; a personal AI
594
- * account or MCP connection names both its person and its workspace; GitHub
595
- * and SSH name their person alone.
596
- */
597
- export function parseCaptureFlowWire(
598
- value: unknown,
599
- ): (CaptureFlowWire & { location: CredentialLocation }) | null {
600
- const o = exactObject(value, [
601
- "credentialId",
602
- "revision",
603
- "leaseParameter",
604
- "brokerParameter",
605
- "orgId",
606
- "userId",
607
- ]);
608
- if (
609
- !o ||
610
- !isIdValue(o.credentialId) ||
611
- !isIdValue(o.revision) ||
612
- typeof o.leaseParameter !== "string" ||
613
- !(o.brokerParameter === null || typeof o.brokerParameter === "string") ||
614
- !(o.orgId === null || (isIdValue(o.orgId) && isTagValue(o.orgId))) ||
615
- !(o.userId === null || (isIdValue(o.userId) && isTagValue(o.userId)))
616
- ) {
617
- return null;
618
- }
619
- const location = parseLeaseParameterName(o.leaseParameter);
620
- if (!location || location.revision !== o.revision) return null;
621
- const isPerson = location.class === "github" || location.class === "ssh";
622
- if (!isPerson && location.credentialId !== o.credentialId) return null;
623
- if (o.brokerParameter !== null) {
624
- const broker = parseBrokerParameterName(o.brokerParameter);
625
- if (!broker || !sameLocation(broker, location)) return null;
626
- }
627
- if (isPerson && o.brokerParameter !== null) return null;
628
- const ownerOk =
629
- location.scope === "org"
630
- ? o.orgId === location.ownerId && o.userId === null
631
- : o.userId === location.ownerId && (isPerson ? o.orgId === null : o.orgId !== null);
632
- if (!ownerOk) return null;
633
- return {
634
- credentialId: o.credentialId,
635
- revision: o.revision,
636
- leaseParameter: o.leaseParameter,
637
- brokerParameter: o.brokerParameter,
638
- orgId: o.orgId,
639
- userId: o.userId,
640
- location,
641
- };
642
- }
643
-
644
- export function parseCaptureResultWire(value: unknown): CaptureResultWire | null {
645
- const o = exactObject(value, ["credentialId", "revision", "leaseVersion", "expiresAt"]);
646
- return o &&
647
- isIdValue(o.credentialId) &&
648
- isIdValue(o.revision) &&
649
- isPositiveInteger(o.leaseVersion) &&
650
- (o.expiresAt === null || isEpochMs(o.expiresAt))
651
- ? {
652
- credentialId: o.credentialId,
653
- revision: o.revision,
654
- leaseVersion: o.leaseVersion,
655
- expiresAt: o.expiresAt,
656
- }
657
- : null;
658
- }
659
-
660
337
  // ---------------------------------------------------------------------------
661
338
  // Session tags
662
339
  // ---------------------------------------------------------------------------
@@ -672,15 +349,6 @@ export const LEASE_V1_SESSION_TAG = { key: "lease", value: "v1" } as const;
672
349
  * which resource-tag conditions compare. */
673
350
  export const ORG_PATH_SESSION_TAG_KEY = "orgseg";
674
351
 
675
- const TAG_VALUE = /^[\p{L}\p{Z}\p{N}_.:/=+\-@]{1,256}$/u;
676
-
677
- /** STS session tag and SSM resource tag values: the same grammar, refused
678
- * rather than rewritten, so a principal tag and a resource tag written for
679
- * one id always compare equal. */
680
- export function isTagValue(value: unknown): value is string {
681
- return typeof value === "string" && TAG_VALUE.test(value);
682
- }
683
-
684
352
  // ---------------------------------------------------------------------------
685
353
  // The v1 grant and its manifest
686
354
  // ---------------------------------------------------------------------------
@@ -734,7 +402,8 @@ export interface LeaseManifestEngine {
734
402
  engineKind: string;
735
403
  scope: CredentialScope;
736
404
  label: string;
737
- authKind: "env" | "config-dir";
405
+ authKind: "env" | "config-dir" | "custom-connection";
406
+ connection?: CustomConnectionLease;
738
407
  /** Position within its scope; the list itself is already in try order. */
739
408
  position: number;
740
409
  }
@@ -763,6 +432,10 @@ export interface LeaseManifestStatic {
763
432
 
764
433
  /** What a task may read, by name and version — never a value. */
765
434
  export interface CredentialManifest {
435
+ /** Platform connections.enabled, included in the digest; absent fails closed. */
436
+ connectionsEnabled?: CustomConnectionCombination[];
437
+ /** Pins refused by connection policy, never secret material. Omitted if empty. */
438
+ connectionsUnsupported?: string[];
766
439
  /** Omitted on older peers and when empty, preserving old digests. */
767
440
  taskSecrets?: TaskSecretManifestEntry[];
768
441
  /** The task's projects, lead first. */
@@ -956,22 +629,6 @@ export interface CredentialsActivatedFrame {
956
629
 
957
630
  type WireObject = Record<string, unknown>;
958
631
 
959
- function exactObject(
960
- value: unknown,
961
- required: readonly string[],
962
- optional: readonly string[] = [],
963
- ): WireObject | null {
964
- if (!value || typeof value !== "object" || Array.isArray(value)) return null;
965
- const object = value as WireObject;
966
- const keys = Object.keys(object);
967
- if (
968
- keys.some(key => !required.includes(key) && !optional.includes(key)) ||
969
- required.some((key) => !Object.hasOwn(object, key))
970
- ) {
971
- return null;
972
- }
973
- return object;
974
- }
975
632
 
976
633
  function isBoundedString(value: unknown, max: number): value is string {
977
634
  return typeof value === "string" && value.length > 0 && value.length <= max;
@@ -981,17 +638,13 @@ function isOpId(value: unknown): value is string {
981
638
  return isBoundedString(value, MAX_LEASE_OP_ID_CHARS) && OP_ID.test(value);
982
639
  }
983
640
 
984
- function isPositiveInteger(value: unknown): value is number {
985
- return Number.isSafeInteger(value) && (value as number) > 0;
986
- }
641
+
987
642
 
988
643
  function isNonNegativeInteger(value: unknown): value is number {
989
644
  return Number.isSafeInteger(value) && (value as number) >= 0;
990
645
  }
991
646
 
992
- function isEpochMs(value: unknown): value is number {
993
- return isPositiveInteger(value);
994
- }
647
+
995
648
 
996
649
  function isScope(value: unknown): value is CredentialScope {
997
650
  return value === "org" || value === "user";
@@ -1058,7 +711,7 @@ function parseEngine(
1058
711
  "label",
1059
712
  "authKind",
1060
713
  "position",
1061
- ]);
714
+ ], ["connection"]);
1062
715
  if (
1063
716
  !o ||
1064
717
  !isIdValue(o.credentialId) ||
@@ -1071,11 +724,13 @@ function parseEngine(
1071
724
  !isEngineKind(o.engineKind) ||
1072
725
  !isScope(o.scope) ||
1073
726
  !isBoundedString(o.label, MAX_MANIFEST_LABEL_CHARS) ||
1074
- (o.authKind !== "env" && o.authKind !== "config-dir") ||
727
+ (o.authKind !== "env" && o.authKind !== "config-dir" && o.authKind !== "custom-connection") ||
1075
728
  !isNonNegativeInteger(o.position)
1076
729
  ) {
1077
730
  return null;
1078
731
  }
732
+ const connection = o.connection === undefined ? undefined : parseCustomConnectionLease(o.connection, o.engineKind);
733
+ if (connection === null || ((o.authKind === "custom-connection") !== (connection !== undefined)) || (connection && (connection.expiresAt ?? null) !== o.expiresAt)) return null;
1079
734
  const location = parseLeaseParameterName(o.parameter);
1080
735
  if (
1081
736
  !location ||
@@ -1100,6 +755,7 @@ function parseEngine(
1100
755
  label: o.label,
1101
756
  authKind: o.authKind,
1102
757
  position: o.position,
758
+ ...(connection ? { connection } : {}),
1103
759
  };
1104
760
  }
1105
761
 
@@ -1243,7 +899,7 @@ function parseManifest(
1243
899
  grant: { orgId: string; userId: string; taskId: string },
1244
900
  credentialSessions: ReadonlySet<string>,
1245
901
  ): CredentialManifest | null {
1246
- const o = exactObject(value, ["projects", "engines", "mcp", "github", "ssh"], ["taskSecrets"]);
902
+ const o = exactObject(value, ["projects", "engines", "mcp", "github", "ssh"], ["taskSecrets", "connectionsEnabled", "connectionsUnsupported"]);
1247
903
  if (
1248
904
  !o ||
1249
905
  !Array.isArray(o.projects) ||
@@ -1255,6 +911,14 @@ function parseManifest(
1255
911
  ) {
1256
912
  return null;
1257
913
  }
914
+ const connectionsEnabled = o.connectionsEnabled === undefined ? undefined : parseCustomConnectionEnabled(o.connectionsEnabled);
915
+ if (connectionsEnabled === null) return null;
916
+ const connectionsUnsupported = o.connectionsUnsupported;
917
+ if (connectionsUnsupported !== undefined && (!Array.isArray(connectionsUnsupported) ||
918
+ connectionsUnsupported.length === 0 || connectionsUnsupported.length > MAX_MANIFEST_ENGINES ||
919
+ !connectionsUnsupported.every(isIdValue) || new Set(connectionsUnsupported).size !== connectionsUnsupported.length)) return null;
920
+ const customManifest = { ...(connectionsEnabled ? { connectionsEnabled } : {}),
921
+ ...(connectionsUnsupported ? { connectionsUnsupported: [...connectionsUnsupported] as string[] } : {}) };
1258
922
  const projects: LeaseManifestProject[] = [];
1259
923
  for (const item of o.projects) {
1260
924
  const p = exactObject(item, ["projectId", "sessionId"]);
@@ -1316,9 +980,9 @@ function parseManifest(
1316
980
  entries.push(e as unknown as TaskSecretManifestEntry);
1317
981
  }
1318
982
  if (!uniqueBy(entries, e => e.name)) return null;
1319
- return { projects, engines, mcp, github, ssh, taskSecrets: entries };
983
+ return { projects, engines, mcp, github, ssh, taskSecrets: entries, ...customManifest };
1320
984
  }
1321
- return { projects, engines, mcp, github, ssh };
985
+ return { projects, engines, mcp, github, ssh, ...customManifest };
1322
986
  }
1323
987
 
1324
988
  function parseFiles(value: unknown): LeaseFiles | null {