Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/web/e2e/notes-edit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ test('a note changed since it loaded keeps the editor and the text, and says so'
});

test('⌘S, or Ctrl+S, in the editor saves', async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'phone', 'a phone has no ⌘ or Ctrl key without a keyboard');
// A phone has no ⌘ or Ctrl key without a keyboard.
test.skip(testInfo.project.name === 'phone');
const requests = await saves(page, 204);
await typed(page);

Expand Down
8 changes: 7 additions & 1 deletion docs/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ The v1.2 design in §4 to §6 stays as reference. A piece of it is built only wh
| `planner-image` | The planner's image: the service with the Copilot CLI that the Copilot SDK names, from the CLI's release, checked against a pinned SHA-256, on distroless's `cc`, for linux/amd64 alone; its package is unpacked at build time, so it runs on a read-only root file system. The images workflow builds it, and `copilot:check` fails until its version is the SDK's and its checksum the release's (decision 416) | Done (#204) |
| `planner-page` | The Planner page at `/planner` lists the plans, the last updated first, each with the day it last changed and its title on one line, cut to 80 characters, or "Untitled plan"; with Copilot off, and while the gateway doesn't forward to the planner, it says "Copilot isn't set up.". Decision 403's `planner-page` splits in two, so `copilot-planner` has twenty-two steps (decision 417) | Done (#205) |
| `planner-prompt` | The Planner page starts a plan from a prompt: a field over the list, with Start under it, sends the prompt as typed, then empties it and lists the new plan; a failed start keeps the prompt and says why, in plain words for a 404, a 413 or a 502 (decision 418) | Done (#206) |
| `sonar-flake-fixes` | Clears the two issues that SonarQube Cloud's run on `00caae0` found, which turned its gate red, and the planner's flaky stop test: the planner's image fetches the Copilot CLI with BuildKit's `ADD --checksum` rather than `wget`; the notes editor's ⌘S test gives its skip's reason in a comment; and the stop test ends the questions' waits alone, since the SDK doesn't wait for their errors before it closes the connection. Nothing changes on screen or on the wire (decision 419) | Done (#207) |

After these: the product phases (§8.2), each split into steps.

Expand Down Expand Up @@ -2025,6 +2026,11 @@ Decisions 400 to 499 are the vault session's: `vault-edit-sync`, then `copilot-p
- **A failed start** keeps the prompt and says why, by status, where the service's own text would say less: a 404 "Copilot isn't set up.", a 413 "The prompt is too long." and a 502 "Copilot couldn't start the plan."; any other answer, the service's text, or "The plan didn't start (500)." without one; and no answer, "The plan didn't start: the planner isn't reachable."
- **After a start** the page stays on the list; going to the new plan waits for `planner-chat`, which gives a plan its page.
- **Rejected**: the logic in the component, which SonarQube Cloud can't read (AGENTS.md); the service's "Bad Gateway" or "Request Entity Too Large" as the reason; a one-line field, which hides most of a long prompt.
419. **Sonar's two issues and the stop test's flake** (`sonar-flake-fixes`): the lead's choices (2026-10-30), for the user to review. SonarQube Cloud's run on `00caae0` failed its gate on the new code's security rating, C, and listed two open issues, both from the planner's steps; the planner's stop test also failed now and then in CI. `sonar-flake-fixes` clears the three in one PR before the next step, as AGENTS.md asks; nothing changes on screen or on the wire.
- **The CLI's download** (docker:S6506, HTTPS on HTTP clients that follow redirects): the image's `wget` followed the release's redirect to another host. BuildKit's `ADD --checksum` now fetches the tarball and checks it against the SHA-256 that the Dockerfile pins, so no HTTP client runs in the build's shell. A wrong checksum stops the build with a digest mismatch, as `sha256sum -c` did, and the checksum guards the tarball wherever a redirect leads. The build arguments, the check of the platform and `copilot:check` stay as they were (decision 416). Sonar publishes no more than the rule's name, so its next run tells whether `ADD` satisfies it.
- **The skipped test** (typescript:S1607): SonarJS reads the end-to-end tests with its Vitest rules, which want a comment beside `test.skip`, so the notes editor's ⌘S test gives its reason in a comment right above the skip, as nudge's did (decision 168), rather than as the skip's argument.
- **The flake**: `TestStopEndsTheQuestions` failed in CI on a PR that changed no Go code, and in 8 of 1,800 runs with twelve copies at once, each time because the fake CLI never got the question's error. The SDK sends a handler's error after the handler returns, on a goroutine of its own, and its `Stop` closes the connection without waiting for that goroutine, so the error can be lost. So the client's `Stop` can't promise that the CLI gets the error, which amends decision 410's stop: the questions that wait still end, so no handler outlives the service, and a call that the CLI hears nothing back from ends as interrupted once its plan comes back (decision 415). The test now ends the waits alone, as `Stop`'s first step does, with the connection open, and still checks that the CLI gets the error; its cleanup stops the client. It passed 7,200 runs with twelve copies at once.
- **Rejected**: disabling the redirects, which breaks the download, since GitHub serves its releases from another host; `curl` with its protocols limited to HTTPS, which the build's Alpine image lacks; `Stop` waiting for the handlers, which narrows the race but can't close it, since the SDK sends the error after the handler returns.

Decisions 500 on are the orchestrator's, after 199.

Expand All @@ -2038,7 +2044,7 @@ Decisions 500 on are the orchestrator's, after 199.
- **Token expiry**: Jira ≤ 365 d, org PAT lifetime policies, the Copilot PAT (decision 73), the GitHub PAT (≤ 1 year, decision 202) → expiry tracking and reminders.
- **The GitHub token's reach**: a classic PAT's `repo` scope is full control of every repository the user can reach, though the `github` service only reads (decision 202); fine-grained PATs, which can be read-only, cover one owner each.
- **Rate limits**: Bitbucket 1,000 req/h; GitHub search 30 req/min.
- **Copilot SDK**: the SDK and the `copilot` CLI must move together, as each SDK release names its CLI version and checks the protocol (decision 74); `mise run copilot:check` fails while they differ (decision 404). `Usage.GetMetrics` is experimental; `SessionLimits` and the mode RPCs, also experimental, aren't used. The CLI writes the session's `plan.md` without asking the permission handler, and a later CLI could allow more by itself, so the tool allowlist and the empty working directory stay as well (§3). Employer-managed settings can override the local config.
- **Copilot SDK**: the SDK and the `copilot` CLI must move together, as each SDK release names its CLI version and checks the protocol (decision 74); `mise run copilot:check` fails while they differ (decision 404). `Usage.GetMetrics` is experimental; `SessionLimits` and the mode RPCs, also experimental, aren't used. The CLI writes the session's `plan.md` without asking the permission handler, and a later CLI could allow more by itself, so the tool allowlist and the empty working directory stay as well (§3). Employer-managed settings can override the local config. The SDK's `Stop` closes the connection without waiting for the errors that the handlers return as it stops, so the CLI may miss them (decision 419).
- **Sign-in**:
- Any process on the host passes the Tailscale lock by calling `127.0.0.1:8080` with a forged `Tailscale-User-Login` and the ts.net `Host` (tested 2026-10-22). Tailscale's docs warn of the same: listening on the loopback only limits forgery to the host's own processes (decision 77). Since `signin-lock`, such a process also needs a live session's token, which only the browser holds, in an HttpOnly cookie that no service behind the gateway receives. Since `signin-screen` narrowed the lock to `/api`, it can still read the app's files, which hold no data (decision 86). On the laptop, NetworkPolicies let only the operator's proxy pods reach the gateway, but Kubernetes always lets a node reach its own pods, so the laptop's own processes can forge both headers there too (decision 137).
- Google's login stays inside the iPhone Home Screen app on iOS 27 (§2). A later iOS may hand it to Safari again, whose cookie jar is separate; then the iPhone would rely on the Tailscale identity alone, or a passkey (decision 63).
Expand Down
6 changes: 3 additions & 3 deletions services/planner/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ FROM --platform=$BUILDPLATFORM golang:1.27.3-alpine@sha256:d200d2dd76b03a99b5265
ARG COPILOT_VERSION=1.0.105
ARG COPILOT_SHA256=aba1f06f27bc0447656647f76e0a4cf58b4b2a341c75ff04a750982b6aeb6bd2
ARG TARGETARCH
ADD --checksum=sha256:$COPILOT_SHA256 \
https://github.com/github/copilot-cli/releases/download/v$COPILOT_VERSION/copilot-linux-x64.tar.gz \
/tmp/copilot.tar.gz
RUN if [ "$TARGETARCH" != amd64 ]; then echo "The planner's image is for linux/amd64 alone." >&2; exit 1; fi \
&& wget -qO /tmp/copilot.tar.gz \
"https://github.com/github/copilot-cli/releases/download/v$COPILOT_VERSION/copilot-linux-x64.tar.gz" \
&& echo "$COPILOT_SHA256 /tmp/copilot.tar.gz" | sha256sum -c - \
&& mkdir /out && tar -xzf /tmp/copilot.tar.gz -C /out copilot

# The CLI unpacks its package on its first start, into the user's cache folder; unpacking it here,
Expand Down
5 changes: 3 additions & 2 deletions services/planner/internal/copilot/copilot.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,9 @@ func (c *Client) Start(ctx context.Context) error {
return nil
}

// Stop ends the questions' waits for their answers, so that the CLI gets an error for each,
// leaves the plans' sessions, which the CLI keeps, and stops the CLI.
// Stop ends the questions' waits for their answers, each with an error for the CLI, leaves the
// plans' sessions, which the CLI keeps, and stops the CLI. The SDK sends each error once its
// handler returns, but closes the connection without waiting for it, so the CLI may miss it.
func (c *Client) Stop() error {
c.stop()

Expand Down
7 changes: 3 additions & 4 deletions services/planner/internal/copilot/question_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,9 @@ func TestStopEndsTheQuestions(t *testing.T) {
}

next(t, updates)

if err := client.Stop(); err != nil {
t.Fatal(err)
}
// Only Stop's first step: the SDK sends the error once the handler returns, and the rest of
// Stop, which closes the connection, doesn't wait for it. The cleanup stops the client.
client.stop()

if got := fake.replied(t); got != nil {
t.Errorf("the CLI got %s, want an error", got)
Expand Down
Loading