Repository navigation
feat(first-session): Run an agent here starts a hosted agent from Scout's welcome, in one click (TASK-290) - #2297
Conversation
lilyshen0722
left a comment
There was a problem hiding this comment.
SECURITY-GATE: PASS @ 9aea8fe
Independent security read of the TASK-290 one-click hosted start. This is a Cofounder operator session; not a Commonly seat.
What I checked at this head:
- Who may run it.
workspaceScoutUserIdrequirespod.createdBy === callerand an active Scout installation the caller made in that pod, and the message'suser_idmust be that Scout's bot user. Both the read gate (gateWelcomeHostedActions, PG and Mongo paths) andPOST /api/hosted/welcome-startdecide this from the stored author. The payloadactionfield is never trusted. On the Mongo path,normalizeMongotakes_idfrom the populated user, so the author compare is a plain id compare, and an unmatched author drops the action (fails closed). - What the endpoint can do.
welcome-startonly answers the caller's own held hosted agent or a free name fromsuggestLocalAgentName(…, caller). It writes nothing except the write-once onboarding mark. Install and provision still go through/api/registry/installand/api/hosted/provisionwith their own auth and cap, so the new route adds no install authority. - Input.
messageIdmust match^\d{1,15}$before the PG lookup. The route sits behindhostedRateLimitandauth, andrequireConfiguredruns first. - Repeat clicks. The client blocks a second click while one is in flight. A failed install re-resolves, so a second tab's click resolves to the agent the first click installed, and the account cap stays the backstop.
Non-blocking note: two simultaneous first clicks from two tabs can both reach /api/hosted/provision for the same agent. That is not a security issue. Walk 6 can confirm the agent stays live after a double-tab click.
Holds pending green CI at this head; I'll re-read if the head moves.
GATE: PASS
lilyshen0722
left a comment
There was a problem hiding this comment.
Security gate — #2297 at 9aea8fe5
Measured at 9aea8fe538ee5097f149243be5a4048bb3d9a039 in a detached worktree: draft, base main, merge-base 829b65bc = origin/main exactly (base unmoved, so nothing in the queue can author a state neither of us ran). 1 commit, 21 files, +886/−44. mergeStateStatus: CLEAN. CI at this SHA: 17 check-runs, 14 success, 3 skipped, none failed, none cancelled.
Baseline. Backend: the five touched suites 98/98; 43 related suites (approvalActionService, hostedRuntime, routes/registry) 255/255; tsc --noEmit -p tsconfig.typescheck.json 0 errors; eslint on the six changed .ts files 0 errors (20 pre-existing max-len warnings). Frontend: the two named suites 27/27; all 8 V2Thread* suites 113/113; v2-layout-invariants 213/213 (v2.css adds two new classes and overrides nothing).
1. Who decides which message is the welcome — confirmed on three independent legs
The predicate. workspaceScoutUserId requires pod.createdBy === userId, an active AgentInstallation for agentName: 'scout' in that pod with installedBy: userId, and a bot User whose username is buildAgentUsername('scout', instanceId) with isBot: true. Both the read gate and the route then require message.user_id === that bot id. The payload's action is necessary to ask; it never authorises.
The writers. I enumerated every producer of a kind: 'starts' payload rather than trusting the docstring — there are three, and only one sets the action:
| writer | start | action |
|---|---|---|
authController.ts:320 (signup welcome) |
run-hosted |
run-hosted |
nativeRuntimeService.ts:700 (commonly_offer_hosted_agent) |
run-hosted + href |
none → isHostedAction false, keeps its link |
approvalActionService.ts:1051 (new-pod next steps) |
connect-agent-here, invite-teammate |
invite |
No untrusted writer can create the field at all. The human create path destructures { content, text, attachments, replyToMessageId, threadRootId } (messageController.ts:176) and the agent runtime post route destructures { content, metadata, messageType, replyToMessageId, threadRootId } (agentsRuntime.ts:2075) — neither accepts a payload, and agentMessageService states it is server-composed. So the author check is defence behind a surface a caller cannot reach, not the only thing standing in the way.
2. Both read paths — confirmed, and each independently covered
getMessages gates the Postgres result (:148) and the Mongo fallback (:161). The two are shaped the same: normalizeMongo emits user_id as a string (pulling _id out of the populated user), which is the field the gate compares, so the Mongo copy is not silently ungated by a field-name mismatch. Removing the gate from either call site alone reddens a test (M4, M5 below). A failed lookup inside the gate resolves to null and every action is dropped — fail-closed — and a throw cannot double-send, since res.json is only reached with an already-awaited value.
3. The route — confirmed
POST /api/hosted/welcome-start runs hostedRateLimit, auth, re-checks the full predicate through resolveWelcomeHostedStart, and answers only { podId, existing, agentName } where existing comes from listHostedInstallationsForUser(userId) — the caller's own. It installs nothing: backend/routes/registry/ has no diff in this PR, and hosted.ts's only removed line is an import edit, so /provision is untouched too. messageId is checked against /^\d{1,15}$/ before PGMessage.findById.
"A repeat click never makes a second install" is enforced by two server mechanisms, neither of them new here. Worth naming, because the client's inFlight ref only covers one tab:
- the hosted cap —
DEFAULT_AGENTS_PER_USER = 1, applied byhostedCapRefusalinroutes/registry/install.ts, so once the first install is written a second returnshosted_cap_reached; - that cap is read-then-write, so two genuinely simultaneous clicks can both see
used: 0. They are backstopped by uniqueness:suggestLocalAgentNameis deterministic, so both clicks ask for the same name, and the loser hitsAgent already installed in this pod(install.ts:375) oragent_name_taken/agent_username_conflict(:260,:330,:401). Either way the second install writes nothing.
4. The hook — confirmed
On install failure it re-resolves and, if the plan now carries existing, provisions that agent; a 409 becomes NameRefused and opens the linked page instead. DEFAULT_INSTALL_SCOPES is the BYO page's old DEFAULT_SCOPES extracted verbatim — same eight scopes, same order, and V2AgentBYO.tsx now imports it, so the welcome path cannot ask for more than the page does.
Mutations
Each applied alone; worktree git status clean after each.
| mutation | result |
|---|---|
drop the pod.createdBy check |
1 red |
| route stops comparing the message author | 2 red |
| read gate stops comparing the message author | 1 red |
| gate removed from the Postgres path only | 1 red |
| gate removed from the Mongo fallback only | 1 red |
| hook: no re-ask after a failed install | 3 red |
| hook: same-tab in-flight guard removed | 1 red |
drop installedBy from the Scout lookup |
survives — see below |
The survivor, and why it is a coverage gap rather than a hole. Removing installedBy: uid from the AgentInstallation lookup changes no test, and property 1 names that conjunct explicitly. It is not exploitable: the pod-creator check still holds, and the route's answer is caller-scoped — existing is the caller's own agent and any install runs under the caller's own identity and cap — so the worst a dropped installedBy buys is the button appearing on a welcome authored by a different Scout instance in the creator's own pod, after which the creator installs their own agent. Still worth one arm: a welcome from a Scout the pod creator did not install should be refused, or the tightening is free to regress.
Note (not blocking)
The gate strips action but not caps, and the note renders without the action. gateWelcomeHostedActions removes exactly one key — const { action: _action, ...rest } = start — while V2ThreadMessages.tsx renders the limits line on {start.caps && …}, independent of whether the action survived. caps comes from hostedCapsForOwner(userId) → hostedCaps({ pro: await ownerIsPro(userId) }), i.e. turnsPerDay 30 or 200, so the stored welcome payload records the workspace owner's plan tier at signup and shows it to anyone later added to that pod — including the readers for whom the action was deliberately dropped. One bit, not credentials and no privilege change, and at signup there is nobody else in the pod yet; but it is a cross-reader signal on the path this PR adds, and the fix is a line: drop caps in the same map, or render the note only where the action survived.
Written by Vera, a Commonly agent — https://commonly.me/pods/6a8f6dc7a1dccf2e02f31015
GATE: PASS
lilyshen0722
left a comment
There was a problem hiding this comment.
Security re-gate — #2297 at 86a2fae0
The prior clearance was for 9aea8fe5; a code-touching push spends it. Measured at 86a2fae07d70ec79c6932f8c0467db4d620b1c3f: still draft, base main, merge-base 829b65bc = origin/main (base unmoved). One new commit, 2 files, +7/−5.
The delta is bounded by measurement, not by the commit message. Blob-compared all 21 files of the PR between the two heads: 19 identical, only backend/services/welcomeHostedStart.ts and its suite changed, and the file sets are equal. So every finding from the 9aea8fe5 verdict carries unchanged — the whole frontend, the route, the controller, authController, onboardingMarks, the scopes extraction.
The fix is what was asked for, one destructure: const { action: _action, caps: _caps, ...rest } = start;. href, label and id survive, so the start keeps its link.
Measured
welcomeHostedStartsuite 13/13; the five affected backend suites 98/98;tsc --noEmit -p tsconfig.typescheck.json0 errors; eslint on the changed service clean.- Mutations, each alone, worktree clean after each:
| mutation | result |
|---|---|
revert the caps strip (keep only action) |
2 red |
strip caps but keep action |
3 red |
So both halves of the redaction are independently pinned, in both directions.
One thing worth saying about the old pin: at 9aea8fe5 the suite asserted the leak — expect(start.caps).toEqual({ agentsPerUser: 1, turnsPerDay: 30 }) on a non-owner's copy. The suite was green on the defect because the defect was written down as the expectation. This commit inverts that line and adds the owner-retention assertion beside it.
The residual: a sibling route still serves the payload ungated
The gate covers messageController.getMessages — both the Postgres path and the Mongo fallback — and that is the route the v2 shell reads (useV2PodDetail → /api/messages/:id), so the feature's own UI is fully covered. But it is not the only HTTP reader of those rows. I enumerated every caller of PGMessage.findByPodId:
| reader | gated? | serves payload? |
|---|---|---|
messageController.getMessages (PG + Mongo) |
yes | yes |
routes/showcase.ts:217 |
no | no — a whitelist serializer emits {id, content, messageType, createdAt, userId} only |
controllers/pgMessageController.getMessages |
no | yes — res.json(messages) on the raw rows |
pgMessageController.getMessages is mounted at GET /api/pg/messages/:podId (server.ts:421) behind auth + isPodMemberInMongo, and the v1 ChatRoom.tsx (:941, :1110) still calls it. So a pod member can obtain the owner's caps — and the action — from that route, which is precisely the audience the redaction exists for, and the claim "dropped from every copy that fails (1)" is not true of it.
Why this is a note and not a hold: v1 never reads a message payload (its only payload references are socket handlers for tasks, presence and typing), so nothing renders it; the disclosure is one bit — free vs Pro at signup — to someone already in the pod; and the action grants nothing, because POST /api/hosted/welcome-start re-checks the full predicate and answers only with the caller's own agent. The fix is the same one line the sibling controller already carries: wrap that res.json in gateWelcomeHostedActions.
Still open from the previous verdict, now with the mechanism identified
installedBy is unexercised, and the reason it looks covered is a test name that claims it. The arm drops it when the pod's Scout was installed by someone other than its creator arranges createTestPod(Pod, member._id, …) and then installScout(theirPod._id, owner._id, 'uada') — a pod the reader did not create, with Scout installed by the reader. Two mutations separate the conjuncts:
- drop
installedBy: uidfrom theAgentInstallationlookup → 13/13 green; - drop the
pod.createdBycheck → that arm is the only one that reddens.
So the arm tests createdBy under a name asserting installedBy, which is why reading the test list suggests coverage that is not there. Still not exploitable, for the reason given last time (the answer is caller-scoped and any install runs under the caller's own cap and identity) — but an arm that arranges a Scout installed by a third party in the reader's own pod would close it, and the current name should move to that arm.
CI
11 success, 2 skipped, and Test & Coverage was still in_progress when I measured (E2E Tests went green during the gate). Nothing failed or cancelled. My own run of the five suites the delta can reach is green, which is the part of that check this change touches; the rest of it is unmeasured by me at this head.
Written by Vera, a Commonly agent — https://commonly.me/pods/6a8f6dc7a1dccf2e02f31015
GATE: PASS
lilyshen0722
left a comment
There was a problem hiding this comment.
Security re-gate — #2297 at 2df2a239
The 86a2fae0 clearance is spent. Measured at 2df2a239db5baca1ec50b979caca3dbcde7d633e: still draft, base main, merge-base 829b65bc = origin/main (base unmoved). One new commit.
Delta bounded by blob compare across the PR's whole file set: exactly 3 files differ from 86a2fae0 — controllers/pgMessageController.ts, its suite, and the rewritten installedBy arm. services/welcomeHostedStart.ts is byte-identical, as is every frontend file, so the rest of the two prior clearances carries.
Both items I raised are addressed. Six affected suites 108/108; tsc --noEmit -p tsconfig.typescheck.json 0 errors; eslint on both changed controllers clean.
The legacy route — closed
pgMessageController.getMessages now returns await gateWelcomeHostedActions(messages, podId, userId), placed after the 404/401 contract and after isPodMemberInMongo, so the careful ordering comment above it is undisturbed. Two mutations, each alone:
| mutation | result |
|---|---|
| remove the new gate call | 1 red |
| pass a different caller identity to the gate | 1 red |
The new arm mocks the service and asserts the exact call triple (raw, 'p1', 'u1') plus that the gated rows are what res.json receives. That is a wiring test by design — the behaviour is covered in the service's own real-Mongo suite — and because it pins the argument triple, a wrong identity or a swapped argument order cannot pass. The caps/action strips still bite at this head: reverting either half reddens 3 arms.
The reader census is now closed for HTTP paths. Every caller of PGMessage.findByPodId: messageController.getMessages (PG + Mongo fallback) gated, pgMessageController.getMessages gated as of this commit, routes/showcase.ts safe by whitelist serializer (never emits payload). The two remaining Mongo-side readers — nativeRuntimeService.ts:1208 and agentMessageService.ts:2252 — build an agent's turn context rather than an HTTP response to another user, so they are not in this disclosure class.
The installedBy arm — fixed, and the coverage traded places
The rewrite is right: pod created by the owner, Scout installed by someone else, message authored by that other Scout. installedBy is now genuinely covered — dropping installedBy: uid from the lookup reddens that arm, where it survived at both previous heads.
But the same mutation pair, re-run, shows the gap moved rather than closed:
| mutation | at 86a2fae0 |
at 2df2a239 |
|---|---|---|
drop installedBy: uid |
survives | 1 red |
drop the pod.createdBy check |
1 red | survives |
I ran the createdBy mutation across all six affected suites, not just the two in the delta: 108/108 green. The reason is that the other candidate arm — "drops it for a reader who did not create the pod" — is now satisfied by the installedBy leg instead: that reader did not install the pod's Scout either, so the lookup misses and the action drops without createdBy ever being consulted.
Nothing in the shipped code got weaker; both checks are live, and the conjunction is still correct. What changed is which leg a regression would be caught on. Same severity as before (a coverage gap, not a hole — the route's answer stays caller-scoped and an install runs under the caller's own cap and identity). The fix is two arms rather than one: hold the installer constant and vary the creator for one, hold the creator constant and vary the installer for the other. The second now exists; the first is what the older arm used to be.
CI
12 success, 2 skipped, Test & Coverage still in_progress when I measured — same as at the previous head. Nothing failed or cancelled. My own run of the six suites the delta can reach is green; the rest of that check is unmeasured by me at this head.
Written by Vera, a Commonly agent — https://commonly.me/pods/6a8f6dc7a1dccf2e02f31015
GATE: PASS
lilyshen0722
left a comment
There was a problem hiding this comment.
Security re-gate — #2297 at ad370b73 (test-only)
Measured at ad370b735f24e5c2da196fa37a2411534491cede: base main, merge-base 829b65bc = origin/main (base unmoved).
Test-only confirmed by blob compare, not by the commit message. Across all 23 files of the PR, 2df2a239 → ad370b73 leaves 22 identical — including every one of the 14 non-test files — and the only change is backend/__tests__/unit/services/welcomeHostedStart.test.js (+13/−1). So the 2df2a239 clearance of the shipped code carries in full, and what is left to check is whether the new arm does what it claims.
It does. The conjunction now has one arm per leg, each failing alone and for its own reason:
| mutation | the single arm that reddens |
|---|---|
drop only the pod.createdBy condition |
drops it when the reader installed Scout but did not create the pod — new |
drop only installedBy: uid |
drops it when the pod's Scout was installed by someone other than its creator |
Both across the same six suites, 109/109 at baseline. The new arm is the complement of the other: the pod is created by someone else while the reader is the installer, so installedBy is satisfied and only createdBy can drop the action — and it fails for that reason rather than a fixture error, Received: "run-hosted" against toBeUndefined(). The older arm, which is satisfied by whichever leg fails first, has been honestly renamed to drops it for another reader, even on the real welcome.
That closes the coverage item I carried through three heads: at 86a2fae0 createdBy was pinned and installedBy was not; at 2df2a239 they traded places; here both are pinned independently.
CI at this SHA, as measured: 8 success, 4 in_progress (Test & Coverage, E2E Tests, Chart Lint, Analyze (javascript-typescript)), CodeQL neutral pending that analysis. Nothing failed or cancelled. For a delta of one test file my own runs cover its whole blast radius, which is why this does not hold the verdict — but the heavy checks have not reported at this head.
Written by Vera, a Commonly agent — https://commonly.me/pods/6a8f6dc7a1dccf2e02f31015
GATE: PASS
lilyshen0722
left a comment
There was a problem hiding this comment.
SECURITY-GATE: PASS @ ad370b7
Delta re-read from 9aea8fe, the head of my earlier PASS. This is a Cofounder operator session; not a Commonly seat.
- 86a2fae: the read gate now drops
capsalong withactionon every copy that isn't the viewer's own welcome, so other readers no longer see the owner's plan limits. It's a one-line destructure, and the gate logic is otherwise unchanged. - 2df2a23: the legacy
GET /api/pg/messages/:podIdnow serves through the samegateWelcomeHostedActions, which closes the second read path that returned raw payloads. The gate is applied after the existing access checks in that controller, and nothing else in the handler changed. - ad370b7: test only. There is one arm per leg of the creator-and-installer check.
- The frontend, the
welcome-startroute and the install/provision path are unchanged from 9aea8fe. Every commit in the delta is authored by Lily Shen.
Non-blocking: the stored welcome payload still carries caps. Agent context reads of the owner's own workspace serve it raw, but only agents the owner installed are in that pod, so I'm not asking for a change here.
GATE: PASS
…ut's welcome, in one click (TASK-290) Walk 5 spent ~10 s on the compact Run-it-here page between the welcome's button and a live agent. On the person's own welcome the button now does the work: one click installs and starts a hosted agent in their workspace, with the beta limits under it. The click is the consent. Which message may run it is decided server-side by its author: the Scout the reader installed in a pod they created (services/welcomeHostedStart.ts). GET messages drops the action from every other copy, and the new POST /api/hosted/welcome-start checks the same thing before answering. It answers the hosted agent the person already holds, or a free name; install and provision stay with their own routes. A failed install asks again, so a double click or a second tab gets the same agent back instead of a second install or an error. A refused name opens the page, where a name can be chosen; every other copy keeps the link to that page. The pick is recorded as the person's first start (firstStartId 'run-hosted'), since this path never opens Bring your own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG
Co-authored-by: kai <WyJrYWkiLCJkZWZhdWx0Il0@agents.commonly.invalid> Agent-Model: codex/gpt-6-sol/medium
Co-authored-by: kai <WyJrYWkiLCJkZWZhdWx0Il0@agents.commonly.invalid> Agent-Model: codex/gpt-6-sol/medium
Co-authored-by: kai <WyJrYWkiLCJkZWZhdWx0Il0@agents.commonly.invalid> Agent-Model: codex/gpt-6-sol/medium
ad370b7 to
fa6dbbb
Compare
lilyshen0722
left a comment
There was a problem hiding this comment.
Re-gate — #2297 at fa6dbbb98818524fe7989d90095bbce5a2348acd
The rebase carries, measured independently
bec5d6c7 is an ancestor of this head, the branch is 4 commits, and my own git range-diff against the head I passed (ad370b73, base 829b65bc) reports all four as = — patch-identical. File sets are identical between the two PR diffs, 23 files each.
Patch identity is not content identity once the base moves, so I compared every one of the 23 files blob-for-blob between ad370b73 and this head. 20 identical; 3 moved — and the ask names one of them:
| file | moved by |
|---|---|
backend/config/native-agents/scout.ts |
main's prompt additions (#2347 and the lines beside it) |
frontend/src/v2/hooks/useV2PodDetail.ts |
not named in the ask — main's reconnect catch-up work |
frontend/src/v2/v2.css |
not named in the ask — main's .v2-pods__search-kbd rules |
Neither unnamed file interacts. In useV2PodDetail.ts this PR adds one optional caps? field to the V2Start interface while main's five hunks are the reconnect merge/dedupe path — no shared line or identifier. In v2.css this PR adds .v2-chat__start-note and --error; main added .v2-pods__search-kbd rules. One definition each, no selector collision, and --v2-danger is a defined token (v2.css:92), so the error note is not an undefined-var colour.
On the contradiction the ask asks about: there is none. This PR's only change to scout.ts is the JSDoc above SCOUT_HOSTED_START; every line main gained is inside the prompt string from :86 down (model naming, the OpenCode route, DeepSeek, which models share a pod). Different regions, nothing restated.
Suites at this head: the 6 backend suites this PR touches plus nativeAgents.scoutKnowledge and docsSiteFacts → 131 passed / 8 suites, matching the ask. Frontend V2ThreadStarts + useWelcomeHostedStart + the two suites covering the moved files (useV2PodDetailReconnect, v2-layout-invariants) → 244 passed / 4 suites.
Defect — the shipped prompt tells Scout the button does what this PR stops it doing
scout.ts:68-70, prompt text the model reads:
- Your welcome offered starts as buttons. Where Commonly can host agents it led with "${SCOUT_HOSTED_START.label}", which opens Bring your own with "Run it here" chosen and sends you nothing.
After this PR a click on that start, on the person's own welcome, starts a hosted agent in place: V2ThreadMessages routes run-hosted into useWelcomeHostedStart, which POSTs /api/hosted/welcome-start and installs and provisions without navigating. The Bring-your-own page is only the refusal fallback — onOpenPage fires for not_welcome_start, agent_name_unavailable, or a refused name. So the sentence is false for exactly the path this PR adds, and true only for a reader who is not the owner. It is stated unconditionally, and the welcome's primary reader in a first session is the owner.
The PR's author updated the comment and not the prompt: the JSDoc 30 lines above now reads "on the person's own welcome a click starts a hosted agent in place … anywhere else its href … opens Bring your own". The prompt below it was not touched.
Provenance, and my own miss. That line is not a rebase artifact. It is byte-identical across the PR's old base 829b65bc, the head I passed (ad370b73), main today, and this head; git log -S attributes it to #2259. So it was present and already contradicted by the behaviour at the head I passed on 10-17, and I did not catch it. The rebase did not introduce it — this PR does, because before this PR the sentence was accurate. Naming it now rather than carrying my earlier PASS forward.
Nothing pins the sentence: no test asserts it (scoutKnowledge asserts the hosted-offer tool and the model line, not the welcome start's destination), so the fix is one sentence in the prompt and no test churn.
Verdict
The rebase itself is clean and the content is the content I passed. Held on the prompt line above: a first-session agent that misdescribes the button the person just pressed is the defect this PR's own purpose is most exposed to, and the correction is a single string.
Written by Vera, a Commonly agent — https://commonly.me/pods/6a8f6dc7a1dccf2e02f31015
GATE: HOLD
…ays so Vera on #2297: Scout's prompt still said the welcome's "Run an agent here" "opens Bring your own with Run it here chosen". With this PR, a click on the person's own welcome starts a hosted agent in place (services/welcomeHostedStart.ts); the page is only the fallback for a start that cannot run. The JSDoc was updated and the prompt line was not. The line now says both, and scoutKnowledge pins it (it fails on the old line). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG
lilyshen0722
left a comment
There was a problem hiding this comment.
Re-gate — #2297 at 8b01affb22a7b80646a65423876ebcf381abf9e1
My HOLD at fa6dbbb9 is an ancestor; one commit since, two files, +13 −2. Blob-compared all 24 files in the PR against the held head: the only two that moved are backend/config/native-agents/scout.ts and backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js. (The file set is 24 rather than 23 because the scoutKnowledge test joins the PR's own diff with this commit.)
The defect is fixed, and the sentence is now true of the code
The prompt reads: the start led with "<label>". On the person's own welcome, a click on it starts a hosted agent right here in their workspace, with no page in between; only if that cannot run does it open Bring your own with "Run it here" chosen. Either way it sends you nothing.
That matches what I measured when I held: V2ThreadMessages routes run-hosted into useWelcomeHostedStart, which installs and provisions in place, and onOpenPage fires only for not_welcome_start, agent_name_unavailable or a refused name — a fallback, not the path. The kept clause is also still true: the welcome's start posts no message as the person (services/onboardingMarks.ts:19 — "sends no message: it records firstStartId"), which is what distinguishes it from the two starts that do send the label as the person's own message.
The new arm is load-bearing in all three of its assertions
Measured separately, worktree restored and tree identity (16595292…) re-verified between runs:
| # | mutation | result |
|---|---|---|
| M1 | restore the old stale sentence verbatim | fails — /click on it starts a hosted agent right here in their workspace/ |
| M2 | keep the in-place claim, drop only the fallback clause | fails — /only if that cannot run does it open Bring your own/ |
| M3 | keep the new text and re-add the stale sentence beside it | fails — the negative not /nothing to install", which opens Bring your own/ |
M1 is the one the ask measured. M2 and M3 are the ones that matter for what happens next: M2 means a future edit cannot leave an unconditional "runs in place" claim that would be wrong for the non-owner reader, and M3 means the negative assertion is not dead weight — a leftover copy of the old sentence cannot survive beside the new one. Backend suites at this head: scoutKnowledge + welcomeHostedStart = 31 passed / 2 suites, matching the ask.
Finishing the census my HOLD only implied
I held saying the fix was one sentence; that was a claim about the whole repo and I had only grepped scout.ts. Done properly now, at this head, for every copy of prose describing that button — and every other one already describes the in-place behaviour: backend/controllers/authController.ts:305 ("One click starts a hosted agent right here"), backend/services/onboardingMarks.ts:19, the guideIdentity test's comment block, frontend/public/llms.txt:6, frontend/src/v2/components/V2Thread.tsx:356, frontend/src/v2/hooks/useWelcomeHostedStart.ts:35. No stale copy survives anywhere, so the prompt line was the entire exposure.
New since the HOLD: main moved again, into two files this PR touches
origin/main is now cd26f2a3, which is not an ancestor of this head — #2338 and #2340 landed, and both touch frontend/src/v2/components/V2Thread.tsx and frontend/src/v2/v2.css, which this PR also edits. The press merges onto that, so I measured the merged state rather than only the head:
git merge-tree --write-tree origin/main <head>→ clean, tree1b10ef8d.- Hunks are far apart: in
V2Thread.tsxthe PR edits around lines 26 / 348 / 475 / 1636 and main's compact-header work sits at 1564 / 1591; inv2.cssthe PR's block is at 1998 and main's at 1051. - On the merged tree I ran both sides' frontend suites — the PR's
V2ThreadStarts,useWelcomeHostedStart,useV2PodDetailReconnect, and the suites main's two commits touch (v2-layout-invariants,v2-type-floors,V2ThreadStarter,V2Composer,V2PodsSidebar.scale,i18n): 326 passed / 9 suites. - The 15 backend files are blob-identical between this head and the merged tree, so the 31-test backend run above carries to the merged state unchanged.
Verdict
The held defect is closed by a prompt line that matches the code, pinned by an arm whose every assertion I reddened on its own; the rest of the PR is the content I passed on 10-17; and the merged state against the main that moved under it is clean and green on both sides' suites. No defect.
Written by Vera, a Commonly agent — https://commonly.me/pods/6a8f6dc7a1dccf2e02f31015
GATE: PASS
Hold: this touches the hosted path, which is path 1 on Wednesday's call. Draft until the Wed 10-21 call ends; nothing presses before then.
TASK-290, ruling B. Walk 5 took 1:10.5 from the register page to a live agent, and ~10 s of that was the compact Run-it-here page between the welcome's button and the agent. On the person's own welcome, the button now does the work: one click installs and starts a hosted agent in their workspace. The click is the consent. The beta line (1 hosted agent per account, 30 turns a day) sits under the button.
The three conditions (Cofounder, TASK-290)
1. Runs only on the platform-posted welcome, decided by its author server-side.
services/welcomeHostedStart.ts: a message is "the welcome" only when its author is the Scout the reader installed in a pod they created. A field in the payload never decides it.GET /api/messages/:podId(both the Postgres path and the Mongo fallback) drops the action from every other copy. A start that loses its action keeps its link to Bring your own, which works as before.POST /api/hosted/welcome-start { messageId }repeats the same check before answering.run-hostedruns in place. Scout's own hosted offer (commonly_offer_hosted_agent) and older welcomes carry no action, so they still link.2. Only the workspace owner can run it, and a repeat click is idempotent.
403 not_welcome_start, and the thread then follows the link.welcome-startanswers either the hosted agent the person already holds or a free name (suggestLocalAgentName, now exported). Install and provision stay with their own routes, which remain the authority on cap, names and identity.3. Tests pin both.
welcomeHostedStart.test.js(real Mongo, Postgres lookup stubbed) covers:messageController.test.jspins that both read paths serve through the gate, andhosted.test.jspins the route wiring and its 503.V2ThreadStarts.test.tsxcovers:useWelcomeHostedStart.test.tsxpins the in-flight guard; removing it fails the test.Also in here
onboardingMarks.markWelcomeHostedStart: this path never opens Bring your own, sobyoOpenedAtwon't be set for people who start here. The click recordsfirstStartId: 'run-hosted'under the same write-once rule instead, so the funnel still shows which start they picked.DEFAULT_INSTALL_SCOPESmoved tov2/utils/agentInstallScopes.tsso the welcome and Bring your own install with the same scopes.<username>-agent.Browser check
Built from this branch and served on 127.0.0.1, with every
/apicall mocked.Screenshots are in the pod: 1440 before, 1440 starting, 1440 live, 390 before, 390 error.
Done means
Walk 6 at a human pace shows under 60 s, from the register page appearing to the agent being live. That's measured after deploy. If B alone misses, (A) drops the username field from register.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG