@mono-agent/agent-runtime 0.17.1 → 0.18.0
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/MIGRATION.md +30 -3
- package/README.md +93 -12
- package/package.json +7 -2
- package/src/ai/failure.js +2 -1
- package/src/ai/index.js +2 -0
- package/src/ai/providers/acp-client.js +1124 -0
- package/src/ai/providers/acp-privacy.js +124 -0
- package/src/ai/providers/acp-public.js +21 -0
- package/src/ai/providers/acp-session-tokens.js +129 -0
- package/src/ai/providers/acp-transport.js +259 -0
- package/src/ai/providers/acp.js +523 -0
- package/src/ai/runtime/capabilities.js +16 -0
- package/src/ai/runtime/model-refs.js +20 -1
- package/src/ai/runtime/registry.js +6 -0
- package/src/ai/runtime/router.js +4 -1
- package/src/ai/types.js +9 -5
- package/src/runtime.js +4 -2
- package/types/ai/failure.d.ts +2 -2
- package/types/ai/index.d.ts +2 -0
- package/types/ai/providers/acp-client.d.ts +223 -0
- package/types/ai/providers/acp-privacy.d.ts +25 -0
- package/types/ai/providers/acp-public.d.ts +7 -0
- package/types/ai/providers/acp-session-tokens.d.ts +34 -0
- package/types/ai/providers/acp-transport.d.ts +32 -0
- package/types/ai/providers/acp.d.ts +93 -0
- package/types/ai/runtime/capabilities.d.ts +21 -0
- package/types/ai/types.d.ts +29 -9
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
const PRIVATE_PROTOCOL_KEYS = new Set(["_meta", "sessionId", "session_id"]);
|
|
4
|
+
const REDACTED = "[redacted]";
|
|
5
|
+
const MAX_HOST_VALUE_DEPTH = 32;
|
|
6
|
+
const MAX_HOST_VALUE_NODES = 4_096;
|
|
7
|
+
// ACP v1's SessionUpdate union is closed. Never carry an arbitrary peer
|
|
8
|
+
// discriminator onto a host-facing event, even when the SDK boundary is
|
|
9
|
+
// bypassed or becomes more permissive.
|
|
10
|
+
const ACP_SESSION_UPDATE_KINDS = new Set([
|
|
11
|
+
"user_message_chunk",
|
|
12
|
+
"agent_message_chunk",
|
|
13
|
+
"agent_thought_chunk",
|
|
14
|
+
"tool_call",
|
|
15
|
+
"tool_call_update",
|
|
16
|
+
"plan",
|
|
17
|
+
"plan_update",
|
|
18
|
+
"plan_removed",
|
|
19
|
+
"available_commands_update",
|
|
20
|
+
"current_mode_update",
|
|
21
|
+
"config_option_update",
|
|
22
|
+
"session_info_update",
|
|
23
|
+
"usage_update",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* ACP protocol session ids are connection state, not host-facing metadata.
|
|
28
|
+
* Remove their canonical keys, discard extension metadata, and redact copies
|
|
29
|
+
* embedded by an agent in otherwise public strings.
|
|
30
|
+
*
|
|
31
|
+
* @param {unknown} value
|
|
32
|
+
* @param {ReadonlyArray<unknown>} [rawSecrets]
|
|
33
|
+
* @returns {{value: any, truncated: boolean}}
|
|
34
|
+
*/
|
|
35
|
+
export function sanitizeAcpHostValueWithStatus(value, rawSecrets = []) {
|
|
36
|
+
const secrets = rawSecrets
|
|
37
|
+
.filter((secret) => typeof secret === "string" && secret.length > 0)
|
|
38
|
+
.sort((left, right) => /** @type {string} */ (right).length - /** @type {string} */ (left).length);
|
|
39
|
+
|
|
40
|
+
/** @param {string} text */
|
|
41
|
+
const redact = (text) => {
|
|
42
|
+
let result = text;
|
|
43
|
+
for (const secret of secrets) {
|
|
44
|
+
result = result.replaceAll(/** @type {string} */ (secret), REDACTED);
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
let nodes = 0;
|
|
50
|
+
let truncated = false;
|
|
51
|
+
|
|
52
|
+
/** @param {unknown} item @param {WeakSet<object>} ancestors @param {number} depth @returns {any} */
|
|
53
|
+
const visit = (item, ancestors, depth) => {
|
|
54
|
+
nodes += 1;
|
|
55
|
+
if (nodes > MAX_HOST_VALUE_NODES || depth > MAX_HOST_VALUE_DEPTH) {
|
|
56
|
+
truncated = true;
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
if (typeof item === "string") return redact(item);
|
|
60
|
+
if (Array.isArray(item)) {
|
|
61
|
+
if (ancestors.has(item)) {
|
|
62
|
+
truncated = true;
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
ancestors.add(item);
|
|
66
|
+
const result = [];
|
|
67
|
+
for (const entry of item) {
|
|
68
|
+
if (nodes >= MAX_HOST_VALUE_NODES) {
|
|
69
|
+
truncated = true;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
result.push(visit(entry, ancestors, depth + 1));
|
|
73
|
+
}
|
|
74
|
+
ancestors.delete(item);
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
if (!item || typeof item !== "object") return item;
|
|
78
|
+
if (ancestors.has(item)) {
|
|
79
|
+
truncated = true;
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
ancestors.add(item);
|
|
83
|
+
/** @type {Record<string, unknown>} */
|
|
84
|
+
const result = Object.create(null);
|
|
85
|
+
for (const [key, entry] of Object.entries(item)) {
|
|
86
|
+
if (nodes >= MAX_HOST_VALUE_NODES) {
|
|
87
|
+
truncated = true;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
if (PRIVATE_PROTOCOL_KEYS.has(key)) continue;
|
|
91
|
+
Object.defineProperty(result, redact(key), {
|
|
92
|
+
value: visit(entry, ancestors, depth + 1),
|
|
93
|
+
enumerable: true,
|
|
94
|
+
configurable: true,
|
|
95
|
+
writable: true,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
ancestors.delete(item);
|
|
99
|
+
return result;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return { value: visit(value, new WeakSet(), 0), truncated };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Compatibility wrapper for callback/list surfaces that intentionally accept
|
|
107
|
+
* a bounded representation. Protocol normalization should use the status form
|
|
108
|
+
* above so it can fail explicitly instead of consuming partial data.
|
|
109
|
+
*
|
|
110
|
+
* @param {unknown} value
|
|
111
|
+
* @param {ReadonlyArray<unknown>} [rawSecrets]
|
|
112
|
+
* @returns {any}
|
|
113
|
+
*/
|
|
114
|
+
export function sanitizeAcpHostValue(value, rawSecrets = []) {
|
|
115
|
+
return sanitizeAcpHostValueWithStatus(value, rawSecrets).value;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** @param {unknown} value @returns {string|null} */
|
|
119
|
+
export function ownAcpSessionUpdateKind(value) {
|
|
120
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
121
|
+
if (!Object.hasOwn(value, "sessionUpdate")) return null;
|
|
122
|
+
const kind = /** @type {{sessionUpdate?: unknown}} */ (value).sessionUpdate;
|
|
123
|
+
return typeof kind === "string" && ACP_SESSION_UPDATE_KINDS.has(kind) ? kind : null;
|
|
124
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Public high-level ACP surface. The owned stdio connection and raw protocol
|
|
2
|
+
// session state stay in acp-client.js and are intentionally not package exports.
|
|
3
|
+
|
|
4
|
+
/** @typedef {import("./acp-client.js").AcpProfileDescriptor} AcpProfileDescriptor */
|
|
5
|
+
/** @typedef {import("./acp-client.js").AcpCallbackContext} AcpCallbackContext */
|
|
6
|
+
/** @typedef {import("./acp-client.js").AcpInteractionRequest} AcpInteractionRequest */
|
|
7
|
+
/** @typedef {import("./acp-client.js").AcpClientHostOptions} AcpClientHostOptions */
|
|
8
|
+
/** @typedef {import("./acp-client.js").AcpListedSession} AcpListedSession */
|
|
9
|
+
/** @typedef {import("./acp-client.js").AcpSessionListResult} AcpSessionListResult */
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
ACP_PROTOCOL_VERSION,
|
|
13
|
+
AcpClientError,
|
|
14
|
+
authenticateAcpProfile,
|
|
15
|
+
deleteAcpSession,
|
|
16
|
+
listAcpSessions,
|
|
17
|
+
logoutAcpProfile,
|
|
18
|
+
probeAcpProfile,
|
|
19
|
+
validateAcpProfileId,
|
|
20
|
+
validateAcpProviderSessionId,
|
|
21
|
+
} from "./acp-client.js";
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
const PROFILE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
4
|
+
const BASE64URL_RE = /^[A-Za-z0-9_-]+$/;
|
|
5
|
+
const MAX_PROFILE_ID_LENGTH = 128;
|
|
6
|
+
const MAX_RAW_TOKEN_BYTES = 4_096;
|
|
7
|
+
const MAX_ENCODED_TOKEN_LENGTH = Math.ceil(MAX_RAW_TOKEN_BYTES * 4 / 3);
|
|
8
|
+
const PROVIDER_SESSION_PREFIX = "acp:v1:";
|
|
9
|
+
const SESSION_CURSOR_PREFIX = "acp-cursor:v1:";
|
|
10
|
+
const MAX_PROVIDER_SESSION_ID_LENGTH = PROVIDER_SESSION_PREFIX.length
|
|
11
|
+
+ MAX_PROFILE_ID_LENGTH + 1 + MAX_ENCODED_TOKEN_LENGTH;
|
|
12
|
+
const MAX_SESSION_CURSOR_LENGTH = SESSION_CURSOR_PREFIX.length
|
|
13
|
+
+ MAX_PROFILE_ID_LENGTH + 1 + MAX_ENCODED_TOKEN_LENGTH;
|
|
14
|
+
|
|
15
|
+
export class AcpClientError extends Error {
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} code
|
|
18
|
+
* @param {string} message
|
|
19
|
+
* @param {Record<string, unknown>} [details]
|
|
20
|
+
*/
|
|
21
|
+
constructor(code, message, details = {}) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "AcpClientError";
|
|
24
|
+
this.code = code;
|
|
25
|
+
this.details = { ...details, code };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** @param {string} profileId @returns {string} */
|
|
30
|
+
export function validateAcpProfileId(profileId) {
|
|
31
|
+
if (typeof profileId !== "string" || !PROFILE_ID_RE.test(profileId)) {
|
|
32
|
+
throw new AcpClientError(
|
|
33
|
+
"invalid_profile_id",
|
|
34
|
+
"ACP profile id must use 1-128 ASCII letters, digits, dots, underscores, or hyphens and start alphanumeric.",
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return profileId;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @param {unknown} value @param {string} code @param {string} label */
|
|
41
|
+
function requiredTokenString(value, code, label) {
|
|
42
|
+
if (typeof value !== "string" || value.trim() !== value || value.length === 0 || value.includes("\0")) {
|
|
43
|
+
throw new AcpClientError(code, `${label} must be a non-empty trimmed string without NUL bytes.`);
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @param {string} encoded @param {string} code @param {string} label */
|
|
49
|
+
function decodeToken(encoded, code, label) {
|
|
50
|
+
if (!BASE64URL_RE.test(encoded)) throw new AcpClientError(code, `Invalid ${label} encoding.`);
|
|
51
|
+
const bytes = Buffer.from(encoded, "base64url");
|
|
52
|
+
if (bytes.length === 0 || bytes.toString("base64url") !== encoded) {
|
|
53
|
+
throw new AcpClientError(code, `Non-canonical ${label} encoding.`);
|
|
54
|
+
}
|
|
55
|
+
if (bytes.length > MAX_RAW_TOKEN_BYTES) {
|
|
56
|
+
throw new AcpClientError(code, `${label} exceeds ${MAX_RAW_TOKEN_BYTES} bytes.`);
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
return requiredTokenString(new TextDecoder("utf-8", { fatal: true }).decode(bytes), code, label);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (error instanceof AcpClientError) throw error;
|
|
62
|
+
throw new AcpClientError(code, `${label} is not valid UTF-8.`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @param {string} profileId @param {string} sessionId */
|
|
67
|
+
export function encodeAcpProviderSessionId(profileId, sessionId) {
|
|
68
|
+
validateAcpProfileId(profileId);
|
|
69
|
+
requiredTokenString(sessionId, "invalid_session_id", "ACP session id");
|
|
70
|
+
if (Buffer.byteLength(sessionId, "utf8") > MAX_RAW_TOKEN_BYTES) {
|
|
71
|
+
throw new AcpClientError("invalid_session_id", `ACP session id exceeds ${MAX_RAW_TOKEN_BYTES} bytes.`);
|
|
72
|
+
}
|
|
73
|
+
return `${PROVIDER_SESSION_PREFIX}${profileId}:${Buffer.from(sessionId, "utf8").toString("base64url")}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Internal protocol-state decoder. This module is not a package export. @param {string} providerSessionId */
|
|
77
|
+
export function decodeAcpProviderSessionId(providerSessionId) {
|
|
78
|
+
if (typeof providerSessionId !== "string") {
|
|
79
|
+
throw new AcpClientError("invalid_session_id", "ACP provider session id must be a string.");
|
|
80
|
+
}
|
|
81
|
+
if (providerSessionId.length > MAX_PROVIDER_SESSION_ID_LENGTH) {
|
|
82
|
+
throw new AcpClientError("invalid_session_id", "ACP provider session id exceeds the supported length.");
|
|
83
|
+
}
|
|
84
|
+
const match = /^acp:v1:([^:]+):([^:]+)$/.exec(providerSessionId);
|
|
85
|
+
if (!match) throw new AcpClientError("invalid_session_id", "Invalid ACP provider session id.");
|
|
86
|
+
const profileId = validateAcpProfileId(match[1]);
|
|
87
|
+
const sessionId = decodeToken(match[2], "invalid_session_id", "ACP session id");
|
|
88
|
+
return { profileId, sessionId };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Validate an opaque provider-session handle and its profile binding without
|
|
93
|
+
* exposing the remote agent's protocol session id.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} providerSessionId
|
|
96
|
+
* @param {string} expectedProfileId
|
|
97
|
+
* @returns {string}
|
|
98
|
+
*/
|
|
99
|
+
export function validateAcpProviderSessionId(providerSessionId, expectedProfileId) {
|
|
100
|
+
const profileId = validateAcpProfileId(expectedProfileId);
|
|
101
|
+
const decoded = decodeAcpProviderSessionId(providerSessionId);
|
|
102
|
+
if (decoded.profileId !== profileId) {
|
|
103
|
+
throw new AcpClientError("invalid_session_id", "ACP provider session belongs to a different profile.");
|
|
104
|
+
}
|
|
105
|
+
return providerSessionId;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** @param {string} profileId @param {string} cursor */
|
|
109
|
+
export function encodeAcpSessionCursor(profileId, cursor) {
|
|
110
|
+
validateAcpProfileId(profileId);
|
|
111
|
+
requiredTokenString(cursor, "invalid_cursor", "ACP session cursor");
|
|
112
|
+
if (Buffer.byteLength(cursor, "utf8") > MAX_RAW_TOKEN_BYTES) {
|
|
113
|
+
throw new AcpClientError("invalid_cursor", `ACP session cursor exceeds ${MAX_RAW_TOKEN_BYTES} bytes.`);
|
|
114
|
+
}
|
|
115
|
+
return `${SESSION_CURSOR_PREFIX}${profileId}:${Buffer.from(cursor, "utf8").toString("base64url")}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** @param {string} profileId @param {unknown} cursor */
|
|
119
|
+
export function decodeAcpSessionCursor(profileId, cursor) {
|
|
120
|
+
validateAcpProfileId(profileId);
|
|
121
|
+
if (typeof cursor !== "string" || cursor.length > MAX_SESSION_CURSOR_LENGTH) {
|
|
122
|
+
throw new AcpClientError("invalid_cursor", "ACP session cursor must be an opaque cursor returned by listAcpSessions.");
|
|
123
|
+
}
|
|
124
|
+
const match = /^acp-cursor:v1:([^:]+):([^:]+)$/.exec(cursor);
|
|
125
|
+
if (!match || match[1] !== profileId) {
|
|
126
|
+
throw new AcpClientError("invalid_cursor", "ACP session cursor is invalid for this profile.");
|
|
127
|
+
}
|
|
128
|
+
return decodeToken(match[2], "invalid_cursor", "ACP session cursor");
|
|
129
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
// Strict, bounded ACP v1 newline transport for an owned stdio child process.
|
|
4
|
+
// The SDK's stock ndJsonStream intentionally tolerates malformed input and its
|
|
5
|
+
// line buffer is unbounded, which is a poor fit for a long-lived host boundary.
|
|
6
|
+
|
|
7
|
+
const DEFAULT_MAX_LINE_BYTES = 1024 * 1024;
|
|
8
|
+
const MAX_MAX_LINE_BYTES = 16 * 1024 * 1024;
|
|
9
|
+
|
|
10
|
+
export class AcpTransportError extends Error {
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} code
|
|
13
|
+
* @param {string} message
|
|
14
|
+
*/
|
|
15
|
+
constructor(code, message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "AcpTransportError";
|
|
18
|
+
this.code = code;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {unknown} value
|
|
24
|
+
* @param {number} [fallback]
|
|
25
|
+
* @returns {number}
|
|
26
|
+
*/
|
|
27
|
+
export function normalizeAcpMaxLineBytes(value, fallback = DEFAULT_MAX_LINE_BYTES) {
|
|
28
|
+
if (value === undefined) return fallback;
|
|
29
|
+
if (!Number.isInteger(value) || Number(value) < 1024 || Number(value) > MAX_MAX_LINE_BYTES) {
|
|
30
|
+
throw new AcpTransportError(
|
|
31
|
+
"invalid_transport_policy",
|
|
32
|
+
`ACP maxLineBytes must be an integer between 1024 and ${MAX_MAX_LINE_BYTES}.`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return Number(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The transport only accepts individual JSON-RPC 2.0 objects. Batches are not
|
|
40
|
+
* part of ACP v1 and are rejected before the SDK sees them.
|
|
41
|
+
* @param {unknown} value
|
|
42
|
+
* @returns {value is Record<string, unknown>}
|
|
43
|
+
*/
|
|
44
|
+
function isIndividualJsonRpcMessage(value) {
|
|
45
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
46
|
+
const record = /** @type {Record<string, unknown>} */ (value);
|
|
47
|
+
if (record.jsonrpc !== "2.0") return false;
|
|
48
|
+
|
|
49
|
+
const hasId = Object.hasOwn(record, "id");
|
|
50
|
+
const hasMethod = Object.hasOwn(record, "method");
|
|
51
|
+
const hasParams = Object.hasOwn(record, "params");
|
|
52
|
+
const hasResult = Object.hasOwn(record, "result");
|
|
53
|
+
const hasError = Object.hasOwn(record, "error");
|
|
54
|
+
|
|
55
|
+
const validId = !hasId
|
|
56
|
+
|| record.id === null
|
|
57
|
+
|| typeof record.id === "string"
|
|
58
|
+
|| (typeof record.id === "number" && Number.isFinite(record.id));
|
|
59
|
+
if (!validId) return false;
|
|
60
|
+
|
|
61
|
+
if (hasMethod) {
|
|
62
|
+
if (typeof record.method !== "string" || record.method.length === 0) return false;
|
|
63
|
+
if (hasParams) {
|
|
64
|
+
const params = record.params;
|
|
65
|
+
if (!params || typeof params !== "object") return false;
|
|
66
|
+
}
|
|
67
|
+
return !hasResult && !hasError;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!hasId || hasParams || hasResult === hasError) return false;
|
|
71
|
+
if (!hasError) return true;
|
|
72
|
+
if (!record.error || typeof record.error !== "object" || Array.isArray(record.error)) return false;
|
|
73
|
+
const error = /** @type {Record<string, unknown>} */ (record.error);
|
|
74
|
+
return Number.isInteger(error.code) && typeof error.message === "string";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {Buffer} line
|
|
79
|
+
* @returns {Record<string, unknown>}
|
|
80
|
+
*/
|
|
81
|
+
function decodeLine(line) {
|
|
82
|
+
if (line.length === 0) {
|
|
83
|
+
throw new AcpTransportError("invalid_jsonrpc", "ACP peer emitted an empty NDJSON line.");
|
|
84
|
+
}
|
|
85
|
+
let text;
|
|
86
|
+
try {
|
|
87
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(line);
|
|
88
|
+
} catch {
|
|
89
|
+
throw new AcpTransportError("invalid_utf8", "ACP peer emitted invalid UTF-8.");
|
|
90
|
+
}
|
|
91
|
+
let parsed;
|
|
92
|
+
try {
|
|
93
|
+
parsed = JSON.parse(text);
|
|
94
|
+
} catch {
|
|
95
|
+
throw new AcpTransportError("invalid_jsonrpc", "ACP peer emitted malformed JSON.");
|
|
96
|
+
}
|
|
97
|
+
if (!isIndividualJsonRpcMessage(parsed)) {
|
|
98
|
+
throw new AcpTransportError("invalid_jsonrpc", "ACP peer emitted a non-ACP JSON-RPC message.");
|
|
99
|
+
}
|
|
100
|
+
return parsed;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build the Stream shape consumed by @agentclientprotocol/sdk from a child
|
|
105
|
+
* process's stdin/stdout. Both inbound and outbound payloads are capped.
|
|
106
|
+
*
|
|
107
|
+
* @param {{stdin: import('node:stream').Writable|null, stdout: import('node:stream').Readable|null}} child
|
|
108
|
+
* @param {{maxLineBytes?: number}} [options]
|
|
109
|
+
* @returns {{readable: ReadableStream<any>, writable: WritableStream<any>}}
|
|
110
|
+
*/
|
|
111
|
+
export function createBoundedAcpStdioStream(child, options = {}) {
|
|
112
|
+
const maxLineBytes = normalizeAcpMaxLineBytes(options.maxLineBytes);
|
|
113
|
+
if (!child.stdin || !child.stdout) {
|
|
114
|
+
throw new AcpTransportError("stdio_unavailable", "ACP child stdio is unavailable.");
|
|
115
|
+
}
|
|
116
|
+
const input = child.stdout;
|
|
117
|
+
const output = child.stdin;
|
|
118
|
+
|
|
119
|
+
let pending = Buffer.alloc(0);
|
|
120
|
+
let settled = false;
|
|
121
|
+
let inputEnded = false;
|
|
122
|
+
/** @type {ReadableStreamDefaultController<any>|null} */
|
|
123
|
+
let readableController = null;
|
|
124
|
+
|
|
125
|
+
const cleanupReadable = () => {
|
|
126
|
+
input.off("data", onData);
|
|
127
|
+
input.off("end", onEnd);
|
|
128
|
+
input.off("error", onError);
|
|
129
|
+
};
|
|
130
|
+
const failReadable = (error) => {
|
|
131
|
+
if (settled) return;
|
|
132
|
+
settled = true;
|
|
133
|
+
input.pause();
|
|
134
|
+
cleanupReadable();
|
|
135
|
+
readableController?.error(error instanceof Error
|
|
136
|
+
? error
|
|
137
|
+
: new AcpTransportError("transport_failed", "ACP stdout failed."));
|
|
138
|
+
};
|
|
139
|
+
const firstPendingLineIsTooLarge = () => {
|
|
140
|
+
const newline = pending.indexOf(0x0a);
|
|
141
|
+
if (newline !== -1) {
|
|
142
|
+
const contentBytes = newline > 0 && pending[newline - 1] === 0x0d ? newline - 1 : newline;
|
|
143
|
+
return contentBytes > maxLineBytes;
|
|
144
|
+
}
|
|
145
|
+
if (pending.length <= maxLineBytes) return false;
|
|
146
|
+
return pending.length > maxLineBytes + 1 || pending[pending.length - 1] !== 0x0d;
|
|
147
|
+
};
|
|
148
|
+
const finishReadableIfEnded = () => {
|
|
149
|
+
if (!inputEnded || settled || !readableController) return false;
|
|
150
|
+
if (pending.length === 0) {
|
|
151
|
+
settled = true;
|
|
152
|
+
cleanupReadable();
|
|
153
|
+
readableController.close();
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
if (pending.indexOf(0x0a) === -1) {
|
|
157
|
+
failReadable(new AcpTransportError(
|
|
158
|
+
"unterminated_line",
|
|
159
|
+
"ACP peer closed stdout with an unterminated JSON-RPC line.",
|
|
160
|
+
));
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
};
|
|
165
|
+
const drainPending = () => {
|
|
166
|
+
if (settled || !readableController) return;
|
|
167
|
+
while (readableController.desiredSize !== null && readableController.desiredSize > 0) {
|
|
168
|
+
const newline = pending.indexOf(0x0a);
|
|
169
|
+
if (newline === -1) break;
|
|
170
|
+
let line = pending.subarray(0, newline);
|
|
171
|
+
pending = pending.subarray(newline + 1);
|
|
172
|
+
if (line.length > 0 && line[line.length - 1] === 0x0d) line = line.subarray(0, line.length - 1);
|
|
173
|
+
if (line.length > maxLineBytes) {
|
|
174
|
+
failReadable(new AcpTransportError("line_too_large", "ACP peer exceeded the inbound line limit."));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
readableController.enqueue(decodeLine(line));
|
|
179
|
+
} catch (error) {
|
|
180
|
+
failReadable(error);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (firstPendingLineIsTooLarge()) {
|
|
185
|
+
failReadable(new AcpTransportError("line_too_large", "ACP peer exceeded the inbound line limit."));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (finishReadableIfEnded()) return;
|
|
189
|
+
if (readableController.desiredSize !== null
|
|
190
|
+
&& readableController.desiredSize > 0
|
|
191
|
+
&& pending.indexOf(0x0a) === -1) {
|
|
192
|
+
input.resume();
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
const onData = (chunk) => {
|
|
196
|
+
if (settled) return;
|
|
197
|
+
input.pause();
|
|
198
|
+
const next = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
199
|
+
pending = pending.length === 0 ? next : Buffer.concat([pending, next]);
|
|
200
|
+
drainPending();
|
|
201
|
+
};
|
|
202
|
+
const onEnd = () => {
|
|
203
|
+
if (settled) return;
|
|
204
|
+
inputEnded = true;
|
|
205
|
+
drainPending();
|
|
206
|
+
};
|
|
207
|
+
const onError = () => failReadable(new AcpTransportError("transport_failed", "ACP stdout failed."));
|
|
208
|
+
|
|
209
|
+
const readable = new ReadableStream({
|
|
210
|
+
start(controller) {
|
|
211
|
+
readableController = controller;
|
|
212
|
+
input.pause();
|
|
213
|
+
input.on("data", onData);
|
|
214
|
+
input.once("end", onEnd);
|
|
215
|
+
input.once("error", onError);
|
|
216
|
+
drainPending();
|
|
217
|
+
},
|
|
218
|
+
pull() {
|
|
219
|
+
drainPending();
|
|
220
|
+
},
|
|
221
|
+
cancel() {
|
|
222
|
+
if (!settled) {
|
|
223
|
+
settled = true;
|
|
224
|
+
cleanupReadable();
|
|
225
|
+
}
|
|
226
|
+
input.destroy();
|
|
227
|
+
},
|
|
228
|
+
}, { highWaterMark: 1 });
|
|
229
|
+
|
|
230
|
+
const writable = new WritableStream({
|
|
231
|
+
async write(message) {
|
|
232
|
+
let json;
|
|
233
|
+
try {
|
|
234
|
+
json = JSON.stringify(message);
|
|
235
|
+
} catch {
|
|
236
|
+
throw new AcpTransportError("invalid_outbound_json", "ACP outbound message is not JSON serializable.");
|
|
237
|
+
}
|
|
238
|
+
if (typeof json !== "string" || Buffer.byteLength(json, "utf8") > maxLineBytes) {
|
|
239
|
+
throw new AcpTransportError("line_too_large", "ACP outbound message exceeded the line limit.");
|
|
240
|
+
}
|
|
241
|
+
if (output.destroyed || output.writableEnded) {
|
|
242
|
+
throw new AcpTransportError("transport_closed", "ACP stdin is closed.");
|
|
243
|
+
}
|
|
244
|
+
await new Promise((resolve, reject) => {
|
|
245
|
+
output.write(`${json}\n`, (error) => error ? reject(error) : resolve(undefined));
|
|
246
|
+
});
|
|
247
|
+
},
|
|
248
|
+
close() {
|
|
249
|
+
if (!output.destroyed && !output.writableEnded) output.end();
|
|
250
|
+
},
|
|
251
|
+
abort() {
|
|
252
|
+
output.destroy();
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
return { readable, writable };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export const ACP_DEFAULT_MAX_LINE_BYTES = DEFAULT_MAX_LINE_BYTES;
|