@forloop-cc/forloop-opencode-plugin-planner 2.2.26 → 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.26",
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: 'app',
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
- : '## Connection request created — waiting for the app owner',
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: 'app',
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',
@@ -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: 'app' as const,
248
+ subjectType,
241
249
  scopes: requested,
242
250
  };
243
251
 
@@ -261,7 +269,7 @@ 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', 'app');
272
+ linkParams.set('subject', subjectType);
265
273
  const fallbackConnectUrl = spaceUrl ? `${spaceUrl}?${linkParams.toString()}` : null;
266
274
 
267
275
  // Persist the pending request and nudge the live client to open the
@@ -277,11 +285,18 @@ export function createCoreServiceIntegrationRequestTool(client: ForLoopAPIClient
277
285
  sprintId: callerSprintId,
278
286
  targetAgentId: args.agentId,
279
287
  scopes: requested,
280
- subjectType: 'app',
288
+ subjectType,
281
289
  requestedBy: 'planner',
282
290
  reason: args.reason,
283
291
  });
284
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
+ }
285
300
  requestId = typeof request.id === 'string' ? request.id : null;
286
301
  dropped = Array.isArray(created?.droppedScopes) ? created.droppedScopes : [];
287
302
  connectUrl = created?.link ?? request.link ?? connectUrl;
@@ -300,25 +315,29 @@ export function createCoreServiceIntegrationRequestTool(client: ForLoopAPIClient
300
315
  }
301
316
  }
302
317
 
318
+ const userSubject = subjectType === 'user';
319
+ const approver = userSubject ? 'user' : 'app owner';
303
320
  const manualSteps = connectUrl
304
321
  ? [
305
322
  `1. Open ${connectUrl}`,
306
- ` This opens **${target.name}**'s connection panel in this app, with the scopes below and \`This app\` preselected.`,
323
+ ` This opens **${target.name}**'s connection panel in this app, with the scopes below and ${userSubject ? '`You`' : '`This app`'} preselected.`,
307
324
  '2. Review the service, scopes and price shown above.',
308
325
  '3. Click **Approve**.',
309
326
  ]
310
327
  : [
311
328
  `1. Open the app's space${spaceUrl ? `: ${spaceUrl}` : ''}.`,
312
329
  '2. Open **Aivy Actions → App Connections (Beta)** and choose the **Connect** tab.',
313
- `3. Find **${target.name}**, tick the scopes below, choose **This app**, then click **Approve**.`,
330
+ `3. Find **${target.name}**, tick the scopes below, choose ${userSubject ? '**You**' : '**This app**'}, then click **Approve**.`,
314
331
  ];
332
+ const pendingLabel = userSubject ? 'pending user consent' : 'pending owner approval';
315
333
 
316
334
  return [
317
335
  delivered
318
- ? '## App Connections opened — waiting for the app owner to approve'
319
- : '## Approval needed — the app owner must approve this request',
336
+ ? `## App Connections opened — waiting for the ${approver} to approve`
337
+ : `## Approval needed — the ${approver} must approve this request`,
320
338
  `- Service: **${target.name}** (\`${target.agentId}\`)`,
321
339
  `- serviceCode: ${target.serviceCode ?? 'unknown'}`,
340
+ `- subject: ${userSubject ? 'user-subject (user consent)' : 'app-subject (app owner)'}`,
322
341
  `- reason: ${args.reason}`,
323
342
  `- scopes: ${requested.join(', ')}`,
324
343
  `- cost: ${formatPrice(target)}`,
@@ -333,15 +352,17 @@ export function createCoreServiceIntegrationRequestTool(client: ForLoopAPIClient
333
352
  ...(delivered ? [] : ['', ...manualSteps]),
334
353
  '',
335
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.',
336
- 'After the user approves, call `a2a_check_connections` to read the real connection id and scopes.',
355
+ `After the ${approver} approves, call \`a2a_check_connections\` to read the real connection id and scopes.`,
337
356
  '',
338
- '### Connection payload (owner handoff)',
357
+ `### Connection payload (${approver} handoff)`,
339
358
  '```json',
340
359
  JSON.stringify(connectionPayload, null, 2),
341
360
  '```',
342
361
  '',
343
362
  'The planner can never approve. Never send credentials or create the connection from the planner.',
344
- 'If the user cannot or does not complete the approval now, write `Connection: pending owner approval` in the integration brief and continue — do not block the plan and do not ask again.',
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.`,
345
366
  ].join('\n');
346
367
  } catch (error: any) {
347
368
  return `❌ Error creating integration request: ${error.message}`;