Skip to content

Commit 5f3e7f3

Browse files
PrateekJannuclaude
andcommitted
feat(desktop+mobile): Electron shell + Expo companion verified; final polish
- apps/desktop (8 tests, agent-built + verified): esbuild-bundled main/preload, contextIsolation renderer hosting the shared SPA, LocalRunManager running core agent loop + LocalExecutor through the backend inference proxy with batched event mirroring (done always last), cancel/failure paths tested - apps/mobile (33 tests, agent-built + verified): 5 RN screens (login, runs, run detail w/ cursor-polled timeline + 2s frames + approvals, machines, wallet), state-based navigator, awaiting-approval banner, Maestro flows - E2E desktop green: Electron boots, secure bridge, no Node leak, local target + warning (ELECTRON_RUN_AS_NODE stripped - IDE host env broke launches) - core: opt-in live sandbox smoke (COWORK_RUN_LIVE=1 + sk-coasty-test-* only) - prettier normalization across the tree; READMEs for web + backend - SUMMARY.md: ~446 tests, coverage table, platform matrix, drift notes Full gate: 18/18 turbo tasks, lint, format, security scan, web E2E 3/3, desktop E2E 1/1 - all green, all offline, zero spend. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f3280e8 commit 5f3e7f3

91 files changed

Lines changed: 2627 additions & 598 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

