@frockbot/plugin-user-machine 0.0.0 → 0.1.1

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/frockbot.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "schemaVersion": 4,
3
+ "id": "user-machine",
4
+ "displayName": "Registered machines",
5
+ "version": "0.0.1",
6
+ "compatibility": {
7
+ "frockbot": ">=0.0.1"
8
+ },
9
+ "dependencies": {
10
+ "shell": ">=0.0.1",
11
+ "ui-theme": ">=0.0.1"
12
+ },
13
+ "contributions": {
14
+ "backend": [
15
+ {
16
+ "entry": "./user",
17
+ "host": "user"
18
+ },
19
+ {
20
+ "entry": "./backend",
21
+ "host": "gateway"
22
+ }
23
+ ],
24
+ "runtime": {
25
+ "entry": "./agent"
26
+ },
27
+ "client": {
28
+ "entry": "./client",
29
+ "mounts": [
30
+ {
31
+ "slot": "frockbot.user-settings-sections",
32
+ "order": 30
33
+ }
34
+ ],
35
+ "outlets": []
36
+ },
37
+ "desktop": {
38
+ "entry": "./desktop",
39
+ "execution": "trusted-main",
40
+ "commands": []
41
+ }
42
+ },
43
+ "configuration": {
44
+ "settings": [],
45
+ "connectionTypes": [],
46
+ "capabilities": [
47
+ {
48
+ "id": "machine-registry",
49
+ "kind": "tool",
50
+ "connectionTypes": [],
51
+ "admission": {
52
+ "turnTypes": ["chat", "automation", "subagent"]
53
+ }
54
+ },
55
+ {
56
+ "id": "machine-control",
57
+ "kind": "tool",
58
+ "connectionTypes": [],
59
+ "admission": {
60
+ "turnTypes": ["chat"]
61
+ }
62
+ }
63
+ ]
64
+ },
65
+ "permissions": []
66
+ }
package/package.json CHANGED
@@ -1,14 +1,62 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-user-machine",
3
- "version": "0.0.0",
4
- "description": "Placeholder reserving this name for trusted publishing. Superseded by the first release.",
5
- "license": "UNLICENSED",
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./agent": "./src/agent.ts",
9
+ "./approval": "./src/approval.ts",
10
+ "./backend": "./src/backend.ts",
11
+ "./client": "./src/client/index.ts",
12
+ "./client/state": "./src/client/state.ts",
13
+ "./delivery": "./src/delivery.ts",
14
+ "./desktop": "./src/desktop.ts",
15
+ "./device": "./src/device.ts",
16
+ "./device-runner": "./src/device-runner.ts",
17
+ "./frockbot.json": "./frockbot.json",
18
+ "./intent": "./src/intent.ts",
19
+ "./manifest": "./src/manifest.ts",
20
+ "./package.json": "./package.json",
21
+ "./pairing": "./src/pairing.ts",
22
+ "./storage-keys": "./src/storage-keys.ts",
23
+ "./store": "./src/store.ts",
24
+ "./target": "./src/target.ts",
25
+ "./testing": "./src/testing.ts",
26
+ "./user": "./src/user.ts"
27
+ },
28
+ "frockbot": {
29
+ "manifest": "./frockbot.json"
30
+ },
31
+ "scripts": {
32
+ "test": "bun test src",
33
+ "typecheck": "vue-tsc --noEmit -p tsconfig.json"
34
+ },
35
+ "dependencies": {
36
+ "@frockbot/client-core": "0.1.1",
37
+ "@frockbot/client-ui": "0.1.1",
38
+ "@frockbot/desktop-core": "0.1.1",
39
+ "@frockbot/kernel-agent-loop": "0.1.1",
40
+ "@frockbot/kernel-contracts": "0.1.1",
41
+ "@frockbot/machine-protocol": "0.1.1",
42
+ "@frockbot/plugin-shell": "0.1.1",
43
+ "cordis": "4.0.0-rc.8",
44
+ "vue": "3.5.41"
45
+ },
46
+ "devDependencies": {
47
+ "@frockbot/plugin-tools": "0.1.1",
48
+ "@types/bun": "1.4.0",
49
+ "@vitejs/plugin-vue": "6.0.8",
50
+ "typescript": "5.9.3",
51
+ "vite": "8.2.2",
52
+ "vue-tsc": "3.3.10"
53
+ },
54
+ "publishConfig": {
55
+ "access": "public"
56
+ },
6
57
  "repository": {
7
58
  "type": "git",
8
59
  "url": "git+https://github.com/timoconnellaus/frockbot.git",
9
60
  "directory": "packages/plugin-user-machine"
10
- },
11
- "publishConfig": {
12
- "access": "public"
13
61
  }
