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.
- package/LICENSE +21 -0
- package/README.md +519 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +93 -0
- package/dist/src/api-client.d.ts +14 -0
- package/dist/src/api-client.d.ts.map +1 -0
- package/dist/src/api-client.js +53 -0
- package/dist/src/auth-service.d.ts +35 -0
- package/dist/src/auth-service.d.ts.map +1 -0
- package/dist/src/auth-service.js +197 -0
- package/dist/src/cli.d.ts +9 -0
- package/dist/src/cli.d.ts.map +1 -0
- package/dist/src/cli.js +50 -0
- package/dist/src/config.d.ts +16 -0
- package/dist/src/config.d.ts.map +1 -0
- package/dist/src/config.js +14 -0
- package/dist/src/device-flow.d.ts +35 -0
- package/dist/src/device-flow.d.ts.map +1 -0
- package/dist/src/device-flow.js +169 -0
- package/dist/src/runtime.d.ts +18 -0
- package/dist/src/runtime.d.ts.map +1 -0
- package/dist/src/runtime.js +18 -0
- package/dist/src/store.d.ts +49 -0
- package/dist/src/store.d.ts.map +1 -0
- package/dist/src/store.js +66 -0
- package/dist/src/tools/awaken.d.ts +4 -0
- package/dist/src/tools/awaken.d.ts.map +1 -0
- package/dist/src/tools/awaken.js +17 -0
- package/dist/src/tools/connect.d.ts +9 -0
- package/dist/src/tools/connect.d.ts.map +1 -0
- package/dist/src/tools/connect.js +41 -0
- package/dist/src/tools/delete-artifact.d.ts +6 -0
- package/dist/src/tools/delete-artifact.d.ts.map +1 -0
- package/dist/src/tools/delete-artifact.js +24 -0
- package/dist/src/tools/explore.d.ts +9 -0
- package/dist/src/tools/explore.d.ts.map +1 -0
- package/dist/src/tools/explore.js +35 -0
- package/dist/src/tools/fetch.d.ts +6 -0
- package/dist/src/tools/fetch.d.ts.map +1 -0
- package/dist/src/tools/fetch.js +21 -0
- package/dist/src/tools/index.d.ts +4 -0
- package/dist/src/tools/index.d.ts.map +1 -0
- package/dist/src/tools/index.js +58 -0
- package/dist/src/tools/list-artifacts.d.ts +7 -0
- package/dist/src/tools/list-artifacts.d.ts.map +1 -0
- package/dist/src/tools/list-artifacts.js +32 -0
- package/dist/src/tools/list-contexts.d.ts +7 -0
- package/dist/src/tools/list-contexts.d.ts.map +1 -0
- package/dist/src/tools/list-contexts.js +32 -0
- package/dist/src/tools/recall.d.ts +13 -0
- package/dist/src/tools/recall.d.ts.map +1 -0
- package/dist/src/tools/recall.js +64 -0
- package/dist/src/tools/reflect.d.ts +8 -0
- package/dist/src/tools/reflect.d.ts.map +1 -0
- package/dist/src/tools/reflect.js +38 -0
- package/dist/src/tools/restore-context.d.ts +8 -0
- package/dist/src/tools/restore-context.d.ts.map +1 -0
- package/dist/src/tools/restore-context.js +34 -0
- package/dist/src/tools/retrieve-artifact.d.ts +6 -0
- package/dist/src/tools/retrieve-artifact.d.ts.map +1 -0
- package/dist/src/tools/retrieve-artifact.js +24 -0
- package/dist/src/tools/save-artifact.d.ts +8 -0
- package/dist/src/tools/save-artifact.d.ts.map +1 -0
- package/dist/src/tools/save-artifact.js +27 -0
- package/dist/src/tools/save-context.d.ts +7 -0
- package/dist/src/tools/save-context.d.ts.map +1 -0
- package/dist/src/tools/save-context.js +27 -0
- package/dist/src/tools/search.d.ts +9 -0
- package/dist/src/tools/search.d.ts.map +1 -0
- package/dist/src/tools/search.js +41 -0
- package/dist/src/tools/store.d.ts +11 -0
- package/dist/src/tools/store.d.ts.map +1 -0
- package/dist/src/tools/store.js +36 -0
- package/dist/src/tools/update-memory.d.ts +11 -0
- package/dist/src/tools/update-memory.d.ts.map +1 -0
- package/dist/src/tools/update-memory.js +34 -0
- package/dist/src/types/typebox.d.ts +19 -0
- package/dist/src/types/typebox.d.ts.map +1 -0
- package/dist/src/types/typebox.js +91 -0
- package/dist/src/types.d.ts +73 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +7 -0
- package/dist/src/validation.d.ts +21 -0
- package/dist/src/validation.d.ts.map +1 -0
- package/dist/src/validation.js +43 -0
- 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"}
|
package/dist/src/cli.js
ADDED
|
@@ -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
|
+
}
|