SUMMARY.md

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# SUMMARY
2+
3+
What was built, how it is verified, the platform status matrix, coverage
4+
numbers, and every deviation from the brief / live Coasty docs. Built
5+
2026-06-11 against the live docs snapshot (`https://coasty.ai/docs/llms.txt`,
6+
fetched the same day).
7+
8+
## What was built
9+
10+
A complete, working implementation of the brief: a cross-platform agentic
11+
coworker on the Coasty Computer Use API, as a pnpm + Turborepo monorepo
12+
(TypeScript `strict` everywhere, zero native npm modules):
13+
14+
- **`packages/core`** — typed client for every documented Coasty endpoint
15+
(timeouts, Retry-After-aware backoff with full jitter, POSTs retried only
16+
with an `Idempotency-Key`, reconnecting SSE streams), the shared agent loop,
17+
the full workflow-DSL validator/evaluator (13 ops, templating, guards), the
18+
cost estimator mirroring the documented pricing table, and isomorphic
19+
webhook HMAC sign/verify. Zero runtime deps.
20+
- **`packages/executor`** — the `Executor` interface +
21+
`RemoteMachineExecutor` (cloud VMs), `BrowserExecutor` (Playwright), and
22+
`LocalExecutor` with native OS bridges (Windows reference implementation: a
23+
persistent PowerShell daemon — verified live on real hardware via the
24+
opt-in capture smoke test; macOS/Linux best-effort). Model→input coordinate
25+
scaling handled; `raw` code execution refused everywhere by policy.
26+
- **`tools/mock-coasty`** — a faithful offline mock of the entire API: key
27+
kinds + billing headers, the full error catalog, exact pricing math, the
28+
run state machine with per-step billing, durable SSE with `Last-Event-ID`
29+
replay, HMAC-signed webhooks, a workflow interpreter, sandbox machines with
30+
generated-PNG screenshots. Every test and demo runs against it — **no test
31+
can ever spend money**.
32+
- **`apps/backend`** — Fastify + `node:sqlite`: bearer-token auth, the Coasty
33+
proxy (sole key holder), HMAC-verified webhook receiver, durable event
34+
mirroring + SSE fan-out with replay, server-side estimates with the
35+
`confirmCostCents` handshake and budget caps, local-run mirroring for the
36+
desktop, per-user notification feed.
37+
- **`packages/ui` + `apps/web`** — dark-first design system (20 accessible
38+
components) and the SPA: delegate-with-cost-confirm, live run view (SSE
39+
timeline + screen frames + approvals), workflow builder with instant
40+
validation + estimates, machines + wallet, settings.
41+
- **`apps/desktop`** — Electron shell (contextIsolation, no Node in the
42+
renderer) hosting the same SPA; `LocalRunManager` runs the agent loop on the
43+
user's own screen through the backend inference proxy and mirrors events so
44+
any device can supervise.
45+
- **`apps/mobile`** — Expo/React Native companion: runs, live machine frames,
46+
approvals with notes, workflow approvals, machines, wallet; in-app
47+
awaiting-approval banner; Maestro flows included.
48+
- **Docs**: README (≤10-min offline quickstart), ARCHITECTURE, SECURITY,
49+
DECISIONS, DEPLOYMENT, COOKBOOK, CONTRIBUTING, per-app READMEs. **CI**:
50+
GitHub Actions (ubuntu + windows matrix: lint/format/typecheck/unit/
51+
integration/security-scan on push; E2E with xvfb on PRs; non-blocking audit).
52+
53+
## Verification status
54+
55+
`pnpm test`, `pnpm typecheck`, `pnpm lint`, `pnpm format`,
56+
`pnpm security:scan`**all green, fully offline** (18/18 turbo tasks across
57+
9 packages). E2E (Playwright, against mock + real backend + built SPA):
58+
**web 3/3, desktop 1/1 — green** on Windows 11.
59+
60+
| Suite | Tests | Notes |
61+
| --- | --- | --- |
62+
| core (unit) | 166 + live-smoke gate | loop, DSL, cost table, HMAC vectors (valid/tampered/stale/future/malformed/rotation), retry, SSE parser, client incl. SSE-reconnect Last-Event-ID |
63+
| executor (unit) | 31 | fake-daemon protocol, DPI scaling, action mapping; +1 opt-in native capture smoke (passed on real hardware) |
64+
| mock-coasty | 56 | pricing math incl. HD boundary, run state machine, SSE drop→reconnect (no dupes/gaps), signed webhooks verified by hand-rolled HMAC, workflow guards/approvals, machines |
65+
| backend (integration) | 22 | real HTTP vs in-process mock: lifecycle, awaiting_human→resume, webhook tamper/stale/unknown → 401, SSE replay+reconnect, BUDGET_EXCEEDED / ESTIMATE_CHANGED / 402 paths, local runs, allowlisted actions |
66+
| ui (RTL) | 107 | all 20 components: roles/names, loading/error/empty, keyboard interactions |
67+
| web (RTL) | 19 | login, delegate→confirm-cost→create, budget-error surfacing, empty/error states, event mapping |
68+
| desktop (unit) | 8 | LocalRunManager happy path/cancel/failure/batching vs fake executor + scripted backend; build smoke |
69+
| mobile (RTL via react-native-web) | 33 | all 5 screens incl. cursor-polled timeline, approval flow, banners |
70+
| **E2E web** | 3 | full journey: login→provision→delegate→confirm $1.25→live timeline+frames→approve with note→succeeded+cost summary; workflow build→validate→run→approve→output; server-side budget refusal. Plus a runtime watcher asserting **no request ever contains key/secret material** |
71+
| **E2E desktop** | 1 | Electron boots, secure bridge present, no Node leak in renderer, login works, "This computer (local screen)" target + local-control warning |
72+
| **Total** | **≈446** | |
73+
74+
Coverage (v8, lines): core **94.1%**, ui **99.9%**, mobile **98.4%**,
75+
mock-coasty **84.2%**, backend **83.5%**, executor **64.7%** (the embedded
76+
PowerShell daemon string and untestable-on-CI unix bridges dominate the
77+
uncovered lines), desktop **63.4%** (Electron main/preload are E2E-covered
78+
instead), web **25.5% by unit tests** — the pages are primarily covered by the
79+
three full-journey E2E flows.
80+
81+
## Platform status matrix
82+
83+
| Capability | Desktop (Electron) | Web | Mobile (Expo) |
84+
| --- | --- | --- | --- |
85+
| Local screen control | ✅ LocalExecutor + PowerShell bridge (capture verified on real hardware; input path unit-tested + gated) | ❌ by design → cloud machine | ❌ by design → cloud machine |
86+
| Cloud-machine control + live view | ✅ (same SPA) | ✅ E2E-verified | ✅ frames polled 2s (component-tested) |
87+
| Task chat + run dashboard || ✅ E2E ||
88+
| Workflow builder | ✅ full | ✅ full, E2E | ✅ view + approve |
89+
| Approvals / human takeover || ✅ E2E | ✅ approve/reject + note |
90+
| Cost / wallet view || ✅ E2E ||
91+
| Verified how | unit + Playwright `_electron` | unit + Playwright | unit via react-native-web; Maestro flows shipped (emulator required) |
92+
93+
## Spend-safety guarantees (tested)
94+
95+
Estimate shown → `confirmCostCents` must echo the server's number → per-user
96+
budget cap must cover the worst case (else 422 with a suggested `maxSteps`) →
97+
wallet pre-flight → Coasty-side `budget_cents` / `max_steps` / `ttl_minutes`
98+
guards. Test keys/mock bill $0; the live-smoke suite refuses non-sandbox keys.
99+
100+
## Deviations from the brief (rationale in DECISIONS.md)
101+
102+
1. **Electron instead of Tauri** (D1) — no Rust toolchain on the dev machine;
103+
the brief's fallback. Native access isolated behind `NativeBridge` for a
104+
future Tauri port.
105+
2. **`node:sqlite` instead of Postgres + Prisma** (D4) — offline tests +
106+
<10-min newcomer setup; repository layer makes Postgres a contained swap.
107+
3. **Vite SPA instead of Next.js** (D3) — same bundle serves web + desktop.
108+
4. **Mobile E2E via react-native-web + shipped Maestro flows** (D7) — no
109+
emulator on the build machine; same screens E2E-able in chromium.
110+
5. **OS push stubbed; in-app notifications real** (D8).
111+
6. **Contract testing approach**: instead of a standalone schema suite, the
112+
contract is pinned three ways — core's client tests assert exact outbound
113+
paths/headers/bodies for all 43 endpoints; mock-coasty (built independently
114+
of core, D9) asserts documented field names/status codes/pricing; backend
115+
integration runs the real client against the mock end-to-end.
116+
7. **Schedules & Triggers API not implemented** — documented but outside the
117+
product surface of the brief (runs/workflows/machines cover the scope).
118+
119+
## Drift between the brief and the live docs (docs were followed)
120+
121+
- Run resume body is `{note}`; **workflow** resume is `{approved, note}` — the
122+
brief implied `{approved}` for runs.
123+
- Idempotency is an `Idempotency-Key` **header**, not a body field.
124+
- `cua_version` values are `v1 | v3 | v4` (no v2; v4 needs professional tier).
125+
- The docs' Reference action table and its code examples disagree on params
126+
(`wait` `{ms}` vs `{seconds}`; `key_press` `{key}` vs `{keys}`; `scroll`
127+
`{direction,amount}` vs `{clicks}`; `drag` `{from_x…}` vs `{x1…}`) — core
128+
accepts both shapes and canonicalizes (`normalizeAction`); the mock emits
129+
the Reference shape.
130+
- HD surcharge boundary is strict (`>1280` or `>720`; exactly 1280×720 is SD)
131+
— encoded in the cost estimator and its boundary tests.
132+
- The webhook replay window (5 min) is documented for trigger webhooks; we
133+
apply the same ±300s tolerance to run webhooks (defense-in-depth).
134+
135+
## Known limitations / next steps
136+
137+
- Demo single-tenant auth (D6) — put real identity in front before public
138+
deployment (`SECURITY.md`).
139+
- macOS/Linux native bridges are structured + typed but untested on real
140+
hardware (no such hardware in this environment); Windows is the reference.
141+
- Live-screen view is screenshot frames (1–2s), not VNC video (A3).
142+
- Optional live sandbox smoke (`COWORK_RUN_LIVE=1` + `sk-coasty-test-*`)
143+
exercises free/sandbox endpoints only; it was not run during this build
144+
(offline-first policy) and skips cleanly when unset.