14
62
  }
@@ -0,0 +1,509 @@
1
+ // The machine tools: what they admit, what they refuse, and the one thing they
2
+ // never do — run anything.
3
+ import { describe, expect, test } from "bun:test";
4
+ import {
5
+ SessionStore,
6
+ type Session,
7
+ type ToolCall,
8
+ type ToolExecutionContext,
9
+ type TurnTypeV1,
10
+ } from "@frockbot/kernel-contracts";
11
+ import { ToolRegistry } from "@frockbot/plugin-tools";
12
+ import { Context } from "cordis";
13
+ import type {
14
+ MachineCommandResultV1,
15
+ MachineListEntryV1,
16
+ MachineListViewV1,
17
+ } from "@frockbot/machine-protocol";
18
+ import {
19
+ createMachineRuntimePlugin,
20
+ machineAdmissionCeilingV1,
21
+ MACHINE_COMMAND_CHECK_TOOL_V1,
22
+ MACHINE_CONTROL_CAPABILITY_V1,
23
+ MACHINE_COPY_FROM_COMPUTER_TOOL_V1,
24
+ MACHINE_COPY_TO_COMPUTER_TOOL_V1,
25
+ MACHINE_EXEC_TOOL_V1,
26
+ MACHINE_LIST_TOOL_V1,
27
+ MACHINE_READ_TOOL_V1,
28
+ MACHINE_REGISTRY_CAPABILITY_V1,
29
+ type MachineRuntimeHostV1,
30
+ } from "./agent.js";
31
+ import {
32
+ decodeMachineIntentRecordV1,
33
+ machineIntentKeyV1,
34
+ type MachineIntentRecordV1,
35
+ } from "./intent.js";
36
+ import type { MachineTargetViewV1 } from "./target.js";
37
+
38
+ const SESSION_ID = "user-1:bot-1";
39
+ const NOW = "2026-09-01T00:00:00.000Z";
40
+ const EFFECT_ID = "tool:4:2:0";
41
+ /** The approval id that effect maps to; a card id may carry no colon. */
42
+ const APPROVAL_ID = "tool.4.2.0";
43
+
44
+ function entry(
45
+ overrides: Partial<MachineListEntryV1> = {},
46
+ ): MachineListEntryV1 {
47
+ return {
48
+ machineId: "mac-1",
49
+ label: "Tims-M5-MacBook-Pro.local",
50
+ platform: "macos",
51
+ capabilities: ["exec", "files"],
52
+ connected: true,
53
+ lastSeenAt: NOW,
54
+ registeredAt: NOW,
55
+ ...overrides,
56
+ };
57
+ }
58
+
59
+ interface Harness {
60
+ root: Context;
61
+ session: Session;
62
+ storage: Map<string, unknown>;
63
+ target: MachineTargetViewV1;
64
+ result?: MachineCommandResultV1;
65
+ dispose(): Promise<void>;
66
+ }
67
+
68
+ async function mount(
69
+ options: {
70
+ target?: Partial<MachineTargetViewV1>;
71
+ machines?: MachineListEntryV1[];
72
+ result?: MachineCommandResultV1;
73
+ writer?: boolean;
74
+ } = {},
75
+ ): Promise<Harness> {
76
+ const root = new Context();
77
+ await root.plugin(SessionStore);
78
+ await root.plugin(ToolRegistry);
79
+ const session = root.sessions.create(SESSION_ID);
80
+ session.appendBatch([
81
+ { type: "turn/start", turn: 4 },
82
+ { type: "step/start", turn: 4, step: 2 },
83
+ ]);
84
+ const store = new Map<string, unknown>();
85
+ const target: MachineTargetViewV1 = {
86
+ schemaVersion: 1,
87
+ machineId: "mac-1",
88
+ entry: entry(),
89
+ queuedCommands: 0,
90
+ commandsToday: 0,
91
+ serverTime: NOW,
92
+ ...options.target,
93
+ };
94
+ const view: MachineListViewV1 = {
95
+ schemaVersion: 1,
96
+ machines: options.machines ?? [entry()],
97
+ serverTime: NOW,
98
+ };
99
+ const host: MachineRuntimeHostV1 = {
100
+ botId: "bot-1",
101
+ ...(options.writer === false
102
+ ? {}
103
+ : {
104
+ writer: {
105
+ sessionId: SESSION_ID,
106
+ turnId: "turn-4",
107
+ runId: "run-1",
108
+ },
109
+ }),
110
+ storage: {
111
+ get: async <T>(key: string) => store.get(key) as T | undefined,
112
+ put: async (key: string, value: unknown) => {
113
+ store.set(key, value);
114
+ },
115
+ },
116
+ list: async () => view,
117
+ describeTarget: async () => target,
118
+ readResult: async () => options.result,
119
+ };
120
+ await root.plugin(createMachineRuntimePlugin(host));
121
+ return {
122
+ root,
123
+ session,
124
+ storage: store,
125
+ target,
126
+ ...(options.result === undefined ? {} : { result: options.result }),
127
+ dispose: () => root.fiber.dispose(),
128
+ };
129
+ }
130
+
131
+ function contextFor(turnType: TurnTypeV1): ToolExecutionContext {
132
+ return {
133
+ botId: "bot-1",
134
+ agentId: "bot-1",
135
+ sessionId: SESSION_ID,
136
+ compositionGenerationId: "2026-09-01T00:00:00.000Z:0123456789abcdef",
137
+ turnType,
138
+ effectId: EFFECT_ID,
139
+ signal: new AbortController().signal,
140
+ };
141
+ }
142
+
143
+ async function invoke(
144
+ harness: Harness,
145
+ name: string,
146
+ input: unknown,
147
+ turnType: TurnTypeV1 = "chat",
148
+ ) {
149
+ const call: ToolCall = { id: "call-1", name, input };
150
+ const context = contextFor(turnType);
151
+ const preparation = await harness.root.tools.prepare(call, context);
152
+ if (preparation.kind === "denied") return preparation.result;
153
+ return harness.root.tools.executePrepared(preparation, context);
154
+ }
155
+
156
+ const CONTROL_TOOLS = [
157
+ MACHINE_EXEC_TOOL_V1,
158
+ MACHINE_READ_TOOL_V1,
159
+ MACHINE_COPY_TO_COMPUTER_TOOL_V1,
160
+ MACHINE_COPY_FROM_COMPUTER_TOOL_V1,
161
+ ];
162
+
163
+ describe("machine tool admission", () => {
164
+ test("the registry tools are on every turn type and control is chat only", async () => {
165
+ const harness = await mount();
166
+ try {
167
+ const names = (turnType: TurnTypeV1) =>
168
+ harness.root.tools.schemas({ turnType }).map((tool) => tool.name);
169
+ for (const turnType of ["chat", "automation", "subagent"] as const) {
170
+ expect(names(turnType)).toContain(MACHINE_LIST_TOOL_V1);
171
+ expect(names(turnType)).toContain(MACHINE_COMMAND_CHECK_TOOL_V1);
172
+ }
173
+ for (const tool of CONTROL_TOOLS) {
174
+ expect(names("chat")).toContain(tool);
175
+ // Row 49 ships `partial`: an automation Turn has no voice to ask an
176
+ // approval with, so it does not get a tool that needs one.
177
+ expect(names("automation")).not.toContain(tool);
178
+ expect(names("subagent")).not.toContain(tool);
179
+ }
180
+ } finally {
181
+ await harness.dispose();
182
+ }
183
+ });
184
+
185
+ test("the ceiling is read out of the manifest, not restated", () => {
186
+ expect(machineAdmissionCeilingV1(MACHINE_REGISTRY_CAPABILITY_V1)).toEqual([
187
+ "chat",
188
+ "automation",
189
+ "subagent",
190
+ ]);
191
+ expect(machineAdmissionCeilingV1(MACHINE_CONTROL_CAPABILITY_V1)).toEqual([
192
+ "chat",
193
+ ]);
194
+ expect(machineAdmissionCeilingV1("no-such-capability")).toBeUndefined();
195
+ });
196
+
197
+ test("a Turn with no writer gets the registry and nothing that reaches a laptop", async () => {
198
+ const harness = await mount({ writer: false });
199
+ try {
200
+ const names = harness.root.tools
201
+ .schemas({ turnType: "chat" })
202
+ .map((tool) => tool.name);
203
+ expect(names).toEqual([
204
+ MACHINE_LIST_TOOL_V1,
205
+ MACHINE_COMMAND_CHECK_TOOL_V1,
206
+ ]);
207
+ } finally {
208
+ await harness.dispose();
209
+ }
210
+ });
211
+ });
212
+
213
+ describe("machine_list", () => {
214
+ test("projects the registry and says so when there is nothing to project", async () => {
215
+ const listed = await mount();
216
+ try {
217
+ const result = await invoke(listed, MACHINE_LIST_TOOL_V1, {});
218
+ expect(result.isError).toBe(false);
219
+ expect(JSON.parse(result.content)).toEqual({
220
+ machines: [
221
+ {
222
+ machineId: "mac-1",
223
+ label: "Tims-M5-MacBook-Pro.local",
224
+ platform: "macos",
225
+ capabilities: ["exec", "files"],
226
+ connected: true,
227
+ lastSeenAt: NOW,
228
+ },
229
+ ],
230
+ });
231
+ } finally {
232
+ await listed.dispose();
233
+ }
234
+ const empty = await mount({ machines: [] });
235
+ try {
236
+ const result = await invoke(empty, MACHINE_LIST_TOOL_V1, {});
237
+ expect(result.content).toContain("No machines are registered");
238
+ expect(result.isError).toBe(false);
239
+ } finally {
240
+ await empty.dispose();
241
+ }
242
+ });
243
+ });
244
+
245
+ describe("a control tool's approval", () => {
246
+ test("records intent, asks, ends the Turn, and runs nothing", async () => {
247
+ const harness = await mount();
248
+ try {
249
+ const result = await invoke(harness, MACHINE_EXEC_TOOL_V1, {
250
+ machineId: "mac-1",
251
+ command: "git status",
252
+ });
253
+ expect(result.isError).toBe(false);
254
+ expect(result.endsTurn).toBe(true);
255
+ expect(result.content).toContain("Nothing has been sent to the machine");
256
+
257
+ // The intent is durable, and it is what the settlement will read back
258
+ // from an approvalId that carries nothing but a decision.
259
+ const intent = decodeMachineIntentRecordV1(
260
+ harness.storage.get(machineIntentKeyV1(APPROVAL_ID)),
261
+ );
262
+ expect(intent).toMatchObject({
263
+ approvalId: APPROVAL_ID,
264
+ commandId: APPROVAL_ID,
265
+ machineId: "mac-1",
266
+ botId: "bot-1",
267
+ runId: "run-1",
268
+ turn: 4,
269
+ });
270
+ expect(intent.op).toEqual({
271
+ kind: "exec",
272
+ command: "git status",
273
+ timeoutMs: 60_000,
274
+ maxOutputBytes: 1_024 * 1_024,
275
+ });
276
+ expect(intent.decision).toBeUndefined();
277
+
278
+ // The card is on the durable log, at the open step, as an approval.
279
+ const sends = harness.session.events.filter(
280
+ (event) => event.type === "send/to-user",
281
+ );
282
+ expect(sends).toHaveLength(1);
283
+ const send = sends[0] as unknown as {
284
+ turn: number;
285
+ step: number;
286
+ occurrenceId: string;
287
+ payload: {
288
+ type: string;
289
+ approvalId: string;
290
+ risk: string;
291
+ action: string;
292
+ };
293
+ };
294
+ expect(send.turn).toBe(4);
295
+ expect(send.step).toBe(2);
296
+ expect(send.occurrenceId).toBe(EFFECT_ID);
297
+ expect(send.payload.type).toBe("approval");
298
+ expect(send.payload.approvalId).toBe(APPROVAL_ID);
299
+ expect(send.payload.risk).toBe("high");
300
+ expect(send.payload.action).toContain("git status");
301
+ } finally {
302
+ await harness.dispose();
303
+ }
304
+ });
305
+
306
+ test("each control tool builds its own op and each is one card", async () => {
307
+ const cases: Array<[string, Record<string, unknown>, string]> = [
308
+ [
309
+ MACHINE_READ_TOOL_V1,
310
+ { machineId: "mac-1", path: "/etc/hosts" },
311
+ "read",
312
+ ],
313
+ [
314
+ MACHINE_COPY_TO_COMPUTER_TOOL_V1,
315
+ { machineId: "mac-1", path: "/a", workspacePath: "b" },
316
+ "copy-to-computer",
317
+ ],
318
+ [
319
+ MACHINE_COPY_FROM_COMPUTER_TOOL_V1,
320
+ { machineId: "mac-1", path: "/a", workspacePath: "b" },
321
+ "copy-from-computer",
322
+ ],
323
+ ];
324
+ for (const [name, input, kind] of cases) {
325
+ const harness = await mount();
326
+ try {
327
+ const result = await invoke(harness, name, input);
328
+ expect(result.isError).toBe(false);
329
+ expect(result.endsTurn).toBe(true);
330
+ const intent = decodeMachineIntentRecordV1(
331
+ harness.storage.get(machineIntentKeyV1(APPROVAL_ID)),
332
+ );
333
+ expect(intent.op.kind).toBe(kind as never);
334
+ } finally {
335
+ await harness.dispose();
336
+ }
337
+ }
338
+ });
339
+ });
340
+
341
+ describe("a control tool's visible refusals", () => {
342
+ const asked = { machineId: "mac-1", command: "git status" };
343
+
344
+ async function refusalFor(
345
+ options: Parameters<typeof mount>[0],
346
+ input: Record<string, unknown> = asked,
347
+ ): Promise<string> {
348
+ const harness = await mount(options);
349
+ try {
350
+ const result = await invoke(harness, MACHINE_EXEC_TOOL_V1, input);
351
+ expect(result.isError).toBe(true);
352
+ expect(result.endsTurn).toBeUndefined();
353
+ // Nothing was recorded and nobody was asked.
354
+ expect(harness.storage.size).toBe(0);
355
+ expect(
356
+ harness.session.events.filter((event) => event.type === "send/to-user"),
357
+ ).toHaveLength(0);
358
+ return result.content;
359
+ } finally {
360
+ await harness.dispose();
361
+ }
362
+ }
363
+
364
+ test("an unknown machine", async () => {
365
+ expect(
366
+ await refusalFor({ target: { entry: undefined } as never }),
367
+ ).toContain('no machine "mac-1" is registered');
368
+ });
369
+
370
+ test("a revoked machine", async () => {
371
+ expect(
372
+ await refusalFor({ target: { entry: entry({ revokedAt: NOW }) } }),
373
+ ).toContain("was revoked");
374
+ });
375
+
376
+ test("a machine that is not connected", async () => {
377
+ expect(
378
+ await refusalFor({ target: { entry: entry({ connected: false }) } }),
379
+ ).toContain("is not connected");
380
+ });
381
+
382
+ test("a machine that does not report the capability the op needs", async () => {
383
+ expect(
384
+ await refusalFor({
385
+ target: { entry: entry({ capabilities: ["files"] }) },
386
+ }),
387
+ ).toContain("does not report the exec capability");
388
+ });
389
+
390
+ test("a queue that is already full, and a day that is already spent", async () => {
391
+ // Quotas refuse visibly, and the leading "Refused:" is what makes
392
+ // `plugin-audit` classify the row `refused` rather than an effect that ran.
393
+ const depth = await refusalFor({ target: { queuedCommands: 16 } });
394
+ expect(depth).toStartWith("Refused:");
395
+ expect(depth).toContain("commands waiting");
396
+ const day = await refusalFor({ target: { commandsToday: 500 } });
397
+ expect(day).toStartWith("Refused:");
398
+ expect(day).toContain("machine commands today");
399
+ });
400
+
401
+ test("an input the protocol will not decode", async () => {
402
+ expect(await refusalFor({}, { machineId: "mac-1" })).toContain("command");
403
+ expect(await refusalFor({}, { command: "ls" })).toContain(
404
+ "machineId must be a non-empty string",
405
+ );
406
+ });
407
+ });
408
+
409
+ describe("machine_command_check", () => {
410
+ test("reads the whole result once there is one", async () => {
411
+ const harness = await mount({
412
+ result: {
413
+ schemaVersion: 1,
414
+ commandId: APPROVAL_ID,
415
+ finishedAt: NOW,
416
+ outcome: "ok",
417
+ truncated: false,
418
+ exitCode: 0,
419
+ stdout: "nothing to commit\n",
420
+ },
421
+ });
422
+ try {
423
+ const result = await invoke(harness, MACHINE_COMMAND_CHECK_TOOL_V1, {
424
+ commandId: APPROVAL_ID,
425
+ });
426
+ expect(result.isError).toBe(false);
427
+ expect(result.content).toContain("outcome: ok");
428
+ expect(result.content).toContain("exitCode: 0");
429
+ expect(result.content).toContain("nothing to commit");
430
+ } finally {
431
+ await harness.dispose();
432
+ }
433
+ });
434
+
435
+ test("says where a command stands when it has no result yet", async () => {
436
+ const harness = await mount();
437
+ try {
438
+ // Nothing asked for at all.
439
+ const unknown = await invoke(harness, MACHINE_COMMAND_CHECK_TOOL_V1, {
440
+ commandId: "cmd-none",
441
+ });
442
+ expect(unknown.content).toContain("was asked for by this bot");
443
+
444
+ await invoke(harness, MACHINE_EXEC_TOOL_V1, {
445
+ machineId: "mac-1",
446
+ command: "git status",
447
+ });
448
+ const pending = await invoke(harness, MACHINE_COMMAND_CHECK_TOOL_V1, {
449
+ commandId: APPROVAL_ID,
450
+ });
451
+ expect(pending.content).toContain("waiting on the user's approval");
452
+
453
+ const key = machineIntentKeyV1(APPROVAL_ID);
454
+ const intent = decodeMachineIntentRecordV1(harness.storage.get(key));
455
+ const settled: MachineIntentRecordV1 = {
456
+ ...intent,
457
+ decision: "denied",
458
+ decidedAt: NOW,
459
+ outcome: "denied",
460
+ };
461
+ harness.storage.set(key, settled);
462
+ const denied = await invoke(harness, MACHINE_COMMAND_CHECK_TOOL_V1, {
463
+ commandId: APPROVAL_ID,
464
+ });
465
+ expect(denied.content).toContain("was denied by the user");
466
+
467
+ harness.storage.set(key, {
468
+ ...intent,
469
+ decision: "approved",
470
+ decidedAt: NOW,
471
+ dispatchedAt: NOW,
472
+ outcome: "dispatched",
473
+ });
474
+ const queued = await invoke(harness, MACHINE_COMMAND_CHECK_TOOL_V1, {
475
+ commandId: APPROVAL_ID,
476
+ });
477
+ expect(queued.content).toContain("has not answered yet");
478
+
479
+ // An approval-exempt read (row 57g's six Messages reads) is dispatched by
480
+ // its own tool and carries no decision, so it must not read as "waiting
481
+ // on the user" — nobody was asked.
482
+ harness.storage.set(key, {
483
+ ...intent,
484
+ dispatchedAt: NOW,
485
+ outcome: "dispatched",
486
+ });
487
+ const exempt = await invoke(harness, MACHINE_COMMAND_CHECK_TOOL_V1, {
488
+ commandId: APPROVAL_ID,
489
+ });
490
+ expect(exempt.content).not.toContain("approval");
491
+ expect(exempt.content).toContain("has not answered yet");
492
+ } finally {
493
+ await harness.dispose();
494
+ }
495
+ });
496
+
497
+ test("refuses a commandId that is not one", async () => {
498
+ const harness = await mount();
499
+ try {
500
+ const result = await invoke(harness, MACHINE_COMMAND_CHECK_TOOL_V1, {
501
+ commandId: "",
502
+ });
503
+ expect(result.isError).toBe(true);
504
+ expect(result.content).toStartWith("Refused:");
505
+ } finally {
506
+ await harness.dispose();
507
+ }
508
+ });
509
+ });