@anhlp/alp-protocol 0.0.0-stage → 0.0.1-beta.3
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 +202 -0
- package/NOTICE +7 -0
- package/README.md +16 -2
- package/conformance/cases.json +951 -0
- package/conformance/run.d.mts +32 -0
- package/conformance/run.mjs +403 -0
- package/dist/connection.d.ts +9 -0
- package/dist/connection.js +0 -0
- package/dist/contract-v1.d.ts +5113 -0
- package/dist/contract-v1.js +5 -0
- package/dist/contract.d.ts +25 -0
- package/dist/contract.js +28 -0
- package/dist/dto.d.ts +104 -0
- package/dist/dto.js +0 -0
- package/dist/events.d.ts +313 -0
- package/dist/events.js +0 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +2 -0
- package/dist/public.d.ts +1 -0
- package/dist/public.js +2 -0
- package/dist/rpc.d.ts +570 -0
- package/dist/rpc.js +0 -0
- package/dist/session.d.ts +145 -0
- package/dist/session.js +0 -0
- package/dist/validate.d.ts +12 -0
- package/dist/validate.js +151 -0
- package/dist/wire.d.ts +80 -0
- package/dist/wire.js +62 -0
- package/fixtures/ipc-v1.jsonl +4 -0
- package/fixtures/v1/cases.json +3919 -0
- package/package.json +127 -4
- package/schema/ipc-v1.json +2335 -0
- package/schema/methods-v1.json +4757 -0
- package/src/connection.ts +9 -0
- package/src/contract-v1.js +2 -0
- package/src/contract.js +44 -0
- package/src/dto.ts +78 -0
- package/src/events.ts +181 -0
- package/src/index.ts +6 -0
- package/src/public.js +2 -0
- package/src/rpc.ts +113 -0
- package/src/session.ts +97 -0
- package/src/validate.js +115 -0
- package/src/wire.js +87 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks `value` against `schema`, resolving `#/$defs/...` against `root`.
|
|
3
|
+
* @param {any} schema
|
|
4
|
+
* @param {unknown} value
|
|
5
|
+
* @param {any} [root]
|
|
6
|
+
* @returns {SchemaIssue[]} empty when the value conforms
|
|
7
|
+
*/
|
|
8
|
+
export function validateSchema(schema: any, value: unknown, root?: any): SchemaIssue[];
|
|
9
|
+
export type SchemaIssue = {
|
|
10
|
+
path: string;
|
|
11
|
+
message: string;
|
|
12
|
+
};
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// packages/protocol/src/validate.js
|
|
2
|
+
var MAX_ISSUES = 8;
|
|
3
|
+
function typeOf(value) {
|
|
4
|
+
if (value === null) return "null";
|
|
5
|
+
if (Array.isArray(value)) return "array";
|
|
6
|
+
if (typeof value === "number") return !Number.isFinite(value) ? "non-finite number" : Number.isInteger(value) ? "integer" : "number";
|
|
7
|
+
return typeof value;
|
|
8
|
+
}
|
|
9
|
+
var typeMatches = (want, have) => want === have || want === "number" && have === "integer";
|
|
10
|
+
function equal(a, b) {
|
|
11
|
+
if (a === b) return true;
|
|
12
|
+
if (typeOf(a) !== typeOf(b) || a === null || typeof a !== "object") return false;
|
|
13
|
+
if (Array.isArray(a)) return a.length === /** @type {unknown[]} */
|
|
14
|
+
b.length && a.every((item, index) => equal(
|
|
15
|
+
item,
|
|
16
|
+
/** @type {unknown[]} */
|
|
17
|
+
b[index]
|
|
18
|
+
));
|
|
19
|
+
const left = Object.keys(a).filter((key) => (
|
|
20
|
+
/** @type {any} */
|
|
21
|
+
a[key] !== void 0
|
|
22
|
+
));
|
|
23
|
+
const right = Object.keys(
|
|
24
|
+
/** @type {object} */
|
|
25
|
+
b
|
|
26
|
+
).filter((key) => (
|
|
27
|
+
/** @type {any} */
|
|
28
|
+
b[key] !== void 0
|
|
29
|
+
));
|
|
30
|
+
return left.length === right.length && left.every((key) => equal(
|
|
31
|
+
/** @type {any} */
|
|
32
|
+
a[key],
|
|
33
|
+
/** @type {any} */
|
|
34
|
+
b[key]
|
|
35
|
+
));
|
|
36
|
+
}
|
|
37
|
+
function resolve(root, ref) {
|
|
38
|
+
if (!ref.startsWith("#/")) throw new Error(`Unsupported schema reference ${ref}`);
|
|
39
|
+
let target = root;
|
|
40
|
+
for (const part of ref.slice(2).split("/")) target = target?.[part.replaceAll("~1", "/").replaceAll("~0", "~")];
|
|
41
|
+
if (target === void 0) throw new Error(`Unresolved schema reference ${ref}`);
|
|
42
|
+
return target;
|
|
43
|
+
}
|
|
44
|
+
function check(schema, value, at, root, issues) {
|
|
45
|
+
if (issues.length >= MAX_ISSUES || schema === true || schema === void 0) return;
|
|
46
|
+
if (schema === false) {
|
|
47
|
+
issues.push({ path: at, message: "is not allowed" });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (schema.$ref) {
|
|
51
|
+
check(resolve(root, schema.$ref), value, at, root, issues);
|
|
52
|
+
if (Object.keys(schema).length === 1) return;
|
|
53
|
+
}
|
|
54
|
+
const kind = typeOf(value);
|
|
55
|
+
if (schema.type !== void 0) {
|
|
56
|
+
const wanted = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
57
|
+
if (!wanted.some(
|
|
58
|
+
/** @param {string} want */
|
|
59
|
+
(want) => typeMatches(want, kind)
|
|
60
|
+
)) {
|
|
61
|
+
issues.push({ path: at, message: `must be ${wanted.join(" or ")}` });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if ("const" in schema && !equal(schema.const, value)) {
|
|
66
|
+
issues.push({ path: at, message: `must be ${JSON.stringify(schema.const)}` });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (schema.enum && !schema.enum.some(
|
|
70
|
+
/** @param {unknown} option */
|
|
71
|
+
(option) => equal(option, value)
|
|
72
|
+
)) {
|
|
73
|
+
issues.push({ path: at, message: `must be one of ${schema.enum.map(
|
|
74
|
+
/** @param {unknown} option */
|
|
75
|
+
(option) => JSON.stringify(option)
|
|
76
|
+
).join(", ")}` });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (kind === "integer" || kind === "number") {
|
|
80
|
+
const number = (
|
|
81
|
+
/** @type {number} */
|
|
82
|
+
value
|
|
83
|
+
);
|
|
84
|
+
if (schema.minimum !== void 0 && number < schema.minimum) issues.push({ path: at, message: `must be at least ${schema.minimum}` });
|
|
85
|
+
if (schema.maximum !== void 0 && number > schema.maximum) issues.push({ path: at, message: `must be at most ${schema.maximum}` });
|
|
86
|
+
}
|
|
87
|
+
if (kind === "string") {
|
|
88
|
+
const text = (
|
|
89
|
+
/** @type {string} */
|
|
90
|
+
value
|
|
91
|
+
);
|
|
92
|
+
let length = 0;
|
|
93
|
+
if (schema.minLength !== void 0 || schema.maxLength !== void 0) for (const _point of text) length++;
|
|
94
|
+
if (schema.minLength !== void 0 && length < schema.minLength) issues.push({ path: at, message: `must have at least ${schema.minLength} characters` });
|
|
95
|
+
if (schema.maxLength !== void 0 && length > schema.maxLength) issues.push({ path: at, message: `must have at most ${schema.maxLength} characters` });
|
|
96
|
+
if (schema.pattern !== void 0 && !new RegExp(schema.pattern, "u").test(text)) issues.push({ path: at, message: `must match ${schema.pattern}` });
|
|
97
|
+
}
|
|
98
|
+
if (kind === "array" && schema.minItems !== void 0 && /** @type {unknown[]} */
|
|
99
|
+
value.length < schema.minItems) issues.push({ path: at, message: `must have at least ${schema.minItems} items` });
|
|
100
|
+
if (kind === "array" && schema.maxItems !== void 0 && /** @type {unknown[]} */
|
|
101
|
+
value.length > schema.maxItems) issues.push({ path: at, message: `must have at most ${schema.maxItems} items` });
|
|
102
|
+
if (kind === "array" && schema.items !== void 0) {
|
|
103
|
+
const array = (
|
|
104
|
+
/** @type {unknown[]} */
|
|
105
|
+
value
|
|
106
|
+
);
|
|
107
|
+
for (let index = 0; index < array.length && issues.length < MAX_ISSUES; index++) check(schema.items, array[index] === void 0 ? null : array[index], `${at}/${index}`, root, issues);
|
|
108
|
+
}
|
|
109
|
+
if (kind === "object") {
|
|
110
|
+
const object = (
|
|
111
|
+
/** @type {Record<string, unknown>} */
|
|
112
|
+
value
|
|
113
|
+
);
|
|
114
|
+
const properties = schema.properties ?? {};
|
|
115
|
+
for (const name of schema.required ?? []) if (object[name] === void 0) issues.push({ path: `${at}/${name}`, message: "is required" });
|
|
116
|
+
for (const [name, child] of Object.entries(object)) {
|
|
117
|
+
if (child === void 0) continue;
|
|
118
|
+
if (Object.hasOwn(properties, name)) check(properties[name], child, `${at}/${name}`, root, issues);
|
|
119
|
+
else if (schema.additionalProperties !== void 0) check(schema.additionalProperties, child, `${at}/${name}`, root, issues);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (schema.not !== void 0) {
|
|
123
|
+
const inner = [];
|
|
124
|
+
check(schema.not, value, at, root, inner);
|
|
125
|
+
if (!inner.length) issues.push({ path: at, message: "matches a schema it must not match" });
|
|
126
|
+
}
|
|
127
|
+
for (
|
|
128
|
+
const [keyword, exactlyOne] of
|
|
129
|
+
/** @type {const} */
|
|
130
|
+
[["anyOf", false], ["oneOf", true]]
|
|
131
|
+
) {
|
|
132
|
+
if (!schema[keyword]) continue;
|
|
133
|
+
const passing = schema[keyword].filter(
|
|
134
|
+
/** @param {any} option */
|
|
135
|
+
(option) => {
|
|
136
|
+
const inner = [];
|
|
137
|
+
check(option, value, at, root, inner);
|
|
138
|
+
return !inner.length;
|
|
139
|
+
}
|
|
140
|
+
).length;
|
|
141
|
+
if (exactlyOne ? passing !== 1 : passing === 0) issues.push({ path: at, message: exactlyOne && passing > 1 ? "matches more than one allowed shape" : "matches none of the allowed shapes" });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function validateSchema(schema, value, root = schema) {
|
|
145
|
+
const issues = [];
|
|
146
|
+
check(schema, value, "", root, issues);
|
|
147
|
+
return issues;
|
|
148
|
+
}
|
|
149
|
+
export {
|
|
150
|
+
validateSchema
|
|
151
|
+
};
|
package/dist/wire.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a path relative to a workspace names something workspace.files.list and
|
|
3
|
+
* workspace.files.read never show, whatever the grant: a segment that is a blocked directory, or
|
|
4
|
+
* any segment (file or directory) named as a secret is (BLOCKED_* above). Compared without case,
|
|
5
|
+
* as macOS and Windows file systems name files. alpd and the gateway both check it; alpd also
|
|
6
|
+
* refuses a file whose content holds a private key, whatever its name.
|
|
7
|
+
* @param {string} relative a path relative to the workspace, '/'-separated
|
|
8
|
+
*/
|
|
9
|
+
export function blockedFilePath(relative: string): boolean;
|
|
10
|
+
/** UTF-8 socket writers use one newline-terminated JSON value per frame. @param {unknown} value */
|
|
11
|
+
export function encodeFrame(value: unknown): string;
|
|
12
|
+
/** Parse one complete frame. Buffering and failure handling belong to the socket owner. @param {string} line @returns {unknown} */
|
|
13
|
+
export function decodeFrame(line: string): unknown;
|
|
14
|
+
/** Wire protocol of alpd (plans/reference/ALPD.md §3). Bump only for incompatible changes. */
|
|
15
|
+
export const PROTOCOL_VERSION: 1;
|
|
16
|
+
export const MAX_FRAME: number;
|
|
17
|
+
export class AlpRpcError extends Error {
|
|
18
|
+
constructor(error: any);
|
|
19
|
+
code: any;
|
|
20
|
+
data: any;
|
|
21
|
+
}
|
|
22
|
+
export const ERROR: Readonly<{
|
|
23
|
+
failed: 1000;
|
|
24
|
+
notFound: 1001;
|
|
25
|
+
forbidden: 1003;
|
|
26
|
+
conflict: 1004;
|
|
27
|
+
unavailable: 1005;
|
|
28
|
+
protocol: 1006;
|
|
29
|
+
}>;
|
|
30
|
+
/** The JSON-RPC 2.0 codes alpd answers with (docs/protocol/alp-json-rpc-v1.md §5). */
|
|
31
|
+
export const RPC_ERROR: Readonly<{
|
|
32
|
+
parseError: -32700;
|
|
33
|
+
invalidRequest: -32600;
|
|
34
|
+
methodNotFound: -32601;
|
|
35
|
+
invalidParams: -32602;
|
|
36
|
+
}>;
|
|
37
|
+
/**
|
|
38
|
+
* Features a daemon lists in its daemon.hello result. A client uses a feature only when listed;
|
|
39
|
+
* a daemon that lists none (one older than this list) has none of them. Unknown names are ignored.
|
|
40
|
+
*/
|
|
41
|
+
export const CAPABILITY: Readonly<{
|
|
42
|
+
attachAfter: "attach.after";
|
|
43
|
+
promptReceipts: "prompt.receipts";
|
|
44
|
+
extensionMethods: "extension.methods";
|
|
45
|
+
team: "team";
|
|
46
|
+
task: "task";
|
|
47
|
+
library: "library";
|
|
48
|
+
libraryProjectWrite: "library.project.write";
|
|
49
|
+
settingsLanguage: "settings.language";
|
|
50
|
+
sessionListPage: "session.list.page";
|
|
51
|
+
questionWatch: "question.watch";
|
|
52
|
+
questionWatchTurns: "question.watch.turns";
|
|
53
|
+
workspaceFiles: "workspace.files";
|
|
54
|
+
workspaceFilesRead: "workspace.files.read";
|
|
55
|
+
promptImages: "prompt.images";
|
|
56
|
+
}>;
|
|
57
|
+
/**
|
|
58
|
+
* What alpd takes as images in one session.prompt (capability `prompt.images`): inline base64 of
|
|
59
|
+
* these types only, never a path or URL; at most `maxCount` of them, each at most `maxBytes`
|
|
60
|
+
* decoded, and `maxTotalBytes` together, so their base64 fits one MAX_FRAME. A gateway may allow
|
|
61
|
+
* less (docs/alp/remote-gateway.md).
|
|
62
|
+
*/
|
|
63
|
+
export const PROMPT_IMAGES: Readonly<{
|
|
64
|
+
mimeTypes: readonly string[];
|
|
65
|
+
maxCount: 8;
|
|
66
|
+
maxBytes: number;
|
|
67
|
+
maxTotalBytes: number;
|
|
68
|
+
}>;
|
|
69
|
+
export function base64Bytes(data: string): number;
|
|
70
|
+
export const RPC_METHODS: readonly string[];
|
|
71
|
+
/**
|
|
72
|
+
* Limits of workspace.files.list and workspace.files.read (capability `workspace.files.read`,
|
|
73
|
+
* docs/protocol/alp-json-rpc-v1.md §12.4): a file of at most `maxReadBytes`, a listing of at most
|
|
74
|
+
* `maxListEntries` entries, a relative path of at most `maxPathLength` characters.
|
|
75
|
+
*/
|
|
76
|
+
export const WORKSPACE_FILES: Readonly<{
|
|
77
|
+
maxReadBytes: number;
|
|
78
|
+
maxListEntries: 1000;
|
|
79
|
+
maxPathLength: 4096;
|
|
80
|
+
}>;
|
package/dist/wire.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// packages/protocol/src/wire.js
|
|
2
|
+
var PROTOCOL_VERSION = 1;
|
|
3
|
+
var MAX_FRAME = 16 * 1024 * 1024;
|
|
4
|
+
var AlpRpcError = class extends Error {
|
|
5
|
+
constructor(error) {
|
|
6
|
+
super(error?.message ?? "ALP daemon request failed");
|
|
7
|
+
this.code = error?.code;
|
|
8
|
+
this.data = error?.data;
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
var ERROR = Object.freeze({ failed: 1e3, notFound: 1001, forbidden: 1003, conflict: 1004, unavailable: 1005, protocol: 1006 });
|
|
12
|
+
var RPC_ERROR = Object.freeze({ parseError: -32700, invalidRequest: -32600, methodNotFound: -32601, invalidParams: -32602 });
|
|
13
|
+
var CAPABILITY = Object.freeze({ attachAfter: "attach.after", promptReceipts: "prompt.receipts", extensionMethods: "extension.methods", team: "team", task: "task", library: "library", libraryProjectWrite: "library.project.write", settingsLanguage: "settings.language", sessionListPage: "session.list.page", questionWatch: "question.watch", questionWatchTurns: "question.watch.turns", workspaceFiles: "workspace.files", workspaceFilesRead: "workspace.files.read", promptImages: "prompt.images" });
|
|
14
|
+
var PROMPT_IMAGES = Object.freeze({ mimeTypes: Object.freeze(["image/png", "image/jpeg", "image/gif", "image/webp"]), maxCount: 8, maxBytes: 5 * 1024 * 1024, maxTotalBytes: 11 * 1024 * 1024 });
|
|
15
|
+
var base64Bytes = (data) => Math.floor(data.length * 3 / 4) - (data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0);
|
|
16
|
+
var RPC_METHODS = Object.freeze(["assignment.recall", "board.list", "catalog.get", "daemon.hello", "daemon.pause", "daemon.pauses", "daemon.resume", "daemon.shutdown", "daemon.status", "daemon.usage", "extension.register", "extension.unregister", "library.delete", "library.duplicate", "library.get", "library.list", "library.models", "library.rename", "library.save", "library.skills.set", "question.answer", "question.list", "question.watch", "session.archive", "session.attach", "session.close", "session.configure", "session.create", "session.get", "session.interrupt", "session.list", "session.log", "session.message", "session.preview", "session.prompt", "session.release", "session.rename", "session.status", "settings.language.get", "settings.language.set", "task.create", "task.gate.resolve", "task.list", "task.update", "team.list", "workspace.files.list", "workspace.files.read", "workspace.files.search"]);
|
|
17
|
+
var WORKSPACE_FILES = Object.freeze({ maxReadBytes: 1024 * 1024, maxListEntries: 1e3, maxPathLength: 4096 });
|
|
18
|
+
var BLOCKED_DIRECTORIES = /* @__PURE__ */ new Set([".git", ".alp", ".ssh", ".gnupg", ".aws", ".azure", ".kube", ".docker", "gcloud"]);
|
|
19
|
+
var BLOCKED_NAMES = /* @__PURE__ */ new Set([".netrc", "_netrc", ".npmrc", ".yarnrc.yml", ".pypirc", ".pgpass", ".htpasswd", ".git-credentials", ".dockercfg", ".boto", ".s3cfg", ".mcp.json", "auth.json", "kubeconfig", "settings.local.json", ".vault-token", "application_default_credentials.json", "token.json"]);
|
|
20
|
+
var BLOCKED_PAIRS = /* @__PURE__ */ new Set(["gh/hosts.yml"]);
|
|
21
|
+
var BLOCKED_STEMS = /* @__PURE__ */ new Set(["credential", "credentials", "secret", "secrets"]);
|
|
22
|
+
var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set(["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs", "py", "go", "rs", "java", "kt", "kts", "rb", "php", "cs", "swift", "c", "cc", "cpp", "h", "hpp", "m", "mm", "scala", "dart", "vue", "svelte"]);
|
|
23
|
+
var BLOCKED_EXTENSIONS = /* @__PURE__ */ new Set(["pem", "key", "p12", "pfx", "p8", "jks", "keystore", "ppk", "der", "gpg", "pgp", "asc", "kdbx", "ovpn", "tfstate", "tfvars"]);
|
|
24
|
+
var BLOCKED_PATTERN = /^(?:\.env|id_|service[-_]?account)|_history$|\.tfstate\.backup$/;
|
|
25
|
+
function blockedFilePath(relative) {
|
|
26
|
+
let previous = "";
|
|
27
|
+
for (const segment of String(relative).toLowerCase().split(/[\\/]/)) {
|
|
28
|
+
if (!segment) continue;
|
|
29
|
+
if (BLOCKED_PAIRS.has(`${previous}/${segment}`)) return true;
|
|
30
|
+
previous = segment;
|
|
31
|
+
if (BLOCKED_DIRECTORIES.has(segment) || BLOCKED_NAMES.has(segment) || BLOCKED_PATTERN.test(segment)) return true;
|
|
32
|
+
const dot = segment.lastIndexOf(".");
|
|
33
|
+
const extension = dot >= 0 ? segment.slice(dot + 1) : "";
|
|
34
|
+
if (BLOCKED_EXTENSIONS.has(extension)) return true;
|
|
35
|
+
const parts = segment.replace(/^\.+/, "").split(".");
|
|
36
|
+
if (parts.length > 1) parts.pop();
|
|
37
|
+
if (parts.some((part) => BLOCKED_STEMS.has(part)) && !SOURCE_EXTENSIONS.has(extension)) return true;
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
function encodeFrame(value) {
|
|
42
|
+
return `${JSON.stringify(value)}
|
|
43
|
+
`;
|
|
44
|
+
}
|
|
45
|
+
function decodeFrame(line) {
|
|
46
|
+
return JSON.parse(line);
|
|
47
|
+
}
|
|
48
|
+
export {
|
|
49
|
+
AlpRpcError,
|
|
50
|
+
CAPABILITY,
|
|
51
|
+
ERROR,
|
|
52
|
+
MAX_FRAME,
|
|
53
|
+
PROMPT_IMAGES,
|
|
54
|
+
PROTOCOL_VERSION,
|
|
55
|
+
RPC_ERROR,
|
|
56
|
+
RPC_METHODS,
|
|
57
|
+
WORKSPACE_FILES,
|
|
58
|
+
base64Bytes,
|
|
59
|
+
blockedFilePath,
|
|
60
|
+
decodeFrame,
|
|
61
|
+
encodeFrame
|
|
62
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
{"jsonrpc":"2.0","id":1,"method":"daemon.hello","params":{"protocolVersion":1,"client":{"name":"fixture","version":"0.9.5"}}}
|
|
2
|
+
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1}}
|
|
3
|
+
{"jsonrpc":"2.0","method":"event","params":{"sessionId":"ses_fixture","epoch":"fixture-epoch","seq":1,"ts":"2026-10-14T00:00:00.000Z","event":{"type":"session.ready"}}}
|
|
4
|
+
{"jsonrpc":"2.0","id":2,"error":{"code":1001,"message":"No session ses_fixture"}}
|