apps/backend/README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# @open-cowork/backend
2+
3+
The open-cowork backend: Fastify + `node:sqlite`. The **only** component that
4+
holds `COASTY_API_KEY` and per-run webhook secrets.
5+
6+
## Run
7+
8+
```bash
9+
cp ../../.env.example ../../.env # repo-root .env is auto-loaded
10+
pnpm dev:backend # http://127.0.0.1:4000
11+
```
12+
13+
Defaults point at the bundled mock (`pnpm dev:mock`); see `DEPLOYMENT.md` for
14+
live configuration and the production checklist.
15+
16+
## Responsibilities
17+
18+
- **Auth**: demo email login → hashed, expiring bearer tokens.
19+
- **Coasty proxy**: runs, workflows, machines, and the inference session proxy
20+
(`/api/proxy/sessions*`) used by the desktop local loop.
21+
- **Spend safety**: server-computed estimates, the `confirmCostCents`
22+
handshake (`409 ESTIMATE_CHANGED` on mismatch), per-user budget caps
23+
(`422 BUDGET_EXCEEDED` with a suggested `maxSteps`), wallet pre-flight 402s,
24+
`budget_cents` guards passed to Coasty.
25+
- **Realtime**: per-run Coasty SSE ingestion → durable `events` table
26+
(upstream seq preserved) → SSE fan-out with `Last-Event-ID` replay; REST
27+
polling fallback at `/api/runs/:id/events.json?after=N`; per-user
28+
notification feed at `/api/events`.
29+
- **Webhooks**: `POST /webhooks/coasty` — per-run HMAC verification over the
30+
raw body (constant-time, ±5 min) *before* any state change.
31+
- **Local runs**: desktop-executed runs are mirrored via `/api/local-runs*`
32+
so every device can supervise them.
33+
34+
## API quick reference
35+
36+
All under `/api` with `Authorization: Bearer <token>` (login + `/health` +
37+
`/webhooks/*` excepted). See `apps/backend/src/routes/*.ts` for the full
38+
surface and `e2e/tests/web.spec.ts` for it in action.
39+
40+
## Test
41+
42+
```bash
43+
pnpm --filter @open-cowork/backend test
44+
```
45+
46+
22 integration tests boot the real server against an in-process mock-coasty
47+
over actual HTTP — run lifecycles, SSE replay/reconnect, signed/tampered/stale
48+
webhooks, budget refusals, workflow approvals, machine lifecycles. Offline,
49+
free, deterministic.

