Skip to content

feat(cli): accept mobile file attachments in remote sessions - #12394

Merged
iscekic merged 7 commits into
mainfrom
feat/mobile-file-attachments
Jul 22, 2026
Merged

feat(cli): accept mobile file attachments in remote sessions#12394
iscekic merged 7 commits into
mainfrom
feat/mobile-file-attachments

Conversation

@iscekic

@iscekic iscekic commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

What & why

Remote CLI sessions can now receive file attachments sent from the Kilo mobile app. The mobile client uploads each file to R2 and sends a first-class FilePartInput whose url is an HTTPS presigned GET and whose filename is the server-issued <uuid>.<ext> basename. This change makes the CLI fetch and materialize those attachments for the agent, and advertise a capability so the mobile app only enables attachments for CLIs that support them.

Companion cloud change (upload/download presign, capability plumbing, mobile UX): Kilo-Org/cloud#4628.

How

  • New Kilo-owned helper src/kilocode/remote-attachments.ts, invoked from remote-sender.ts inside the dispatchLongRunning callback immediately before prompt(...) — parsing, synchronous normalization, and schema validation stay before the send_message ACK, so an unresolved attachment fetch can never delay the ACK.
  • Fetches are HTTPS-only, reject redirects, never forward credentials, are bounded to 5 MB + 1 byte (partial deleted on overflow), and time out. Any per-attachment failure is replaced with an explanatory text part so the rest of the prompt still runs.
  • Classification is derived solely from the validated filename extension via a canonical table: text/* is canonicalized to text/plain for re-entry into the existing resolvePart branches, PDF stays application/pdf, images pass through, and generic binaries are written to a per-session scratch dir and surfaced as a text part naming the absolute path/filename/mime/size.
  • Scratch dir lives under Global.Path.tmp/remote-attachments/<sessionID> (dirs 0700, files 0600); basenames derive from the attachment id + validated extension (never the client filename); the dir is removed on Session.Event.Deleted and on sender dispose.
  • The relay heartbeat now carries optional capabilities.attachments. Builds without this change simply never send it.

Confined to Kilo-owned dirs (src/kilocode/, src/kilo-sessions/); no shared upstream files touched. Includes a minor changeset.

The mobile client uploads each attachment to R2 and sends a first-class
FilePartInput with a server-issued <uuid>.<ext> basename. The CLI fetches
the file over HTTPS, re-emits it as a data: URL for text / image / PDF, or
writes it to a per-session scratch directory for generic binaries so the
agent's tools can read it.

- Fetches are HTTPS-only, reject redirects, never forward credentials, are
  bounded to 5 MB + 1 byte (partial deleted on overflow), and time out.
- Any per-attachment failure becomes an explanatory text part so the rest
  of the prompt still runs; the send_message ACK is unaffected because
  materialization happens inside the long-running dispatch before prompt().
- Scratch directory (0700 / files 0600) lives under Global.Path.tmp and is
  removed on session deletion and sender dispose. Basenames derive from the
  attachment id + validated extension, never the client-supplied filename.
- The relay heartbeat now advertises capabilities.attachments so the mobile
  app only enables attachments for CLIs that support them.
Comment thread packages/opencode/src/kilocode/remote-attachments.ts
Comment thread packages/opencode/src/kilocode/remote-attachments.ts Outdated
Comment thread packages/opencode/src/kilocode/remote-attachments.ts
Comment thread packages/opencode/src/kilocode/remote-attachments.ts Outdated
Comment thread packages/opencode/src/kilocode/remote-attachments.ts
Comment thread packages/opencode/src/kilo-sessions/remote-sender.ts Outdated
Comment thread packages/opencode/test/kilocode/remote-attachments.test.ts
Comment thread .changeset/remote-session-file-attachments.md Outdated
@kilo-code-bot

kilo-code-bot Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

All previously-flagged issues (CRITICAL path traversal, WARNING disposed-once-checked, WARNING deletion/materialization race, and the SUGGESTION items including the unbounded deleted Set) are now verified fixed. This increment adds pending/retired/cleaning bookkeeping in remote-sender.ts so in-flight materializations finish safely after a session is deleted, a new RemoteAttachments.failClosed helper replaces the previous pass-through behavior so a closed/deleted session's fetchable file parts always resolve to a safe text part instead of leaking the raw URL, the dead cleanupSession helper was removed now that dispose-driven cleanup covers it, and both remote-attachments.test.ts and remote-sender.test.ts gained targeted regression coverage for these races.

Files Reviewed (4 files)
  • packages/opencode/src/kilo-sessions/remote-sender.ts - 0 new issues; resolves prior WARNING (deletion race) and SUGGESTION (unbounded deleted Set)
  • packages/opencode/src/kilocode/remote-attachments.ts - 0 new issues; resolves prior WARNING (disposed checked once)
  • packages/opencode/test/kilocode/remote-attachments.test.ts - 0 issues
  • packages/opencode/test/kilocode/sessions/remote-sender.test.ts - 0 issues
Previous Review Summaries (2 snapshots, latest commit 7b9a787)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 7b9a787)

Status: 1 Issue Found | Recommendation: Address before merge

All 8 previously-flagged issues (CRITICAL path traversal, WARNING disposal race, WARNING sender/deletion race, and the SUGGESTION items) were verified fixed in the latest commits: scratch filenames now use random UUIDs independent of the untrusted id, dispose() drains in-flight materialization before removing scratch state, failed writes clean up partial files, the any parts type was replaced with the real SessionPrompt union, attachment fetches are restricted to Cloudflare R2 hosts with no redirects/credentials, the sender now guards against recreating a materializer after session deletion, the path-traversal regression test now exercises the actually-unsanitized id/sessionID fields, and the changeset is now a concise user-facing note.

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 1
Issue Details (click to expand)

SUGGESTION

File Line Issue
packages/opencode/src/kilo-sessions/remote-sender.ts 306 deleted Set of session ids grows unbounded for the lifetime of the sender; never pruned
Files Reviewed (9 files)
  • .changeset/remote-session-file-attachments.md - fix verified
  • packages/opencode/src/kilo-sessions/remote-protocol.ts - 0 issues
  • packages/opencode/src/kilo-sessions/remote-sender.ts - 1 issue, 1 fix verified
  • packages/opencode/src/kilo-sessions/remote-ws.ts - 0 issues
  • packages/opencode/src/kilocode/remote-attachments.ts - 5 fixes verified, 0 new issues
  • packages/opencode/test/kilocode/remote-attachments.test.ts - 1 fix verified
  • packages/opencode/test/kilocode/sessions/remote-protocol.test.ts - 0 issues
  • packages/opencode/test/kilocode/sessions/remote-sender.test.ts - 0 issues
  • packages/opencode/test/kilocode/sessions/remote-ws.test.ts - 0 issues

Fix these issues in Kilo Cloud

Previous review (commit 4f1734e)

Status: 8 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 1
WARNING 3
SUGGESTION 4
Issue Details (click to expand)

CRITICAL

File Line Issue
packages/opencode/src/kilocode/remote-attachments.ts 313 part.id is used unsanitized to build the on-disk scratch filename/path (path.join(scratchDir, basename)), allowing path traversal / arbitrary file write if id contains ../ sequences

WARNING

File Line Issue
packages/opencode/src/kilocode/remote-attachments.ts 297 disposed flag only checked once at top of materialize, not per part -- races with dispose()'s scratch-dir removal
packages/opencode/src/kilo-sessions/remote-sender.ts 732 Session-delete-triggered dispose() can race an in-flight materialize() for the same session with no coordination
packages/opencode/test/kilocode/remote-attachments.test.ts 348 The "path traversal" test only exercises a malicious filename, not the actually-unsanitized id field, so it doesn't cover the CRITICAL issue above

SUGGESTION

File Line Issue
packages/opencode/src/kilocode/remote-attachments.ts 334 Partial/truncated file not removed on writeFile failure
packages/opencode/src/kilocode/remote-attachments.ts 105 any used for materialize's parts instead of the already-defined MaterializedPart union
packages/opencode/src/kilocode/remote-attachments.ts 217 No host allowlist for fetched attachment URLs beyond scheme check (defense-in-depth)
.changeset/remote-session-file-attachments.md 5 Changeset reads as an implementation summary (protocol/byte-limit details) rather than user-facing release notes
Files Reviewed (9 files)
  • .changeset/remote-session-file-attachments.md - 1 issue
  • packages/opencode/src/kilo-sessions/remote-protocol.ts - 0 issues
  • packages/opencode/src/kilo-sessions/remote-sender.ts - 1 issue
  • packages/opencode/src/kilo-sessions/remote-ws.ts - 0 issues
  • packages/opencode/src/kilocode/remote-attachments.ts - 5 issues
  • packages/opencode/test/kilocode/remote-attachments.test.ts - 1 issue
  • packages/opencode/test/kilocode/sessions/remote-protocol.test.ts - 0 issues
  • packages/opencode/test/kilocode/sessions/remote-sender.test.ts - 0 issues
  • packages/opencode/test/kilocode/sessions/remote-ws.test.ts - 0 issues

Fix these issues in Kilo Cloud


Reviewed by claude-sonnet-5 · Input: 46 · Output: 12.8K · Cached: 1.1M

Review guidance: REVIEW.md from base branch main

@iscekic

iscekic commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

(bot) @kilocode-bot please review the latest head 08c0ef2.

@kilo-code-bot

kilo-code-bot Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Code Review — head 08c0ef2

Focused re-review of the attachment materialization path against the four issues flagged earlier, plus a fresh pass over the current diff.

Previously identified issues

# Severity Issue Status
1 CRITICAL part.id used unsanitized to build the on-disk scratch filename → path traversal / arbitrary write ✅ Fixed
2 WARNING disposed flag checked once, not per part → races dispose()'s scratch-dir removal ✅ Fixed
3 WARNING Session-delete dispose() can race an in-flight materialize() with no coordination ✅ Fixed
4 SUGGESTION Traversal only tested via malicious filename, not id ✅ Fixed

1 — Path traversal (CRITICAL): Fixed. The on-disk name no longer derives from part.id. The scratch dir is path.join(root, SCRATCH_DIRNAME, Buffer.from(sessionID).toString("base64url")) (remote-attachments.ts:269) and the basename is `${crypto.randomUUID()}.${extension}` (:300), where extension is run through safeExtension() (^[A-Za-z0-9]{1,16}$, else "bin"). part.id is now used only as the emitted part's id field (:299, :309, :343) and never touches a filesystem path. Confirmed by the new test at remote-attachments.test.ts:402, which feeds id: prt_../../escaped, sessionID: ses_../../escaped, filename: ../../../etc/passwd and asserts the resolved dir does not escape the scratch root, only a single <uuid>.bin entry is created, and the traversal token never appears in output.

2 — Per-part disposal race (WARNING): Fixed. Rather than re-checking a flag per part, create() now tracks in-flight jobs in an active set (:274, :356-372) and dispose() does Promise.allSettled([...active]).then(cleanup) (:374-381). Scratch removal is deferred until the running run() settles, so writes can no longer be clobbered mid-materialize. Covered by remote-attachments.test.ts:542 ("dispose waits for an already-started materialize before removing scratch").

3 — Delete-vs-materialize race (WARNING): Fixed. The Session.Event.Deleted listener in remote-sender.ts (~:304-314) removes the cached materializer and calls result.dispose(), which uses the same active/allSettled coordination as above; the deleted/closed guards in attachmentFor (:316-323) stop a resumed send from re-creating ownership. Covered by sender tests :346 and :409.

New / remaining observations (all minor)

Severity Location Finding
SUGGESTION remote-attachments.ts:390-408 cleanupSession() is exported and unit-tested (:582) but not wired into production — the deletion path uses result.dispose() instead. It's effectively dead prod code, and its doc comment ("The RemoteSender registers a single Session.Event.Deleted listener that dispatches here") is now stale/inaccurate. Remove it or wire it in, and fix the comment.
SUGGESTION remote-sender.ts (deleted set, ~:299) deleted grows unbounded over the lifetime of a long-lived sender that sees many session deletions. Minor memory growth; consider pruning alongside cache eviction.
SUGGESTION remote-attachments.ts:374-381 + prompt path On deletion during an in-flight materialize that already wrote a scratch file, dispose() cleanup can remove the scratch dir concurrently with prompt() consuming the emitted "read the file at <path>" text part. Acceptable since the session is being torn down, but the emitted path can dangle.
NIT remote-attachments.ts:355 After dispose()/closed, materialize() returns the parts unchanged, so a file part with an http(s) R2 URL passes through to resolvePart, which cannot fetch it. Teardown-only graceful degradation; noting for completeness.

Verdict

All four previously identified issues — including the CRITICAL path traversal and the id-based test-coverage gap — are resolved. The SSRF/safety net in fetchOne (HTTPS-only, R2 host allowlist, redirect: "error", credentials: "omit", bounded body + timeout) looks solid. Remaining items are minor code-quality/doc cleanups and do not block.

@kilo-code-bot

kilo-code-bot Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

The review has already been posted. All 4 previously flagged issues are confirmed fixed at head 08c0ef29, with a few new non-blocking observations noted (dead code, unbounded deleted set, minor race edge case, post-dispose http passthrough). No further action needed from me.

@iscekic

iscekic commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

(bot) Latest-head re-review acknowledged. All eight original inline findings are fixed, replied to, and resolved. The new observations are non-blocking teardown/code-cleanup notes: the deleted-session tombstones intentionally last for the sender lifetime so delayed work cannot recreate deleted materializers, and the remaining race/passthrough cases occur only during teardown. Per the review verdict, no additional behavioral change is needed in this PR.

@iscekic

iscekic commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

(bot) @kilocode-bot please verify latest head 7b9a787 after removing redundant kilocode_change markers from Kilo-owned paths.

@kilo-code-bot

kilo-code-bot Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review — head 7b9a787 (incremental vs 08c0ef2)

Re-reviewed the delta since the last approved head. LGTM — approving.

1. Marker removal is comment-only, no logic change.
The full diff 08c0ef2..7b9a787 spans 6 files (remote-protocol.ts, remote-sender.ts, remote-ws.ts + their tests) and every removed line is a kilocode_change marker comment. The two lines that "changed" — the RemoteAttachments import and the two capabilities: { attachments: true } heartbeat sends — only dropped a trailing // kilocode_change … comment; the code is byte-identical. In dispose() the closed = true guard, unsub, attachmentBusUnsub?.(), per-session result.dispose(), and attachmentCache.clear() are all preserved.

Removing the markers here is correct: all touched paths (src/kilo-sessions/, test/kilocode/) contain kilocode in the path and are exempt from marker requirements.

2. remote-attachments.ts is untouched (byte-for-byte identical) between the two heads, so the previously-confirmed fixes remain intact at this head:

  • Path traversal (CRITICAL): scratch basename is ${crypto.randomUUID()}.${extension} with extension clamped by safeExtension (/^[A-Za-z0-9]{1,16}$/, else bin); part.id is never used in the filename, and scratchDir uses base64url(sessionID). No attacker-controlled path segments.
  • Disposed-flag check: materialize() returns a copy early when closed, preventing new work after teardown.
  • dispose/materialize race: dispose() sets closed, then awaits Promise.allSettled([...active]) before cleanup(), and materialize() registers the job in active before returning — no cleanup-before-completion window.

3. Nothing else changed — the diff is exclusively marker removals. No functional, test-behavior, or schema changes flagged.

@kilo-code-bot

kilo-code-bot Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review already posted at head 7b9a787 — LGTM, no blocking issues. All previously flagged items remain fixed; the only diff is kilocode_change marker removal (comment-only, no logic change). No further action needed.

Comment thread packages/opencode/src/kilo-sessions/remote-sender.ts Outdated
@iscekic

iscekic commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

(bot) @kilocode-bot please re-review latest head 6750e1c. The four minor observations from your prior review are now addressed, with additional race/fail-closed tests.

@kilo-code-bot

kilo-code-bot Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review — head 6750e1c (diff vs previously reviewed 7b9a787)

Verdict: ✅ Approve. All four minor observations from the 08c0ef2 pass are addressed, the new race/fail-closed tests genuinely exercise the fixed behavior, and no regression was introduced in the previously-fixed critical/major areas.

1. Dead code — resolved

RemoteAttachments.cleanupSession (the unused helper superseded by Result.dispose()) is fully removed from remote-attachments.ts along with its two tests. Confirmed no remaining references anywhere in the tree.

2. Unbounded deleted Set — resolved

remote-sender.ts no longer calls deleted.add(id) unconditionally on every Session.Event.Deleted. It now only records a session when there is actual attachment work to fail-close (a cached materializer or an in-flight pending prompt), and the id is removed again once cleanup settles — in both clean()'s finally and at the end of finish(), each guarded by if (!pending.has(id)). Sessions with no attachments never enter the set, so it is now bounded to live attachment state instead of growing for the lifetime of the connection.

3. Minor race edge case — resolved

The new pending / retired / cleaning triad correctly coordinates deletion against an in-flight prompt:

  • begin() marks a prompt pending before dispatch; finish() (wired as the dispatchLongRunning settle callback via a complete() guard in both the inner and outer finally) always runs exactly once.
  • A Deleted event arriving while pending retires the materializer instead of disposing it, so scratch files aren't yanked out from under an active materialize()/prompt(). finish() then triggers clean() once the prompt settles.
  • clean() is idempotent (dedupes on the cleaning map) and dispose() errors are logged via options.log.warn rather than swallowed.

4. Post-dispose HTTP passthrough — resolved

materialize() after dispose (the closed branch) now returns failClosed(parts) instead of [...parts], and the sender uses RemoteAttachments.failClosed(...) whenever remote parts exist but no materializer is available. This replaces any fetchable file part with an explanatory text part and never forwards the presigned R2 URL downstream.

New tests — verified (traced against implementation)

  • remote-attachments.test.ts: the renamed "materialize after dispose fails fetchable parts closed without exposing the URL" asserts the failure text and not.toContain("cloudflarestorage.com").

  • remote-sender.test.ts adds three cases that I traced end-to-end against the coordination logic:

    • "does not create first attachments when delayed send resumes after session deletion" — deletion while pending with no materializer ⇒ fail-closed prompt, no URL leak, deleted cleared so a later send re-creates the materializer; also covers the dispose-while-pending retirement path.
    • "keeps materialized scratch owned until an in-flight prompt settles after deletion" — proves dispose is deferred (disposed=0) until the in-flight prompt releases, then runs exactly once; a reused send during cleanup fails closed without leaking the URL.
    • "blocks a new attachment generation while idle-cache deletion cleanup runs" — proves the deleted gate + cleaning map prevent a second materializer/scratch dir while cleanup is in flight (factories/materialized stay at 1).

    Each is deterministic via Promise.withResolvers handshakes and asserts disposed/factories/materialized counts plus URL non-leakage. Note: I could not execute the suite in this review sandbox (package node_modules unavailable in the isolated worktree and test execution outside the workspace root is blocked), so test pass was verified by close static tracing rather than a live run.

Regression check — intact

  • Path traversal (CRITICAL fix): scratch basenames still derive from crypto.randomUUID() + safeExtension(extensionOf(filename)) (remote-attachments.ts:306), and safeExtension still rejects ../.. tokens (:118). The diff only touched failClosed, the closed branch, and the cleanupSession removal — none of the scratch write/naming path. The traversal tests (remote-attachments.test.ts:78, :406) remain.
  • Disposal coordination: dispose() still disposes non-pending cache entries and now retires pending ones for finish()/clean() to reclaim — strengthened, not weakened.
  • Capability plumbing: heartbeat capabilities: { attachments: true } (remote-ws.ts:239,275) untouched.

No blocking issues. Nice job tightening the deletion/cleanup coordination.

@kilo-code-bot

kilo-code-bot Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review already posted at head 6750e1c — Approve, no blocking issues. All four minor observations from the prior pass are confirmed resolved with regression coverage. No further action needed.

@iscekic

iscekic commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

(bot) Final local iOS E2E passed on exact head 6750e1c9425c16ee7a2700f7bf3d259d118b0326 with companion cloud head 4ed258a655a9e364639c21809ac33fe1a9793e4c: logo.png uploaded, upload/download presigns returned 200, the source CLI materialized the image, and Auto Efficient / Qwen 3.7 Plus returned an accurate image description with no retrieval/source error. All 27 checks pass and all review threads are resolved.

Comment on lines +274 to +275
protocolVersion: InstallationVersion,
capabilities: { attachments: true },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can also gate it on protocol version without introducing "capabilities"

@iscekic
iscekic merged commit e72238a into main Jul 22, 2026
30 checks passed
@iscekic
iscekic deleted the feat/mobile-file-attachments branch July 22, 2026 15:14
iscekic added a commit that referenced this pull request Jul 22, 2026
Integrate #12394 (mobile file attachments). Resolved additive heartbeat-wire
conflicts in remote-protocol.ts / remote-ws.ts by keeping both the K1
instance-advertisement field and the #12394 capabilities.attachments field;
combined the round-trip tests. Typecheck + focused kilo-sessions suites green.
// canonicalize to `text/plain` at re-entry (per text caveat in the
// design). The binary fallback is `application/octet-stream` and is
// applied to any extension not present here AND to extensionless inputs.
export const EXTENSION_MIME: Record<string, string> = {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EXTENSION_MIMElooks pretty hardcoded to me and incomplete

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's fine, we can never have an exhaustive list anyway and application/octet-stream covers the fallback.

try {
reader.releaseLock()
} catch {
// reader already detached; nothing to do

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code has a few catch statements without logging, please consider doing so.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are in finally, so logging has already happened.

t7tran pushed a commit to t7tran/kilocode that referenced this pull request Aug 14, 2026
…g#12394)

* feat(cli): accept mobile file attachments in remote sessions

The mobile client uploads each attachment to R2 and sends a first-class
FilePartInput with a server-issued <uuid>.<ext> basename. The CLI fetches
the file over HTTPS, re-emits it as a data: URL for text / image / PDF, or
writes it to a per-session scratch directory for generic binaries so the
agent's tools can read it.

- Fetches are HTTPS-only, reject redirects, never forward credentials, are
  bounded to 5 MB + 1 byte (partial deleted on overflow), and time out.
- Any per-attachment failure becomes an explanatory text part so the rest
  of the prompt still runs; the send_message ACK is unaffected because
  materialization happens inside the long-running dispatch before prompt().
- Scratch directory (0700 / files 0600) lives under Global.Path.tmp and is
  removed on session deletion and sender dispose. Basenames derive from the
  attachment id + validated extension, never the client-supplied filename.
- The relay heartbeat now advertises capabilities.attachments so the mobile
  app only enables attachments for CLIs that support them.

* fix(cli): secure remote attachment materialization

* chore(cli): remove redundant change markers

* fix(cli): coordinate remote attachment lifetime

* fix(cli): fail closed during attachment cleanup

* fix(cli): track idle attachment cleanup
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants