@yolo-labs/yolobridge 0.2.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,12 +29,40 @@ async function parseErrorBody(res) {
29
29
  return { message: `HTTP ${res.status}` };
30
30
  }
31
31
  }
32
- export async function attach(cfg, workspaceId, hostLabel) {
32
+ /**
33
+ * `POST /v1/workspaces/:workspaceId/yolobridge/attach`.
34
+ *
35
+ * `scopedToken` / `scopedTokenExpiresAt` are the workspace-scoped daemon
36
+ * credential common-api mints at attach
37
+ * (`docs/YOLOBRIDGE_SCOPED_CREDENTIAL_PLAN.md`): a token confined to THIS
38
+ * workspace's YoloBridge surface, which the post-attach calls
39
+ * (`detach`/`openStream`/`postHeartbeat`/`postReadOutputReply`) use instead of
40
+ * the full-account token. `scopedTokenExpiresAt` is absolute epoch-ms computed
41
+ * server-side at mint, so the refresh schedule never requires decoding the JWT.
42
+ *
43
+ * REQUIRED since 0.7.0 (card 09, D6 — "no backwards support"). Both fields or
44
+ * neither is still the rule; what changed is that "neither" is now an ERROR
45
+ * rather than a degrade. This used to be optional to protect a daemon binary
46
+ * frozen on a laptop against a common-api predating the mint — but Boundary B
47
+ * now refuses an account token on every post-attach route, so a daemon that
48
+ * attaches without a scoped credential cannot do anything afterwards. Accepting
49
+ * the response would buy it exactly one successful call and then a 403 loop
50
+ * with no diagnosis; failing here names the real problem at the one moment the
51
+ * operator is still watching the terminal.
52
+ *
53
+ * Every other exported function's signature is unchanged: they still take "the
54
+ * bearer token to send" via `ApiClientConfig`, and which token that is remains
55
+ * the caller's decision.
56
+ */
57
+ export async function attach(cfg, workspaceId, hostLabel, remoteHost) {
33
58
  const fetchImpl = cfg.fetchImpl ?? fetch;
34
59
  const res = await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/attach`, {
35
60
  method: 'POST',
36
61
  headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' },
37
- body: JSON.stringify(hostLabel ? { hostLabel } : {}),
62
+ body: JSON.stringify({
63
+ ...(hostLabel ? { hostLabel } : {}),
64
+ ...(remoteHost && Object.values(remoteHost).some(Boolean) ? { remoteHost } : {}),
65
+ }),
38
66
  });
39
67
  if (!res.ok) {
40
68
  const { message, code } = await parseErrorBody(res);
@@ -44,7 +72,54 @@ export async function attach(cfg, workspaceId, hostLabel) {
44
72
  if (typeof body?.tileId !== 'string' || typeof body?.attachmentId !== 'string') {
45
73
  throw new YoloBridgeApiError('attach returned an unexpected shape', res.status);
46
74
  }
47
- return { tileId: body.tileId, attachmentId: body.attachmentId };
75
+ // Both-or-neither, and "neither" is a failure (see the doc comment). A token
76
+ // with no expiry cannot be renewed on time and an expiry with no token is
77
+ // nothing, so a half-pair is refused by the same check — there is no shape
78
+ // here that yields a usable-but-unschedulable credential.
79
+ if (typeof body?.scopedToken !== 'string' || typeof body?.scopedTokenExpiresAt !== 'number') {
80
+ throw new YoloBridgeApiError('attach returned no workspace-scoped credential — this server cannot host a YoloBridge daemon', res.status);
81
+ }
82
+ return {
83
+ tileId: body.tileId,
84
+ attachmentId: body.attachmentId,
85
+ scopedToken: body.scopedToken,
86
+ scopedTokenExpiresAt: body.scopedTokenExpiresAt,
87
+ };
88
+ }
89
+ /**
90
+ * `POST /v1/workspaces/:workspaceId/yolobridge/attach/:attachmentId/refresh` —
91
+ * renew the workspace-scoped daemon credential (card 07,
92
+ * docs/YOLOBRIDGE_SCOPED_CREDENTIAL_PLAN.md, D1).
93
+ *
94
+ * `cfg.accessToken` MUST be the scoped token being renewed: this endpoint
95
+ * authenticates by the presented credential itself ("proof of recent prior
96
+ * possession"), so the token IS the request's identity. There is no refresh
97
+ * credential — deliberately. The daemon never holds a long-lived one, which is
98
+ * the entire point of the scoping work: a stolen laptop yields a credential
99
+ * that expires in an hour and can only be renewed while it is still fresh.
100
+ *
101
+ * The server accepts a token that has JUST expired, within a narrow grace
102
+ * window (15 minutes server-side), so a clock skew or a short sleep across the
103
+ * scheduled renewal recovers instead of forcing a re-attach. Past that, the
104
+ * refusal is terminal and the remedy is `yolo-bridge attach`.
105
+ *
106
+ * Unlike `attach`, the response shape is validated STRICTLY: this call only
107
+ * ever reaches a server that already issued a scoped token, so a reply missing
108
+ * one is a genuine protocol disagreement, not an old-server degrade. Returning
109
+ * a half-pair would leave the caller unable to schedule the next renewal.
110
+ */
111
+ export async function refreshScopedToken(cfg, workspaceId, attachmentId) {
112
+ const fetchImpl = cfg.fetchImpl ?? fetch;
113
+ const res = await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/attach/${encodeURIComponent(attachmentId)}/refresh`, { method: 'POST', headers: authHeaders(cfg) });
114
+ if (!res.ok) {
115
+ const { message, code } = await parseErrorBody(res);
116
+ throw new YoloBridgeApiError(`scoped credential refresh failed: ${message}`, res.status, code);
117
+ }
118
+ const body = (await res.json());
119
+ if (typeof body?.scopedToken !== 'string' || typeof body?.scopedTokenExpiresAt !== 'number') {
120
+ throw new YoloBridgeApiError('scoped credential refresh returned an unexpected shape', res.status);
121
+ }
122
+ return { scopedToken: body.scopedToken, scopedTokenExpiresAt: body.scopedTokenExpiresAt };
48
123
  }
49
124
  /**
50
125
  * `GET /v1/workspaces/selectable` — slim `{id,name,status}` list of the
@@ -78,6 +153,16 @@ export async function listSelectableWorkspaces(cfg) {
78
153
  }
79
154
  return workspaces;
80
155
  }
156
+ /**
157
+ * `DELETE /v1/workspaces/:workspaceId/yolobridge/attach/:attachmentId`.
158
+ *
159
+ * `cfg.accessToken` must be the WORKSPACE-SCOPED credential, not the account
160
+ * token: this is one of the daemon-only routes Boundary B guards, and an
161
+ * account token is refused there with 403 YOLOBRIDGE_SCOPED_TOKEN_REQUIRED
162
+ * (card 09). Both callers comply — the daemon's own cleanup path via
163
+ * `scopedCfg()`, and standalone `yolo-bridge detach` via the credential
164
+ * `attachment.json` persisted at attach.
165
+ */
81
166
  export async function detach(cfg, workspaceId, attachmentId) {
82
167
  const fetchImpl = cfg.fetchImpl ?? fetch;
83
168
  const res = await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/attach/${attachmentId}`, {