thepopebot 1.2.74 → 1.2.75-beta.2

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.
Files changed (63) hide show
  1. package/README.md +11 -0
  2. package/api/CLAUDE.md +2 -0
  3. package/api/index.js +95 -3
  4. package/bin/cli.js +1 -1
  5. package/bin/managed-paths.js +1 -1
  6. package/bin/sync.js +7 -2
  7. package/config/instrumentation.js +4 -0
  8. package/lib/chat/actions.js +4 -4
  9. package/lib/chat/components/chat-input.js +2 -2
  10. package/lib/chat/components/chat-input.jsx +2 -2
  11. package/lib/chat/components/settings-chat-page.js +41 -37
  12. package/lib/chat/components/settings-chat-page.jsx +39 -37
  13. package/lib/chat/components/settings-jobs-page.js +68 -10
  14. package/lib/chat/components/settings-jobs-page.jsx +99 -34
  15. package/lib/code/code-page.js +2 -1
  16. package/lib/code/code-page.jsx +2 -1
  17. package/lib/code/terminal-view.js +6 -3
  18. package/lib/code/terminal-view.jsx +6 -3
  19. package/lib/db/api-keys.js +35 -2
  20. package/lib/db/config.js +40 -4
  21. package/lib/maintenance.js +35 -0
  22. package/lib/oauth/helper.js +34 -0
  23. package/lib/tools/create-agent-job.js +3 -0
  24. package/lib/tools/docker.js +12 -5
  25. package/package.json +3 -2
  26. package/templates/docker-compose.custom.yml +1 -0
  27. package/templates/docker-compose.litellm.yml +1 -0
  28. package/templates/docker-compose.yml +2 -0
  29. package/templates/skills/agent-job-secrets/SKILL.md +25 -0
  30. package/templates/skills/agent-job-secrets/agent-job-secrets.js +66 -0
  31. package/templates/skills/playwright-cli/SKILL.md +294 -0
  32. package/templates/skills/brave-search/SKILL.md +0 -79
  33. package/templates/skills/brave-search/content.js +0 -86
  34. package/templates/skills/brave-search/package-lock.json +0 -621
  35. package/templates/skills/brave-search/package.json +0 -14
  36. package/templates/skills/brave-search/search.js +0 -199
  37. package/templates/skills/browser-tools/SKILL.md +0 -196
  38. package/templates/skills/browser-tools/browser-content.js +0 -103
  39. package/templates/skills/browser-tools/browser-cookies.js +0 -35
  40. package/templates/skills/browser-tools/browser-eval.js +0 -53
  41. package/templates/skills/browser-tools/browser-hn-scraper.js +0 -108
  42. package/templates/skills/browser-tools/browser-nav.js +0 -44
  43. package/templates/skills/browser-tools/browser-pick.js +0 -162
  44. package/templates/skills/browser-tools/browser-screenshot.js +0 -34
  45. package/templates/skills/browser-tools/browser-start.js +0 -87
  46. package/templates/skills/browser-tools/package-lock.json +0 -2556
  47. package/templates/skills/browser-tools/package.json +0 -19
  48. package/templates/skills/get-secret/SKILL.md +0 -34
  49. package/templates/skills/get-secret/get-secret.js +0 -33
  50. package/templates/skills/google-docs/SKILL.md +0 -23
  51. package/templates/skills/google-docs/create.sh +0 -69
  52. package/templates/skills/google-drive/SKILL.md +0 -47
  53. package/templates/skills/google-drive/delete.sh +0 -47
  54. package/templates/skills/google-drive/download.sh +0 -50
  55. package/templates/skills/google-drive/list.sh +0 -41
  56. package/templates/skills/google-drive/upload.sh +0 -76
  57. package/templates/skills/kie-ai/SKILL.md +0 -38
  58. package/templates/skills/kie-ai/generate-image.sh +0 -77
  59. package/templates/skills/kie-ai/generate-video.sh +0 -69
  60. package/templates/skills/youtube-transcript/SKILL.md +0 -41
  61. package/templates/skills/youtube-transcript/package-lock.json +0 -24
  62. package/templates/skills/youtube-transcript/package.json +0 -8
  63. package/templates/skills/youtube-transcript/transcript.js +0 -84
@@ -1,8 +1,8 @@
1
1
  'use client';
2
2
 
3
3
  import { useState, useEffect, useRef, useCallback } from 'react';
