@plosson/agentio 0.8.1 → 0.8.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plosson/agentio",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "CLI for LLM agents to interact with communication and tracking services",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -809,6 +809,29 @@ describe('handleAuthorizePost — happy path', () => {
809
809
  expect(url.searchParams.get('state')).toBe('STATE_XYZ');
810
810
  });
811
811
 
812
+ test('api_key with trailing whitespace still authorizes (trim)', async () => {
813
+ // Regression: operators often paste the key with a trailing newline
814
+ // from terminals. Byte-for-byte compare would silently reject.
815
+ const ctx = makeCtx();
816
+ const clientId = await registerTestClient(ctx);
817
+ for (const suffix of [' ', '\n', '\r\n', ' \t']) {
818
+ const res = await handleAuthorizePost(
819
+ authorizePost({
820
+ client_id: clientId,
821
+ redirect_uri: 'http://localhost:53682/callback',
822
+ response_type: 'code',
823
+ code_challenge: 'CHAL',
824
+ code_challenge_method: 'S256',
825
+ state: '',
826
+ scope: 'gchat:default',
827
+ api_key: TEST_API_KEY + suffix,
828
+ }),
829
+ ctx
830
+ );
831
+ expect(res.status).toBe(302);
832
+ }
833
+ });
834
+
812
835
  test('issued code is consumable from the store', async () => {
813
836
  const ctx = makeCtx();
814
837
  const clientId = await registerTestClient(ctx);
@@ -539,7 +539,10 @@ export async function handleAuthorizePost(
539
539
 
540
540
  const params = result.params;
541
541
  const client = ctx.oauthStore.findClient(params.clientId);
542
- const apiKey = form.get('api_key') ?? '';
542
+ // Trim pasted whitespace — same UX issue as setup-page.ts: a stray
543
+ // newline or trailing space from copy-paste would otherwise fail the
544
+ // byte-for-byte compare with a confusing error.
545
+ const apiKey = (form.get('api_key') ?? '').trim();
543
546
 
544
547
  if (!apiKey || !constantTimeEquals(apiKey, ctx.apiKey)) {
545
548
  // Re-render the form with an error. We deliberately do NOT redirect
@@ -32,10 +32,14 @@ interface FakeProfile {
32
32
  // Mutable container the mock consults on each call.
33
33
  let currentProfiles: FakeProfile[] = [];
34
34
 
35
- function setProfiles(fixture: Record<string, string[]>): void {
36
- currentProfiles = Object.entries(fixture).map(([service, names]) => ({
35
+ type ProfileFixture = string | { name: string; readOnly?: boolean };
36
+
37
+ function setProfiles(fixture: Record<string, ProfileFixture[]>): void {
38
+ currentProfiles = Object.entries(fixture).map(([service, entries]) => ({
37
39
  service,
38
- profiles: names.map((n) => ({ name: n })),
40
+ profiles: entries.map((e) =>
41
+ typeof e === 'string' ? { name: e } : { name: e.name, readOnly: e.readOnly }
42
+ ),
39
43
  }));
40
44
  }
41
45
 
@@ -160,6 +164,23 @@ describe('POST / — login', () => {
160
164
  expect(res.headers.get('set-cookie')).toBeNull();
161
165
  });
162
166
 
167
+ test('correct api_key with trailing whitespace (newline/space) → 302', async () => {
168
+ // Regression: operators routinely paste the key with a trailing
169
+ // newline from terminals. Byte-for-byte compare would silently
170
+ // reject an otherwise-valid key; trim makes it forgiving.
171
+ for (const suffix of [' ', '\n', '\r\n', ' \t']) {
172
+ const res = await handleRequest(
173
+ req('POST', '/', {
174
+ contentType: FORM,
175
+ body: formBody({ api_key: API_KEY + suffix }),
176
+ }),
177
+ ctx
178
+ );
179
+ expect(res.status).toBe(302);
180
+ expect(res.headers.get('set-cookie')).toContain(expectedCookieValue(API_KEY));
181
+ }
182
+ });
183
+
163
184
  test('correct api_key → 302 to /, sets cookie with expected attributes', async () => {
164
185
  const res = await handleRequest(
165
186
  req('POST', '/', {
@@ -246,6 +267,33 @@ describe('GET / — authenticated (valid cookie)', () => {
246
267
  expect(html).not.toContain('<h2>jira</h2>');
247
268
  });
248
269
 
270
+ test('read-only profiles render an RO badge; writable profiles do not', async () => {
271
+ setProfiles({
272
+ gmail: [
273
+ 'work', // writable
274
+ { name: 'archive', readOnly: true },
275
+ ],
276
+ });
277
+ const res = await handleRequest(
278
+ req('GET', '/', { cookie: validCookie() }),
279
+ ctx
280
+ );
281
+ const html = await res.text();
282
+
283
+ // Find the two profile rows and check each one independently.
284
+ const workRow = html.match(
285
+ /<label class="profile">[^<]*<input[^>]*value="gmail:work"[^>]*>[^<]*(?:<span[^<]*<\/span>)?[^<]*<\/label>/
286
+ );
287
+ const archiveRow = html.match(
288
+ /<label class="profile">[^<]*<input[^>]*value="gmail:archive"[^>]*>[^<]*(?:<span[^<]*<\/span>)?[^<]*<\/label>/
289
+ );
290
+ expect(workRow).not.toBeNull();
291
+ expect(archiveRow).not.toBeNull();
292
+ expect(workRow![0]).not.toContain('ro-badge');
293
+ expect(archiveRow![0]).toContain('ro-badge');
294
+ expect(archiveRow![0]).toContain('>RO<');
295
+ });
296
+
249
297
  test('no configured profiles → empty-state message, no checkboxes', async () => {
250
298
  setProfiles({});
251
299
  const res = await handleRequest(
@@ -147,6 +147,24 @@ label.profile {
147
147
  cursor: pointer;
148
148
  }
149
149
  label.profile input { margin-right: 0.5rem; }
150
+ .ro-badge {
151
+ display: inline-block;
152
+ margin-left: 0.5rem;
153
+ padding: 0 0.4rem;
154
+ font-size: 0.7rem;
155
+ font-weight: 600;
156
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
157
+ letter-spacing: 0.03em;
158
+ text-transform: uppercase;
159
+ color: #92400e;
160
+ background: #fef3c7;
161
+ border: 1px solid #fde68a;
162
+ border-radius: 4px;
163
+ vertical-align: 1px;
164
+ }
165
+ @media (prefers-color-scheme: dark) {
166
+ .ro-badge { color: #fde68a; background: #3f2e08; border-color: #5c4510; }
167
+ }
150
168
  .output { display: flex; flex-direction: column; gap: 0.4rem; margin-top: 1.4rem; }
151
169
  .output label { font-weight: 600; font-size: 0.9rem; }
152
170
  .output .row { display: flex; gap: 0.5rem; }
@@ -186,9 +204,14 @@ ${errorBlock}
186
204
  </html>`;
187
205
  }
188
206
 
207
+ interface ProfileView {
208
+ name: string;
209
+ readOnly: boolean;
210
+ }
211
+
189
212
  interface ServiceProfileList {
190
213
  service: string;
191
- profiles: string[];
214
+ profiles: ProfileView[];
192
215
  }
193
216
 
194
217
  function profilesPageHtml(origin: string, configured: ServiceProfileList[]): string {
@@ -198,10 +221,13 @@ function profilesPageHtml(origin: string, configured: ServiceProfileList[]): str
198
221
  .map((s) => {
199
222
  const items = s.profiles
200
223
  .map((p) => {
201
- const value = `${s.service}:${p}`;
224
+ const value = `${s.service}:${p.name}`;
225
+ const badge = p.readOnly
226
+ ? ' <span class="ro-badge" title="read-only">RO</span>'
227
+ : '';
202
228
  return ` <label class="profile"><input type="checkbox" name="sp" value="${escapeHtml(
203
229
  value
204
- )}"> ${escapeHtml(p)}</label>`;
230
+ )}"> ${escapeHtml(p.name)}${badge}</label>`;
205
231
  })
206
232
  .join('\n');
207
233
  return ` <section class="service">
@@ -314,7 +340,10 @@ async function handleSetupGet(
314
340
  .filter((s) => s.profiles.length > 0)
315
341
  .map((s) => ({
316
342
  service: s.service,
317
- profiles: s.profiles.map((p) => p.name),
343
+ profiles: s.profiles.map((p) => ({
344
+ name: p.name,
345
+ readOnly: p.readOnly === true,
346
+ })),
318
347
  }));
319
348
 
320
349
  return new Response(profilesPageHtml(origin, configured), {
@@ -338,7 +367,11 @@ async function handleSetupPost(
338
367
  });
339
368
  }
340
369
 
341
- const apiKey = form.get('api_key') ?? '';
370
+ // Trim: operators routinely paste the key with a trailing newline or
371
+ // space from terminals / password managers. constantTimeEquals is a
372
+ // byte-for-byte compare, so an extra whitespace character silently
373
+ // fails the login with a confusing "Invalid API key" error.
374
+ const apiKey = (form.get('api_key') ?? '').trim();
342
375
  if (!apiKey || !constantTimeEquals(apiKey, ctx.apiKey)) {
343
376
  return new Response(loginFormHtml('Invalid API key.'), {
344
377
  status: 401,