@agent-device/proxy 0.0.0-stage → 0.21.23
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/LICENSE +21 -0
- package/README.md +107 -2
- package/dist/errors.mjs +304 -0
- package/dist/index.d.mts +36 -0
- package/dist/index.mjs +463 -0
- package/dist/node-http.mjs +68 -0
- package/dist/process.mjs +1147 -0
- package/dist/version.mjs +179 -0
- package/package.json +32 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Callstack
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,108 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @agent-device/proxy
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The proxy behind `agent-device proxy`, as a library. Embed it in your own gateway to give remote
|
|
4
|
+
`agent-device` clients access to a daemon on another machine, over whichever transport you choose.
|
|
5
|
+
|
|
6
|
+
The proxy:
|
|
7
|
+
|
|
8
|
+
- authenticates clients with a token you choose, and never exposes the daemon's own token;
|
|
9
|
+
- forwards only the routes a remote client needs: `/health`, `/rpc`, uploads, artifacts, and
|
|
10
|
+
request diagnostics;
|
|
11
|
+
- refuses install sources that name a file on the daemon host;
|
|
12
|
+
- rewrites upload tickets so clients upload through the proxy, not to the daemon directly.
|
|
13
|
+
|
|
14
|
+
Requires Node.js 22.12 or later.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install @agent-device/proxy
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Keep its version close to the daemon behind it. Clients refuse a proxy whose daemon speaks a
|
|
23
|
+
different RPC protocol version, before any command runs.
|
|
24
|
+
|
|
25
|
+
## Serve the proxy over HTTP
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { createDaemonProxyServer } from '@agent-device/proxy';
|
|
29
|
+
|
|
30
|
+
const server = createDaemonProxyServer({
|
|
31
|
+
upstreamBaseUrl: 'http://127.0.0.1:4310',
|
|
32
|
+
upstreamToken: process.env.AGENT_DEVICE_DAEMON_TOKEN!,
|
|
33
|
+
clientToken: process.env.GATEWAY_CLIENT_TOKEN!,
|
|
34
|
+
});
|
|
35
|
+
server.listen(8080);
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Clients connect with `agent-device connect proxy --daemon-base-url https://gateway.example.com/agent-device`
|
|
39
|
+
and `AGENT_DEVICE_DAEMON_AUTH_TOKEN` set to `clientToken`.
|
|
40
|
+
|
|
41
|
+
To mount the proxy in an existing `node:http` or `node:https` server, use
|
|
42
|
+
`createDaemonProxyRequestListener(createDaemonProxy(options))`.
|
|
43
|
+
|
|
44
|
+
## Bring your own transport
|
|
45
|
+
|
|
46
|
+
`createDaemonProxy` has no server of its own. It answers standard `Request` objects with standard
|
|
47
|
+
`Response` objects, so any Node.js-compatible runtime or framework that speaks the Fetch API can
|
|
48
|
+
host it:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { createDaemonProxy } from '@agent-device/proxy';
|
|
52
|
+
|
|
53
|
+
const proxy = createDaemonProxy({
|
|
54
|
+
upstreamBaseUrl: 'http://127.0.0.1:4310',
|
|
55
|
+
upstreamToken: daemonToken,
|
|
56
|
+
clientToken,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// A fetch-style server entry, as used by Bun and by Hono on Node.js
|
|
60
|
+
export default { fetch: proxy.handle };
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
For a WebSocket or other message transport, turn each incoming message into a `Request` and send
|
|
64
|
+
the `Response` back. Three things the proxy relies on:
|
|
65
|
+
|
|
66
|
+
- `request.url` is the URL the client used. Upload tickets point clients at its origin. An
|
|
67
|
+
`x-forwarded-proto` header overrides the scheme.
|
|
68
|
+
- `request.signal` aborts when the client goes away. The proxy then cancels the daemon request, so
|
|
69
|
+
work the client stopped waiting for does not keep running.
|
|
70
|
+
- Response bodies stream. Artifact downloads can be large, so forward the body as it arrives.
|
|
71
|
+
|
|
72
|
+
`handle` never rejects. Errors come back as JSON responses with a 4xx or 5xx status, except a route
|
|
73
|
+
the proxy does not serve, which gets a plain `Not found` 404.
|
|
74
|
+
|
|
75
|
+
### Reach the daemon over your own transport
|
|
76
|
+
|
|
77
|
+
By default the proxy calls the daemon with the global `fetch`. Pass `upstreamFetch` when the daemon
|
|
78
|
+
is only reachable some other way, such as a tunnel the device host opened to your gateway:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
const proxy = createDaemonProxy({
|
|
82
|
+
upstreamBaseUrl: 'http://device-host-17.internal',
|
|
83
|
+
upstreamToken: daemonToken,
|
|
84
|
+
clientToken,
|
|
85
|
+
upstreamFetch: (request) => deviceHostTunnel.send(request),
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`upstreamFetch` receives a `Request` addressed to `upstreamBaseUrl` and must resolve with the
|
|
90
|
+
daemon's `Response`. Abort the exchange when `request.signal` aborts.
|
|
91
|
+
|
|
92
|
+
## Options
|
|
93
|
+
|
|
94
|
+
| Option | Default | Meaning |
|
|
95
|
+
| --- | --- | --- |
|
|
96
|
+
| `upstreamBaseUrl` | required | Base URL of the daemon's HTTP server. |
|
|
97
|
+
| `upstreamToken` | required | The daemon's auth token. |
|
|
98
|
+
| `clientToken` | required | Token clients must send as their daemon auth token. |
|
|
99
|
+
| `upstreamFetch` | global `fetch` | Sends one request to the daemon. |
|
|
100
|
+
| `maxRpcBodyBytes` | 1 MiB | Larger `/rpc` bodies get a 400 response. |
|
|
101
|
+
| `upstreamTimeoutMs` | 5 minutes | Longest time one daemon request may take, including its body. |
|
|
102
|
+
|
|
103
|
+
## Health and restarts
|
|
104
|
+
|
|
105
|
+
`GET /health` (also served as `/agent-device/health`) needs no token. It reports the proxy's version and `instanceId`, and
|
|
106
|
+
includes the daemon's own health under `upstream`. A new `createDaemonProxy` call gets a new
|
|
107
|
+
`instanceId`, so connected clients notice the restart and re-check the connection before sending
|
|
108
|
+
more commands.
|
package/dist/errors.mjs
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
//#region ../kernel/src/redaction.ts
|
|
2
|
+
const SENSITIVE_KEY_RE = /(token|secret|password|authorization|cookie|api[_-]?key|access[_-]?key|private[_-]?key|user[_-]?code|device[_-]?code|refresh[_-]?credential)/i;
|
|
3
|
+
const SECRET_TOKEN_RE = /\b(?:bearer\s+[a-z0-9._-]+|adc_(?:agent|live|refresh|cli)_[a-z0-9._-]+)\b/gi;
|
|
4
|
+
const SENSITIVE_ASSIGNMENT_RE = /\b([a-z0-9_-]*(?:api[_-]?key|token|secret|password|user[_-]?code|device[_-]?code|refresh[_-]?credential)[a-z0-9_-]*)(\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi;
|
|
5
|
+
const URL_RE = /https?:\/\/[^\s"'<>]+/gi;
|
|
6
|
+
const REDACTED_STRING_MAX_LENGTH = 400;
|
|
7
|
+
const REDACTED_STDERR_MAX_LENGTH = 8192;
|
|
8
|
+
const TRUNCATION_SUFFIX = "...<truncated>";
|
|
9
|
+
function redactDiagnosticData(input) {
|
|
10
|
+
return redactValue(input, /* @__PURE__ */ new WeakSet());
|
|
11
|
+
}
|
|
12
|
+
/** Sanitizes an untrusted structured cause before it crosses a process or client boundary. */
|
|
13
|
+
function sanitizeErrorCause(cause) {
|
|
14
|
+
if (!cause || typeof cause !== "object") return void 0;
|
|
15
|
+
const candidate = cause;
|
|
16
|
+
if (typeof candidate.message !== "string" || candidate.message.length === 0) return void 0;
|
|
17
|
+
return redactDiagnosticData({
|
|
18
|
+
message: candidate.message,
|
|
19
|
+
...typeof candidate.code === "string" && candidate.code.length > 0 ? { code: candidate.code } : {}
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
function redactValue(value, seen, keyHint) {
|
|
23
|
+
if (value === null || value === void 0) return value;
|
|
24
|
+
if (typeof value === "string") return redactString(value, keyHint);
|
|
25
|
+
if (typeof value !== "object") return value;
|
|
26
|
+
if (seen.has(value)) return "[Circular]";
|
|
27
|
+
seen.add(value);
|
|
28
|
+
if (Array.isArray(value)) return value.map((entry) => redactValue(entry, seen));
|
|
29
|
+
const output = {};
|
|
30
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
31
|
+
if (SENSITIVE_KEY_RE.test(key)) {
|
|
32
|
+
output[key] = "[REDACTED]";
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
output[key] = redactValue(entry, seen, key);
|
|
36
|
+
}
|
|
37
|
+
return output;
|
|
38
|
+
}
|
|
39
|
+
function redactString(value, keyHint) {
|
|
40
|
+
const trimmed = value.trim();
|
|
41
|
+
if (!trimmed) return boundRedactedString(value, keyHint);
|
|
42
|
+
if (keyHint && SENSITIVE_KEY_RE.test(keyHint)) return "[REDACTED]";
|
|
43
|
+
let output = redactUrls(trimmed);
|
|
44
|
+
output = output.replace(SECRET_TOKEN_RE, "[REDACTED]");
|
|
45
|
+
output = output.replace(SENSITIVE_ASSIGNMENT_RE, (match, key, separator, rawValue, offset, input) => {
|
|
46
|
+
if (isSafeSetupUrlAssignment({
|
|
47
|
+
key,
|
|
48
|
+
separator,
|
|
49
|
+
rawValue,
|
|
50
|
+
offset,
|
|
51
|
+
input
|
|
52
|
+
})) return match;
|
|
53
|
+
if (isDocumentedTokenPlaceholder(rawValue)) return match;
|
|
54
|
+
return `${key}${separator}[REDACTED]`;
|
|
55
|
+
});
|
|
56
|
+
return boundRedactedString(output, keyHint);
|
|
57
|
+
}
|
|
58
|
+
function boundRedactedString(value, keyHint) {
|
|
59
|
+
if (keyHint === "stderr") {
|
|
60
|
+
if (value.length <= REDACTED_STDERR_MAX_LENGTH) return value;
|
|
61
|
+
const marker = `\n${TRUNCATION_SUFFIX}\n`;
|
|
62
|
+
const retainedLength = REDACTED_STDERR_MAX_LENGTH - marker.length;
|
|
63
|
+
const headLength = Math.ceil(retainedLength / 2);
|
|
64
|
+
return `${value.slice(0, headLength)}${marker}${value.slice(-(retainedLength - headLength))}`;
|
|
65
|
+
}
|
|
66
|
+
if (value.length <= REDACTED_STRING_MAX_LENGTH) return value;
|
|
67
|
+
return `${value.slice(0, 386)}${TRUNCATION_SUFFIX}`;
|
|
68
|
+
}
|
|
69
|
+
function redactUrls(value) {
|
|
70
|
+
return value.replace(URL_RE, (url) => redactUrl(url) ?? url);
|
|
71
|
+
}
|
|
72
|
+
function isDocumentedTokenPlaceholder(value) {
|
|
73
|
+
return /^adc_(?:agent|live|refresh|cli)_\.\.\.$/i.test(value);
|
|
74
|
+
}
|
|
75
|
+
function isSafeSetupUrlAssignment(options) {
|
|
76
|
+
if (options.key.toLowerCase() !== "token") return false;
|
|
77
|
+
if (!options.separator.includes(":")) return false;
|
|
78
|
+
try {
|
|
79
|
+
if (new URL(options.rawValue).pathname.replace(/\/+$/, "") !== "/api-keys") return false;
|
|
80
|
+
return /(?:^|\b)(?:service\/)?api\s+$/i.test(options.input.slice(0, options.offset));
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function redactUrl(value) {
|
|
86
|
+
try {
|
|
87
|
+
const parsed = new URL(value);
|
|
88
|
+
if (parsed.search) parsed.search = "?REDACTED";
|
|
89
|
+
if (parsed.username || parsed.password) {
|
|
90
|
+
parsed.username = "REDACTED";
|
|
91
|
+
parsed.password = "REDACTED";
|
|
92
|
+
}
|
|
93
|
+
return parsed.toString();
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
//#endregion
|
|
99
|
+
//#region ../kernel/src/errors.ts
|
|
100
|
+
/**
|
|
101
|
+
* The known error codes as a value, so gates can enumerate them: every code
|
|
102
|
+
* here must resolve a hint through `defaultHintForCode`, and every code
|
|
103
|
+
* `retriableForErrorCode` classifies must have a recovery quiz in the help
|
|
104
|
+
* benchmark (scripts/__tests__/help-conformance-error-recovery-coverage.test.ts).
|
|
105
|
+
* `KnownAppErrorCode` is derived from this array, so a new code cannot be added
|
|
106
|
+
* to the type without entering the enumeration.
|
|
107
|
+
*/
|
|
108
|
+
const KNOWN_APP_ERROR_CODES = [
|
|
109
|
+
"INVALID_ARGS",
|
|
110
|
+
"DEVICE_NOT_FOUND",
|
|
111
|
+
"DEVICE_IN_USE",
|
|
112
|
+
"TOOL_MISSING",
|
|
113
|
+
"APP_NOT_INSTALLED",
|
|
114
|
+
"UNSUPPORTED_PLATFORM",
|
|
115
|
+
"UNSUPPORTED_OPERATION",
|
|
116
|
+
"NOT_IMPLEMENTED",
|
|
117
|
+
"COMMAND_FAILED",
|
|
118
|
+
"SESSION_NOT_FOUND",
|
|
119
|
+
"UNAUTHORIZED",
|
|
120
|
+
"AMBIGUOUS_MATCH",
|
|
121
|
+
"REPLAY_DIVERGENCE",
|
|
122
|
+
"REPAIR_SESSION_EXPIRED",
|
|
123
|
+
"REPAIR_COMMIT_FAILED",
|
|
124
|
+
"UNKNOWN"
|
|
125
|
+
];
|
|
126
|
+
var AppError = class extends Error {
|
|
127
|
+
code;
|
|
128
|
+
details;
|
|
129
|
+
cause;
|
|
130
|
+
constructor(code, message, details, cause) {
|
|
131
|
+
super(message);
|
|
132
|
+
this.code = code;
|
|
133
|
+
this.details = details;
|
|
134
|
+
this.cause = cause;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* `details.reason` of a request its requester abandoned — an explicit cancel or
|
|
139
|
+
* a client disconnect. One definition, so every layer that must let a
|
|
140
|
+
* cancellation through untouched (retry loops, provider adapters, runner
|
|
141
|
+
* transports) dispatches on the same typed reason.
|
|
142
|
+
*/
|
|
143
|
+
const REQUEST_CANCELED_REASON = "request_canceled";
|
|
144
|
+
const REQUEST_CANCELED_MESSAGE = "request canceled";
|
|
145
|
+
const REQUEST_CANCELED_HINT = "The request was canceled intentionally (explicit cancel or client disconnect) — no retry is needed unless the cancellation was unintended.";
|
|
146
|
+
/**
|
|
147
|
+
* The canceled-request error. `details` may add evidence (what was released,
|
|
148
|
+
* which command was interrupted) or override the hint; the reason itself is
|
|
149
|
+
* not overridable, so a caller cannot build one this predicate misses.
|
|
150
|
+
*/
|
|
151
|
+
function createRequestCanceledError(details, cause) {
|
|
152
|
+
return new AppError("COMMAND_FAILED", REQUEST_CANCELED_MESSAGE, {
|
|
153
|
+
hint: REQUEST_CANCELED_HINT,
|
|
154
|
+
...details,
|
|
155
|
+
reason: REQUEST_CANCELED_REASON
|
|
156
|
+
}, cause);
|
|
157
|
+
}
|
|
158
|
+
function asAppError(err, fallbackCode = "UNKNOWN") {
|
|
159
|
+
if (err instanceof AppError) return err;
|
|
160
|
+
if (err instanceof Error) return new AppError(fallbackCode, err.message, void 0, err);
|
|
161
|
+
return new AppError(fallbackCode, "Unknown error", { err });
|
|
162
|
+
}
|
|
163
|
+
function normalizeError(err, context = {}) {
|
|
164
|
+
const appErr = asAppError(err);
|
|
165
|
+
const details = appErr.details ? redactDiagnosticData(appErr.details) : void 0;
|
|
166
|
+
const diagnosticId = stringDetail(details, "diagnosticId") ?? context.diagnosticId;
|
|
167
|
+
const logPath = stringDetail(details, "logPath") ?? context.logPath;
|
|
168
|
+
const logPathUnavailable = stringDetail(details, "logPathUnavailable");
|
|
169
|
+
const diagnosticsRecord = readDiagnosticsRecordRef(details?.diagnosticsRecord) ?? context.diagnosticsRecord;
|
|
170
|
+
const hint = stringDetail(details, "hint") ?? defaultHintForCode(appErr.code);
|
|
171
|
+
const retriable = booleanDetail(details, "retriable") ?? retriableForErrorCode(appErr.code);
|
|
172
|
+
const supportedOn = stringDetail(details, "supportedOn");
|
|
173
|
+
const cleanDetails = stripDiagnosticMeta(details);
|
|
174
|
+
const message = maybeEnrichCommandFailedMessage(appErr.code, appErr.message, details);
|
|
175
|
+
const cause = sanitizeErrorCause(appErr.cause);
|
|
176
|
+
return {
|
|
177
|
+
code: appErr.code,
|
|
178
|
+
message,
|
|
179
|
+
...cause !== void 0 ? { cause } : {},
|
|
180
|
+
hint,
|
|
181
|
+
diagnosticId,
|
|
182
|
+
logPath,
|
|
183
|
+
...logPathUnavailable !== void 0 ? { logPathUnavailable } : {},
|
|
184
|
+
...diagnosticsRecord !== void 0 ? { diagnosticsRecord } : {},
|
|
185
|
+
...retriable !== void 0 ? { retriable } : {},
|
|
186
|
+
...supportedOn !== void 0 ? { supportedOn } : {},
|
|
187
|
+
details: cleanDetails
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
const GENERIC_EXIT_MESSAGE = /^\S+ exited with code -?\d+$/;
|
|
191
|
+
function maybeEnrichCommandFailedMessage(code, message, details) {
|
|
192
|
+
if (code !== "COMMAND_FAILED") return message;
|
|
193
|
+
if (details?.processExitError !== true) return message;
|
|
194
|
+
const excerpt = firstStderrLine(typeof details?.stderr === "string" ? details.stderr : "");
|
|
195
|
+
if (!excerpt) return message;
|
|
196
|
+
if (GENERIC_EXIT_MESSAGE.test(message)) return excerpt;
|
|
197
|
+
if (message.includes(excerpt)) return message;
|
|
198
|
+
return `${message}: ${excerpt}`;
|
|
199
|
+
}
|
|
200
|
+
const STDERR_SKIP_PATTERNS = [
|
|
201
|
+
/^an error was encountered processing the command/i,
|
|
202
|
+
/^underlying error\b/i,
|
|
203
|
+
/^simulator device failed to complete the requested operation/i
|
|
204
|
+
];
|
|
205
|
+
const STDERR_NOISE_PREFIX = /^(?:(?:adb|xcrun|simctl):\s*)?(?:error:\s*)?/i;
|
|
206
|
+
function firstStderrLine(stderr) {
|
|
207
|
+
for (const rawLine of stderr.split("\n")) {
|
|
208
|
+
const line = rawLine.trim();
|
|
209
|
+
if (!line) continue;
|
|
210
|
+
if (STDERR_SKIP_PATTERNS.some((pattern) => pattern.test(line))) continue;
|
|
211
|
+
const excerpt = line.replace(STDERR_NOISE_PREFIX, "").trim();
|
|
212
|
+
if (!excerpt) continue;
|
|
213
|
+
return excerpt.length > 200 ? `${excerpt.slice(0, 200)}...` : excerpt;
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
function stringDetail(details, key) {
|
|
218
|
+
const value = details?.[key];
|
|
219
|
+
return typeof value === "string" ? value : void 0;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Narrows an untrusted `diagnosticsRecord` — off the wire or out of a details
|
|
223
|
+
* bag — to the locator type, or `undefined`. One reader, so a daemon payload
|
|
224
|
+
* and a details bag can never be accepted on different terms.
|
|
225
|
+
*/
|
|
226
|
+
function readDiagnosticsRecordRef(value) {
|
|
227
|
+
if (!value || typeof value !== "object") return void 0;
|
|
228
|
+
const { session, requestId } = value;
|
|
229
|
+
if (typeof session !== "string" || typeof requestId !== "string") return void 0;
|
|
230
|
+
if (session.length === 0 || requestId.length === 0) return void 0;
|
|
231
|
+
return {
|
|
232
|
+
session,
|
|
233
|
+
requestId
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function booleanDetail(details, key) {
|
|
237
|
+
const value = details?.[key];
|
|
238
|
+
return typeof value === "boolean" ? value : void 0;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Facts a publisher leaves for a later catch in the same process, never for a caller: whether a rule
|
|
242
|
+
* row named this failure, and whether the host's own deadline ended the command behind it. Both
|
|
243
|
+
* describe our machinery rather than the caller's problem, and the caller was already handed the
|
|
244
|
+
* verdict those facts produced as `reason` and `hint` (#2690 review).
|
|
245
|
+
*/
|
|
246
|
+
const INTERNAL_PLUMBING_DETAIL_KEYS = ["startupRuleMatched", "startupHostDeadlineHit"];
|
|
247
|
+
function stripDiagnosticMeta(details) {
|
|
248
|
+
if (!details) return void 0;
|
|
249
|
+
const output = { ...details };
|
|
250
|
+
for (const key of INTERNAL_PLUMBING_DETAIL_KEYS) delete output[key];
|
|
251
|
+
delete output.hint;
|
|
252
|
+
delete output.diagnosticId;
|
|
253
|
+
delete output.logPath;
|
|
254
|
+
delete output.logPathUnavailable;
|
|
255
|
+
delete output.diagnosticsRecord;
|
|
256
|
+
delete output.retriable;
|
|
257
|
+
delete output.supportedOn;
|
|
258
|
+
return Object.keys(output).length > 0 ? output : void 0;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Conservative retriability policy for the Phase 2 typed-error graft. A code is
|
|
262
|
+
* listed only when the verdict is clear (a retry can succeed without the caller
|
|
263
|
+
* changing anything, or it definitely cannot); unlisted codes stay `undefined`
|
|
264
|
+
* so the error wire shape is unchanged unless we have a confident answer.
|
|
265
|
+
*
|
|
266
|
+
* Keyed by `KnownAppErrorCode`, so a verdict cannot be given to a code the
|
|
267
|
+
* registry does not enumerate — that would classify a code as retriable while
|
|
268
|
+
* escaping the gate that requires a recovery quiz for it.
|
|
269
|
+
*/
|
|
270
|
+
const RETRIABILITY_BY_CODE = { DEVICE_IN_USE: true };
|
|
271
|
+
function isKnownAppErrorCode(code) {
|
|
272
|
+
return KNOWN_APP_ERROR_CODES.includes(code);
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* The retriability verdict for a code, or `undefined` when there is none —
|
|
276
|
+
* including for every daemon/runner-originated code outside
|
|
277
|
+
* `KNOWN_APP_ERROR_CODES`, which cannot carry a verdict at all.
|
|
278
|
+
*/
|
|
279
|
+
function retriableForErrorCode(code) {
|
|
280
|
+
return isKnownAppErrorCode(code) ? RETRIABILITY_BY_CODE[code] : void 0;
|
|
281
|
+
}
|
|
282
|
+
function defaultHintForCode(code) {
|
|
283
|
+
switch (code) {
|
|
284
|
+
case "INVALID_ARGS": return "Check command arguments and run --help for usage examples.";
|
|
285
|
+
case "SESSION_NOT_FOUND": return "Run open first or pass an explicit device selector.";
|
|
286
|
+
case "TOOL_MISSING": return "Install required platform tooling and ensure it is available in PATH.";
|
|
287
|
+
case "DEVICE_NOT_FOUND": return "Verify the target device is booted/connected and selectors match.";
|
|
288
|
+
case "APP_NOT_INSTALLED": return "Run apps to discover the exact installed package or bundle id, or install the app before open.";
|
|
289
|
+
case "UNSUPPORTED_OPERATION": return "This command is not available for the selected platform/device.";
|
|
290
|
+
case "UNSUPPORTED_PLATFORM": return "This platform is not supported for the requested operation; run devices to inspect available targets.";
|
|
291
|
+
case "AMBIGUOUS_MATCH": return "Multiple candidates matched. Narrow the query or pass an exact identifier.";
|
|
292
|
+
case "DEVICE_IN_USE": return "The device is busy with another agent-device request; retry once it frees up.";
|
|
293
|
+
case "REPLAY_DIVERGENCE": return "Read details.divergence (screen/suggestions) for repair context, or rerun with --json for the full report.";
|
|
294
|
+
case "REPAIR_SESSION_EXPIRED": return "The --save-script repair session was reaped before it was finalized; re-run replay <script> --save-script from the start.";
|
|
295
|
+
case "REPAIR_COMMIT_FAILED": return "The repair transaction completed, but committing its healed script failed at teardown (no-clobber refusal or a filesystem error); inspect the target path/permissions, then re-run replay <script> --save-script to retry.";
|
|
296
|
+
case "NOT_IMPLEMENTED": return "This command is part of the planned API but is not implemented yet.";
|
|
297
|
+
case "COMMAND_FAILED": return "Retry with --debug and inspect diagnostics log for details.";
|
|
298
|
+
case "UNAUTHORIZED": return "Refresh daemon metadata and retry the command.";
|
|
299
|
+
case "UNKNOWN": return "Unexpected internal error. Retry with --debug and report the diagnostics log if it persists.";
|
|
300
|
+
default: return "Retry with --debug and inspect diagnostics log for details.";
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
//#endregion
|
|
304
|
+
export { redactDiagnosticData as i, createRequestCanceledError as n, normalizeError as r, AppError as t };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
//#region src/daemon-proxy.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Carries one request to the upstream daemon and resolves with its response. The default is the
|
|
5
|
+
* global `fetch`; supply your own to reach the daemon over another transport, such as a
|
|
6
|
+
* WebSocket tunnel. Abort the exchange when `request.signal` aborts.
|
|
7
|
+
*/
|
|
8
|
+
type DaemonProxyUpstreamFetch = (request: Request) => Promise<Response>;
|
|
9
|
+
type DaemonProxyOptions = {
|
|
10
|
+
/** Base URL of the upstream agent-device daemon HTTP server. */
|
|
11
|
+
upstreamBaseUrl: string;
|
|
12
|
+
/** Auth token of the upstream daemon. Never leaves the proxy. */
|
|
13
|
+
upstreamToken: string;
|
|
14
|
+
/** Token proxy clients must present as their daemon auth token. */
|
|
15
|
+
clientToken: string;
|
|
16
|
+
maxRpcBodyBytes?: number;
|
|
17
|
+
upstreamTimeoutMs?: number;
|
|
18
|
+
upstreamFetch?: DaemonProxyUpstreamFetch;
|
|
19
|
+
};
|
|
20
|
+
type DaemonProxy = {
|
|
21
|
+
/** Identifies this proxy instance in its health payload, so clients notice a restart. */
|
|
22
|
+
readonly instanceId: string;
|
|
23
|
+
/**
|
|
24
|
+
* Answers one client request. `request.url` must be the URL the client used, because upload
|
|
25
|
+
* tickets are rewritten to its origin; an `x-forwarded-proto` header overrides its scheme.
|
|
26
|
+
* Abort `request.signal` when the client goes away so in-flight daemon work is cancelled.
|
|
27
|
+
* Resolves with an error response rather than rejecting.
|
|
28
|
+
*/
|
|
29
|
+
handle(request: Request): Promise<Response>;
|
|
30
|
+
};
|
|
31
|
+
declare function createDaemonProxy(options: DaemonProxyOptions): DaemonProxy;
|
|
32
|
+
declare function createDaemonProxyServer(options: DaemonProxyOptions): http.Server;
|
|
33
|
+
/** Serves a proxy from any `node:http` or `node:https` server; upload tickets follow its scheme. */
|
|
34
|
+
declare function createDaemonProxyRequestListener(proxy: DaemonProxy): http.RequestListener;
|
|
35
|
+
//#endregion
|
|
36
|
+
export { type DaemonProxy, type DaemonProxyOptions, type DaemonProxyUpstreamFetch, createDaemonProxy, createDaemonProxyRequestListener, createDaemonProxyServer };
|