@runuai/host 0.9.170 → 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.
@@ -70,3 +70,26 @@ export function isPublicAddress(ip: string): boolean {
70
70
  return false;
71
71
  }
72
72
  }
73
+
74
+ // Internal custom connections add only these explicitly approved destinations.
75
+ // Do not change the public proxy's policy or unwrap IPv4 embedded in IPv6.
76
+ const internalIpv4 = new BlockList();
77
+ for (const [network, prefix] of [["10.0.0.0", 8], ["172.16.0.0", 12], ["192.168.0.0", 16], ["100.64.0.0", 10]] as const) internalIpv4.addSubnet(network, prefix, "ipv4");
78
+ const metadataIpv4 = new BlockList();
79
+ metadataIpv4.addAddress("100.100.100.200", "ipv4"); // Alibaba Cloud metadata within CGNAT
80
+ const internalIpv6 = new BlockList();
81
+ internalIpv6.addSubnet("fc00::", 7, "ipv6");
82
+ const metadataIpv6 = new BlockList();
83
+ metadataIpv6.addAddress("fd00:ec2::254", "ipv6");
84
+ export function isInternalConnectionAddress(ip: string): boolean {
85
+ if (typeof ip !== "string" || !/^[0-9a-fA-FxX:.]+$/.test(ip)) return false;
86
+ // WHATWG URL canonicalization normalizes decimal/octal/hex IPv4 spellings.
87
+ let normalized: string;
88
+ try { normalized = new URL(`http://${ip.includes(":") ? `[${ip}]` : ip}`).hostname.replace(/^\[|\]$/g, ""); } catch { return false; }
89
+ if (isPublicAddress(normalized)) return true;
90
+ switch (isIP(normalized)) {
91
+ case 4: return internalIpv4.check(normalized, "ipv4") && !metadataIpv4.check(normalized, "ipv4");
92
+ case 6: return internalIpv6.check(normalized, "ipv6") && !metadataIpv6.check(normalized, "ipv6");
93
+ default: return false;
94
+ }
95
+ }
@@ -120,8 +120,11 @@ async function execCleanup(
120
120
  export const CREDENTIAL_UNLINK_HELPER = String.raw`
121
121
  import os
122
122
  import sys
123
+ import shutil
124
+ import stat
123
125
 
124
126
  root, absolute = sys.argv[1:3]
127
+ directory = len(sys.argv) == 4 and sys.argv[3] == "directory"
125
128
  prefix = root.rstrip("/") + "/"
126
129
  if not absolute.startswith(prefix):
127
130
  raise RuntimeError("credential path escaped its root")
@@ -145,7 +148,13 @@ try:
145
148
  opened.append(current)
146
149
  leaf = parts[-1]
147
150
  try:
148
- os.unlink(leaf, dir_fd=current)
151
+ if directory:
152
+ info = os.stat(leaf, dir_fd=current, follow_symlinks=False)
153
+ if not stat.S_ISDIR(info.st_mode) or not shutil.rmtree.avoids_symlink_attacks:
154
+ raise RuntimeError("credential directory cannot be traversed safely")
155
+ shutil.rmtree(leaf, dir_fd=current)
156
+ else:
157
+ os.unlink(leaf, dir_fd=current)
149
158
  except FileNotFoundError:
150
159
  pass
151
160
  try:
@@ -212,7 +221,8 @@ export async function removeRuntimeCredentialFile(
212
221
  }
213
222
  const removed = await execCleanup(
214
223
  environment,
215
- ["/usr/bin/python3", "-I", "-", "/home/node", path],
224
+ ["/usr/bin/python3", "-I", "-", "/home/node", path,
225
+ ...(/^\/home\/node\/\.uai-connection\/[a-f0-9]{64}$/.test(path) ? ["directory"] : [])],
216
226
  Buffer.from(CREDENTIAL_UNLINK_HELPER, "utf8"),
217
227
  );
218
228
  if (removed.exitCode !== 0) {
@@ -220,7 +230,7 @@ export async function removeRuntimeCredentialFile(
220
230
  }
221
231
  }
222
232
 
223
- /** Exact, non-recursive deletion for one validated inventory row. */
233
+ /** Delete one validated file, or the confined connection home owning it. */
224
234
  export async function removeCredentialCleanupPath(
225
235
  row: HostCredentialCleanupPath,
226
236
  ): Promise<void> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.170",
3
+ "version": "0.9.172",
4
4
  "description": "Uai host \u2014 runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",
package/runner/runner.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  * bind mount (no sockets/FIFOs — they don't cross the macOS↔VM boundary):
7
7
  *
8
8
  * <sessionDir>/inbox.jsonl host appends; runner tails → CLI stdin
9
- * <sessionDir>/outbox.jsonl CLI stdout lines verbatim + __uai meta lines
9
+ * <sessionDir>/outbox.jsonl CLI stdout protocol + __uai meta (connections redacted)
10
10
  * <sessionDir>/heartbeat rewritten every HEARTBEAT_MS (host checks mtime)
11
11
  * <sessionDir>/runner.json pid, protocol, argv, startedAt
12
12
  *
@@ -31,6 +31,232 @@ const POLL_MS = 50;
31
31
  const HEARTBEAT_MS = 5_000;
32
32
  const STDERR_CAP = 8 * 1024;
33
33
  const STOP_GRACE_MS = 5_000;
34
+ const customConnection = process.env.UAI_CUSTOM_CONNECTION === "1";
35
+ const connectionKeys = [...new Set([
36
+ process.env.ANTHROPIC_AUTH_TOKEN,
37
+ process.env.ANTHROPIC_API_KEY,
38
+ process.env.UAI_CUSTOM_API_KEY,
39
+ process.env.AWS_BEARER_TOKEN_BEDROCK,
40
+ ].filter((key) => typeof key === "string" && key.length > 0))]
41
+ .sort((a, b) => b.length - a.length);
42
+
43
+ function redactConnectionKeys(text) {
44
+ for (const key of connectionKeys) {
45
+ text = text.split(key).join("[redacted]".includes(key) ? "" : "[redacted]");
46
+ }
47
+ return text;
48
+ }
49
+
50
+ // Never persist upstream diagnostics from a custom endpoint: these can contain
51
+ // credentials, request bodies or arbitrary provider responses, beyond known keys.
52
+ function connectionReason(value) {
53
+ const text = typeof value === "string" ? value : JSON.stringify(value) ?? "";
54
+ if (/401|unauthorized|authentication_failed|unrecognized.?client|expired.?token|token.{0,80}(?:expired|revoked)|invalid.?api.?key/i.test(text)) return "unauthorized";
55
+ if (/403|forbidden|access.?denied/i.test(text)) return "forbidden";
56
+ if (/404|model.{0,30}not.found/i.test(text)) return "model-not-found";
57
+ if (/429|rate.?limit|throttl/i.test(text)) return "rate-limited";
58
+ if (/expired/i.test(text)) return "expired";
59
+ if (/408|timeout|timed.out/i.test(text)) return "timeout";
60
+ return "upstream-error";
61
+ }
62
+
63
+ function connectionError(value) {
64
+ const reason = connectionReason(value);
65
+ // JSON-RPC error responses must retain their error object and numeric code.
66
+ return value && typeof value === "object" && !Array.isArray(value)
67
+ ? { ...(typeof value.code === "number" ? { code: value.code } : {}), message: reason }
68
+ : reason;
69
+ }
70
+
71
+ // Tool/model payloads can contain fields named error, result or stderrTail.
72
+ // Preserve their meaning; only exact credentials are redacted recursively.
73
+ function redactConnectionValue(value) {
74
+ if (typeof value === "string") return redactConnectionKeys(value);
75
+ if (Array.isArray(value)) return value.map(redactConnectionValue);
76
+ if (!value || typeof value !== "object") return value;
77
+ return Object.fromEntries(Object.entries(value).map(([key, item]) =>
78
+ [redactConnectionKeys(key), redactConnectionValue(item)]));
79
+ }
80
+
81
+ // Suppress provider diagnostics only in the CLI's protocol-level failures,
82
+ // never an error object nested in a completed tool item or model output.
83
+ function redactConnectionRecord(value) {
84
+ if (!value || typeof value !== "object" || Array.isArray(value)) return redactConnectionValue(value);
85
+ const claudeApiError = value.type === "assistant" && value.is_api_error_message === true;
86
+ const claudeResultError = value.type === "result" && value.is_error === true;
87
+ const protocolError = claudeApiError || claudeResultError ||
88
+ (value.type === "system" && value.subtype === "api_retry") ||
89
+ value.type === "error" || value.type === "turn.failed" ||
90
+ (Object.hasOwn(value, "id") && value.error);
91
+ if (claudeApiError) {
92
+ value = { ...value, message: { ...value.message,
93
+ content: [{ type: "text", text: connectionReason(value) }] } };
94
+ }
95
+ if (protocolError) {
96
+ value = { ...value };
97
+ if (Object.hasOwn(value, "error")) value.error = connectionError(value.error);
98
+ if (Array.isArray(value.errors)) value.errors = value.errors.map(connectionError);
99
+ if (claudeResultError && Object.hasOwn(value, "result")) value.result = connectionReason(value.result);
100
+ if (value.type === "error") {
101
+ for (const key of ["message", "detail", "body"]) {
102
+ if (Object.hasOwn(value, key)) value[key] = connectionReason(value[key]);
103
+ }
104
+ }
105
+ }
106
+ if (value.method === "error" && value.params?.error) {
107
+ value = { ...value, params: { ...value.params, error: connectionError(value.params.error) } };
108
+ }
109
+ if (value.method === "turn/completed" && value.params?.turn?.status === "failed" && value.params.turn.error) {
110
+ value = { ...value, params: { ...value.params,
111
+ turn: { ...value.params.turn, error: connectionError(value.params.turn.error) } } };
112
+ }
113
+ if (value.__uai === "exit" && Object.hasOwn(value, "stderrTail")) {
114
+ value = { ...value, stderrTail: value.stderrTail ? connectionReason(value.stderrTail) : "" };
115
+ }
116
+ return redactConnectionValue(value);
117
+ }
118
+
119
+ function safeOutboxLine(line) {
120
+ if (!customConnection) return line;
121
+ // Wait for a complete JSON line, decode its strings before redacting, then
122
+ // serialize again. Replacing raw JSON bytes would miss escaped keys or corrupt
123
+ // quote/backslash-containing keys. Separate protocol deltas retain their shape;
124
+ // the host also redacts across assembled message deltas before publication.
125
+ try {
126
+ return JSON.stringify(redactConnectionRecord(JSON.parse(line)));
127
+ } catch {
128
+ return JSON.stringify({ type: "error", error: connectionReason(line) });
129
+ }
130
+ }
131
+
132
+ // SDK/app-server retries must not hide a rejected connection credential.
133
+ // Restrict classification to CLI error records, never generated model text.
134
+ let connectionFailureSeen = false;
135
+ let connectionTransientRetries = 0;
136
+ // Codex's built-in AWS backend has a fixed HTTP retry budget and rejects
137
+ // retry overrides. Do not multiply that budget with another stream cycle.
138
+ const connectionStreamRetryBudget = process.env.AWS_BEARER_TOKEN_BEDROCK &&
139
+ process.env.CLAUDE_CODE_USE_BEDROCK !== "1" ? 0 : 1;
140
+ function connectionFailureFrame(value) {
141
+ if (!customConnection) return value;
142
+ if (value.method === "turn/started" || value.type === "turn.started" ||
143
+ (value.type === "result" && value.is_error === false)) connectionTransientRetries = 0;
144
+ const claudeRetry = value.type === "system" && value.subtype === "api_retry";
145
+ const rpcError = value.error && Object.hasOwn(value, "id");
146
+ const failed = claudeRetry || rpcError || value.type === "error" || value.type === "turn.failed" ||
147
+ (value.type === "assistant" && value.is_api_error_message === true) ||
148
+ (value.type === "result" && value.is_error === true) || value.method === "error" ||
149
+ (value.method === "turn/completed" && value.params?.turn?.status === "failed");
150
+ if (!failed) return value;
151
+ const detail = value.params?.error ?? value.params?.turn?.error ?? value.error ?? value.message ?? value.result ?? "";
152
+ const text = typeof detail === "string" ? detail : JSON.stringify(detail);
153
+ const status = value.error_status ?? detail?.status ?? detail?.statusCode ??
154
+ ([401, 403].includes(detail?.code) ? detail.code : undefined);
155
+ const auth = status === 401 || status === 403 ||
156
+ /\b(?:status|code|API Error:)\s*[:=]?\s*(?:401|403)\b|\b401 Unauthorized\b|\b403 Forbidden\b|authentication_failed|unrecognized.?client|access.?denied|expired.?token|invalid.?api.?key|invalid.?bearer.?token|security token.{0,80}(?:expired|invalid)/i.test(text);
157
+ const retry = claudeRetry || value.params?.willRetry === true ||
158
+ (value.type === "error" && /^Reconnecting\.\.\./.test(text));
159
+ if (!auth && (!retry || ++connectionTransientRetries <= connectionStreamRetryBudget)) return value;
160
+ const reason = auth ? (status === 403 && !/authentication_failed/i.test(text) ? "forbidden" : connectionReason(text)) : connectionReason(value);
161
+ const safeReason = auth && !["unauthorized", "forbidden", "expired"].includes(reason) ? "unauthorized" : reason;
162
+ connectionFailureSeen = true;
163
+ stopCli();
164
+ if (rpcError) return { jsonrpc: value.jsonrpc, id: value.id, error: { ...(typeof value.error.code === "number" ? { code: value.error.code } : {}), message: safeReason } };
165
+ if (value.method) return { method: "error", params: { threadId: value.params?.threadId,
166
+ turnId: value.params?.turnId, willRetry: false, error: { message: safeReason } } };
167
+ if (["system", "assistant", "result"].includes(value.type)) return {
168
+ type: "result", subtype: "error_during_execution", is_error: true,
169
+ result: safeReason, errors: [safeReason],
170
+ };
171
+ return { type: "error", message: safeReason };
172
+ }
173
+
174
+ // Retain only a possible credential prefix across protocol deltas. Every
175
+ // persisted text fragment is safe when concatenated with earlier fragments.
176
+ const deltaTails = new Map();
177
+ let deltaTailBytes = 0;
178
+ // Bound retained bytes, rather than stopping a healthy multi-item turn at
179
+ // an arbitrary item count. Completed items release their state immediately.
180
+ const DELTA_TAIL_MAX_BYTES = 4 * 1024 * 1024;
181
+ function forgetDeltaTail(stream) {
182
+ const pending = deltaTails.get(stream);
183
+ if (pending) deltaTailBytes -= pending.bytes;
184
+ deltaTails.delete(stream);
185
+ }
186
+ function streamingDelta(value) {
187
+ if (value.type === "stream_event" && value.event?.type === "content_block_delta") {
188
+ for (const field of ["text", "thinking", "partial_json", "signature"]) {
189
+ if (typeof value.event.delta?.[field] === "string") {
190
+ return { stream: `claude:${value.event.index ?? 0}:${field}`, target: value.event.delta, field };
191
+ }
192
+ }
193
+ }
194
+ if (typeof value.method === "string" && /delta$/i.test(value.method) && typeof value.params?.delta === "string") {
195
+ return { stream: `codex:${value.method}:${value.params.threadId ?? ""}:${value.params.turnId ?? ""}:${value.params.itemId ?? ""}`,
196
+ target: value.params, field: "delta" };
197
+ }
198
+ return null;
199
+ }
200
+ function safeOutboxLines(line) {
201
+ if (!customConnection) return [line];
202
+ let value;
203
+ try { value = JSON.parse(line); } catch { return [safeOutboxLine(line)]; }
204
+ if (!value || typeof value !== "object") return [safeOutboxLine(line)];
205
+ if (connectionFailureSeen && value.__uai !== "exit") return [];
206
+ value = connectionFailureFrame(value);
207
+ const delta = streamingDelta(value);
208
+ if (delta) {
209
+ const text = redactConnectionKeys((deltaTails.get(delta.stream)?.tail ?? "") + delta.target[delta.field]);
210
+ let keep = 0;
211
+ for (const key of connectionKeys) {
212
+ for (let n = Math.min(key.length - 1, text.length); n > keep; n--) {
213
+ if (text.endsWith(key.slice(0, n))) { keep = n; break; }
214
+ }
215
+ }
216
+ const tail = keep ? text.slice(-keep) : "";
217
+ delta.target[delta.field] = keep ? text.slice(0, -keep) : text;
218
+ forgetDeltaTail(delta.stream);
219
+ if (tail) {
220
+ // Only retain the protocol envelope, not a potentially large provider
221
+ // payload or the already-persisted text.
222
+ const frame = value.type === "stream_event"
223
+ ? { type: value.type, event: { type: value.event.type, index: value.event.index,
224
+ delta: { type: value.event.delta.type, [delta.field]: "" } } }
225
+ : { method: value.method, params: { threadId: value.params.threadId,
226
+ turnId: value.params.turnId, itemId: value.params.itemId, delta: "" } };
227
+ const bytes = Buffer.byteLength(delta.stream) + Buffer.byteLength(tail) + Buffer.byteLength(JSON.stringify(frame));
228
+ if (deltaTailBytes + bytes > DELTA_TAIL_MAX_BYTES) {
229
+ stopCli();
230
+ return [JSON.stringify({ type: "error", error: "upstream-error" })];
231
+ }
232
+ deltaTails.set(delta.stream, { tail, frame, bytes });
233
+ deltaTailBytes += bytes;
234
+ }
235
+ }
236
+ const lines = [];
237
+ for (const [stream, pending] of deltaTails) {
238
+ const threadMatches = value.params?.threadId === undefined || pending.frame.params?.threadId === value.params.threadId;
239
+ const completedTurn = value.params?.turnId ?? value.params?.turn?.id;
240
+ const turnMatches = completedTurn === undefined || pending.frame.params?.turnId === completedTurn;
241
+ const terminal = value.__uai === "exit" ||
242
+ (value.type === "result" && pending.frame.type === "stream_event") ||
243
+ (value.method === "turn/completed" && pending.frame.params && threadMatches && turnMatches);
244
+ const claudeEnd = value.type === "stream_event" && value.event?.type === "content_block_stop" &&
245
+ pending.frame.type === "stream_event" && pending.frame.event.index === value.event.index;
246
+ const codexEnd = value.method === "item/completed" && pending.frame.params &&
247
+ typeof (value.params?.item?.id ?? value.params?.itemId) === "string" &&
248
+ pending.frame.params.itemId === (value.params.item?.id ?? value.params.itemId) &&
249
+ (value.params.threadId === undefined || pending.frame.params.threadId === value.params.threadId) &&
250
+ (value.params.turnId === undefined || pending.frame.params.turnId === value.params.turnId);
251
+ if (!terminal && !claudeEnd && !codexEnd) continue;
252
+ const d = streamingDelta(pending.frame);
253
+ d.target[d.field] = pending.tail;
254
+ lines.push(JSON.stringify(redactConnectionRecord(pending.frame)));
255
+ forgetDeltaTail(stream);
256
+ }
257
+ lines.push(JSON.stringify(redactConnectionRecord(value)));
258
+ return lines;
259
+ }
34
260
 
35
261
  // ---- argv ------------------------------------------------------------------
36
262
 
@@ -52,7 +278,9 @@ const runnerJsonPath = join(sessionDir, "runner.json");
52
278
  // Single append point — appendFileSync with O_APPEND keeps lines atomic for
53
279
  // the sizes we write; the runner is the outbox's only writer.
54
280
  function outbox(line) {
55
- appendFileSync(outboxPath, line.endsWith("\n") ? line : `${line}\n`);
281
+ for (const safe of safeOutboxLines(line)) {
282
+ appendFileSync(outboxPath, safe.endsWith("\n") ? safe : `${safe}\n`);
283
+ }
56
284
  }
57
285
  function meta(kind, extra = {}) {
58
286
  outbox(JSON.stringify({ __uai: kind, ts: Date.now(), ...extra }));
@@ -117,7 +345,7 @@ child.on("exit", (code, signal) => {
117
345
  if (exiting) return;
118
346
  exiting = true;
119
347
  if (stdoutBuf.trim().length > 0) outbox(stdoutBuf); // flush the partial tail
120
- meta("exit", { code, signal: signal ?? null, stderrTail });
348
+ meta("exit", { code: connectionFailureSeen ? 1 : code, signal: signal ?? null, stderrTail });
121
349
  process.exit(0);
122
350
  });
123
351
  child.on("error", (err) => {
@@ -0,0 +1,102 @@
1
+ /** Pure capture contract, independent of leases and custom connections. */
2
+ import { parseLeaseParameterName, parseBrokerParameterName, sameLocation, isIdValue, isTagValue, type CredentialLocation } from "./credential-location";
3
+ import { exactObject, isPositiveInteger, isEpochMs } from "./credential-wire-validation";
4
+
5
+ /**
6
+ * What the cloud tells the sign-in box to write for one capture: the
7
+ * revision's parameters (the box never chooses a name) and the ids it tags
8
+ * them with. The box checks every field against the others before it writes.
9
+ */
10
+ export interface CaptureFlowWire {
11
+ credentialId: string;
12
+ revision: string;
13
+ leaseParameter: string;
14
+ /** Null for a static credential. */
15
+ brokerParameter: string | null;
16
+ /** The workspace tag: required except for GitHub and SSH. */
17
+ orgId: string | null;
18
+ /** The person, for a personal credential. */
19
+ userId: string | null;
20
+ }
21
+
22
+ /** What the box answers once both parts are written: never material. */
23
+ export interface CaptureResultWire {
24
+ credentialId: string;
25
+ revision: string;
26
+ /** The lease-readable parameter's Parameter Store version. */
27
+ leaseVersion: number;
28
+ /** The access token's expiry when the box could read it. */
29
+ expiresAt: number | null;
30
+ }
31
+
32
+ /**
33
+ * A capture flow, rebuilt and checked, with the location its parameters name,
34
+ * or null. The two parameters must name the same credential and revision under
35
+ * their own roots, and the tags must be the parameter's owner: a workspace
36
+ * credential's `orgId` is its owner and it has no `userId`; a personal AI
37
+ * account or MCP connection names both its person and its workspace; GitHub
38
+ * and SSH name their person alone.
39
+ */
40
+ export function parseCaptureFlowWire(
41
+ value: unknown,
42
+ ): (CaptureFlowWire & { location: CredentialLocation }) | null {
43
+ const o = exactObject(value, [
44
+ "credentialId",
45
+ "revision",
46
+ "leaseParameter",
47
+ "brokerParameter",
48
+ "orgId",
49
+ "userId",
50
+ ]);
51
+ if (
52
+ !o ||
53
+ !isIdValue(o.credentialId) ||
54
+ !isIdValue(o.revision) ||
55
+ typeof o.leaseParameter !== "string" ||
56
+ !(o.brokerParameter === null || typeof o.brokerParameter === "string") ||
57
+ !(o.orgId === null || (isIdValue(o.orgId) && isTagValue(o.orgId))) ||
58
+ !(o.userId === null || (isIdValue(o.userId) && isTagValue(o.userId)))
59
+ ) {
60
+ return null;
61
+ }
62
+ const location = parseLeaseParameterName(o.leaseParameter);
63
+ if (!location || location.revision !== o.revision) return null;
64
+ const isPerson = location.class === "github" || location.class === "ssh";
65
+ if (!isPerson && location.credentialId !== o.credentialId) return null;
66
+ if (o.brokerParameter !== null) {
67
+ const broker = parseBrokerParameterName(o.brokerParameter);
68
+ if (!broker || !sameLocation(broker, location)) return null;
69
+ }
70
+ if (isPerson && o.brokerParameter !== null) return null;
71
+ const ownerOk =
72
+ location.scope === "org"
73
+ ? o.orgId === location.ownerId && o.userId === null
74
+ : o.userId === location.ownerId && (isPerson ? o.orgId === null : o.orgId !== null);
75
+ if (!ownerOk) return null;
76
+ return {
77
+ credentialId: o.credentialId,
78
+ revision: o.revision,
79
+ leaseParameter: o.leaseParameter,
80
+ brokerParameter: o.brokerParameter,
81
+ orgId: o.orgId,
82
+ userId: o.userId,
83
+ location,
84
+ };
85
+ }
86
+
87
+ export function parseCaptureResultWire(value: unknown): CaptureResultWire | null {
88
+ const o = exactObject(value, ["credentialId", "revision", "leaseVersion", "expiresAt"]);
89
+ return o &&
90
+ isIdValue(o.credentialId) &&
91
+ isIdValue(o.revision) &&
92
+ isPositiveInteger(o.leaseVersion) &&
93
+ (o.expiresAt === null || isEpochMs(o.expiresAt))
94
+ ? {
95
+ credentialId: o.credentialId,
96
+ revision: o.revision,
97
+ leaseVersion: o.leaseVersion,
98
+ expiresAt: o.expiresAt,
99
+ }
100
+ : null;
101
+ }
102
+