@frockbot/plugin-user-machine 0.0.0 → 0.1.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/src/target.ts ADDED
@@ -0,0 +1,86 @@
1
+ // One machine, as the tool that is about to ask for it needs to see it.
2
+ //
3
+ // A control tool has five things to check before it may put a card in front of
4
+ // a person — is the machine registered, is it revoked, is it connected, does it
5
+ // report the capability the op needs, and is the User inside their quota — and
6
+ // four of them are facts only the User Durable Object holds. Resolving them one
7
+ // at a time would be four round trips and four chances to answer against a
8
+ // different instant, so this is the single narrow view the tool reads: the
9
+ // registry row, and the two counters the quota is arithmetic over.
10
+ //
11
+ // It carries no token digest, no key version and no user id. A projection hands
12
+ // out what the caller renders a decision from, and nothing that proves
13
+ // anything.
14
+ import {
15
+ MachineDecodeError,
16
+ decodeMachineListEntryV1,
17
+ type MachineListEntryV1,
18
+ } from "@frockbot/machine-protocol";
19
+
20
+ export interface MachineTargetViewV1 {
21
+ schemaVersion: 1;
22
+ machineId: string;
23
+ /** Absent when this User has no machine by that id. */
24
+ entry?: MachineListEntryV1;
25
+ /** Commands already waiting on this machine's queue. */
26
+ queuedCommands: number;
27
+ /** Commands this User has dispatched today, across every machine. */
28
+ commandsToday: number;
29
+ serverTime: string;
30
+ }
31
+
32
+ export function decodeMachineTargetViewV1(
33
+ input: unknown,
34
+ label = "machine target",
35
+ ): MachineTargetViewV1 {
36
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
37
+ throw new MachineDecodeError(`${label} must be an object`);
38
+ }
39
+ const value = input as Record<string, unknown>;
40
+ for (const key of Object.keys(value)) {
41
+ if (
42
+ ![
43
+ "schemaVersion",
44
+ "machineId",
45
+ "entry",
46
+ "queuedCommands",
47
+ "commandsToday",
48
+ "serverTime",
49
+ ].includes(key)
50
+ ) {
51
+ throw new MachineDecodeError(`${label} has an unexpected key "${key}"`);
52
+ }
53
+ }
54
+ if (value.schemaVersion !== 1) {
55
+ throw new MachineDecodeError(`${label} schemaVersion is unsupported`);
56
+ }
57
+ if (typeof value.machineId !== "string" || value.machineId.length === 0) {
58
+ throw new MachineDecodeError(`${label} machineId must be a string`);
59
+ }
60
+ const count = (candidate: unknown, name: string): number => {
61
+ if (
62
+ typeof candidate !== "number" ||
63
+ !Number.isSafeInteger(candidate) ||
64
+ candidate < 0
65
+ ) {
66
+ throw new MachineDecodeError(`${label} ${name} is invalid`);
67
+ }
68
+ return candidate;
69
+ };
70
+ if (
71
+ typeof value.serverTime !== "string" ||
72
+ Number.isNaN(Date.parse(value.serverTime))
73
+ ) {
74
+ throw new MachineDecodeError(`${label} serverTime is not a timestamp`);
75
+ }
76
+ return {
77
+ schemaVersion: 1,
78
+ machineId: value.machineId,
79
+ ...(value.entry === undefined
80
+ ? {}
81
+ : { entry: decodeMachineListEntryV1(value.entry, `${label} entry`) }),
82
+ queuedCommands: count(value.queuedCommands, "queuedCommands"),
83
+ commandsToday: count(value.commandsToday, "commandsToday"),
84
+ serverTime: value.serverTime,
85
+ };
86
+ }
package/src/testing.ts ADDED
@@ -0,0 +1,352 @@
1
+ // The stub device agent, and the in-memory storage the store is tested on.
2
+ //
3
+ // `MachineAgentDriverV1` is the honest half of "no native binary in slice R".
4
+ // It is not a mock of the protocol: it speaks the real wire, over an injected
5
+ // `fetch`, against the real routes — pair, enroll, poll, claim, result — and
6
+ // decodes every answer with the same decoders the desktop agent will. What it
7
+ // does *not* do is shell out. So the untested surface is `child_process` and
8
+ // nothing else, and the day a real agent lands it can be checked byte for byte
9
+ // against this one.
10
+ //
11
+ // It is deliberately scriptable in the ways a laptop actually fails: a command
12
+ // it claims and never answers (the machine slept), one it never claims (the
13
+ // poll was lost), one it answers twice (the POST was retried), and one it
14
+ // claims twice (two agents, or one agent and its own retry).
15
+
16
+ import {
17
+ MACHINE_LIMITS_V1,
18
+ decodeMachineClaimReceiptV1,
19
+ decodeMachineEnrollmentReceiptV1,
20
+ decodeMachineListViewV1,
21
+ decodeMachinePairingOfferV1,
22
+ decodeMachinePollResultV1,
23
+ decodeMachineResultReceiptV1,
24
+ machineRoutePathV1,
25
+ type MachineCapabilityV1,
26
+ type MachineClaimReceiptV1,
27
+ type MachineCommandResultV1,
28
+ type MachineCommandV1,
29
+ type MachineListViewV1,
30
+ type MachinePairingOfferV1,
31
+ type MachinePlatformV1,
32
+ type MachineResultReceiptV1,
33
+ } from "@frockbot/machine-protocol";
34
+ import type { MachineStorageV1, MachineStorageWritesV1 } from "./store.js";
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Storage
38
+ // ---------------------------------------------------------------------------
39
+
40
+ export interface MemoryMachineStorageV1 extends MachineStorageV1 {
41
+ /** Every key currently held, sorted. Useful for asserting purges. */
42
+ keys(): string[];
43
+ }
44
+
45
+ function reads(map: Map<string, unknown>): MachineStorageWritesV1 {
46
+ return {
47
+ get<T>(key: string): Promise<T | undefined> {
48
+ return Promise.resolve(map.get(key) as T | undefined);
49
+ },
50
+ list<T>(options: {
51
+ prefix: string;
52
+ limit?: number;
53
+ }): Promise<Map<string, T>> {
54
+ const entries = [...map.entries()]
55
+ .filter(([key]) => key.startsWith(options.prefix))
56
+ .sort(([left], [right]) => left.localeCompare(right))
57
+ .slice(0, options.limit ?? Number.POSITIVE_INFINITY);
58
+ return Promise.resolve(new Map(entries as Array<[string, T]>));
59
+ },
60
+ put(key: string, value: unknown): Promise<void> {
61
+ map.set(key, structuredClone(value));
62
+ return Promise.resolve();
63
+ },
64
+ delete(key: string): Promise<boolean> {
65
+ return Promise.resolve(map.delete(key));
66
+ },
67
+ };
68
+ }
69
+
70
+ /** The Durable Object's storage contract and nothing more. */
71
+ export function createMemoryMachineStorageV1(): MemoryMachineStorageV1 {
72
+ const map = new Map<string, unknown>();
73
+ const base = reads(map);
74
+ return {
75
+ ...base,
76
+ keys: () => [...map.keys()].sort(),
77
+ async transaction<T>(
78
+ closure: (transaction: MachineStorageWritesV1) => Promise<T>,
79
+ ): Promise<T> {
80
+ const snapshot = new Map(map);
81
+ try {
82
+ return await closure(base);
83
+ } catch (error) {
84
+ map.clear();
85
+ for (const [key, value] of snapshot) map.set(key, value);
86
+ throw error;
87
+ }
88
+ },
89
+ };
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // The stub agent
94
+ // ---------------------------------------------------------------------------
95
+
96
+ /** What the scripted agent does with one command it was handed. */
97
+ export type MachineAgentActionV1 =
98
+ | {
99
+ kind: "result";
100
+ result: Omit<MachineCommandResultV1, "schemaVersion" | "commandId">;
101
+ /** Post the result twice, as a retried POST does. */
102
+ twice?: boolean;
103
+ }
104
+ /** Claim it and never answer: the laptop slept. The lease is what recovers. */
105
+ | { kind: "vanish" }
106
+ /** Claim it twice, as two agents — or one agent and its own retry — would. */
107
+ | {
108
+ kind: "double-claim";
109
+ result: Omit<MachineCommandResultV1, "schemaVersion" | "commandId">;
110
+ }
111
+ /** Leave it queued: the poll answer was lost before it was acted on. */
112
+ | { kind: "ignore" };
113
+
114
+ export interface MachineAgentDriverOptionsV1 {
115
+ /** Injected: `SELF.fetch` in workerd, a stub in a unit test. */
116
+ fetch(input: string, init?: RequestInit): Promise<Response>;
117
+ /** The origin every path is resolved against. */
118
+ origin: string;
119
+ label?: string;
120
+ platform?: MachinePlatformV1;
121
+ agentVersion?: string;
122
+ capabilities?: MachineCapabilityV1[];
123
+ /** What to do with a command. Defaults to exit 0 with empty output. */
124
+ handle?(command: MachineCommandV1): Promise<MachineAgentActionV1>;
125
+ now?(): number;
126
+ }
127
+
128
+ export interface MachineAgentRunSummaryV1 {
129
+ delivered: MachineCommandV1[];
130
+ claimed: string[];
131
+ alreadyClaimed: string[];
132
+ reported: string[];
133
+ replayed: string[];
134
+ }
135
+
136
+ export class MachineAgentError extends Error {
137
+ override readonly name = "MachineAgentError";
138
+ readonly status: number;
139
+ readonly body: string;
140
+ constructor(status: number, body: string) {
141
+ super(`machine agent request failed with ${status}: ${body.slice(0, 200)}`);
142
+ this.status = status;
143
+ this.body = body;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * A device agent, in TypeScript, with no `child_process`.
149
+ *
150
+ * It holds exactly what a real one does: its machine id and the token it was
151
+ * handed at enrollment. Both are public here — a test needs to present a
152
+ * revoked token and a forged one — and neither is ever written anywhere.
153
+ */
154
+ export class MachineAgentDriverV1 {
155
+ machineId: string | undefined;
156
+ token: string | undefined;
157
+ /** Every command this agent has been delivered, in order. */
158
+ readonly delivered: MachineCommandV1[] = [];
159
+
160
+ constructor(private readonly options: MachineAgentDriverOptionsV1) {}
161
+
162
+ private now(): number {
163
+ return this.options.now?.() ?? Date.now();
164
+ }
165
+
166
+ private async call(
167
+ path: string,
168
+ init: RequestInit & { token?: string } = {},
169
+ ): Promise<unknown> {
170
+ const headers = new Headers(init.headers);
171
+ if (init.token) headers.set("authorization", `Bearer ${init.token}`);
172
+ if (init.body !== undefined)
173
+ headers.set("content-type", "application/json");
174
+ const response = await this.options.fetch(`${this.options.origin}${path}`, {
175
+ ...init,
176
+ headers,
177
+ });
178
+ const text = await response.text();
179
+ if (!response.ok) throw new MachineAgentError(response.status, text);
180
+ return text.length === 0 ? undefined : (JSON.parse(text) as unknown);
181
+ }
182
+
183
+ /** The status of a call that is expected to be refused. */
184
+ async attempt(
185
+ path: string,
186
+ init: RequestInit & { token?: string } = {},
187
+ ): Promise<number> {
188
+ const headers = new Headers(init.headers);
189
+ if (init.token) headers.set("authorization", `Bearer ${init.token}`);
190
+ const response = await this.options.fetch(`${this.options.origin}${path}`, {
191
+ ...init,
192
+ headers,
193
+ });
194
+ await response.text();
195
+ return response.status;
196
+ }
197
+
198
+ /** Present a pairing code and become a registered machine. */
199
+ async enroll(offer: MachinePairingOfferV1 | string): Promise<string> {
200
+ const code = typeof offer === "string" ? offer : offer.code;
201
+ const receipt = decodeMachineEnrollmentReceiptV1(
202
+ await this.call(machineRoutePathV1("enroll"), {
203
+ method: "POST",
204
+ token: code,
205
+ body: JSON.stringify({
206
+ schemaVersion: 1,
207
+ code,
208
+ label: this.options.label ?? "Stub-Machine.local",
209
+ platform: this.options.platform ?? "macos",
210
+ agentVersion: this.options.agentVersion ?? "0.0.1",
211
+ capabilities: this.options.capabilities ?? ["exec", "files"],
212
+ }),
213
+ }),
214
+ );
215
+ this.machineId = receipt.machineId;
216
+ this.token = receipt.token;
217
+ return receipt.token;
218
+ }
219
+
220
+ private identity(): { machineId: string; token: string } {
221
+ if (!this.machineId || !this.token) {
222
+ throw new MachineAgentError(401, "this agent has not enrolled");
223
+ }
224
+ return { machineId: this.machineId, token: this.token };
225
+ }
226
+
227
+ /** One long poll. `waitSeconds` of 0 answers immediately. */
228
+ async poll(waitSeconds = 0): Promise<MachineCommandV1[]> {
229
+ const { machineId, token } = this.identity();
230
+ const answered = decodeMachinePollResultV1(
231
+ await this.call(
232
+ machineRoutePathV1("poll", {
233
+ machineId,
234
+ waitSeconds: Math.min(
235
+ waitSeconds,
236
+ MACHINE_LIMITS_V1.pollMaxWaitSeconds,
237
+ ),
238
+ }),
239
+ { token },
240
+ ),
241
+ );
242
+ this.delivered.push(...answered.commands);
243
+ return answered.commands;
244
+ }
245
+
246
+ async claim(commandId: string): Promise<MachineClaimReceiptV1> {
247
+ const { machineId, token } = this.identity();
248
+ return decodeMachineClaimReceiptV1(
249
+ await this.call(machineRoutePathV1("claim", { machineId, commandId }), {
250
+ method: "POST",
251
+ token,
252
+ body: JSON.stringify({}),
253
+ }),
254
+ );
255
+ }
256
+
257
+ async report(
258
+ commandId: string,
259
+ result: Omit<MachineCommandResultV1, "schemaVersion" | "commandId">,
260
+ ): Promise<MachineResultReceiptV1> {
261
+ const { machineId, token } = this.identity();
262
+ return decodeMachineResultReceiptV1(
263
+ await this.call(machineRoutePathV1("result", { machineId, commandId }), {
264
+ method: "POST",
265
+ token,
266
+ body: JSON.stringify({
267
+ schemaVersion: 1,
268
+ commandId,
269
+ ...result,
270
+ }),
271
+ }),
272
+ );
273
+ }
274
+
275
+ /** The registry as the browser reads it. Only a test ever calls this. */
276
+ async listMachines(
277
+ fetchAsUser: (path: string) => Promise<Response>,
278
+ ): Promise<MachineListViewV1> {
279
+ const response = await fetchAsUser(machineRoutePathV1("list"));
280
+ const text = await response.text();
281
+ if (!response.ok) throw new MachineAgentError(response.status, text);
282
+ return decodeMachineListViewV1(JSON.parse(text) as unknown);
283
+ }
284
+
285
+ /**
286
+ * One turn of the agent's loop: poll, then claim, run and answer each
287
+ * command the script says to.
288
+ */
289
+ async runOnce(waitSeconds = 0): Promise<MachineAgentRunSummaryV1> {
290
+ const commands = await this.poll(waitSeconds);
291
+ const summary: MachineAgentRunSummaryV1 = {
292
+ delivered: commands,
293
+ claimed: [],
294
+ alreadyClaimed: [],
295
+ reported: [],
296
+ replayed: [],
297
+ };
298
+ for (const command of commands) {
299
+ const action = this.options.handle
300
+ ? await this.options.handle(command)
301
+ : ({
302
+ kind: "result",
303
+ result: {
304
+ finishedAt: new Date(this.now()).toISOString(),
305
+ outcome: "ok",
306
+ truncated: false,
307
+ exitCode: 0,
308
+ stdout: "",
309
+ },
310
+ } satisfies MachineAgentActionV1);
311
+ if (action.kind === "ignore") continue;
312
+ const claimed = await this.claim(command.commandId);
313
+ (claimed.status === "claimed"
314
+ ? summary.claimed
315
+ : summary.alreadyClaimed
316
+ ).push(command.commandId);
317
+ if (action.kind === "vanish") continue;
318
+ if (action.kind === "double-claim") {
319
+ const second = await this.claim(command.commandId);
320
+ summary.alreadyClaimed.push(second.commandId);
321
+ const receipt = await this.report(command.commandId, action.result);
322
+ (receipt.status === "recorded"
323
+ ? summary.reported
324
+ : summary.replayed
325
+ ).push(command.commandId);
326
+ continue;
327
+ }
328
+ const receipt = await this.report(command.commandId, action.result);
329
+ (receipt.status === "recorded"
330
+ ? summary.reported
331
+ : summary.replayed
332
+ ).push(command.commandId);
333
+ if (action.twice) {
334
+ const replay = await this.report(command.commandId, action.result);
335
+ (replay.status === "recorded"
336
+ ? summary.reported
337
+ : summary.replayed
338
+ ).push(command.commandId);
339
+ }
340
+ }
341
+ return summary;
342
+ }
343
+ }
344
+
345
+ /** The pairing offer a browser fetch answered with, decoded. */
346
+ export async function readMachinePairingOfferV1(
347
+ response: Response,
348
+ ): Promise<MachinePairingOfferV1> {
349
+ const text = await response.text();
350
+ if (!response.ok) throw new MachineAgentError(response.status, text);
351
+ return decodeMachinePairingOfferV1(JSON.parse(text) as unknown);
352
+ }
@@ -0,0 +1,182 @@
1
+ // The User Contribution's own two facts: a hold that ends early, and a door
2
+ // that stays shut without the deployment secret.
3
+ import { describe, expect, test } from "bun:test";
4
+ import { MachineUserBackendContribution } from "./user.ts";
5
+ import { createMemoryMachineStorageV1 } from "./testing.ts";
6
+ import { machineTokenDigestV1 } from "@frockbot/machine-protocol";
7
+
8
+ const SECRET = "machine-user-secret-0123456789abcdef";
9
+ const T0 = Date.parse("2026-09-01T00:00:00.000Z");
10
+
11
+ function contribution(secret: string | undefined) {
12
+ return new MachineUserBackendContribution({
13
+ storage: createMemoryMachineStorageV1(),
14
+ readSecret: () => secret,
15
+ now: () => T0,
16
+ // A hold that never ends on its own, so only a dispatch can end it.
17
+ sleep: () => new Promise<void>(() => {}),
18
+ });
19
+ }
20
+
21
+ /** Pair, enroll, and hand back what a machine needs to speak. */
22
+ async function enrolled(
23
+ authority: MachineUserBackendContribution,
24
+ userId: string,
25
+ ) {
26
+ const offer = await authority.createPairing(userId, {});
27
+ const receipt = await authority.enroll(
28
+ { userId, machineId: offer.machineId, nonce: "n" },
29
+ {
30
+ schemaVersion: 1,
31
+ code: offer.code,
32
+ label: "held.local",
33
+ platform: "macos",
34
+ agentVersion: "0.0.1",
35
+ capabilities: ["exec"],
36
+ },
37
+ );
38
+ return {
39
+ machineId: offer.machineId,
40
+ claims: { u: userId, m: offer.machineId, v: receipt.keyVersion },
41
+ digest: await machineTokenDigestV1(receipt.token),
42
+ };
43
+ }
44
+
45
+ function command(machineId: string, commandId: string) {
46
+ return {
47
+ schemaVersion: 1 as const,
48
+ commandId,
49
+ machineId,
50
+ botId: "bot-1",
51
+ runId: "run-1",
52
+ turn: 1,
53
+ approvalId: commandId,
54
+ op: {
55
+ kind: "exec" as const,
56
+ command: "uname -a",
57
+ timeoutMs: 1_000,
58
+ maxOutputBytes: 1_024,
59
+ },
60
+ issuedAt: new Date(T0).toISOString(),
61
+ status: "queued" as const,
62
+ };
63
+ }
64
+
65
+ describe("the User Contribution", () => {
66
+ test("a long poll ends the moment a command is queued", async () => {
67
+ const authority = contribution(SECRET);
68
+ const offer = await authority.createPairing("hold-user", {});
69
+ const receipt = await authority.enroll(
70
+ { userId: "hold-user", machineId: offer.machineId, nonce: "n" },
71
+ {
72
+ schemaVersion: 1,
73
+ code: offer.code,
74
+ label: "held.local",
75
+ platform: "macos",
76
+ agentVersion: "0.0.1",
77
+ capabilities: ["exec"],
78
+ },
79
+ );
80
+ const held = authority.poll(
81
+ { u: "hold-user", m: offer.machineId, v: receipt.keyVersion },
82
+ await machineTokenDigestV1(receipt.token),
83
+ offer.machineId,
84
+ 25,
85
+ );
86
+ // The `sleep` above never resolves, so this only returns because the
87
+ // dispatch woke it — which is the whole claim.
88
+ await authority.dispatch({
89
+ schemaVersion: 1,
90
+ commandId: "tool:1:1:0",
91
+ machineId: offer.machineId,
92
+ botId: "bot",
93
+ runId: "run",
94
+ turn: 1,
95
+ approvalId: "tool:1:1:0",
96
+ op: {
97
+ kind: "exec",
98
+ command: "uname -a",
99
+ timeoutMs: 1_000,
100
+ maxOutputBytes: 1_024,
101
+ },
102
+ issuedAt: new Date(T0).toISOString(),
103
+ status: "queued",
104
+ });
105
+ const answered = await held;
106
+ expect(answered.commands.map((command) => command.commandId)).toEqual([
107
+ "tool:1:1:0",
108
+ ]);
109
+ });
110
+
111
+ test("without a secret nothing can be paired", async () => {
112
+ await expect(
113
+ contribution(undefined).createPairing("u", {}),
114
+ ).rejects.toThrow(/not configured/);
115
+ });
116
+
117
+ test("one read answers the five questions a control tool has to ask", async () => {
118
+ const authority = contribution(SECRET);
119
+ const { machineId } = await enrolled(authority, "target-user");
120
+ const target = await authority.describeTarget(machineId);
121
+ expect(target.entry?.machineId).toBe(machineId);
122
+ expect(target.entry?.connected).toBe(true);
123
+ expect(target.entry?.capabilities).toEqual(["exec"]);
124
+ expect(target.queuedCommands).toBe(0);
125
+ expect(target.commandsToday).toBe(0);
126
+ await authority.dispatch(command(machineId, "tool:1:1:0"));
127
+ const after = await authority.describeTarget(machineId);
128
+ expect(after.queuedCommands).toBe(1);
129
+ expect(after.commandsToday).toBe(1);
130
+ // A machine this User does not hold is a view with no row, never a throw:
131
+ // the tool has to be able to say so in words.
132
+ const missing = await authority.describeTarget("mac-nobody");
133
+ expect(missing.entry).toBeUndefined();
134
+ });
135
+
136
+ test("a recorded result is delivered once, and a replay tells nobody", async () => {
137
+ const authority = contribution(SECRET);
138
+ const { machineId, claims, digest } = await enrolled(
139
+ authority,
140
+ "deliver-user",
141
+ );
142
+ await authority.dispatch(command(machineId, "tool:1:1:0"));
143
+ await authority.claim(claims, digest, machineId, "tool:1:1:0");
144
+ const answer = {
145
+ schemaVersion: 1,
146
+ commandId: "tool:1:1:0",
147
+ finishedAt: new Date(T0).toISOString(),
148
+ outcome: "ok",
149
+ truncated: false,
150
+ exitCode: 0,
151
+ stdout: "Darwin",
152
+ };
153
+ const first = await authority.recordResult(
154
+ claims,
155
+ digest,
156
+ machineId,
157
+ "tool:1:1:0",
158
+ answer,
159
+ );
160
+ expect(first.status).toBe("recorded");
161
+ const deliveries = await authority.takeDeliveries();
162
+ expect(deliveries).toHaveLength(1);
163
+ expect(deliveries[0]).toMatchObject({
164
+ botId: "bot-1",
165
+ commandId: "tool:1:1:0",
166
+ machineId,
167
+ outcome: "ok",
168
+ });
169
+ // The agent retried. "Recovery never silently duplicates" applies to the
170
+ // telling as much as to the running.
171
+ const replay = await authority.recordResult(
172
+ claims,
173
+ digest,
174
+ machineId,
175
+ "tool:1:1:0",
176
+ answer,
177
+ );
178
+ expect(replay.status).toBe("replayed");
179
+ // Taking is removing, and a replay writes nothing to take.
180
+ expect(await authority.takeDeliveries()).toEqual([]);
181
+ });
182
+ });