Resolve the box conversation through a box-scoped route

5a3ef6ea2346 · AtlantisPleb · · parent a24cc461b193

Resolve the box conversation through a box-scoped route

`resolveConversationId` read the account's conversation from
`GET /api/v1/user`, which sits behind `forge:write`. A `box:control` token
cannot reach it, so every box command run with a box-scoped credential
refused before it began — and the message blamed the account for having no
conversation rather than naming the scope that could not ask.

It now asks `GET /api/v1/conversation`, the route that pipeline serves, and
falls back to `/user` for a deployment that predates it. The CLI ships on its
own schedule and cannot assume the server is ahead of it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 166 · 2026-08-25T21:05:01.889939Z

Changed files

  • modified packages/openagents-cli/src/box-client.ts
  • modified packages/openagents-cli/test/box-command.test.ts

Diff

2 files changed, +77 -5

packages/openagents-cli/src/box-client.ts modified +27 -5

@@ -190,20 +190,42 @@ export const boxClientLayer = Layer.effect(

190 190
    const resolveConversationId = Effect.fn("BoxClient.resolveConversationId")(function* (
191 191
      input: AuthenticatedApi,
192 192
    ) {
193
      const response = yield* transport.request({
193
      // `/conversation` is the route a `box:control` token can actually
194
      // reach, and it creates the account's conversation when there is none.
195
      // `/user` sits behind `forge:write`, so resolving through it refused
196
      // every box command a box-scoped credential tried to run.
197
      const named = yield* transport.request({
198
        origin: input.origin,
199
        method: "GET",
200
        path: `${API_VERSION_PATH}/conversation`,
201
        token: input.token,
202
      });
203
      if (named.status === 200) {
204
        const convId = asText(asRecord(named.body)["conversation_id"]);
205
        if (convId !== undefined) return convId;
206
      }
207
208
      // A deployment that predates that route still answers on `/user`, so a
209
      // published CLI keeps working against one. Kept deliberately: the CLI
210
      // ships on its own schedule and cannot assume the server is ahead of it.
211
      const user = yield* transport.request({
194 212
        origin: input.origin,
195 213
        method: "GET",
196 214
        path: `${API_VERSION_PATH}/user`,
197 215
        token: input.token,
198 216
      });
199
      if (response.status === 200) {
200
        const body = asRecord(response.body);
201
        const convId = asText(body["conversation_id"]) ?? asText(asRecord(body["user"])["conversation_id"]);
217
      if (user.status === 200) {
218
        const body = asRecord(user.body);
219
        const convId =
220
          asText(body["conversation_id"]) ??
221
          asText(asRecord(body["openagents"])["conversation_id"]) ??
222
          asText(asRecord(body["user"])["conversation_id"]);
202 223
        if (convId !== undefined) return convId;
203 224
      }
225
204 226
      return yield* new ApiError({
205 227
        operation: "resolve user conversation",
206
        status: response.status,
228
        status: named.status === 200 ? user.status : named.status,
207 229
        message:
208 230
          "This deployment does not report a conversation for the account. " +
209 231
          "Pass --conversation <conversation_id> to name the conversation to use.",
packages/openagents-cli/test/box-command.test.ts modified +50

@@ -45,6 +45,56 @@ const harness = (

45 45
};
46 46
47 47
describe("openagents box CLI commands", () => {
48
  it("resolves the conversation through the box-scoped route", async () => {
49
    // A `box:control` token cannot reach `/user`, which sits behind
50
    // `forge:write`. Resolving through `/conversation` is what lets a
51
    // box-scoped credential run a box command at all.
52
    const seen: string[] = [];
53
    const { layer, written } = harness((req) => {
54
      seen.push(req.path);
55
      if (req.path === "/api/v1/conversation") {
56
        return Effect.succeed({ status: 200, body: { conversation_id: "conv-box" } });
57
      }
58
      if (req.path === "/api/v1/user") {
59
        return Effect.succeed({ status: 401, body: { error: { code: "invalid_api_token" } } });
60
      }
61
      if (req.path === "/api/v1/conversations/conv-box/boxes") {
62
        return Effect.succeed({ status: 200, body: { boxes: [] } });
63
      }
64
      return Effect.succeed({ status: 404, body: {} });
65
    });
66
67
    await Effect.runPromise(runCliWith(["box", "list"]).pipe(Effect.provide(layer)));
68
69
    expect(seen).toContain("/api/v1/conversation");
70
    expect(seen).not.toContain("/api/v1/user");
71
    expect(written.length).toBe(1);
72
  });
73
74
  it("falls back to the user route on a deployment without the conversation route", async () => {
75
    // The CLI ships on its own schedule, so a published client still has to
76
    // work against a server that predates the route.
77
    const seen: string[] = [];
78
    const { layer, written: _written } = harness((req) => {
79
      seen.push(req.path);
80
      if (req.path === "/api/v1/conversation") {
81
        return Effect.succeed({ status: 404, body: {} });
82
      }
83
      if (req.path === "/api/v1/user") {
84
        return Effect.succeed({ status: 200, body: { conversation_id: "conv-old" } });
85
      }
86
      if (req.path === "/api/v1/conversations/conv-old/boxes") {
87
        return Effect.succeed({ status: 200, body: { boxes: [] } });
88
      }
89
      return Effect.succeed({ status: 404, body: {} });
90
    });
91
92
    await Effect.runPromise(runCliWith(["box", "list"]).pipe(Effect.provide(layer)));
93
94
    expect(seen).toContain("/api/v1/conversation");
95
    expect(seen).toContain("/api/v1/conversations/conv-old/boxes");
96
  });
97
48 98
  it("lists boxes for the conversation", async () => {
49 99
    const { layer, written } = harness((req) => {
50 100
      if (req.path === "/api/v1/user") {

This page updates live while a promote is in flight · changelog