apps/backend/src/bus.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ export class EventBus {
2525
}
2626

2727
publish(event: BusEvent): void {
28-
for (const l of this.streamListeners.get(this.streamKey(event.streamKind, event.streamId)) ?? []) {
28+
for (const l of this.streamListeners.get(this.streamKey(event.streamKind, event.streamId)) ??
29+
[]) {
2930
try {
3031
l(event);
3132
} catch {

apps/backend/src/config.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ const configSchema = z.object({
2626
/** Server-enforced default per-run budget cap, cents. */
2727
defaultBudgetCents: z.coerce.number().int().min(1).default(500),
2828
/** Session token lifetime in seconds. Default 7 days. */
29-
sessionTtlSeconds: z.coerce.number().int().min(60).default(7 * 24 * 3600),
29+
sessionTtlSeconds: z.coerce
30+
.number()
31+
.int()
32+
.min(60)
33+
.default(7 * 24 * 3600),
3034
});
3135

3236
export type BackendConfig = z.infer<typeof configSchema>;
@@ -47,7 +51,10 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): BackendConfig
4751
const issues = parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
4852
throw new Error(`Invalid backend configuration: ${issues}`);
4953
}
50-
if (parsed.data.coastyApiKey.startsWith('sk-coasty-live-') && !parsed.data.publicUrl.startsWith('https://')) {
54+
if (
55+
parsed.data.coastyApiKey.startsWith('sk-coasty-live-') &&
56+
!parsed.data.publicUrl.startsWith('https://')
57+
) {
5158
// Loud warning, not fatal: local development against live keys is legal but risky.
5259
console.warn(
5360
'[config] WARNING: live Coasty key with a non-https COWORK_PUBLIC_URL — webhooks require https in production.',

apps/backend/src/db.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,9 @@ export class Db {
240240
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
241241
if (opts.status) {
242242
return this.sql
243-
.prepare('SELECT * FROM runs WHERE user_id = ? AND status = ? ORDER BY created_at DESC LIMIT ?')
243+
.prepare(
244+
'SELECT * FROM runs WHERE user_id = ? AND status = ? ORDER BY created_at DESC LIMIT ?',
245+
)
244246
.all(userId, opts.status, limit) as unknown as RunRow[];
245247
}
246248
return this.sql
@@ -267,7 +269,9 @@ export class Db {
267269
if (fields.length === 0) return;
268270
const sets = fields.map((f) => `${f} = ?`).join(', ');
269271
const values = fields.map((f) => patch[f] ?? null);
270-
this.sql.prepare(`UPDATE runs SET ${sets} WHERE id = ?`).run(...(values as (string | number | null)[]), id);
272+
this.sql
273+
.prepare(`UPDATE runs SET ${sets} WHERE id = ?`)
274+
.run(...(values as (string | number | null)[]), id);
271275
}
272276

273277
/** Total spend on runs for a user in the current calendar month (UTC). */
@@ -325,7 +329,9 @@ export class Db {
325329

326330
updateWorkflowRun(
327331
id: string,
328-
patch: Partial<Pick<WorkflowRunRow, 'status' | 'spent_cents' | 'awaiting_step_id' | 'finished_at'>>,
332+
patch: Partial<
333+
Pick<WorkflowRunRow, 'status' | 'spent_cents' | 'awaiting_step_id' | 'finished_at'>
334+
>,
329335
): void {
330336
const fields = Object.keys(patch) as (keyof typeof patch)[];
331337
if (fields.length === 0) return;
@@ -341,7 +347,9 @@ export class Db {
341347
/** Append an event; seq is assigned atomically per stream. Returns the seq. */
342348
appendEvent(streamKind: string, streamId: string, type: string, data: unknown): number {
343349
const row = this.sql
344-
.prepare('SELECT COALESCE(MAX(seq), 0) AS maxSeq FROM events WHERE stream_kind = ? AND stream_id = ?')
350+
.prepare(
351+
'SELECT COALESCE(MAX(seq), 0) AS maxSeq FROM events WHERE stream_kind = ? AND stream_id = ?',
352+
)
345353
.get(streamKind, streamId) as { maxSeq: number };
346354
const seq = row.maxSeq + 1;
347355
this.sql
@@ -353,7 +361,13 @@ export class Db {
353361
}
354362

355363
/** Insert an event with a KNOWN seq (mirroring upstream); ignores duplicates. */
356-
ingestEvent(streamKind: string, streamId: string, seq: number, type: string, data: unknown): boolean {
364+
ingestEvent(
365+
streamKind: string,
366+
streamId: string,
367+
seq: number,
368+
type: string,
369+
data: unknown,
370+
): boolean {
357371
const result = this.sql
358372
.prepare(
359373
'INSERT OR IGNORE INTO events (stream_kind, stream_id, seq, type, data_json, created_at) VALUES (?, ?, ?, ?, ?, ?)',

apps/backend/src/errors.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export class AppError extends Error {
1313

1414
export const unauthorized = (msg = 'Missing or invalid session token'): AppError =>
1515
new AppError(401, 'UNAUTHORIZED', msg);
16-
export const notFound = (what: string): AppError => new AppError(404, 'NOT_FOUND', `${what} not found`);
16+
export const notFound = (what: string): AppError =>
17+
new AppError(404, 'NOT_FOUND', `${what} not found`);
1718
export const badRequest = (msg: string, details?: unknown): AppError =>
1819
new AppError(400, 'BAD_REQUEST', msg, details);

apps/backend/src/ingest.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,13 @@ export class Ingestor {
7373
}
7474

7575
private handleEvent(target: IngestTarget, evt: RunEvent): void {
76-
const inserted = this.db.ingestEvent(target.kind, target.localId, evt.seq, String(evt.type), evt.data);
76+
const inserted = this.db.ingestEvent(
77+
target.kind,
78+
target.localId,
79+
evt.seq,
80+
String(evt.type),
81+
evt.data,
82+
);
7783
if (!inserted) return; // replay overlap — already stored + published
7884

7985
this.applyStateChange(target, evt);
@@ -117,7 +123,8 @@ export class Ingestor {
117123
case 'awaiting_human': {
118124
this.db.updateRun(target.localId, {
119125
status: 'awaiting_human',
120-
awaiting_human_reason: typeof data.reason === 'string' ? data.reason : 'Human takeover requested',
126+
awaiting_human_reason:
127+
typeof data.reason === 'string' ? data.reason : 'Human takeover requested',
121128
});
122129
break;
123130
}
@@ -172,7 +179,10 @@ export class Ingestor {
172179
}
173180
case 'done': {
174181
const status = typeof data.status === 'string' ? data.status : 'succeeded';
175-
this.db.updateWorkflowRun(target.localId, { status, finished_at: new Date().toISOString() });
182+
this.db.updateWorkflowRun(target.localId, {
183+
status,
184+
finished_at: new Date().toISOString(),
185+
});
176186
break;
177187
}
178188
default:

apps/backend/src/main.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ async function main(): Promise<void> {
3030
const { app } = buildServer({ config, logger: true });
3131
await app.listen({ port: config.port, host: config.host });
3232
console.log(`open-cowork backend listening at http://${config.host}:${config.port}`);
33-
console.log(`Coasty upstream: ${config.coastyBaseUrl} (key kind: ${config.coastyApiKey.startsWith('sk-coasty-test-') ? 'test/sandbox — never bills' : 'LIVE — real spend possible'})`);
33+
console.log(
34+
`Coasty upstream: ${config.coastyBaseUrl} (key kind: ${config.coastyApiKey.startsWith('sk-coasty-test-') ? 'test/sandbox — never bills' : 'LIVE — real spend possible'})`,
35+
);
3436

3537
const shutdown = async (): Promise<void> => {
3638
await app.close();

apps/backend/src/routes/machines.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,15 @@ export function registerMachineRoutes(app: FastifyInstance, deps: MachineRouteDe
7474
const usage = await coasty.usage();
7575
const balance = usage.wallet_balance_cents ?? usage.balance;
7676
if (balance < PRICING.provisioningGateCents) {
77-
throw new AppError(402, 'INSUFFICIENT_CREDITS', 'Provisioning requires a $0.20 wallet minimum', {
78-
balanceCents: balance,
79-
requiredCents: PRICING.provisioningGateCents,
80-
});
77+
throw new AppError(
78+
402,
79+
'INSUFFICIENT_CREDITS',
80+
'Provisioning requires a $0.20 wallet minimum',
81+
{
82+
balanceCents: balance,
83+
requiredCents: PRICING.provisioningGateCents,
84+
},
85+
);
8186
}
8287
const res = await coasty.createMachine(
8388
{
@@ -130,9 +135,14 @@ export function registerMachineRoutes(app: FastifyInstance, deps: MachineRouteDe
130135
const { id } = request.params as { id: string };
131136
const body = actionSchema.parse(request.body);
132137
if (!ALLOWED_COMMANDS.has(body.command)) {
133-
throw new AppError(403, 'COMMAND_NOT_ALLOWED', `Command '${body.command}' is not exposed to clients`, {
134-
allowed: [...ALLOWED_COMMANDS],
135-
});
138+
throw new AppError(
139+
403,
140+
'COMMAND_NOT_ALLOWED',
141+
`Command '${body.command}' is not exposed to clients`,
142+
{
143+
allowed: [...ALLOWED_COMMANDS],
144+
},
145+
);
136146
}
137147
return coasty.machineAction(id, { command: body.command, parameters: body.parameters });
138148
});

0 commit comments

Comments
 (0)