clawdbot-penfield 1.0.1

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 (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +519 -0
  3. package/dist/index.d.ts +27 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +93 -0
  6. package/dist/src/api-client.d.ts +14 -0
  7. package/dist/src/api-client.d.ts.map +1 -0
  8. package/dist/src/api-client.js +53 -0
  9. package/dist/src/auth-service.d.ts +35 -0
  10. package/dist/src/auth-service.d.ts.map +1 -0
  11. package/dist/src/auth-service.js +197 -0
  12. package/dist/src/cli.d.ts +9 -0
  13. package/dist/src/cli.d.ts.map +1 -0
  14. package/dist/src/cli.js +50 -0
  15. package/dist/src/config.d.ts +16 -0
  16. package/dist/src/config.d.ts.map +1 -0
  17. package/dist/src/config.js +14 -0
  18. package/dist/src/device-flow.d.ts +35 -0
  19. package/dist/src/device-flow.d.ts.map +1 -0
  20. package/dist/src/device-flow.js +169 -0
  21. package/dist/src/runtime.d.ts +18 -0
  22. package/dist/src/runtime.d.ts.map +1 -0
  23. package/dist/src/runtime.js +18 -0
  24. package/dist/src/store.d.ts +49 -0
  25. package/dist/src/store.d.ts.map +1 -0
  26. package/dist/src/store.js +66 -0
  27. package/dist/src/tools/awaken.d.ts +4 -0
  28. package/dist/src/tools/awaken.d.ts.map +1 -0
  29. package/dist/src/tools/awaken.js +17 -0
  30. package/dist/src/tools/connect.d.ts +9 -0
  31. package/dist/src/tools/connect.d.ts.map +1 -0
  32. package/dist/src/tools/connect.js +41 -0
  33. package/dist/src/tools/delete-artifact.d.ts +6 -0
  34. package/dist/src/tools/delete-artifact.d.ts.map +1 -0
  35. package/dist/src/tools/delete-artifact.js +24 -0
  36. package/dist/src/tools/explore.d.ts +9 -0
  37. package/dist/src/tools/explore.d.ts.map +1 -0
  38. package/dist/src/tools/explore.js +35 -0
  39. package/dist/src/tools/fetch.d.ts +6 -0
  40. package/dist/src/tools/fetch.d.ts.map +1 -0
  41. package/dist/src/tools/fetch.js +21 -0
  42. package/dist/src/tools/index.d.ts +4 -0
  43. package/dist/src/tools/index.d.ts.map +1 -0
  44. package/dist/src/tools/index.js +58 -0
  45. package/dist/src/tools/list-artifacts.d.ts +7 -0
  46. package/dist/src/tools/list-artifacts.d.ts.map +1 -0
  47. package/dist/src/tools/list-artifacts.js +32 -0
  48. package/dist/src/tools/list-contexts.d.ts +7 -0
  49. package/dist/src/tools/list-contexts.d.ts.map +1 -0
  50. package/dist/src/tools/list-contexts.js +32 -0
  51. package/dist/src/tools/recall.d.ts +13 -0
  52. package/dist/src/tools/recall.d.ts.map +1 -0
  53. package/dist/src/tools/recall.js +64 -0
  54. package/dist/src/tools/reflect.d.ts +8 -0
  55. package/dist/src/tools/reflect.d.ts.map +1 -0
  56. package/dist/src/tools/reflect.js +38 -0
  57. package/dist/src/tools/restore-context.d.ts +8 -0
  58. package/dist/src/tools/restore-context.d.ts.map +1 -0
  59. package/dist/src/tools/restore-context.js +34 -0
  60. package/dist/src/tools/retrieve-artifact.d.ts +6 -0
  61. package/dist/src/tools/retrieve-artifact.d.ts.map +1 -0
  62. package/dist/src/tools/retrieve-artifact.js +24 -0
  63. package/dist/src/tools/save-artifact.d.ts +8 -0
  64. package/dist/src/tools/save-artifact.d.ts.map +1 -0
  65. package/dist/src/tools/save-artifact.js +27 -0
  66. package/dist/src/tools/save-context.d.ts +7 -0
  67. package/dist/src/tools/save-context.d.ts.map +1 -0
  68. package/dist/src/tools/save-context.js +27 -0
  69. package/dist/src/tools/search.d.ts +9 -0
  70. package/dist/src/tools/search.d.ts.map +1 -0
  71. package/dist/src/tools/search.js +41 -0
  72. package/dist/src/tools/store.d.ts +11 -0
  73. package/dist/src/tools/store.d.ts.map +1 -0
  74. package/dist/src/tools/store.js +36 -0
  75. package/dist/src/tools/update-memory.d.ts +11 -0
  76. package/dist/src/tools/update-memory.d.ts.map +1 -0
  77. package/dist/src/tools/update-memory.js +34 -0
  78. package/dist/src/types/typebox.d.ts +19 -0
  79. package/dist/src/types/typebox.d.ts.map +1 -0
  80. package/dist/src/types/typebox.js +91 -0
  81. package/dist/src/types.d.ts +73 -0
  82. package/dist/src/types.d.ts.map +1 -0
  83. package/dist/src/types.js +7 -0
  84. package/dist/src/validation.d.ts +21 -0
  85. package/dist/src/validation.d.ts.map +1 -0
  86. package/dist/src/validation.js +43 -0
  87. package/package.json +52 -0
@@ -0,0 +1,53 @@
1
+ export class PenfieldApiClient {
2
+ auth;
3
+ apiUrl;
4
+ logger;
5
+ constructor(auth, apiUrl, logger) {
6
+ this.auth = auth;
7
+ this.apiUrl = apiUrl;
8
+ this.logger = logger;
9
+ }
10
+ async request(method, endpoint, body, queryParams) {
11
+ const token = await this.auth.getAccessToken();
12
+ let url = `${this.apiUrl}${endpoint}`;
13
+ if (queryParams) {
14
+ const params = new URLSearchParams(queryParams);
15
+ url += `?${params.toString()}`;
16
+ }
17
+ this.logger?.info(`[penfield] ${method} ${endpoint}`);
18
+ const response = await fetch(url, {
19
+ method,
20
+ headers: {
21
+ Authorization: `Bearer ${token}`,
22
+ "Content-Type": "application/json",
23
+ },
24
+ body: body ? JSON.stringify(body) : undefined,
25
+ });
26
+ // Handle rate limiting
27
+ if (response.status === 429) {
28
+ const retryAfter = response.headers.get("Retry-After");
29
+ throw new Error(`Rate limit exceeded. Retry after ${retryAfter} seconds`);
30
+ }
31
+ if (!response.ok) {
32
+ const error = await response.json().catch(() => ({}));
33
+ const errorMessage = `API request failed: ${response.status} ${response.statusText}` +
34
+ (error.error?.message ? ` - ${error.error.message}` : "");
35
+ this.logger?.error(`[penfield] ${errorMessage}`);
36
+ throw new Error(errorMessage);
37
+ }
38
+ const data = await response.json();
39
+ return (data.data || data);
40
+ }
41
+ async get(endpoint, queryParams) {
42
+ return this.request("GET", endpoint, undefined, queryParams);
43
+ }
44
+ async post(endpoint, body) {
45
+ return this.request("POST", endpoint, body);
46
+ }
47
+ async put(endpoint, body) {
48
+ return this.request("PUT", endpoint, body);
49
+ }
50
+ async delete(endpoint, queryParams) {
51
+ return this.request("DELETE", endpoint, undefined, queryParams);
52
+ }
53
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Background authentication service for Penfield plugin
3
+ *
4
+ * Handles OAuth 2.0 token refresh silently in the background
5
+ * without agent involvement. Registered as a Clawdbot service.
6
+ *
7
+ * Supports RFC 8628 Device Code Flow and RFC 9700 Token Rotation.
8
+ * For refresh tokens, use a DCR-registered client with offline_access scope.
9
+ *
10
+ * Service lifecycle:
11
+ * - start(): Load credentials, begin background refresh loop
12
+ * - stop(): Clear refresh interval, cleanup
13
+ */
14
+ import type { Logger } from './types.js';
15
+ export interface AuthService {
16
+ /** Start the auth service and background refresh loop */
17
+ start(): Promise<void>;
18
+ /** Stop the service and cleanup */
19
+ stop(): Promise<void>;
20
+ /** Get a valid access token (cached or refreshed) */
21
+ getAccessToken(): Promise<string>;
22
+ /** Check if authenticated with valid credentials */
23
+ isAuthenticated(): boolean;
24
+ }
25
+ interface AuthServiceOptions {
26
+ authUrl: string;
27
+ /** Optional clientId - will be loaded from credentials if not provided */
28
+ clientId?: string;
29
+ }
30
+ export declare function createAuthService(api: {
31
+ logger: Logger;
32
+ resolvePath: (path: string) => string;
33
+ }, options: AuthServiceOptions): AuthService;
34
+ export {};
35
+ //# sourceMappingURL=auth-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth-service.d.ts","sourceRoot":"","sources":["../../src/auth-service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAIH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAKzC,MAAM,WAAW,WAAW;IAC1B,yDAAyD;IACzD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,mCAAmC;IACnC,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACtB,qDAAqD;IACrD,cAAc,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;IAClC,oDAAoD;IACpD,eAAe,IAAI,OAAO,CAAC;CAC5B;AAED,UAAU,kBAAkB;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;CAAE,EAC9D,OAAO,EAAE,kBAAkB,GAC1B,WAAW,CAsMb"}
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Background authentication service for Penfield plugin
3
+ *
4
+ * Handles OAuth 2.0 token refresh silently in the background
5
+ * without agent involvement. Registered as a Clawdbot service.
6
+ *
7
+ * Supports RFC 8628 Device Code Flow and RFC 9700 Token Rotation.
8
+ * For refresh tokens, use a DCR-registered client with offline_access scope.
9
+ *
10
+ * Service lifecycle:
11
+ * - start(): Load credentials, begin background refresh loop
12
+ * - stop(): Clear refresh interval, cleanup
13
+ */
14
+ import { loadCredential, saveCredential, TOKEN_EXPIRY_BUFFER_MS } from './store.js';
15
+ import { refreshAccessToken } from './device-flow.js';
16
+ // Refresh interval: check every 60 minutes
17
+ const REFRESH_INTERVAL_MS = 60 * 60 * 1000;
18
+ export function createAuthService(api, options) {
19
+ const { authUrl, clientId: providedClientId } = options;
20
+ const logger = api.logger;
21
+ // In-memory token cache
22
+ let accessToken = null;
23
+ let refreshToken = null;
24
+ let expiresAt = 0;
25
+ let clientId = providedClientId || null;
26
+ // Background refresh interval
27
+ let refreshInterval = null;
28
+ let isRunning = false;
29
+ /**
30
+ * Load credentials from file into memory cache
31
+ */
32
+ function loadCredentials() {
33
+ const cred = loadCredential(api);
34
+ if (!cred)
35
+ return false;
36
+ accessToken = cred.access;
37
+ refreshToken = cred.refresh || null;
38
+ expiresAt = cred.expires;
39
+ clientId = cred.clientId || clientId;
40
+ return true;
41
+ }
42
+ /**
43
+ * Save current in-memory tokens to file (RFC 9700 token rotation)
44
+ */
45
+ function saveTokensToFile() {
46
+ if (accessToken && expiresAt > 0 && clientId) {
47
+ saveCredential(api, {
48
+ clientId,
49
+ access: accessToken,
50
+ refresh: refreshToken || undefined, // May be undefined for non-DCR clients
51
+ expires: expiresAt,
52
+ });
53
+ }
54
+ }
55
+ /**
56
+ * Check if token is expired or about to expire
57
+ */
58
+ function isTokenExpired() {
59
+ if (!accessToken || !expiresAt)
60
+ return true;
61
+ return Date.now() >= expiresAt - TOKEN_EXPIRY_BUFFER_MS;
62
+ }
63
+ /**
64
+ * Perform OAuth token refresh using RFC 8628 + RFC 9700
65
+ * Retry with exponential backoff on network errors
66
+ */
67
+ async function doRefreshAccessToken() {
68
+ if (!refreshToken) {
69
+ logger.info('[penfield-auth] No refresh token - automatic refresh disabled');
70
+ return false;
71
+ }
72
+ if (!clientId) {
73
+ logger.warn('[penfield-auth] No clientId - cannot refresh token');
74
+ return false;
75
+ }
76
+ const maxRetries = 3;
77
+ let attempt = 0;
78
+ const baseDelay = 1000; // 1 second
79
+ while (attempt < maxRetries) {
80
+ attempt++;
81
+ try {
82
+ const result = await refreshAccessToken(authUrl, refreshToken, clientId);
83
+ // Update in-memory tokens (RFC 9700: always use new refresh token)
84
+ accessToken = result.access_token;
85
+ refreshToken = result.refresh_token || refreshToken; // Rotate if provided
86
+ expiresAt = Date.now() + result.expires_in * 1000;
87
+ // Persist to file
88
+ saveTokensToFile();
89
+ if (attempt > 1) {
90
+ logger.info(`[penfield-auth] Token refreshed successfully after ${attempt} attempts`);
91
+ }
92
+ else {
93
+ logger.info('[penfield-auth] Token refreshed successfully');
94
+ }
95
+ return true;
96
+ }
97
+ catch (err) {
98
+ const isNetworkError = err instanceof TypeError || String(err).includes('fetch') || String(err).includes('network');
99
+ if (isNetworkError && attempt < maxRetries) {
100
+ const delay = baseDelay * Math.pow(2, attempt - 1);
101
+ logger.warn(`[penfield-auth] Network error on attempt ${attempt}/${maxRetries}, retrying in ${delay}ms: ${err}`);
102
+ await new Promise(resolve => setTimeout(resolve, delay));
103
+ }
104
+ else {
105
+ logger.warn(`[penfield-auth] Token refresh failed: ${err}`);
106
+ return false;
107
+ }
108
+ }
109
+ }
110
+ return false;
111
+ }
112
+ /**
113
+ * Background refresh loop - runs every 60 minutes
114
+ */
115
+ async function backgroundRefresh() {
116
+ if (!isRunning)
117
+ return;
118
+ try {
119
+ // Reload from file first (handles edge cases)
120
+ const cred = loadCredential(api);
121
+ if (!cred) {
122
+ // No credentials yet - service is running but not authenticated
123
+ return;
124
+ }
125
+ // Check if we need to refresh
126
+ const msUntilExpiry = cred.expires - Date.now();
127
+ if (msUntilExpiry < TOKEN_EXPIRY_BUFFER_MS) {
128
+ await doRefreshAccessToken();
129
+ }
130
+ }
131
+ catch (err) {
132
+ // Log but don't crash - continue checking on next interval
133
+ logger.warn(`[penfield-auth] Background refresh check failed: ${err}`);
134
+ }
135
+ }
136
+ return {
137
+ async start() {
138
+ if (isRunning)
139
+ return;
140
+ // Load credentials from file
141
+ const hasCreds = loadCredentials();
142
+ if (hasCreds) {
143
+ if (refreshToken) {
144
+ logger.info('[penfield-auth] Credentials loaded, starting background refresh');
145
+ }
146
+ else {
147
+ logger.info('[penfield-auth] Credentials loaded (no refresh token - manual re-auth required when expired)');
148
+ }
149
+ }
150
+ else {
151
+ logger.info('[penfield-auth] No credentials found, service running in unauthenticated mode');
152
+ }
153
+ // Start background refresh loop
154
+ isRunning = true;
155
+ refreshInterval = setInterval(backgroundRefresh, REFRESH_INTERVAL_MS);
156
+ },
157
+ async stop() {
158
+ if (!isRunning)
159
+ return;
160
+ isRunning = false;
161
+ if (refreshInterval) {
162
+ clearInterval(refreshInterval);
163
+ refreshInterval = null;
164
+ }
165
+ logger.info('[penfield-auth] Service stopped');
166
+ },
167
+ async getAccessToken() {
168
+ // Load from file if not in memory
169
+ if (!accessToken || !expiresAt) {
170
+ if (!loadCredentials()) {
171
+ throw new Error('Not authenticated. Run: clawdbot penfield login');
172
+ }
173
+ }
174
+ // Refresh if expired or about to expire
175
+ if (isTokenExpired()) {
176
+ // Try refresh first if we have a refresh token
177
+ if (refreshToken) {
178
+ const success = await doRefreshAccessToken();
179
+ if (!success) {
180
+ throw new Error('Token refresh failed. Run: clawdbot penfield login');
181
+ }
182
+ }
183
+ else {
184
+ throw new Error('Token expired. Run: clawdbot penfield login');
185
+ }
186
+ }
187
+ return accessToken;
188
+ },
189
+ isAuthenticated() {
190
+ if (!accessToken || !expiresAt) {
191
+ return loadCredentials();
192
+ }
193
+ // Even without refresh token, we're authenticated if we have a valid access token
194
+ return Date.now() < expiresAt - TOKEN_EXPIRY_BUFFER_MS;
195
+ },
196
+ };
197
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * CLI command registration for Penfield plugin
3
+ */
4
+ import type { ClawdbotPluginApi } from './types.js';
5
+ /**
6
+ * Register the 'penfield' CLI command group with 'login' subcommand
7
+ */
8
+ export declare function registerLoginCommand(api: ClawdbotPluginApi): void;
9
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/cli.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAsB,MAAM,YAAY,CAAC;AAKxE;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,iBAAiB,GAAG,IAAI,CA4CjE"}
@@ -0,0 +1,50 @@
1
+ /**
2
+ * CLI command registration for Penfield plugin
3
+ */
4
+ import { executeDeviceFlow } from './device-flow.js';
5
+ import { saveCredential } from './store.js';
6
+ import { DEFAULT_AUTH_URL } from './config.js';
7
+ /**
8
+ * Register the 'penfield' CLI command group with 'login' subcommand
9
+ */
10
+ export function registerLoginCommand(api) {
11
+ api.registerCli(({ program, logger }) => {
12
+ const root = program
13
+ .command('penfield')
14
+ .description('Penfield Memory commands');
15
+ root
16
+ .command('login')
17
+ .description('Authenticate with Penfield using OAuth Device Flow')
18
+ .action(async () => {
19
+ try {
20
+ // Use pluginConfig directly (same as index.ts)
21
+ const config = api.pluginConfig;
22
+ const authUrl = config?.authUrl || DEFAULT_AUTH_URL;
23
+ logger.info(`[penfield] CLI login - authUrl: ${authUrl}`);
24
+ // CLI context doesn't have api.runtime, so create a prompter from logger
25
+ const prompter = {
26
+ info(msg) { logger.info(msg); },
27
+ warn(msg) { logger.warn(msg); },
28
+ error(msg) { logger.error(msg); },
29
+ };
30
+ const tokens = await executeDeviceFlow({
31
+ authUrl,
32
+ clientId: config?.clientId, // Optional - will register if not provided
33
+ prompter,
34
+ // openUrl not available in CLI context
35
+ });
36
+ saveCredential(api, {
37
+ clientId: tokens.clientId,
38
+ access: tokens.access_token,
39
+ refresh: tokens.refresh_token,
40
+ expires: Date.now() + tokens.expires_in * 1000,
41
+ });
42
+ }
43
+ catch (err) {
44
+ const message = err instanceof Error ? err.message : String(err);
45
+ logger.error(`Login failed: ${message}`);
46
+ process.exit(1);
47
+ }
48
+ });
49
+ }, { commands: ['penfield'] });
50
+ }
@@ -0,0 +1,16 @@
1
+ import { z } from "zod";
2
+ declare const DEFAULT_AUTH_URL: string;
3
+ declare const DEFAULT_API_URL: string;
4
+ export { DEFAULT_AUTH_URL, DEFAULT_API_URL };
5
+ export declare const PenfieldConfigSchema: z.ZodObject<{
6
+ authUrl: z.ZodOptional<z.ZodString>;
7
+ apiUrl: z.ZodOptional<z.ZodString>;
8
+ }, "strict", z.ZodTypeAny, {
9
+ authUrl?: string | undefined;
10
+ apiUrl?: string | undefined;
11
+ }, {
12
+ authUrl?: string | undefined;
13
+ apiUrl?: string | undefined;
14
+ }>;
15
+ export type PenfieldConfig = z.infer<typeof PenfieldConfigSchema>;
16
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,QAAA,MAAM,gBAAgB,QAA+D,CAAC;AACtF,QAAA,MAAM,eAAe,QAA6D,CAAC;AAEnF,OAAO,EAAE,gBAAgB,EAAE,eAAe,EAAE,CAAC;AAO7C,eAAO,MAAM,oBAAoB;;;;;;;;;EAGtB,CAAC;AAEZ,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC"}
@@ -0,0 +1,14 @@
1
+ import { z } from "zod";
2
+ // Production defaults - can be overridden via config or environment variables
3
+ const DEFAULT_AUTH_URL = process.env.PENFIELD_AUTH_URL || "https://auth.penfield.app";
4
+ const DEFAULT_API_URL = process.env.PENFIELD_API_URL || "https://api.penfield.app";
5
+ export { DEFAULT_AUTH_URL, DEFAULT_API_URL };
6
+ // Minimal config schema - everything optional for device flow
7
+ // Note: enabled is handled by Clawdbot at entry level, not in pluginConfig
8
+ //
9
+ // IMPORTANT: This zod schema is the source of truth for config validation.
10
+ // If you modify this, also update clawdbot.plugin.json configSchema to match.
11
+ export const PenfieldConfigSchema = z.object({
12
+ authUrl: z.string().optional(),
13
+ apiUrl: z.string().optional(),
14
+ }).strict();
@@ -0,0 +1,35 @@
1
+ /**
2
+ * RFC 8628 Device Code Flow implementation for Penfield OAuth
3
+ *
4
+ * Discovers endpoints dynamically from /.well-known/oauth-authorization-server
5
+ */
6
+ export interface DeviceFlowParams {
7
+ /** Base URL of Penfield Auth service (e.g., https://auth.penfield.app) */
8
+ authUrl: string;
9
+ /** OAuth client ID (optional - will register if not provided) */
10
+ clientId?: string;
11
+ /** Prompter for showing messages to user */
12
+ prompter: {
13
+ info(msg: string): void;
14
+ warn(msg: string): void;
15
+ error(msg: string): void;
16
+ };
17
+ /** Optional: open URL in browser */
18
+ openUrl?: (url: string) => Promise<void>;
19
+ }
20
+ export interface DeviceFlowResult {
21
+ clientId: string;
22
+ access_token: string;
23
+ refresh_token?: string;
24
+ expires_in: number;
25
+ scope?: string;
26
+ }
27
+ /**
28
+ * Execute the complete OAuth Device Code Flow (RFC 8628)
29
+ */
30
+ export declare function executeDeviceFlow(params: DeviceFlowParams): Promise<DeviceFlowResult>;
31
+ /**
32
+ * Refresh an access token using a refresh token (RFC 8628 + RFC 9700)
33
+ */
34
+ export declare function refreshAccessToken(authUrl: string, refreshToken: string, clientId: string): Promise<DeviceFlowResult>;
35
+ //# sourceMappingURL=device-flow.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"device-flow.d.ts","sourceRoot":"","sources":["../../src/device-flow.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,MAAM,WAAW,gBAAgB;IAC/B,0EAA0E;IAC1E,OAAO,EAAE,MAAM,CAAC;IAChB,iEAAiE;IACjE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4CAA4C;IAC5C,QAAQ,EAAE;QAAE,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;IACzF,oCAAoC;IACpC,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1C;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA8ED;;GAEG;AACH,wBAAsB,iBAAiB,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CA2G3F;AAED;;GAEG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,MAAM,EACf,YAAY,EAAE,MAAM,EACpB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,gBAAgB,CAAC,CA4B3B"}
@@ -0,0 +1,169 @@
1
+ /**
2
+ * RFC 8628 Device Code Flow implementation for Penfield OAuth
3
+ *
4
+ * Discovers endpoints dynamically from /.well-known/oauth-authorization-server
5
+ */
6
+ /**
7
+ * Discover OAuth endpoints from .well-known/oauth-authorization-server
8
+ */
9
+ async function discoverEndpoints(authUrl) {
10
+ const response = await fetch(`${authUrl}/.well-known/oauth-authorization-server`);
11
+ if (!response.ok) {
12
+ throw new Error(`Failed to discover OAuth endpoints: ${response.statusText}`);
13
+ }
14
+ return response.json();
15
+ }
16
+ /**
17
+ * Register a dynamic client (DCR) if clientId not provided
18
+ * Returns the client_id to use
19
+ */
20
+ async function getOrRegisterClient(authUrl, providedClientId, prompter) {
21
+ if (providedClientId) {
22
+ return providedClientId;
23
+ }
24
+ // Discover endpoints dynamically (RFC 8414)
25
+ const discovery = await discoverEndpoints(authUrl);
26
+ // RFC 8414: registration_endpoint must be advertised if DCR is supported
27
+ if (!discovery.registration_endpoint) {
28
+ throw new Error('Dynamic Client Registration not supported: registration_endpoint not advertised in OAuth discovery');
29
+ }
30
+ const registrationEndpoint = discovery.registration_endpoint;
31
+ const regResp = await fetch(registrationEndpoint, {
32
+ method: 'POST',
33
+ headers: { 'Content-Type': 'application/json' },
34
+ body: JSON.stringify({
35
+ client_name: 'clawdbot-penfield',
36
+ redirect_uris: ['http://localhost:8080/callback'],
37
+ grant_types: ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token'],
38
+ response_types: ['code'],
39
+ token_endpoint_auth_method: 'none',
40
+ scope: 'read write offline_access'
41
+ })
42
+ });
43
+ if (!regResp.ok) {
44
+ const error = await regResp.json().catch(() => ({}));
45
+ throw new Error(`Client registration failed: ${error.error_description || error.error || regResp.statusText}`);
46
+ }
47
+ const client = await regResp.json();
48
+ prompter?.info(`[penfield] Registered dynamic client: ${client.client_id}`);
49
+ return client.client_id;
50
+ }
51
+ /**
52
+ * Execute the complete OAuth Device Code Flow (RFC 8628)
53
+ */
54
+ export async function executeDeviceFlow(params) {
55
+ const { authUrl, clientId, prompter, openUrl } = params;
56
+ // Discover endpoints dynamically
57
+ const discovery = await discoverEndpoints(authUrl);
58
+ const tokenEndpoint = discovery.token_endpoint;
59
+ // Get or register client
60
+ const actualClientId = await getOrRegisterClient(authUrl, clientId, prompter);
61
+ // RFC 8628: device_authorization_endpoint must be advertised for device flow
62
+ if (!discovery.device_authorization_endpoint) {
63
+ throw new Error('Device Authorization Grant not supported: device_authorization_endpoint not advertised in OAuth discovery');
64
+ }
65
+ const deviceEndpoint = discovery.device_authorization_endpoint;
66
+ // Request offline_access for refresh tokens (RFC 8628 + RFC 9700)
67
+ const scope = 'read write offline_access';
68
+ // Step 1: Request device code (form-encoded per RFC 8628)
69
+ const deviceResp = await fetch(deviceEndpoint, {
70
+ method: 'POST',
71
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
72
+ body: new URLSearchParams({
73
+ client_id: actualClientId,
74
+ scope
75
+ })
76
+ });
77
+ if (!deviceResp.ok) {
78
+ const error = await deviceResp.json().catch(() => ({}));
79
+ throw new Error(`Device authorization failed: ${deviceResp.statusText}${error.error ? ` - ${error.error}` : ''}`);
80
+ }
81
+ const deviceData = await deviceResp.json();
82
+ const { device_code, user_code, verification_uri_complete, expires_in, interval } = deviceData;
83
+ // Step 2: Show user instructions
84
+ prompter.info(`🔐 Penfield Authorization Required\n`);
85
+ prompter.info(`To authorize, visit: ${verification_uri_complete || deviceData.verification_uri}`);
86
+ prompter.info(`Or enter code: ${user_code}\n`);
87
+ prompter.info(`Waiting for authorization...`);
88
+ if (openUrl && verification_uri_complete) {
89
+ try {
90
+ await openUrl(verification_uri_complete);
91
+ }
92
+ catch {
93
+ // Silently ignore openUrl failures
94
+ }
95
+ }
96
+ // Step 3: Poll for token (form-encoded per RFC 8628)
97
+ let pollInterval = interval || 5;
98
+ const expiresAt = Date.now() + (expires_in * 1000);
99
+ while (Date.now() < expiresAt) {
100
+ await new Promise(resolve => setTimeout(resolve, pollInterval * 1000));
101
+ const tokenResp = await fetch(tokenEndpoint, {
102
+ method: 'POST',
103
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
104
+ body: new URLSearchParams({
105
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
106
+ device_code,
107
+ client_id: actualClientId
108
+ })
109
+ });
110
+ if (tokenResp.ok) {
111
+ const tokens = await tokenResp.json();
112
+ prompter.info('✅ Authorization successful!');
113
+ return {
114
+ clientId: actualClientId,
115
+ access_token: tokens.access_token,
116
+ refresh_token: tokens.refresh_token,
117
+ expires_in: tokens.expires_in,
118
+ scope: tokens.scope
119
+ };
120
+ }
121
+ const error = await tokenResp.json().catch(() => ({}));
122
+ if (error.error === 'authorization_pending') {
123
+ continue;
124
+ }
125
+ if (error.error === 'slow_down') {
126
+ pollInterval += 5;
127
+ continue;
128
+ }
129
+ if (error.error === 'access_denied') {
130
+ throw new Error('Authorization denied by user');
131
+ }
132
+ if (error.error === 'expired_token') {
133
+ throw new Error('Device code expired - please try again');
134
+ }
135
+ // Provide meaningful error message
136
+ const errorDesc = error.error_description || error.error || 'Unknown error';
137
+ throw new Error(`Authorization failed: ${errorDesc}`);
138
+ }
139
+ throw new Error('Device code expired - please try again');
140
+ }
141
+ /**
142
+ * Refresh an access token using a refresh token (RFC 8628 + RFC 9700)
143
+ */
144
+ export async function refreshAccessToken(authUrl, refreshToken, clientId) {
145
+ // Discover token endpoint dynamically
146
+ const discovery = await discoverEndpoints(authUrl);
147
+ const tokenEndpoint = discovery.token_endpoint;
148
+ const tokenResp = await fetch(tokenEndpoint, {
149
+ method: 'POST',
150
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
151
+ body: new URLSearchParams({
152
+ grant_type: 'refresh_token',
153
+ refresh_token: refreshToken,
154
+ client_id: clientId
155
+ })
156
+ });
157
+ if (!tokenResp.ok) {
158
+ const error = await tokenResp.json().catch(() => ({}));
159
+ throw new Error(`Token refresh failed: ${error.error_description || error.error || tokenResp.statusText}`);
160
+ }
161
+ const tokens = await tokenResp.json();
162
+ return {
163
+ clientId, // Pass through the clientId
164
+ access_token: tokens.access_token,
165
+ refresh_token: tokens.refresh_token, // RFC 9700: token rotation - use new refresh token
166
+ expires_in: tokens.expires_in,
167
+ scope: tokens.scope
168
+ };
169
+ }
@@ -0,0 +1,18 @@
1
+ import { type AuthService } from "./auth-service.js";
2
+ import { PenfieldApiClient } from "./api-client.js";
3
+ import { type PenfieldConfig } from "./config.js";
4
+ import type { ClawdbotPluginApi, Logger } from "./types.js";
5
+ export interface PenfieldRuntime {
6
+ config: PenfieldConfig;
7
+ apiClient: PenfieldApiClient;
8
+ authService: AuthService;
9
+ stop: () => Promise<void>;
10
+ }
11
+ export interface CreateRuntimeParams {
12
+ api: ClawdbotPluginApi;
13
+ config: PenfieldConfig;
14
+ authService: AuthService;
15
+ logger?: Logger;
16
+ }
17
+ export declare function createPenfieldRuntime(params: CreateRuntimeParams): Promise<PenfieldRuntime>;
18
+ //# sourceMappingURL=runtime.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/runtime.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,EAAmB,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC;AACnE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAE5D,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,cAAc,CAAC;IACvB,SAAS,EAAE,iBAAiB,CAAC;IAC7B,WAAW,EAAE,WAAW,CAAC;IACzB,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3B;AAED,MAAM,WAAW,mBAAmB;IAClC,GAAG,EAAE,iBAAiB,CAAC;IACvB,MAAM,EAAE,cAAc,CAAC;IACvB,WAAW,EAAE,WAAW,CAAC;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,wBAAsB,qBAAqB,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,eAAe,CAAC,CAmBjG"}
@@ -0,0 +1,18 @@
1
+ import { PenfieldApiClient } from "./api-client.js";
2
+ import { DEFAULT_API_URL } from "./config.js";
3
+ export async function createPenfieldRuntime(params) {
4
+ const { config, authService, logger } = params;
5
+ logger?.info("[penfield] Initializing runtime");
6
+ const apiUrl = config.apiUrl || DEFAULT_API_URL;
7
+ // Create API client with existing auth service
8
+ const apiClient = new PenfieldApiClient(authService, apiUrl, logger);
9
+ return {
10
+ config,
11
+ apiClient,
12
+ authService,
13
+ async stop() {
14
+ await authService.stop();
15
+ logger?.info("[penfield] Runtime stopped");
16
+ },
17
+ };
18
+ }