4
- import { PlusIcon, CopyIcon, CheckIcon, SpinnerIcon } from './icons.js';
5
- import { SecretRow, Dialog, EmptyState } from './settings-shared.js';
4
+ import { PlusIcon, CopyIcon, CheckIcon, SpinnerIcon, TrashIcon } from './icons.js';
5
+ import { SecretRow, StatusBadge, Dialog, EmptyState } from './settings-shared.js';
6
6
  import { OAUTH_PROVIDERS } from '../../oauth/providers.js';
7
7
  import {
8
8
  getAgentJobSecrets,
@@ -183,7 +183,7 @@ function ProviderCombobox({ value, onChange, inputClass }) {
183
183
  // Unified add secret dialog (manual + OAuth modes)
184
184
  // ─────────────────────────────────────────────────────────────────────────────
185
185
 
186
- function AddSecretDialog({ open, onAdd, onCancel, onOAuthSuccess }) {
186
+ function AddSecretDialog({ open, onAdd, onCancel, onOAuthSuccess, editingSecret }) {
187
187
  // Shared state
188
188
  const [mode, setMode] = useState('manual');
189
189
  const [name, setName] = useState('');
@@ -209,8 +209,8 @@ function AddSecretDialog({ open, onAdd, onCancel, onOAuthSuccess }) {
209
209
  // Reset all state on open
210
210
  useEffect(() => {
211
211
  if (open) {
212
- setMode('manual');
213
- setName('');
212
+ setMode(editingSecret ? 'oauth' : 'manual');
213
+ setName(editingSecret?.key || '');
214
214
  setError(null);
215
215
  // Manual
216
216
  setValue('');
@@ -224,7 +224,7 @@ function AddSecretDialog({ open, onAdd, onCancel, onOAuthSuccess }) {
224
224
  setStatus('form');
225
225
  setCopied(false);
226
226
  setRedirectUri(`${window.location.origin}/api/oauth/callback`);
227
- setTimeout(() => nameRef.current?.focus(), 50);
227
+ if (!editingSecret) setTimeout(() => nameRef.current?.focus(), 50);
228
228
  }
229
229
  return () => {
230
230
  if (timeoutRef.current) clearTimeout(timeoutRef.current);
@@ -344,7 +344,7 @@ function AddSecretDialog({ open, onAdd, onCancel, onOAuthSuccess }) {
344
344
  const inputClass = 'w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-foreground';
345
345
 
346
346
  return (
347
- <Dialog open={open} onClose={onCancel} title="Add Secret">
347
+ <Dialog open={open} onClose={onCancel} title={editingSecret ? 'Re-authorize Secret' : 'Add Secret'}>
348
348
  {status === 'success' ? (
349
349
  <div className="flex items-center justify-center gap-2 py-8 text-green-500">
350
350
  <CheckIcon size={20} />
@@ -359,31 +359,33 @@ function AddSecretDialog({ open, onAdd, onCancel, onOAuthSuccess }) {
359
359
  ) : (
360
360
  <>
361
361
  <div className="space-y-3">
362
- {/* Mode toggle */}
363
- <div className="flex rounded-md border border-border overflow-hidden">
364
- <button
365
- type="button"
366
- onClick={() => handleModeChange('manual')}
367
- className={`flex-1 px-3 py-1.5 text-xs font-medium transition-colors ${
368
- mode === 'manual'
369
- ? 'bg-foreground text-background'
370
- : 'text-muted-foreground hover:text-foreground hover:bg-accent'
371
- }`}
372
- >
373
- Manual
374
- </button>
375
- <button
376
- type="button"
377
- onClick={() => handleModeChange('oauth')}
378
- className={`flex-1 px-3 py-1.5 text-xs font-medium transition-colors ${
379
- mode === 'oauth'
380
- ? 'bg-foreground text-background'
381
- : 'text-muted-foreground hover:text-foreground hover:bg-accent'
382
- }`}
383
- >
384
- OAuth
385
- </button>
386
- </div>
362
+ {/* Mode toggle — hidden when re-authorizing */}
363
+ {!editingSecret && (
364
+ <div className="flex rounded-md border border-border overflow-hidden">
365
+ <button
366
+ type="button"
367
+ onClick={() => handleModeChange('manual')}
368
+ className={`flex-1 px-3 py-1.5 text-xs font-medium transition-colors ${
369
+ mode === 'manual'
370
+ ? 'bg-foreground text-background'
371
+ : 'text-muted-foreground hover:text-foreground hover:bg-accent'
372
+ }`}
373
+ >
374
+ Manual
375
+ </button>
376
+ <button
377
+ type="button"
378
+ onClick={() => handleModeChange('oauth')}
379
+ className={`flex-1 px-3 py-1.5 text-xs font-medium transition-colors ${
380
+ mode === 'oauth'
381
+ ? 'bg-foreground text-background'
382
+ : 'text-muted-foreground hover:text-foreground hover:bg-accent'
383
+ }`}
384
+ >
385
+ OAuth
386
+ </button>
387
+ </div>
388
+ )}
387
389
 
388
390
  {/* Secret name — shared */}
389
391
  <div>
@@ -394,7 +396,8 @@ function AddSecretDialog({ open, onAdd, onCancel, onOAuthSuccess }) {
394
396
  value={name}
395
397
  onChange={(e) => setName(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, ''))}
396
398
  placeholder={mode === 'manual' ? 'e.g. GOOGLE_SERVICE_ACCOUNT_KEY' : 'e.g. GOOGLE_OAUTH_TOKEN'}
397
- className={inputClass}
399
+ className={`${inputClass}${editingSecret ? ' text-muted-foreground bg-muted' : ''}`}
400
+ readOnly={!!editingSecret}
398
401
  onKeyDown={(e) => e.key === 'Enter' && mode === 'manual' && handleSave()}
399
402
  />
400
403
  </div>
@@ -522,6 +525,53 @@ function AddSecretDialog({ open, onAdd, onCancel, onOAuthSuccess }) {
522
525
  );
523
526
  }
524
527
 
528
+ // ─────────────────────────────────────────────────────────────────────────────
529
+ // OAuth secret row — Re-authorize instead of inline edit
530
+ // ─────────────────────────────────────────────────────────────────────────────
531
+
532
+ function OAuthSecretRow({ secret, onReauthorize, onDelete }) {
533
+ const [confirmDelete, setConfirmDelete] = useState(false);
534
+
535
+ const handleDelete = async () => {
536
+ if (!confirmDelete) {
537
+ setConfirmDelete(true);
538
+ setTimeout(() => setConfirmDelete(false), 3000);
539
+ return;
540
+ }
541
+ await onDelete();
542
+ setConfirmDelete(false);
543
+ };
544
+
545
+ return (
546
+ <div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between py-3">
547
+ <div className="flex items-center gap-2 min-w-0">
548
+ <span className="text-sm font-medium font-mono">{secret.key}</span>
549
+ <span className="text-xs text-muted-foreground">OAuth</span>
550
+ <StatusBadge isSet={secret.isSet} />
551
+ </div>
552
+ <div className="flex items-center gap-1.5 shrink-0 self-start sm:self-auto">
553
+ <button
554
+ onClick={onReauthorize}
555
+ className="rounded-md px-2.5 py-1.5 text-xs font-medium border border-border text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
556
+ >
557
+ Re-authorize
558
+ </button>
559
+ <button
560
+ onClick={handleDelete}
561
+ className={`rounded-md p-1.5 text-xs border transition-colors ${
562
+ confirmDelete
563
+ ? 'border-destructive text-destructive hover:bg-destructive/10'
564
+ : 'border-border text-muted-foreground hover:text-destructive hover:border-destructive'
565
+ }`}
566
+ title={confirmDelete ? 'Click again to confirm' : 'Delete'}
567
+ >
568
+ <TrashIcon size={12} />
569
+ </button>
570
+ </div>
571
+ </div>
572
+ );
573
+ }
574
+
525
575
  // ─────────────────────────────────────────────────────────────────────────────
526
576
  // Jobs page
527
577
  // ─────────────────────────────────────────────────────────────────────────────
@@ -530,6 +580,7 @@ export function JobsPage() {
530
580
  const [secrets, setSecrets] = useState([]);
531
581
  const [loading, setLoading] = useState(true);
532
582
  const [showAdd, setShowAdd] = useState(false);
583
+ const [reauthorizing, setReauthorizing] = useState(null);
533
584
 
534
585
  const loadSecrets = async () => {
535
586
  try {
@@ -591,6 +642,13 @@ export function JobsPage() {
591
642
  onCancel={() => setShowAdd(false)}
592
643
  onOAuthSuccess={loadSecrets}
593
644
  />
645
+ <AddSecretDialog
646
+ open={!!reauthorizing}
647
+ onAdd={handleAdd}
648
+ onCancel={() => setReauthorizing(null)}
649
+ onOAuthSuccess={loadSecrets}
650
+ editingSecret={reauthorizing}
651
+ />
594
652
  {secrets.length === 0 ? (
595
653
  <EmptyState
596
654
  message="No job secrets configured yet."
@@ -600,7 +658,14 @@ export function JobsPage() {
600
658
  ) : (
601
659
  <div className="rounded-lg border bg-card p-4">
602
660
  <div className="divide-y divide-border">
603
- {secrets.map((s) => (
661
+ {secrets.map((s) => s.secretType === 'oauth2' ? (
662
+ <OAuthSecretRow
663
+ key={s.key}
664
+ secret={s}
665
+ onReauthorize={() => setReauthorizing(s)}
666
+ onDelete={() => handleDelete(s.key)}
667
+ />
668
+ ) : (
604
669
  <SecretRow
605
670
  key={s.key}
606
671
  label={s.key}
@@ -4,6 +4,7 @@ import { useState, useCallback, useEffect, useRef } from "react";
4
4
  import dynamic from "next/dynamic";
5
5
  import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
6
6
  import { SortableContext, horizontalListSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable";
7
+ import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
7
8
  import { CSS } from "@dnd-kit/utilities";
8
9
  import { AppSidebar } from "../chat/components/app-sidebar.js";
9
10
  import { SidebarProvider, SidebarInset } from "../chat/components/ui/sidebar.js";
@@ -276,7 +277,7 @@ function CodePage({ session, codeWorkspaceId }) {
276
277
  closeTitle: "Close session"
277
278
  }
278
279
  ),
279
- /* @__PURE__ */ jsx(DndContext, { sensors, collisionDetection: closestCenter, onDragEnd: handleDragEnd, children: /* @__PURE__ */ jsx(SortableContext, { items: dynamicTabIds, strategy: horizontalListSortingStrategy, children: tabs.slice(1).map((tab) => /* @__PURE__ */ jsx(
280
+ /* @__PURE__ */ jsx(DndContext, { sensors, collisionDetection: closestCenter, onDragEnd: handleDragEnd, modifiers: [restrictToHorizontalAxis], autoScroll: false, children: /* @__PURE__ */ jsx(SortableContext, { items: dynamicTabIds, strategy: horizontalListSortingStrategy, children: tabs.slice(1).map((tab) => /* @__PURE__ */ jsx(
280
281
  SortableTab,
281
282
  {
282
283
  tab,
@@ -4,6 +4,7 @@ import { useState, useCallback, useEffect, useRef } from 'react';
4
4
  import dynamic from 'next/dynamic';
5
5
  import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
6
6
  import { SortableContext, horizontalListSortingStrategy, useSortable, arrayMove } from '@dnd-kit/sortable';
7
+ import { restrictToHorizontalAxis } from '@dnd-kit/modifiers';
7
8
  import { CSS } from '@dnd-kit/utilities';
8
9
  import { AppSidebar } from '../chat/components/app-sidebar.js';
9
10
  import { SidebarProvider, SidebarInset } from '../chat/components/ui/sidebar.js';
@@ -321,7 +322,7 @@ export default function CodePage({ session, codeWorkspaceId }) {
321
322
  />
322
323
 
323
324
  {/* Dynamic tabs — draggable */}
324
- <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
325
+ <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd} modifiers={[restrictToHorizontalAxis]} autoScroll={false}>
325
326
  <SortableContext items={dynamicTabIds} strategy={horizontalListSortingStrategy}>
326
327
  {tabs.slice(1).map((tab) => (
327
328
  <SortableTab
@@ -6,7 +6,6 @@ import { FitAddon } from "@xterm/addon-fit";
6
6
  import { SearchAddon } from "@xterm/addon-search";
7
7
  import { WebLinksAddon } from "@xterm/addon-web-links";
8
8
  import { SerializeAddon } from "@xterm/addon-serialize";
9
- import { ClipboardAddon } from "@xterm/addon-clipboard";
10
9
  import "@xterm/xterm/css/xterm.css";
11
10
  import { SpinnerIcon, MicIcon } from "../chat/components/icons.js";
12
11
  import { COMMAND_LABELS, CommandOutputDialog } from "../chat/components/code-mode-toggle.js";
@@ -171,7 +170,6 @@ function TerminalView({ codeWorkspaceId, wsPath, isActive = true, showToolbar =
171
170
  term.loadAddon(searchAddon);
172
171
  term.loadAddon(webLinksAddon);
173
172
  term.loadAddon(serializeAddon);
174
- term.loadAddon(new ClipboardAddon());
175
173
  termRef.current = term;
176
174
  fitAddonRef.current = fitAddon;
177
175
  term.open(containerRef.current);
@@ -453,6 +451,11 @@ function TerminalView({ codeWorkspaceId, wsPath, isActive = true, showToolbar =
453
451
  color: #ef4444 !important;
454
452
  background: rgba(239,68,68,0.12) !important;
455
453
  }
454
+ @media (max-width: 767px) {
455
+ .code-toolbar-right {
456
+ overflow-x: auto;
457
+ }
458
+ }
456
459
  .code-voice-dialog {
457
460
  position: absolute;
458
461
  bottom: 100%;
@@ -620,7 +623,7 @@ function TerminalView({ codeWorkspaceId, wsPath, isActive = true, showToolbar =
620
623
  ]
621
624
  }
622
625
  ) }),
623
- /* @__PURE__ */ jsxs("div", { style: { display: "flex", alignItems: "center", gap: 6, overflowX: "auto" }, className: "scrollbar-hide", children: [
626
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", alignItems: "center", gap: 6 }, className: "code-toolbar-right scrollbar-hide", children: [
624
627
  voiceAvailable && /* @__PURE__ */ jsxs("div", { ref: voiceDialogRef, style: { position: "relative" }, children: [
625
628
  /* @__PURE__ */ jsx(
626
629
  "button",
@@ -6,7 +6,6 @@ import { FitAddon } from '@xterm/addon-fit';
6
6
  import { SearchAddon } from '@xterm/addon-search';
7
7
  import { WebLinksAddon } from '@xterm/addon-web-links';
8
8
  import { SerializeAddon } from '@xterm/addon-serialize';
9
- import { ClipboardAddon } from '@xterm/addon-clipboard';
10
9
  import '@xterm/xterm/css/xterm.css';
11
10
  import { SpinnerIcon, MicIcon } from '../chat/components/icons.js';
12
11
  import { COMMAND_LABELS, CommandOutputDialog } from '../chat/components/code-mode-toggle.js';
@@ -204,7 +203,6 @@ export default function TerminalView({ codeWorkspaceId, wsPath, isActive = true,
204
203
  term.loadAddon(searchAddon);
205
204
  term.loadAddon(webLinksAddon);
206
205
  term.loadAddon(serializeAddon);
207
- term.loadAddon(new ClipboardAddon());
208
206
 
209
207
  termRef.current = term;
210
208
  fitAddonRef.current = fitAddon;
@@ -524,6 +522,11 @@ export default function TerminalView({ codeWorkspaceId, wsPath, isActive = true,
524
522
  color: #ef4444 !important;
525
523
  background: rgba(239,68,68,0.12) !important;
526
524
  }
525
+ @media (max-width: 767px) {
526
+ .code-toolbar-right {
527
+ overflow-x: auto;
528
+ }
529
+ }
527
530
  .code-voice-dialog {
528
531
  position: absolute;
529
532
  bottom: 100%;
@@ -694,7 +697,7 @@ export default function TerminalView({ codeWorkspaceId, wsPath, isActive = true,
694
697
  {themeLabel}
695
698
  </button>
696
699
  </div>
697
- <div style={{ display: 'flex', alignItems: 'center', gap: 6, overflowX: 'auto' }} className="scrollbar-hide">
700
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6 }} className="code-toolbar-right scrollbar-hide">
698
701
  {voiceAvailable && (
699
702
  <div ref={voiceDialogRef} style={{ position: 'relative' }}>
700
703
  <button
@@ -32,15 +32,25 @@ function _ensureCache() {
32
32
  if (_cache !== null) return _cache;
33
33
 
34
34
  const db = getDb();
35
+ const ONE_HOUR = 60 * 60 * 1000;
36
+ const cutoff = Date.now() - ONE_HOUR;
37
+
35
38
  const rows = db
36
39
  .select()
37
40
  .from(settings)
38
41
  .where(eq(settings.type, 'api_key'))
39
42
  .all();
40
43
 
41
- _cache = rows.map((row) => {
44
+ const jobKeyRows = db
45
+ .select()
46
+ .from(settings)
47
+ .where(eq(settings.type, 'agent_job_api_key'))
48
+ .all()
49
+ .filter(r => r.lastUsedAt !== null ? r.lastUsedAt > cutoff : r.createdAt > cutoff);
50
+
51
+ _cache = [...rows, ...jobKeyRows].map((row) => {
42
52
  const parsed = JSON.parse(row.value);
43
- return { id: row.id, keyHash: parsed.key_hash };
53
+ return { id: row.id, keyHash: parsed.key_hash, type: row.type };
44
54
  });
45
55
 
46
56
  return _cache;
@@ -181,6 +191,29 @@ export function verifyApiKey(rawKey) {
181
191
  return null;
182
192
  }
183
193
 
194
+ /**
195
+ * Create a per-job API key for an agent job container.
196
+ * Stored as type 'agent_job_api_key'. Valid for 1 hour since last use.
197
+ * @param {string} jobId - Agent job ID (stored as key column for traceability)
198
+ * @returns {{ key: string }} The raw API key to inject into the container
199
+ */
200
+ export function createAgentJobApiKey(jobId) {
201
+ const db = getDb();
202
+ const key = generateApiKey();
203
+ const keyHash = hashApiKey(key);
204
+ const now = Date.now();
205
+ db.insert(settings).values({
206
+ id: randomUUID(),
207
+ type: 'agent_job_api_key',
208
+ key: jobId,
209
+ value: JSON.stringify({ key_hash: keyHash }),
210
+ createdAt: now,
211
+ updatedAt: now,
212
+ }).run();
213
+ invalidateApiKeyCache();
214
+ return { key };
215
+ }
216
+
184
217
  /**
185
218
  * Backfill lastUsedAt column from JSON value for existing api_key rows.
186
219
  * Idempotent — only processes rows that still have last_used_at in JSON.
package/lib/db/config.js CHANGED
@@ -272,11 +272,23 @@ export function deleteAgentJobSecret(key) {
272
272
  export function listAgentJobSecrets() {
273
273
  const db = getDb();
274
274
  const rows = db
275
- .select({ key: settings.key, updatedAt: settings.updatedAt })
275
+ .select({ key: settings.key, value: settings.value, updatedAt: settings.updatedAt })
276
276
  .from(settings)
277
277
  .where(eq(settings.type, 'agent_job_secret'))
278
278
  .all();
279
- return rows.map((r) => ({ key: r.key, isSet: true, updatedAt: r.updatedAt }));
279
+ return rows.map((r) => {
280
+ let secretType = 'manual';
281
+ try {
282
+ const decrypted = decrypt(JSON.parse(r.value));
283
+ try {
284
+ const parsed = JSON.parse(decrypted);
285
+ if (parsed.type === 'oauth2' || parsed.type === 'oauth_token') {
286
+ secretType = parsed.type;
287
+ }
288
+ } catch {}
289
+ } catch {}
290
+ return { key: r.key, isSet: true, updatedAt: r.updatedAt, secretType };
291
+ });
280
292
  }
281
293
 
282
294
  /**
@@ -292,13 +304,37 @@ export function getAllAgentJobSecrets() {
292
304
  .all();
293
305
  return rows.map((r) => {
294
306
  try {
295
- return { key: r.key, value: decrypt(JSON.parse(r.value)) };
296
- } catch {
307
+ const decrypted = decrypt(JSON.parse(r.value));
308
+ try {
309
+ const parsed = JSON.parse(decrypted);
310
+ if (parsed.type === 'oauth2' || parsed.type === 'oauth_token') {
311
+ return { key: r.key, value: null };
312
+ }
313
+ } catch {}
314
+ return { key: r.key, value: decrypted };
315
+ } catch (err) {
316
+ console.warn(`[secrets] failed to decrypt agent secret "${r.key}":`, err.message);
297
317
  return null;
298
318
  }
299
319
  }).filter(Boolean);
300
320
  }
301
321
 
322
+ /**
323
+ * Get a single agent job secret, decrypted. Returns the raw stored string (may be JSON).
324
+ * @param {string} key
325
+ * @returns {string|null}
326
+ */
327
+ export function getAgentJobSecretRaw(key) {
328
+ const db = getDb();
329
+ const row = db
330
+ .select()
331
+ .from(settings)
332
+ .where(and(eq(settings.type, 'agent_job_secret'), eq(settings.key, key)))
333
+ .get();
334
+ if (!row) return null;
335
+ try { return decrypt(JSON.parse(row.value)); } catch { return null; }
336
+ }
337
+
302
338
  // ─────────────────────────────────────────────────────────────────────────────
303
339
  // Migration: import env vars to DB on first run
304
340
  // ─────────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,35 @@
1
+ import cron from 'node-cron';
2
+ import { eq } from 'drizzle-orm';
3
+ import { getDb } from './db/index.js';
4
+ import { settings } from './db/schema.js';
5
+ import { invalidateApiKeyCache } from './db/api-keys.js';
6
+
7
+ const ONE_HOUR = 60 * 60 * 1000;
8
+
9
+ function cleanExpiredAgentJobKeys() {
10
+ try {
11
+ const db = getDb();
12
+ const cutoff = Date.now() - ONE_HOUR;
13
+ const rows = db
14
+ .select({ id: settings.id, lastUsedAt: settings.lastUsedAt, createdAt: settings.createdAt })
15
+ .from(settings)
16
+ .where(eq(settings.type, 'agent_job_api_key'))
17
+ .all();
18
+ const expiredIds = rows
19
+ .filter(r => r.lastUsedAt !== null ? r.lastUsedAt < cutoff : r.createdAt < cutoff)
20
+ .map(r => r.id);
21
+ if (expiredIds.length > 0) {
22
+ for (const id of expiredIds) {
23
+ db.delete(settings).where(eq(settings.id, id)).run();
24
+ }
25
+ invalidateApiKeyCache();
26
+ console.log(`[maintenance] Deleted ${expiredIds.length} expired agent job key(s)`);
27
+ }
28
+ } catch (err) {
29
+ console.error('[maintenance] cleanExpiredAgentJobKeys failed:', err);
30
+ }
31
+ }
32
+
33
+ export function startMaintenanceCron() {
34
+ cron.schedule('0 * * * *', cleanExpiredAgentJobKeys);
35
+ }
@@ -56,3 +56,37 @@ export async function exchangeCodeForToken({ code, clientId, clientSecret, token
56
56
 
57
57
  return data;
58
58
  }
59
+
60
+ /**
61
+ * Refresh an OAuth2 access token using a refresh token.
62
+ *
63
+ * POSTs to the provider's token endpoint with grant_type=refresh_token.
64
+ * Returns the full JSON response (new access_token, possibly new refresh_token, etc.).
65
+ */
66
+ export async function refreshOAuthToken({ refreshToken, clientId, clientSecret, tokenUrl }) {
67
+ const body = new URLSearchParams({
68
+ grant_type: 'refresh_token',
69
+ refresh_token: refreshToken,
70
+ client_id: clientId,
71
+ client_secret: clientSecret,
72
+ });
73
+
74
+ const response = await fetch(tokenUrl, {
75
+ method: 'POST',
76
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
77
+ body: body.toString(),
78
+ });
79
+
80
+ const data = await response.json();
81
+
82
+ if (!response.ok) {
83
+ const errorMsg = data.error_description || data.error || 'Token refresh failed';
84
+ throw new Error(errorMsg);
85
+ }
86
+
87
+ if (!data.access_token) {
88
+ throw new Error('No access_token in refresh response');
89
+ }
90
+
91
+ return data;
92
+ }
@@ -3,6 +3,7 @@ import { z } from 'zod';
3
3
  import { githubApi } from './github.js';
4
4
  import { createModel } from '../ai/model.js';
5
5
  import { getConfig } from '../config.js';
6
+ import { createAgentJobApiKey } from '../db/api-keys.js';
6
7
 
7
8
  /**
8
9
  * Generate a short descriptive title for an agent job using the LLM.
@@ -92,6 +93,7 @@ async function createAgentJob(agentJobDescription, options = {}) {
92
93
 
93
94
  // 6. Launch Docker container locally (fire-and-forget with async cleanup)
94
95
  const repoSlug = `${GH_OWNER}/${GH_REPO}`;
96
+ const { key: agentJobToken } = createAgentJobApiKey(agentJobId);
95
97
  launchAgentJobContainer({
96
98
  agentJobId,
97
99
  repo: repoSlug,
@@ -100,6 +102,7 @@ async function createAgentJob(agentJobDescription, options = {}) {
100
102
  description: agentJobDescription,
101
103
  codingAgent: options.agentBackend,
102
104
  llmModel: options.llmModel,
105
+ agentJobToken,
103
106
  }).catch(err => {
104
107
  console.error(`[agent-job] Failed to launch container for ${agentJobId}:`, err.message);
105
108
  });
@@ -227,7 +227,7 @@ async function runInteractiveContainer({ containerName, repo, branch, codingAgen
227
227
  const { getAllAgentJobSecrets } = await import('../db/config.js');
228
228
  const jobSecrets = getAllAgentJobSecrets();
229
229
  for (const { key, value } of jobSecrets) {
230
- if (!env.some(e => e.startsWith(`${key}=`))) {
230
+ if (value !== null && !env.some(e => e.startsWith(`${key}=`))) {
231
231
  env.push(`${key}=${value}`);
232
232
  }
233
233
  }
@@ -434,7 +434,7 @@ async function runHeadlessContainer({ containerName, repo, branch, featureBranch
434
434
  const { getAllAgentJobSecrets } = await import('../db/config.js');
435
435
  const jobSecrets = getAllAgentJobSecrets();
436
436
  for (const { key, value } of jobSecrets) {
437
- if (!env.some(e => e.startsWith(`${key}=`))) {
437
+ if (value !== null && !env.some(e => e.startsWith(`${key}=`))) {
438
438
  env.push(`${key}=${value}`);
439
439
  }
440
440
  }
@@ -851,7 +851,7 @@ async function removeVolume(name) {
851
851
  * @param {string} [options.llmModel] - Model override
852
852
  * @returns {Promise<{containerId: string, containerName: string, volumeName: string}>}
853
853
  */
854
- async function runAgentJobContainer({ agentJobId, repo, branch, title, description, codingAgent, llmModel }) {
854
+ async function runAgentJobContainer({ agentJobId, repo, branch, title, description, codingAgent, llmModel, agentJobToken }) {
855
855
  const agent = codingAgent || getConfig('CODING_AGENT') || 'claude-code';
856
856
  const version = process.env.THEPOPEBOT_VERSION;
857
857
  const image = `stephengpope/thepopebot:coding-agent-${agent}-${version}`;
@@ -885,11 +885,18 @@ async function runAgentJobContainer({ agentJobId, repo, branch, title, descripti
885
885
  env.push(`GH_TOKEN=${ghToken}`);
886
886
  }
887
887
 
888
- // Inject custom job secrets (merge, don't override built-in env vars)
888
+ // Inject APP_URL so agents can call back to the event handler
889
+ const appUrl = getConfig('APP_URL');
890
+ if (appUrl) env.push(`APP_URL=${appUrl}`);
891
+
892
+ // Inject per-job API token for agent-secrets skill
893
+ if (agentJobToken) env.push(`AGENT_JOB_TOKEN=${agentJobToken}`);
894
+
895
+ // Inject agent job secrets (plain secrets as env vars; oauth types are null — agent must fetch via get)
889
896
  const { getAllAgentJobSecrets } = await import('../db/config.js');
890
897
  const jobSecrets = getAllAgentJobSecrets();
891
898
  for (const { key, value } of jobSecrets) {
892
- if (!env.some(e => e.startsWith(`${key}=`))) {
899
+ if (value !== null && !env.some(e => e.startsWith(`${key}=`))) {
893
900
  env.push(`${key}=${value}`);
894
901
  }
895
902
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thepopebot",
3
- "version": "1.2.74",
3
+ "version": "1.2.75-beta.2",
4
4
  "type": "module",
5
5
  "description": "Create autonomous AI agents with a two-layer architecture: Next.js Event Handler + Docker Agent.",
6
6
  "bin": {
@@ -32,6 +32,7 @@
32
32
  "./middleware": "./lib/auth/middleware.js",
33
33
  "./oauth": "./lib/oauth/helper.js",
34
34
  "./oauth/providers": "./lib/oauth/providers.js",
35
+ "./maintenance": "./lib/maintenance.js",
35
36
  "./package.json": "./package.json"
36
37
  },
37
38
  "files": [
@@ -68,6 +69,7 @@
68
69
  "@ai-sdk/react": "^2.0.0",
69
70
  "@clack/prompts": "^0.10.0",
70
71
  "@dnd-kit/core": "^6.3.1",
72
+ "@dnd-kit/modifiers": "^9.0.0",
71
73
  "@dnd-kit/sortable": "^10.0.0",
72
74
  "@grammyjs/parse-mode": "^2.2.0",
73
75
  "@langchain/anthropic": "^1.3.17",
@@ -77,7 +79,6 @@
77
79
  "@langchain/langgraph-checkpoint-sqlite": "^1.0.1",
78
80
  "@langchain/openai": "^1.2.7",
79
81
  "@monaco-editor/react": "^4.7.0",
80
- "@xterm/addon-clipboard": "^0.2.0",
81
82
  "@xterm/addon-fit": "^0.10.0",
82
83
  "@xterm/addon-search": "^0.15.0",
83
84
  "@xterm/addon-serialize": "^0.13.0",
@@ -67,6 +67,7 @@ services:
67
67
  ACCESS_TOKEN: ${GH_TOKEN}
68
68
  RUNNER_SCOPE: repo
69
69
  LABELS: self-hosted
70
+ EPHEMERAL: "1"
70
71
  volumes:
71
72
  - /var/run/docker.sock:/var/run/docker.sock
72
73
  - .:/project
@@ -65,6 +65,7 @@ services:
65
65
  ACCESS_TOKEN: ${GH_TOKEN}
66
66
  RUNNER_SCOPE: repo
67
67
  LABELS: self-hosted
68
+ EPHEMERAL: "1"
68
69
  volumes:
69
70
  - /var/run/docker.sock:/var/run/docker.sock
70
71
  - .:/project
@@ -62,6 +62,8 @@ services:
62
62
  ACCESS_TOKEN: ${GH_TOKEN}
63
63
  RUNNER_SCOPE: repo
64
64
  LABELS: self-hosted
65
+ EPHEMERAL: "1"
66
+ DISABLE_AUTO_UPDATE: "true"
65
67
  volumes:
66
68
  - /var/run/docker.sock:/var/run/docker.sock
67
69
  - .:/project