@forloop-cc/forloop-opencode-plugin-planner 2.2.25 → 2.2.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@forloop-cc/forloop-opencode-plugin-planner",
|
|
4
|
-
"version": "2.2.
|
|
4
|
+
"version": "2.2.27",
|
|
5
5
|
"description": "ForLoop integration plugin for opencode — Sprint, story, and agent tools for the ForLoop platform",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
|
@@ -7,8 +7,15 @@ type RegistryEntry = {
|
|
|
7
7
|
agentId: string;
|
|
8
8
|
name: string;
|
|
9
9
|
serviceCode?: string | null;
|
|
10
|
+
authMode?: 'none' | 'app' | 'user';
|
|
10
11
|
};
|
|
11
12
|
|
|
13
|
+
// The target manifest is the source of truth for consent: a user-auth service
|
|
14
|
+
// only accepts a user-subject connection, so the planner must never create an
|
|
15
|
+
// app-subject request against it as a workaround.
|
|
16
|
+
const subjectTypeFor = (target: RegistryEntry): 'user' | 'app' =>
|
|
17
|
+
target.authMode === 'user' ? 'user' : 'app';
|
|
18
|
+
|
|
12
19
|
type RequestWire = {
|
|
13
20
|
id: string;
|
|
14
21
|
targetAgentId: string;
|
|
@@ -75,7 +82,7 @@ const requestLines = (request: RequestWire): string[] => {
|
|
|
75
82
|
export function createA2ARequestConnectionTool(client: ForLoopAPIClient) {
|
|
76
83
|
return tool({
|
|
77
84
|
description:
|
|
78
|
-
'Create (or idempotently reuse) a connection request for the current sprint: the app asks a service/peer for an exact scope set. The request is persisted and visible in App Connections, but the planner can never approve it — the signed-in owner must click Approve. Returns the request id and a deep link. Use before writing an integration brief that depends on an external service.',
|
|
85
|
+
'Create (or idempotently reuse) a connection request for the current sprint: the app asks a service/peer for an exact scope set. The request is persisted and visible in App Connections, but the planner can never approve it — the signed-in owner (or, for user-subject grants, the end user themselves) must click Approve. Returns the request id and a deep link. Use before writing an integration brief that depends on an external service. subjectType defaults to the target manifest authMode: "user" for user-owned data (the user consents), otherwise "app" for app-owned/standing integrations. Never force app for a user-auth target.',
|
|
79
86
|
args: {
|
|
80
87
|
agentId: tool.schema.string().describe('Target service/peer agentId (public id)'),
|
|
81
88
|
scopes: tool.schema
|
|
@@ -85,6 +92,10 @@ export function createA2ARequestConnectionTool(client: ForLoopAPIClient) {
|
|
|
85
92
|
.string()
|
|
86
93
|
.optional()
|
|
87
94
|
.describe('Why the app needs this connection (shown to the approving user)'),
|
|
95
|
+
subjectType: tool.schema
|
|
96
|
+
.enum(['user', 'app'])
|
|
97
|
+
.optional()
|
|
98
|
+
.describe('Connection subject: "user" for user-owned data (delegated user consent), "app" for an app-owned/standing integration. Defaults to the target authMode; an app subject is refused for a user-auth target.'),
|
|
88
99
|
},
|
|
89
100
|
async execute(args, context) {
|
|
90
101
|
const tokenResult = await validateToken();
|
|
@@ -107,23 +118,42 @@ export function createA2ARequestConnectionTool(client: ForLoopAPIClient) {
|
|
|
107
118
|
return `❌ No scopes requested for \`${args.agentId}\`. Nothing was created.`;
|
|
108
119
|
}
|
|
109
120
|
|
|
121
|
+
const targetSubject = subjectTypeFor(target)
|
|
122
|
+
if (targetSubject === 'user' && args.subjectType === 'app') {
|
|
123
|
+
return [
|
|
124
|
+
`❌ Target \`${args.agentId}\` requires user-auth (authMode: "user"); refusing to create an app-subject request.`,
|
|
125
|
+
'The end user must consent for their own data. Retry without subjectType (it defaults to "user" for this target) or pass subjectType: "user".',
|
|
126
|
+
].join('\n');
|
|
127
|
+
}
|
|
128
|
+
const subjectType = args.subjectType ?? targetSubject
|
|
110
129
|
const result = await client.createConnectionRequest({
|
|
111
130
|
sprintId,
|
|
112
131
|
targetAgentId: args.agentId,
|
|
113
132
|
scopes: requested,
|
|
114
|
-
subjectType
|
|
133
|
+
subjectType,
|
|
115
134
|
requestedBy: 'planner',
|
|
116
135
|
...(args.reason ? { reason: args.reason } : {}),
|
|
117
136
|
});
|
|
118
137
|
const request = result.request as RequestWire;
|
|
119
138
|
const dropped = Array.isArray(result.droppedScopes) ? result.droppedScopes : [];
|
|
120
139
|
|
|
140
|
+
if (subjectType === 'user' && request.subjectType !== 'user') {
|
|
141
|
+
return [
|
|
142
|
+
'❌ The platform did not create a user-subject connection request; nothing usable was created.',
|
|
143
|
+
`- returned subject: ${request.subjectType ?? 'unknown'}`,
|
|
144
|
+
'Do not use an app-subject request for user-owned data. Record `Connection: pending user consent (UI-only)` and stop until the platform delegated-user exchange and user-subject requests are available.',
|
|
145
|
+
].join('\n');
|
|
146
|
+
}
|
|
147
|
+
|
|
121
148
|
return [
|
|
122
149
|
request.status === 'approved'
|
|
123
150
|
? '## Connection already approved'
|
|
124
|
-
:
|
|
151
|
+
: request.subjectType === 'user'
|
|
152
|
+
? "## Connection request created — waiting for the user's consent"
|
|
153
|
+
: '## Connection request created — waiting for the app owner',
|
|
125
154
|
'',
|
|
126
155
|
`- target: **${target.name}** (\`${target.agentId}\`)`,
|
|
156
|
+
`- request subject: ${request.subjectType === 'user' ? 'user-subject (user consent)' : 'app-subject (app owner)'}`,
|
|
127
157
|
...requestLines({ ...request, link: result.link ?? request.link }),
|
|
128
158
|
...(dropped.length
|
|
129
159
|
? ['', `⚠️ Dropped (not declared by the target manifest): ${dropped.join(', ')}`]
|
|
@@ -132,6 +162,7 @@ export function createA2ARequestConnectionTool(client: ForLoopAPIClient) {
|
|
|
132
162
|
'The planner cannot approve this. Ask the user to click **Approve** in App Connections, or call `a2a_open_connection_request` to bring the panel forward.',
|
|
133
163
|
'Do **not** write the connection id into a brief until `a2a_check_connections` reports an active connection — a pending request is not a grant.',
|
|
134
164
|
'If the user replies that they approved, call `a2a_check_connections` to read the real connection id and scopes.',
|
|
165
|
+
'For user-owned data the user consents in the platform panel; the app owner cannot consent for them.',
|
|
135
166
|
].join('\n');
|
|
136
167
|
} catch (error: any) {
|
|
137
168
|
return `❌ Error creating the connection request: ${error.message}`;
|
|
@@ -258,10 +289,16 @@ export function createA2AOpenConnectionRequestTool(client: ForLoopAPIClient) {
|
|
|
258
289
|
if (!args.agentId) {
|
|
259
290
|
return '❌ Provide a request id, or a target agentId (and scopes) so the request can be created.';
|
|
260
291
|
}
|
|
292
|
+
const target = await findEntry(client, args.agentId);
|
|
293
|
+
if (!target) {
|
|
294
|
+
return `❌ Target \`${args.agentId}\` was not found in the registry. Do not invent a connection request.`;
|
|
295
|
+
}
|
|
296
|
+
const desiredSubject = subjectTypeFor(target);
|
|
261
297
|
const state = (await client.getConnectionState(sprintId)) as ConnectionState;
|
|
262
298
|
const wanted = new Set((args.scopes ?? []).map(scope => scope.trim()).filter(Boolean));
|
|
263
299
|
const match = (state.requests ?? []).find(request => {
|
|
264
300
|
if (request.targetAgentId !== args.agentId) return false;
|
|
301
|
+
if (request.subjectType !== desiredSubject) return false;
|
|
265
302
|
if (request.status !== 'pending' && request.status !== 'approved') return false;
|
|
266
303
|
if (!wanted.size) return true;
|
|
267
304
|
return (
|
|
@@ -280,9 +317,16 @@ export function createA2AOpenConnectionRequestTool(client: ForLoopAPIClient) {
|
|
|
280
317
|
sprintId,
|
|
281
318
|
targetAgentId: args.agentId,
|
|
282
319
|
scopes: [...wanted],
|
|
283
|
-
subjectType:
|
|
320
|
+
subjectType: desiredSubject,
|
|
284
321
|
requestedBy: 'planner',
|
|
285
322
|
});
|
|
323
|
+
if (desiredSubject === 'user' && created.request?.subjectType !== 'user') {
|
|
324
|
+
return [
|
|
325
|
+
'❌ The platform did not create a user-subject connection request; nothing usable was created.',
|
|
326
|
+
`- returned subject: ${created.request?.subjectType ?? 'unknown'}`,
|
|
327
|
+
'Do not use an app-subject request for user-owned data. Record `Connection: pending user consent (UI-only)` and stop.',
|
|
328
|
+
].join('\n');
|
|
329
|
+
}
|
|
286
330
|
requestId = created.request?.id ?? '';
|
|
287
331
|
link = created.link ?? created.request?.link ?? null;
|
|
288
332
|
}
|
|
@@ -25,6 +25,12 @@ type RegistryEntry = {
|
|
|
25
25
|
directIntegrable?: boolean;
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
+
// The target manifest is the source of truth for consent: a user-auth service
|
|
29
|
+
// only accepts a user-subject connection, so the planner must never create an
|
|
30
|
+
// app-subject request against it as a workaround.
|
|
31
|
+
const subjectTypeFor = (target: RegistryEntry): 'user' | 'app' =>
|
|
32
|
+
target.authMode === 'user' ? 'user' : 'app';
|
|
33
|
+
|
|
28
34
|
const formatPrice = (entry: RegistryEntry): string => {
|
|
29
35
|
if (entry.billingPrice == null) return 'price TBD';
|
|
30
36
|
const currency = entry.billingCurrency || 'FLC';
|
|
@@ -168,7 +174,8 @@ export function createCoreServiceGetTool(client: ForLoopAPIClient) {
|
|
|
168
174
|
`# ${args.agentId} — contract ${contract.version}`,
|
|
169
175
|
`- status: ${status}`,
|
|
170
176
|
`${sunset}${deprecated}`,
|
|
171
|
-
`- checksum: ${contract.checksum ?? 'unknown'}`,
|
|
177
|
+
`- checksum: ${contract.checksum ?? 'unknown'} (stored raw manifest)`,
|
|
178
|
+
`- generatedChecksum: ${contract.generatedChecksum ?? 'unknown'} (normalized contract; matches contract-artifact.json)`,
|
|
172
179
|
'',
|
|
173
180
|
'## OpenAPI',
|
|
174
181
|
'```json',
|
|
@@ -190,7 +197,7 @@ export function createCoreServiceGetTool(client: ForLoopAPIClient) {
|
|
|
190
197
|
export function createCoreServiceIntegrationRequestTool(client: ForLoopAPIClient) {
|
|
191
198
|
return tool({
|
|
192
199
|
description:
|
|
193
|
-
'
|
|
200
|
+
'Start the user-approval step for a core-service integration: persist (or idempotently reuse) the pending connection request, then ask the signed-in user’s live client to open App Connections focused on it so they only need to click Approve. Falls back to the approval link when the client is offline. This never approves, never touches credentials, and never constructs API URLs by hand.',
|
|
194
201
|
args: {
|
|
195
202
|
agentId: tool.schema.string().describe('Target core-service agentId'),
|
|
196
203
|
scopes: tool.schema
|
|
@@ -232,12 +239,13 @@ export function createCoreServiceIntegrationRequestTool(client: ForLoopAPIClient
|
|
|
232
239
|
].join('\n');
|
|
233
240
|
}
|
|
234
241
|
|
|
242
|
+
const subjectType = subjectTypeFor(target)
|
|
235
243
|
const resolution = await resolveSprintId(undefined, context?.directory);
|
|
236
244
|
const callerSprintId = resolution.sprintId ?? null;
|
|
237
245
|
const connectionPayload = {
|
|
238
246
|
callerSprintId,
|
|
239
247
|
targetAgentId: args.agentId,
|
|
240
|
-
subjectType
|
|
248
|
+
subjectType,
|
|
241
249
|
scopes: requested,
|
|
242
250
|
};
|
|
243
251
|
|
|
@@ -261,44 +269,100 @@ export function createCoreServiceIntegrationRequestTool(client: ForLoopAPIClient
|
|
|
261
269
|
const linkParams = new URLSearchParams();
|
|
262
270
|
linkParams.set('connect', args.agentId);
|
|
263
271
|
linkParams.set('scopes', requested.join(','));
|
|
264
|
-
linkParams.set('subject',
|
|
265
|
-
const
|
|
272
|
+
linkParams.set('subject', subjectType);
|
|
273
|
+
const fallbackConnectUrl = spaceUrl ? `${spaceUrl}?${linkParams.toString()}` : null;
|
|
274
|
+
|
|
275
|
+
// Persist the pending request and nudge the live client to open the
|
|
276
|
+
// panel. Both steps are best-effort: an offline client (or a backend
|
|
277
|
+
// without the request routes) must still return an actionable link.
|
|
278
|
+
let requestId: string | null = null;
|
|
279
|
+
let delivered = false;
|
|
280
|
+
let connectUrl: string | null = fallbackConnectUrl;
|
|
281
|
+
let dropped: string[] = [];
|
|
282
|
+
if (callerSprintId) {
|
|
283
|
+
try {
|
|
284
|
+
const created = await client.createConnectionRequest({
|
|
285
|
+
sprintId: callerSprintId,
|
|
286
|
+
targetAgentId: args.agentId,
|
|
287
|
+
scopes: requested,
|
|
288
|
+
subjectType,
|
|
289
|
+
requestedBy: 'planner',
|
|
290
|
+
reason: args.reason,
|
|
291
|
+
});
|
|
292
|
+
const request = created?.request ?? {};
|
|
293
|
+
if (subjectType === 'user' && request.subjectType !== 'user') {
|
|
294
|
+
return [
|
|
295
|
+
'❌ The platform did not create a user-subject connection request; nothing usable was created.',
|
|
296
|
+
`- returned subject: ${request.subjectType ?? 'unknown'}`,
|
|
297
|
+
'Do not use an app-subject request for user-owned data. Record `Connection: pending user consent (UI-only)` and stop until the delegated-user exchange is available.',
|
|
298
|
+
].join('\n');
|
|
299
|
+
}
|
|
300
|
+
requestId = typeof request.id === 'string' ? request.id : null;
|
|
301
|
+
dropped = Array.isArray(created?.droppedScopes) ? created.droppedScopes : [];
|
|
302
|
+
connectUrl = created?.link ?? request.link ?? connectUrl;
|
|
303
|
+
|
|
304
|
+
if (requestId) {
|
|
305
|
+
try {
|
|
306
|
+
const opened = await client.openConnectionRequest(requestId);
|
|
307
|
+
delivered = opened?.delivered === true;
|
|
308
|
+
connectUrl = opened?.link ?? opened?.fallback ?? connectUrl;
|
|
309
|
+
} catch {
|
|
310
|
+
delivered = false;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
} catch {
|
|
314
|
+
// Older backend without request persistence: fall back to the link.
|
|
315
|
+
}
|
|
316
|
+
}
|
|
266
317
|
|
|
267
|
-
const
|
|
318
|
+
const userSubject = subjectType === 'user';
|
|
319
|
+
const approver = userSubject ? 'user' : 'app owner';
|
|
320
|
+
const manualSteps = connectUrl
|
|
268
321
|
? [
|
|
269
322
|
`1. Open ${connectUrl}`,
|
|
270
|
-
` This opens **${target.name}**'s connection panel in this app, with the scopes below and
|
|
323
|
+
` This opens **${target.name}**'s connection panel in this app, with the scopes below and ${userSubject ? '`You`' : '`This app`'} preselected.`,
|
|
271
324
|
'2. Review the service, scopes and price shown above.',
|
|
272
|
-
'3. Click **
|
|
273
|
-
'4. Copy the **Connection ID** shown under the new connected app into the integration brief.',
|
|
325
|
+
'3. Click **Approve**.',
|
|
274
326
|
]
|
|
275
327
|
: [
|
|
276
328
|
`1. Open the app's space${spaceUrl ? `: ${spaceUrl}` : ''}.`,
|
|
277
329
|
'2. Open **Aivy Actions → App Connections (Beta)** and choose the **Connect** tab.',
|
|
278
|
-
`3. Find **${target.name}**, tick the scopes below, choose **This app
|
|
279
|
-
'4. Copy the **Connection ID** shown under the new connected app into the integration brief.',
|
|
330
|
+
`3. Find **${target.name}**, tick the scopes below, choose ${userSubject ? '**You**' : '**This app**'}, then click **Approve**.`,
|
|
280
331
|
];
|
|
332
|
+
const pendingLabel = userSubject ? 'pending user consent' : 'pending owner approval';
|
|
281
333
|
|
|
282
334
|
return [
|
|
283
|
-
|
|
335
|
+
delivered
|
|
336
|
+
? `## App Connections opened — waiting for the ${approver} to approve`
|
|
337
|
+
: `## Approval needed — the ${approver} must approve this request`,
|
|
284
338
|
`- Service: **${target.name}** (\`${target.agentId}\`)`,
|
|
285
339
|
`- serviceCode: ${target.serviceCode ?? 'unknown'}`,
|
|
340
|
+
`- subject: ${userSubject ? 'user-subject (user consent)' : 'app-subject (app owner)'}`,
|
|
286
341
|
`- reason: ${args.reason}`,
|
|
287
342
|
`- scopes: ${requested.join(', ')}`,
|
|
288
343
|
`- cost: ${formatPrice(target)}`,
|
|
344
|
+
...(requestId ? [`- requestId: \`${requestId}\``] : []),
|
|
345
|
+
...(dropped.length
|
|
346
|
+
? [`⚠️ Dropped (not declared by the target manifest): ${dropped.join(', ')}`]
|
|
347
|
+
: []),
|
|
289
348
|
'',
|
|
290
|
-
|
|
349
|
+
delivered
|
|
350
|
+
? 'The App Connections panel is now open on the user’s screen, focused on this request. They only need to click **Approve** — do not paste a link or repeat manual steps.'
|
|
351
|
+
: 'The user’s client was not reachable. Send them this link — it opens the same focused panel so they only need to click **Approve**:',
|
|
352
|
+
...(delivered ? [] : ['', ...manualSteps]),
|
|
291
353
|
'',
|
|
292
|
-
'
|
|
293
|
-
|
|
354
|
+
'A pending request is not a grant. Do not write a connection id into the integration brief until `a2a_check_connections` reports an active connection.',
|
|
355
|
+
`After the ${approver} approves, call \`a2a_check_connections\` to read the real connection id and scopes.`,
|
|
294
356
|
'',
|
|
295
|
-
|
|
357
|
+
`### Connection payload (${approver} handoff)`,
|
|
296
358
|
'```json',
|
|
297
359
|
JSON.stringify(connectionPayload, null, 2),
|
|
298
360
|
'```',
|
|
299
361
|
'',
|
|
300
|
-
'
|
|
301
|
-
|
|
362
|
+
'The planner can never approve. Never send credentials or create the connection from the planner.',
|
|
363
|
+
userSubject
|
|
364
|
+
? `If the user cannot or does not consent now, write \`Connection: ${pendingLabel}\` in the integration brief and continue — do not block the plan and do not ask again. The app owner cannot consent for the user.`
|
|
365
|
+
: `If the user cannot or does not complete the approval now, write \`Connection: ${pendingLabel}\` in the integration brief and continue — do not block the plan and do not ask again.`,
|
|
302
366
|
].join('\n');
|
|
303
367
|
} catch (error: any) {
|
|
304
368
|
return `❌ Error creating integration request: ${error.message}`;
|