@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.
- package/lib/agents/claude.ts +13 -0
- package/lib/agents/codex.ts +11 -5
- package/lib/agents/session-state.ts +77 -0
- package/lib/agents/transport.ts +2 -0
- package/lib/agents/types.ts +3 -0
- package/lib/agents/uai.ts +3 -25
- package/lib/assignment-evidence.ts +2 -1
- package/lib/capture-flow.ts +14 -1
- package/lib/cloud-credential-leases.ts +9 -3
- package/lib/cloud-credential-runtime.ts +8 -0
- package/lib/connection-secret-redaction.ts +25 -0
- package/lib/credential-cleanup-inventory.ts +2 -1
- package/lib/credential-consumers/engines.ts +29 -5
- package/lib/custom-connection-capture.ts +395 -0
- package/lib/custom-connection-launch.ts +299 -0
- package/lib/custom-connection-runtime.ts +70 -0
- package/lib/engine-accounts.ts +1 -1
- package/lib/engine-credential-env.ts +2 -0
- package/lib/internal-connections.ts +5 -0
- package/lib/obs.ts +2 -1
- package/lib/openrouter-budget.ts +247 -0
- package/lib/orchestrator.ts +308 -26
- package/lib/secrets-proxy/address.ts +23 -0
- package/lib/task-credential-cleanup.ts +13 -3
- package/package.json +1 -1
- package/runner/runner.mjs +231 -3
- package/src/capture-wire.ts +102 -0
- package/src/cloud-credentials.ts +36 -372
- package/src/credential-location.ts +248 -0
- package/src/credential-wire-validation.ts +25 -0
- package/src/custom-connections.ts +207 -0
- package/src/main.ts +104 -3
- package/src/protocol.ts +18 -0
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
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
|
|
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
|
-
|
|
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
|
+
|