runline 0.34.0 → 0.35.0
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/dist/core/engine.js +12 -8
- package/dist/credentials/registry.js +5 -0
- package/dist/credentials/types.d.ts +10 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/plugin/schema.d.ts +16 -4
- package/dist/plugin/schema.js +28 -5
- package/dist/plugin/types.d.ts +8 -0
- package/dist/plugins/_shared/base64.js +30 -0
- package/dist/plugins/_shared/credentialAdapter.js +23 -3
- package/dist/plugins/_shared/credentials.js +29 -2
- package/dist/plugins/_shared/googleAuth.js +62 -1
- package/dist/plugins/_shared/googleCredentials.js +40 -7
- package/dist/plugins/_shared/localFile.js +40 -0
- package/dist/plugins/_shared/mediaFile.js +12 -12
- package/dist/plugins/_shared/microsoftAuth.js +19 -4
- package/dist/plugins/_shared/microsoftCredentials.js +12 -7
- package/dist/plugins/_shared/shiftCloud.js +14 -18
- package/dist/plugins/_shared/shiftUpload.js +14 -10
- package/dist/plugins/agentMail/src/credentials.js +35 -0
- package/dist/plugins/agentMail/src/drafts.js +137 -0
- package/dist/plugins/agentMail/src/inboxes.js +95 -0
- package/dist/plugins/agentMail/src/index.js +35 -0
- package/dist/plugins/agentMail/src/messages.js +154 -0
- package/dist/plugins/agentMail/src/schemas.js +156 -0
- package/dist/plugins/agentMail/src/shared.js +203 -0
- package/dist/plugins/agentMail/src/threads.js +75 -0
- package/dist/plugins/elevenlabs/src/shared.js +29 -43
- package/dist/plugins/fal/src/index.js +1 -1
- package/dist/plugins/gmail/src/index.js +22 -85
- package/dist/plugins/googleAppsScript/src/index.js +2 -1
- package/dist/plugins/googleCalendar/src/index.js +6 -39
- package/dist/plugins/googleContacts/src/index.js +2 -32
- package/dist/plugins/googleDocs/src/index.js +2 -33
- package/dist/plugins/googleDrive/src/index.js +19 -45
- package/dist/plugins/googleImage/src/index.js +1 -1
- package/dist/plugins/googleSheets/src/index.js +2 -34
- package/dist/plugins/googleSlides/src/index.js +2 -32
- package/dist/plugins/googleTasks/src/index.js +2 -32
- package/dist/plugins/highlevel/src/index.js +8 -2
- package/dist/plugins/microsoftCalendar/src/index.js +2 -7
- package/dist/plugins/microsoftFiles/src/index.js +7 -8
- package/dist/plugins/microsoftMail/src/index.js +2 -7
- package/dist/plugins/openai/src/index.js +1 -1
- package/dist/plugins/recraft/src/index.js +1 -1
- package/dist/plugins/replicate/src/index.js +1 -1
- package/dist/plugins/shiftObjects/src/objects.js +1 -1
- package/dist/plugins/shiftOcr/src/index.js +8 -3
- package/dist/plugins/shiftTranscription/src/transcription.js +1 -1
- package/dist/plugins/together/src/index.js +1 -1
- package/dist/plugins/xai/src/index.js +1 -1
- package/package.json +2 -1
- package/dist/tests/helpers/engine-harness.d.ts +0 -1
- package/dist/tests/helpers/engine-harness.js +0 -236
package/dist/core/engine.js
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { Worker } from "node:worker_threads";
|
|
4
4
|
import { MemoryConnectionProvider } from "../connections/memory.js";
|
|
5
|
-
import { formatValidationError, helpInputs, isTypedInputSchema, normalizeActionInput, validateTypedInput, } from "../plugin/schema.js";
|
|
5
|
+
import { dropUndefined, formatValidationError, helpInputs, isTypedInputSchema, normalizeActionInput, validateTypedInput, } from "../plugin/schema.js";
|
|
6
6
|
import { notifyObserver } from "../utils/observer.js";
|
|
7
7
|
/**
|
|
8
8
|
* How many bodies one worker may run before it is retired.
|
|
@@ -609,6 +609,14 @@ const { FerroSearch } = require(${JSON.stringify(ferrosearchPath)});
|
|
|
609
609
|
// The host's own validator, for actions.check on typed schemas.
|
|
610
610
|
const { Check: __schemaCheck, Errors: __schemaErrors } = require(${JSON.stringify(typeboxValuePath)});
|
|
611
611
|
|
|
612
|
+
// The host's own normalizeActionInput, by its source, so a check sees the
|
|
613
|
+
// arguments exactly as validation and execute do. The closure keeps the
|
|
614
|
+
// helper names it calls out of the scope plugin globals are declared in.
|
|
615
|
+
const __normalizeActionInput = (() => {
|
|
616
|
+
${dropUndefined.toString()}
|
|
617
|
+
return ${normalizeActionInput.toString()};
|
|
618
|
+
})();
|
|
619
|
+
|
|
612
620
|
const __help = ${JSON.stringify(helpData)};
|
|
613
621
|
|
|
614
622
|
const __makeProxy = (path = []) => new Proxy(() => undefined, {
|
|
@@ -783,14 +791,10 @@ const __actionsApi = {
|
|
|
783
791
|
return { ok: false, error: 'Unknown action: ' + path, suggestions: near };
|
|
784
792
|
}
|
|
785
793
|
const signature = __formatSignature(hit.plugin, hit.entry);
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
// a call with an empty argument set; anything else — null, a
|
|
789
|
-
// scalar — is validated as given, exactly as execute would.
|
|
790
|
-
return __checkTyped(hit.entry, args === undefined ? {} : args, signature);
|
|
791
|
-
}
|
|
794
|
+
const input = __normalizeActionInput(args);
|
|
795
|
+
if (hit.entry.schema) return __checkTyped(hit.entry, input, signature);
|
|
792
796
|
const inputs = hit.entry.inputs || {};
|
|
793
|
-
const provided =
|
|
797
|
+
const provided = input && typeof input === 'object' ? input : {};
|
|
794
798
|
const missing = [];
|
|
795
799
|
const unknown = [];
|
|
796
800
|
const typeErrors = [];
|
|
@@ -165,6 +165,11 @@ function validateMethod(method) {
|
|
|
165
165
|
if (auth.rejectionStatus !== undefined &&
|
|
166
166
|
![401, 403].includes(auth.rejectionStatus))
|
|
167
167
|
throw new AuthError("invalid_definition");
|
|
168
|
+
if (auth.grant !== undefined) {
|
|
169
|
+
identifier(auth.grant);
|
|
170
|
+
if (auth.renewal !== "refresh")
|
|
171
|
+
throw new AuthError("invalid_definition");
|
|
172
|
+
}
|
|
168
173
|
if (auth.scopes &&
|
|
169
174
|
(!Array.isArray(auth.scopes) ||
|
|
170
175
|
auth.scopes.some((scope) => typeof scope !== "string" || !scope)))
|
|
@@ -152,6 +152,16 @@ export type CredentialAuthentication = {
|
|
|
152
152
|
/** Explicit renewal strategy, never inferred from available secrets. */
|
|
153
153
|
renewal: "refresh" | "clientCredentials" | "jwtBearer" | "password";
|
|
154
154
|
scopes?: string[];
|
|
155
|
+
/**
|
|
156
|
+
* The grant a refresh-renewed token belongs to, shared by every
|
|
157
|
+
* plugin declaring the same id. Its access tokens depend on the
|
|
158
|
+
* grant's client and token endpoint, not on the plugin that asked:
|
|
159
|
+
* the provider answers a refresh with every scope the grant holds
|
|
160
|
+
* (Google requests none; Microsoft returns every scope consented for
|
|
161
|
+
* the resource). One cached token then serves each of its plugins.
|
|
162
|
+
* Only with `renewal: "refresh"`.
|
|
163
|
+
*/
|
|
164
|
+
grant?: string;
|
|
155
165
|
/** Defaults to 401. A 403 must be explicitly documented by the provider. */
|
|
156
166
|
rejectionStatus?: 401 | 403;
|
|
157
167
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ export type { InstalledPlugin, PluginSource } from "./plugin/installer.js";
|
|
|
23
23
|
export { installPlugin, listInstalled, parsePluginSource, removePlugin, } from "./plugin/installer.js";
|
|
24
24
|
export { discoverPlugins, loadAllPlugins, loadPluginFromPath, loadPluginsFromConfig, } from "./plugin/loader.js";
|
|
25
25
|
export { PluginRegistry, registry } from "./plugin/registry.js";
|
|
26
|
+
export { connectionFields } from "./plugin/schema.js";
|
|
26
27
|
export type { ActionAccess, ActionContext, ActionDef, ConnectionConfig, ConnectionSchema, ConnectionSchemaField, InputField, InputSchema, LegacyConnectionSchema, OAuthConfig, PluginDef, } from "./plugin/types.js";
|
|
27
28
|
export type { RunlineExecuteOptions, RunlineOptions } from "./sdk.js";
|
|
28
29
|
export { Runline } from "./sdk.js";
|
package/dist/index.js
CHANGED
|
@@ -14,5 +14,6 @@ export { createPluginAPI, isPluginFunction, resolvePluginExport, } from "./plugi
|
|
|
14
14
|
export { installPlugin, listInstalled, parsePluginSource, removePlugin, } from "./plugin/installer.js";
|
|
15
15
|
export { discoverPlugins, loadAllPlugins, loadPluginFromPath, loadPluginsFromConfig, } from "./plugin/loader.js";
|
|
16
16
|
export { PluginRegistry, registry } from "./plugin/registry.js";
|
|
17
|
+
export { connectionFields } from "./plugin/schema.js";
|
|
17
18
|
export { Runline } from "./sdk.js";
|
|
18
19
|
export { commandExists, syncExec } from "./utils/cli.js";
|
package/dist/plugin/schema.d.ts
CHANGED
|
@@ -16,13 +16,25 @@ export declare function connectionFields(schema: ConnectionSchema | undefined):
|
|
|
16
16
|
export declare function helpInputs(schema: InputSchema | undefined): Record<string, HelpInput>;
|
|
17
17
|
export declare function validateLegacyInput(schema: LegacyInputSchema, input: unknown): ValidationResult;
|
|
18
18
|
/**
|
|
19
|
-
* A
|
|
19
|
+
* A value as JSON would carry it: plain-object keys whose value is
|
|
20
|
+
* `undefined` are dropped, at every depth. JSON has no `undefined`, and a
|
|
21
|
+
* schema's `required` counts a present key as given, so `{ name: undefined }`
|
|
22
|
+
* would otherwise satisfy a rule that needs a name. Arrays keep their
|
|
23
|
+
* length; other objects (a Buffer, a Date) pass through untouched.
|
|
20
24
|
*
|
|
25
|
+
* Self-contained, because the worker's `actions.check` runs this same
|
|
26
|
+
* source: preflight and execution cannot disagree about what was passed.
|
|
27
|
+
*/
|
|
28
|
+
export declare function dropUndefined(value: unknown): unknown;
|
|
29
|
+
/**
|
|
30
|
+
* An action's arguments as validation and `execute` see them.
|
|
31
|
+
*
|
|
32
|
+
* A call written with no arguments is a call with an empty argument set:
|
|
21
33
|
* `linear.user.me()` supplies no argument, so object validation needs an
|
|
22
34
|
* empty object to accept no-input actions and name missing required fields.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
35
|
+
* An explicit `null` or `42` is an argument, and a wrong one. Keys holding
|
|
36
|
+
* `undefined` are absent, by `dropUndefined`. The worker's `actions.check`
|
|
37
|
+
* runs this source too, so it may call nothing but `dropUndefined`.
|
|
26
38
|
*/
|
|
27
39
|
export declare function normalizeActionInput(input: unknown): unknown;
|
|
28
40
|
export declare function validateTypedInput(schema: TypedInputSchema, rawInput: unknown): ValidationResult;
|
package/dist/plugin/schema.js
CHANGED
|
@@ -187,16 +187,39 @@ export function validateLegacyInput(schema, input) {
|
|
|
187
187
|
return validationResult({ missing, unknown, typeErrors });
|
|
188
188
|
}
|
|
189
189
|
/**
|
|
190
|
-
* A
|
|
190
|
+
* A value as JSON would carry it: plain-object keys whose value is
|
|
191
|
+
* `undefined` are dropped, at every depth. JSON has no `undefined`, and a
|
|
192
|
+
* schema's `required` counts a present key as given, so `{ name: undefined }`
|
|
193
|
+
* would otherwise satisfy a rule that needs a name. Arrays keep their
|
|
194
|
+
* length; other objects (a Buffer, a Date) pass through untouched.
|
|
191
195
|
*
|
|
196
|
+
* Self-contained, because the worker's `actions.check` runs this same
|
|
197
|
+
* source: preflight and execution cannot disagree about what was passed.
|
|
198
|
+
*/
|
|
199
|
+
export function dropUndefined(value) {
|
|
200
|
+
if (Array.isArray(value))
|
|
201
|
+
return value.map((entry) => dropUndefined(entry));
|
|
202
|
+
if (value === null || typeof value !== "object")
|
|
203
|
+
return value;
|
|
204
|
+
const prototype = Object.getPrototypeOf(value);
|
|
205
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
206
|
+
return value;
|
|
207
|
+
return Object.fromEntries(Object.entries(value)
|
|
208
|
+
.filter(([, entry]) => entry !== undefined)
|
|
209
|
+
.map(([key, entry]) => [key, dropUndefined(entry)]));
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* An action's arguments as validation and `execute` see them.
|
|
213
|
+
*
|
|
214
|
+
* A call written with no arguments is a call with an empty argument set:
|
|
192
215
|
* `linear.user.me()` supplies no argument, so object validation needs an
|
|
193
216
|
* empty object to accept no-input actions and name missing required fields.
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
217
|
+
* An explicit `null` or `42` is an argument, and a wrong one. Keys holding
|
|
218
|
+
* `undefined` are absent, by `dropUndefined`. The worker's `actions.check`
|
|
219
|
+
* runs this source too, so it may call nothing but `dropUndefined`.
|
|
197
220
|
*/
|
|
198
221
|
export function normalizeActionInput(input) {
|
|
199
|
-
return input === undefined ? {} : input;
|
|
222
|
+
return input === undefined ? {} : dropUndefined(input);
|
|
200
223
|
}
|
|
201
224
|
export function validateTypedInput(schema, rawInput) {
|
|
202
225
|
const input = normalizeActionInput(rawInput);
|
package/dist/plugin/types.d.ts
CHANGED
|
@@ -108,6 +108,14 @@ export type OAuthConfig = OAuthSetupOptions & ({
|
|
|
108
108
|
interface OAuthSetupOptions {
|
|
109
109
|
/** Scopes to request on the consent screen. */
|
|
110
110
|
scopes: string[];
|
|
111
|
+
/**
|
|
112
|
+
* The grant this plugin's consent belongs to, shared by every plugin
|
|
113
|
+
* declaring the same id (`google`, `microsoft`): one provider, one OAuth
|
|
114
|
+
* client, one person's consent. A host may hold one connection for the
|
|
115
|
+
* whole grant and ask for the union of its plugins' scopes. The plugin's
|
|
116
|
+
* credential declares the same id, so its tokens are the grant's.
|
|
117
|
+
*/
|
|
118
|
+
grant?: string;
|
|
111
119
|
/**
|
|
112
120
|
* Provider-fixed loopback callback (`http://localhost:<port>/<path>`),
|
|
113
121
|
* replacing Runline's default `http://127.0.0.1:47823/callback`. Use only
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as t from "typebox";
|
|
2
|
+
/**
|
|
3
|
+
* Caller-supplied base64 in the standard padded alphabet: whitespace
|
|
4
|
+
* dropped, base64url's `-` and `_` mapped to `+` and `/`, padding
|
|
5
|
+
* restored. So a gmail.message.getAttachment result (base64url, unpadded)
|
|
6
|
+
* and a googleDrive download (standard) are equally accepted. Undefined
|
|
7
|
+
* when the value is not base64: a character outside both alphabets,
|
|
8
|
+
* padding anywhere but the end, or a length no encoding produces.
|
|
9
|
+
*
|
|
10
|
+
* Flat character-class patterns only: values run to many megabytes.
|
|
11
|
+
*/
|
|
12
|
+
export function standardBase64(value) {
|
|
13
|
+
const compact = value.replace(/\s+/g, "").replace(/={1,2}$/, "");
|
|
14
|
+
if (!/^[A-Za-z0-9+/_-]*$/.test(compact) || compact.length % 4 === 1)
|
|
15
|
+
return undefined;
|
|
16
|
+
const standard = compact.replace(/-/g, "+").replace(/_/g, "/");
|
|
17
|
+
return standard.padEnd(Math.ceil(standard.length / 4) * 4, "=");
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* A googleDrive.file.download result, accepted wherever attachment bytes
|
|
21
|
+
* are: exactly its fields, so the result passes through unedited while
|
|
22
|
+
* anything else is still refused. Its `name` and `mimeType` stand in for
|
|
23
|
+
* an attachment's own when those are left out.
|
|
24
|
+
*/
|
|
25
|
+
export const DownloadedFile = t.Object({
|
|
26
|
+
contentBase64: t.String(),
|
|
27
|
+
name: t.Optional(t.String()),
|
|
28
|
+
mimeType: t.Optional(t.String()),
|
|
29
|
+
size: t.Optional(t.Integer({ minimum: 0 })),
|
|
30
|
+
}, { additionalProperties: false });
|
|
@@ -16,10 +16,30 @@ export function credentialBroker(ctx, declaration) {
|
|
|
16
16
|
socketUrl: (input) => transport.socketUrl(binding, input),
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
|
-
/**
|
|
20
|
-
*
|
|
19
|
+
/**
|
|
20
|
+
* Everything a stored token was issued under. A config whose selection
|
|
21
|
+
* hashes differently can no longer use tokens issued for this one. A
|
|
22
|
+
* token of a declared grant was issued under the grant — its method,
|
|
23
|
+
* client and token endpoint (Microsoft's names the tenant) — not the
|
|
24
|
+
* plugin that asked, so every plugin of the grant hashes alike and shares
|
|
25
|
+
* it; any other token is the whole selection's, scopes and targets included.
|
|
26
|
+
*/
|
|
21
27
|
function authorityOf(selection) {
|
|
22
|
-
|
|
28
|
+
const { type, method, application } = selection;
|
|
29
|
+
const auth = Object.hasOwn(type.methods, method)
|
|
30
|
+
? type.methods[method].authentication
|
|
31
|
+
: undefined;
|
|
32
|
+
const issuedUnder = auth?.kind === "oauth2" && auth.grant !== undefined
|
|
33
|
+
? {
|
|
34
|
+
grant: auth.grant,
|
|
35
|
+
method,
|
|
36
|
+
application,
|
|
37
|
+
endpoint: auth.definition.refresh,
|
|
38
|
+
}
|
|
39
|
+
: selection;
|
|
40
|
+
return createHash("sha256")
|
|
41
|
+
.update(JSON.stringify(issuedUnder))
|
|
42
|
+
.digest("hex");
|
|
23
43
|
}
|
|
24
44
|
/**
|
|
25
45
|
* A static secret's structured shape, from the flat fields and fixed values
|
|
@@ -226,6 +226,9 @@ export function credentialRequest(ctx, declaration, call) {
|
|
|
226
226
|
...(call.destination !== undefined
|
|
227
227
|
? { destination: call.destination }
|
|
228
228
|
: {}),
|
|
229
|
+
...(call.idempotencyKey !== undefined
|
|
230
|
+
? { idempotencyKey: call.idempotencyKey }
|
|
231
|
+
: {}),
|
|
229
232
|
});
|
|
230
233
|
}
|
|
231
234
|
/**
|
|
@@ -265,8 +268,8 @@ export function errorIdentifier(value) {
|
|
|
265
268
|
/**
|
|
266
269
|
* How a failed request reads: its HTTP status when the status is the
|
|
267
270
|
* failure, and the identifiers a provider returns for correcting the call
|
|
268
|
-
* — its error `code` and the offending `param`.
|
|
269
|
-
*
|
|
271
|
+
* — its error `code` and the offending `param`. No free text from a failed
|
|
272
|
+
* response reaches it: that is where a provider may echo the credential.
|
|
270
273
|
*/
|
|
271
274
|
export function failureMessage(plugin, status, detail = {}) {
|
|
272
275
|
const identifiers = [detail.code, detail.param && `param: ${detail.param}`]
|
|
@@ -312,6 +315,30 @@ export function graphqlFailed(plugin, errors) {
|
|
|
312
315
|
export function requestFailed(plugin, status) {
|
|
313
316
|
return new Error(failureMessage(plugin, status));
|
|
314
317
|
}
|
|
318
|
+
/**
|
|
319
|
+
* A failed response by its status and the identifiers its error envelope
|
|
320
|
+
* names. `read` picks the code and param out of the parsed JSON body, in
|
|
321
|
+
* the provider's own shape; only what `errorIdentifier` accepts is kept,
|
|
322
|
+
* so a provider's messages never reach the error. A body that is not JSON
|
|
323
|
+
* reports the status alone. The status rides on the error for callers
|
|
324
|
+
* that branch on it.
|
|
325
|
+
*/
|
|
326
|
+
export async function responseFailed(plugin, response, read) {
|
|
327
|
+
let envelope = {};
|
|
328
|
+
try {
|
|
329
|
+
envelope = read(JSON.parse(await response.text()));
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
envelope = {};
|
|
333
|
+
}
|
|
334
|
+
const params = (Array.isArray(envelope.param) ? envelope.param : [envelope.param])
|
|
335
|
+
.map(identifierOf)
|
|
336
|
+
.filter((param) => param !== undefined);
|
|
337
|
+
return Object.assign(new Error(failureMessage(plugin, response.status, {
|
|
338
|
+
code: identifierOf(envelope.code),
|
|
339
|
+
param: params.length ? params.join(", ") : undefined,
|
|
340
|
+
})), { status: response.status });
|
|
341
|
+
}
|
|
315
342
|
/** A request that must succeed, for callers that read the Response itself (headers, text, bytes). */
|
|
316
343
|
export async function credentialOk(ctx, declaration, plugin, call) {
|
|
317
344
|
const response = await credentialRequest(ctx, declaration, call);
|
|
@@ -1,8 +1,69 @@
|
|
|
1
1
|
import { AuthError, downloadResource, } from "runline";
|
|
2
2
|
import { credentialBroker } from "./credentialAdapter.js";
|
|
3
3
|
import { jsonAnswer, requestFailed } from "./credentials.js";
|
|
4
|
-
import { googleCredential, googleResources } from "./googleCredentials.js";
|
|
4
|
+
import { GOOGLE_GRANT, GOOGLE_TOKEN_URL, googleCredential, googleResources, googleServices, } from "./googleCredentials.js";
|
|
5
5
|
export { googleCredential } from "./googleCredentials.js";
|
|
6
|
+
/**
|
|
7
|
+
* A Google plugin's OAuth declaration: Google's endpoints, the offline
|
|
8
|
+
* consent that returns a refresh token (without `access_type=offline` and
|
|
9
|
+
* `prompt=consent` a repeat login omits it), the grant every Google plugin
|
|
10
|
+
* shares, and setup steps that enable the APIs its targets call and name
|
|
11
|
+
* its client variables (`env`: `GMAIL` → `GMAIL_CLIENT_ID`).
|
|
12
|
+
*/
|
|
13
|
+
export function googleOAuth(plugin, scopes, env) {
|
|
14
|
+
return {
|
|
15
|
+
grant: GOOGLE_GRANT,
|
|
16
|
+
authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
17
|
+
tokenUrl: GOOGLE_TOKEN_URL,
|
|
18
|
+
scopes,
|
|
19
|
+
authParams: { access_type: "offline", prompt: "consent" },
|
|
20
|
+
setupHelp: [
|
|
21
|
+
"You need a Google Cloud OAuth client. Takes ~5 minutes, one time;",
|
|
22
|
+
"the same client serves every Google plugin.",
|
|
23
|
+
"Do the steps in order — skipping step 4 causes a 403 when you log in.",
|
|
24
|
+
"",
|
|
25
|
+
"1. Create a Google Cloud project (or pick an existing one):",
|
|
26
|
+
" https://console.cloud.google.com/projectcreate",
|
|
27
|
+
" Make sure the project is selected in the top navigation dropdown.",
|
|
28
|
+
"",
|
|
29
|
+
"2. Enable the APIs this plugin calls (click 'Enable' on each):",
|
|
30
|
+
...googleServices(plugin).map((service) => ` https://console.cloud.google.com/apis/library/${service}`),
|
|
31
|
+
"",
|
|
32
|
+
"3. Configure the OAuth consent screen (first time only):",
|
|
33
|
+
" https://console.cloud.google.com/apis/credentials/consent",
|
|
34
|
+
" Click 'Get started' and fill in:",
|
|
35
|
+
" • App name: runline (or whatever)",
|
|
36
|
+
" • User support email: your email",
|
|
37
|
+
" • Audience: External",
|
|
38
|
+
" • Developer contact: your email",
|
|
39
|
+
" You can skip the Scopes screen — runline declares scopes at auth time.",
|
|
40
|
+
"",
|
|
41
|
+
"4. Add yourself as a test user (required — External apps in Testing mode",
|
|
42
|
+
" only allow authentication from addresses on this list):",
|
|
43
|
+
" https://console.cloud.google.com/auth/audience",
|
|
44
|
+
" Under 'Test users', click '+ Add users' and enter the Google address",
|
|
45
|
+
" you'll use to log in. Consent and refresh tokens expire every 7 days",
|
|
46
|
+
" while the app is in Testing mode.",
|
|
47
|
+
"",
|
|
48
|
+
"5. Create the OAuth client:",
|
|
49
|
+
" https://console.cloud.google.com/apis/credentials",
|
|
50
|
+
" • + Create credentials → OAuth client ID",
|
|
51
|
+
" • Application type: Web application",
|
|
52
|
+
" • Name: runline (or whatever)",
|
|
53
|
+
" • Authorized redirect URIs → + Add URI:",
|
|
54
|
+
" {{redirectUri}}",
|
|
55
|
+
" • Click Create, then copy the Client ID and Client Secret from the",
|
|
56
|
+
" dialog — the secret is only shown once.",
|
|
57
|
+
"",
|
|
58
|
+
"6. Paste them below (or re-run with --client-id / --client-secret,",
|
|
59
|
+
` or export ${env}_CLIENT_ID and ${env}_CLIENT_SECRET).`,
|
|
60
|
+
"",
|
|
61
|
+
"During login you'll see a 'Google hasn't verified this app' warning.",
|
|
62
|
+
"Click 'Advanced → Go to runline (unsafe)' to continue — this is",
|
|
63
|
+
"expected for apps in Testing mode.",
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
6
67
|
/** Translate builtin absolute URLs at one boundary; validate the raw path before URL normalization. */
|
|
7
68
|
export async function googleResponse(ctx, plugin, scopes, url, init = {}, query) {
|
|
8
69
|
const resources = googleResources(plugin);
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { AuthError, } from "runline";
|
|
2
2
|
import { grantSchema } from "./credentials.js";
|
|
3
|
+
/** The grant every Google plugin's consent belongs to. */
|
|
4
|
+
export const GOOGLE_GRANT = "google";
|
|
5
|
+
/**
|
|
6
|
+
* Google's fixed token endpoint: a JSON credential's token_uri never
|
|
7
|
+
* selects egress.
|
|
8
|
+
*/
|
|
9
|
+
export const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
3
10
|
/** Only legacy storage infers a method; host bindings always select it explicitly. */
|
|
4
11
|
export function googleMethod(cfg) {
|
|
5
12
|
if (cfg.authMethod !== undefined) {
|
|
@@ -51,6 +58,19 @@ const API_BASES = {
|
|
|
51
58
|
calendar: "https://www.googleapis.com/calendar/v3/",
|
|
52
59
|
gmail: "https://gmail.googleapis.com/gmail/v1/users/me/",
|
|
53
60
|
};
|
|
61
|
+
/** The Cloud Console service each API base belongs to, to enable it by. */
|
|
62
|
+
const API_SERVICES = {
|
|
63
|
+
drive: "drive.googleapis.com",
|
|
64
|
+
upload: "drive.googleapis.com",
|
|
65
|
+
docs: "docs.googleapis.com",
|
|
66
|
+
sheets: "sheets.googleapis.com",
|
|
67
|
+
slides: "slides.googleapis.com",
|
|
68
|
+
script: "script.googleapis.com",
|
|
69
|
+
tasks: "tasks.googleapis.com",
|
|
70
|
+
people: "people.googleapis.com",
|
|
71
|
+
calendar: "calendar-json.googleapis.com",
|
|
72
|
+
gmail: "gmail.googleapis.com",
|
|
73
|
+
};
|
|
54
74
|
const PLUGIN_TARGETS = {
|
|
55
75
|
googleDrive: ["drive", "upload", "docs"],
|
|
56
76
|
googleDocs: ["docs", "drive"],
|
|
@@ -62,11 +82,19 @@ const PLUGIN_TARGETS = {
|
|
|
62
82
|
googleCalendar: ["calendar"],
|
|
63
83
|
gmail: ["gmail"],
|
|
64
84
|
};
|
|
65
|
-
/**
|
|
66
|
-
|
|
85
|
+
/** The API bases a builtin Google plugin calls; any other name is refused. */
|
|
86
|
+
function targetNames(plugin) {
|
|
67
87
|
if (!Object.hasOwn(PLUGIN_TARGETS, plugin))
|
|
68
88
|
throw new AuthError("request_not_allowed");
|
|
69
|
-
|
|
89
|
+
return PLUGIN_TARGETS[plugin];
|
|
90
|
+
}
|
|
91
|
+
/** The Cloud Console services a plugin calls, each to be enabled once. */
|
|
92
|
+
export function googleServices(plugin) {
|
|
93
|
+
return [...new Set(targetNames(plugin).map((name) => API_SERVICES[name]))];
|
|
94
|
+
}
|
|
95
|
+
/** Builtin routing policy, never derived from action input or a returned URL. */
|
|
96
|
+
export function googleResources(plugin) {
|
|
97
|
+
const names = targetNames(plugin);
|
|
70
98
|
const targets = Object.fromEntries(names.map((name) => [
|
|
71
99
|
name,
|
|
72
100
|
{
|
|
@@ -134,9 +162,7 @@ export function googleCredential(plugin, scopes) {
|
|
|
134
162
|
};
|
|
135
163
|
};
|
|
136
164
|
}
|
|
137
|
-
/** Fixed vendor endpoints; a JSON credential's token_uri can never select egress. */
|
|
138
165
|
export function googleCredentialType(scopes, plugin) {
|
|
139
|
-
const url = "https://oauth2.googleapis.com/token";
|
|
140
166
|
const { targets, probe } = googleResources(plugin);
|
|
141
167
|
return {
|
|
142
168
|
id: "google.oauth",
|
|
@@ -147,10 +173,14 @@ export function googleCredentialType(scopes, plugin) {
|
|
|
147
173
|
kind: "oauth2",
|
|
148
174
|
field: "grant",
|
|
149
175
|
renewal: "refresh",
|
|
176
|
+
grant: GOOGLE_GRANT,
|
|
150
177
|
definition: {
|
|
151
178
|
id: "google.oauth",
|
|
152
179
|
provider: "google",
|
|
153
|
-
refresh: {
|
|
180
|
+
refresh: {
|
|
181
|
+
url: GOOGLE_TOKEN_URL,
|
|
182
|
+
clientAuthentication: "client_secret_post",
|
|
183
|
+
},
|
|
154
184
|
},
|
|
155
185
|
},
|
|
156
186
|
targets,
|
|
@@ -168,7 +198,10 @@ export function googleCredentialType(scopes, plugin) {
|
|
|
168
198
|
definition: {
|
|
169
199
|
id: "google.serviceAccount",
|
|
170
200
|
provider: "google",
|
|
171
|
-
jwtBearer: {
|
|
201
|
+
jwtBearer: {
|
|
202
|
+
url: GOOGLE_TOKEN_URL,
|
|
203
|
+
clientAuthentication: "none",
|
|
204
|
+
},
|
|
172
205
|
},
|
|
173
206
|
},
|
|
174
207
|
targets,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
3
|
+
/**
|
|
4
|
+
* A file on the runline host that an action input names — an attachment,
|
|
5
|
+
* an upload, an image to edit — read as one bounded buffer. It must be a
|
|
6
|
+
* regular file: the nonblocking open lets fstat refuse a FIFO or device
|
|
7
|
+
* without waiting on a writer. The size is checked before allocation and
|
|
8
|
+
* the bytes are read through the same handle, so a file swapped or grown
|
|
9
|
+
* between the check and the read cannot slip past the bound.
|
|
10
|
+
*/
|
|
11
|
+
export async function readLocalFile(path, options) {
|
|
12
|
+
const { subject } = options;
|
|
13
|
+
let file;
|
|
14
|
+
try {
|
|
15
|
+
file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
const code = error.code;
|
|
19
|
+
throw new Error(`${subject} is unreadable${typeof code === "string" ? ` (${code})` : ""}`);
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const stat = await file.stat();
|
|
23
|
+
if (!stat.isFile())
|
|
24
|
+
throw new Error(`${subject} must be a regular file`);
|
|
25
|
+
if (stat.size > options.maxBytes)
|
|
26
|
+
throw new Error(options.oversize);
|
|
27
|
+
const bytes = Buffer.alloc(stat.size);
|
|
28
|
+
let offset = 0;
|
|
29
|
+
while (offset < bytes.length) {
|
|
30
|
+
const { bytesRead } = await file.read(bytes, offset, bytes.length - offset, offset);
|
|
31
|
+
if (!bytesRead)
|
|
32
|
+
throw new Error(`${subject} changed while reading`);
|
|
33
|
+
offset += bytesRead;
|
|
34
|
+
}
|
|
35
|
+
return bytes;
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
await file.close();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
* available (same pattern as googleDrive/googleSlides).
|
|
10
10
|
*/
|
|
11
11
|
import { randomUUID } from "node:crypto";
|
|
12
|
-
import {
|
|
12
|
+
import { writeFileSync } from "node:fs";
|
|
13
13
|
import { tmpdir } from "node:os";
|
|
14
14
|
import { basename, extname, join } from "node:path";
|
|
15
|
+
import { readLocalFile } from "./localFile.js";
|
|
15
16
|
const MIME_EXT = {
|
|
16
17
|
"image/png": "png",
|
|
17
18
|
"image/jpeg": "jpg",
|
|
@@ -66,23 +67,22 @@ const EXT_MIME = {
|
|
|
66
67
|
webp: "image/webp",
|
|
67
68
|
gif: "image/gif",
|
|
68
69
|
};
|
|
70
|
+
/** Larger than any provider accepts inline; a data URI past it only fails remotely. */
|
|
71
|
+
const MAX_IMAGE_BYTES = 50 * 1024 * 1024;
|
|
69
72
|
/**
|
|
70
73
|
* Read a local image for an `image.edit` call: bytes + base64 + mime
|
|
71
|
-
* (from the extension) + a `data:` URI for JSON APIs.
|
|
72
|
-
* plugin's name
|
|
73
|
-
* right tool.
|
|
74
|
+
* (from the extension) + a `data:` URI for JSON APIs. Errors carry the
|
|
75
|
+
* plugin's name so they point at the right tool.
|
|
74
76
|
*/
|
|
75
|
-
export function readImageInput(imagePath, pluginName) {
|
|
77
|
+
export async function readImageInput(imagePath, pluginName) {
|
|
76
78
|
if (typeof imagePath !== "string" || imagePath.trim().length === 0) {
|
|
77
79
|
throw new Error(`${pluginName}: imagePath is required`);
|
|
78
80
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
throw new Error(`${pluginName}: cannot read image at ${imagePath} — ${err instanceof Error ? err.message : String(err)}`);
|
|
85
|
-
}
|
|
81
|
+
const bytes = await readLocalFile(imagePath, {
|
|
82
|
+
subject: `${pluginName}: imagePath`,
|
|
83
|
+
maxBytes: MAX_IMAGE_BYTES,
|
|
84
|
+
oversize: `${pluginName}: imagePath exceeds ${MAX_IMAGE_BYTES / (1024 * 1024)} MiB`,
|
|
85
|
+
});
|
|
86
86
|
const ext = extname(imagePath).slice(1).toLowerCase();
|
|
87
87
|
const mimeType = EXT_MIME[ext] ?? "image/png";
|
|
88
88
|
const base64 = bytes.toString("base64");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { AuthError, downloadResource, } from "runline";
|
|
2
2
|
import { credentialBroker } from "./credentialAdapter.js";
|
|
3
3
|
import { jsonAnswer, requestFailed } from "./credentials.js";
|
|
4
|
-
import { microsoftCredential, microsoftUserBase, } from "./microsoftCredentials.js";
|
|
4
|
+
import { MICROSOFT_GRANT, microsoftCredential, microsoftEndpoint, microsoftUserBase, } from "./microsoftCredentials.js";
|
|
5
5
|
export { microsoftCredential, microsoftDriveBase, } from "./microsoftCredentials.js";
|
|
6
6
|
export function userBase(ctx) {
|
|
7
7
|
return microsoftUserBase(ctx.connection.config);
|
|
@@ -59,8 +59,23 @@ export async function microsoftDownload(value) {
|
|
|
59
59
|
fetch: globalThis.fetch,
|
|
60
60
|
});
|
|
61
61
|
}
|
|
62
|
-
/**
|
|
63
|
-
|
|
62
|
+
/**
|
|
63
|
+
* A Microsoft Graph plugin's OAuth declaration: the multi-tenant endpoints,
|
|
64
|
+
* `offline_access` for a refresh token, the grant every Graph plugin
|
|
65
|
+
* shares, and setup steps naming the Graph permissions it needs
|
|
66
|
+
* (`permissions`, e.g. "Calendars.Read").
|
|
67
|
+
*/
|
|
68
|
+
export function microsoftOAuth(scopes, permissions) {
|
|
69
|
+
return {
|
|
70
|
+
grant: MICROSOFT_GRANT,
|
|
71
|
+
authUrl: microsoftEndpoint("common", "authorize"),
|
|
72
|
+
tokenUrl: microsoftEndpoint("common", "token"),
|
|
73
|
+
scopes: [...scopes, "offline_access"],
|
|
74
|
+
setupHelp: setupHelp(permissions),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/** The OAuth wizard's steps for a Microsoft plugin. */
|
|
78
|
+
function setupHelp(permissions) {
|
|
64
79
|
return [
|
|
65
80
|
`You need a Microsoft Entra (Azure AD) app registration. One-time, ~5 minutes.`,
|
|
66
81
|
"",
|
|
@@ -69,7 +84,7 @@ export function microsoftSetupHelp(apiName) {
|
|
|
69
84
|
"2. Add a Web redirect URI (Authentication → Add platform → Web):",
|
|
70
85
|
" {{redirectUri}}",
|
|
71
86
|
"3. Certificates & secrets → New client secret → copy the VALUE (not the Secret ID).",
|
|
72
|
-
`4. API permissions → Add → Microsoft Graph → Delegated → add the ${
|
|
87
|
+
`4. API permissions → Add → Microsoft Graph → Delegated → add the ${permissions} scopes,`,
|
|
73
88
|
" then 'Grant admin consent'.",
|
|
74
89
|
"5. Paste the Application (client) ID and the client secret VALUE below.",
|
|
75
90
|
];
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { AuthError, } from "runline";
|
|
2
2
|
import { grantSchema, pathSegment } from "./credentials.js";
|
|
3
|
+
/** The grant every Microsoft Graph plugin's delegated consent belongs to. */
|
|
4
|
+
export const MICROSOFT_GRANT = "microsoft";
|
|
5
|
+
/** A tenant's OAuth endpoint on the Microsoft identity platform. */
|
|
6
|
+
export function microsoftEndpoint(tenant, leg) {
|
|
7
|
+
if (!/^[a-zA-Z0-9.-]+$/.test(tenant))
|
|
8
|
+
throw new AuthError("invalid_credentials");
|
|
9
|
+
return `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/${leg}`;
|
|
10
|
+
}
|
|
3
11
|
/** Compatibility inference is confined to legacy configs without an explicit method. */
|
|
4
12
|
export function microsoftMethod(cfg) {
|
|
5
13
|
if (cfg.authMethod !== undefined) {
|
|
@@ -50,11 +58,8 @@ export function microsoftCredential(plugin, scopes) {
|
|
|
50
58
|
}
|
|
51
59
|
/** Scope-appropriate probes never require User.Read merely to check mail/calendar/files. */
|
|
52
60
|
export function microsoftCredentialType(cfg, plugin, scopes) {
|
|
53
|
-
const tenant = cfg.tenantId || "common";
|
|
54
|
-
if (!/^[a-zA-Z0-9.-]+$/.test(tenant))
|
|
55
|
-
throw new AuthError("invalid_credentials");
|
|
56
61
|
const endpoint = {
|
|
57
|
-
url:
|
|
62
|
+
url: microsoftEndpoint(cfg.tenantId || "common", "token"),
|
|
58
63
|
clientAuthentication: "client_secret_post",
|
|
59
64
|
};
|
|
60
65
|
const methods = Object.fromEntries(["delegated", "appOnly"].map((method) => {
|
|
@@ -85,9 +90,9 @@ export function microsoftCredentialType(cfg, plugin, scopes) {
|
|
|
85
90
|
authentication: {
|
|
86
91
|
kind: "oauth2",
|
|
87
92
|
field: "grant",
|
|
88
|
-
|
|
89
|
-
? "refresh"
|
|
90
|
-
: "clientCredentials",
|
|
93
|
+
...(method === "delegated"
|
|
94
|
+
? { renewal: "refresh", grant: MICROSOFT_GRANT }
|
|
95
|
+
: { renewal: "clientCredentials" }),
|
|
91
96
|
definition: {
|
|
92
97
|
id: "microsoft.oauth",
|
|
93
98
|
provider: "microsoft",
|