@joe-broadhead/opencode-gws 0.0.1 → 0.0.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
4
 
5
+ ## [0.0.2] - 2026-11-03
6
+
7
+ ### Fixed
8
+
9
+ - The default sign-in no longer requests the Keep scopes, which Google grants only through domain-wide delegation, so Google no longer refuses the whole consent with `invalid_scope`. No access level requests `keep` or `keep.readonly`, and the `default` account no longer enables Keep: Read and write now requests 45 scopes and Read-only 37. `gws_catalog` and `gws_schema` mark the 7 Keep methods `restriction: "domain-wide-delegation"` (app-only methods keep `appOnly` and gain `restriction: "app-only"`), and the scope check refuses them with that reason. For an interactive account that enables `keep` without adding a Keep scope in `accounts.<alias>.scopes`, status carries `delegationRequired`, the sign-in form says Keep needs delegation, and `/gws-doctor` reports Keep `SKIPPED` with the reason. (#8)
10
+ - A non-admin's Admin SDK refusal of a valid sign-in, such as the Reports API's 401 `Access denied. You are not authorized to read activity records.`, is now `ADMIN_PRIVILEGE_REQUIRED`, not `AUTH_REQUIRED`. Its next step is to sign in with a Workspace admin account that has the privilege (Reports for `admin-reports`) or to remove the service from `enabledServices`. Reads, plans, commits and `/gws-doctor` classify it the same way; it never marks the account as needing sign-in in status or `/connect`, and the doctor reports it as its own status, counted as needing attention. Token failures (`invalid_grant`, an expired or revoked token, `UNAUTHENTICATED`) stay `AUTH_REQUIRED`. (#9)
11
+ - The tools RPC reports `SESSION_UNKNOWN` only when OpenCode says the session does not exist (`Session.NotFoundError`, or a 404). Any other failure to look the session up now propagates unchanged instead of being reported as an unknown session.
12
+
5
13
  ## [0.0.1] - 2026-11-03
6
14
 
7
15
  ### Added
package/README.md CHANGED
@@ -18,7 +18,7 @@ opencode plugin add @joe-broadhead/opencode-gws
18
18
 
19
19
  npm also installs the matching `@joe-broadhead/opencode-gws-bin-<target>` package, which holds the unmodified `gws` binary at the version the plugin pins ([details](https://github.com/joe-broadhead/opencode-gws/blob/HEAD/docs/backend.md)). Nothing downloads at runtime and there is no install script.
20
20
 
21
- With no options, the plugin creates one account, `default`, with every Workspace service it covers at Read and write, so one consent grants every scope it can use. Every change still needs a plan and your approval. To narrow it, set `accounts` ([configuration](https://github.com/joe-broadhead/opencode-gws/blob/HEAD/docs/configuration.md#accounts)).
21
+ With no options, the plugin creates one account, `default`, with every Workspace service a Google sign-in can use at Read and write, so one consent grants every scope it can use. Keep is left out: Google grants its scopes only through domain-wide delegation, which a Workspace admin sets up ([Keep](https://github.com/joe-broadhead/opencode-gws/blob/HEAD/docs/auth.md#keep-needs-domain-wide-delegation)). Every change still needs a plan and your approval. To narrow it, set `accounts` ([configuration](https://github.com/joe-broadhead/opencode-gws/blob/HEAD/docs/configuration.md#accounts)).
22
22
 
23
23
  ## Sign in
24
24
 
@@ -28,7 +28,7 @@ Run `/connect` and choose **Google Workspace**. The TUI shows the sign-in link:
28
28
  opencode auth login gws-default
29
29
  ```
30
30
 
31
- No Google OAuth client ships with the plugin. The first time, the form asks for the client ID and secret of your own Desktop app OAuth client, unless your organisation set one (see [Organisation rollout](#organisation-rollout)). To make one, follow [Create your Desktop client](https://github.com/joe-broadhead/opencode-gws/blob/HEAD/docs/auth.md#create-your-desktop-client). The form lists the exact scopes before Google's consent screen. Read and write is the default for the `default` account and needs an acknowledgment; Read-only is the other choice. The default consent lists about 46 scopes, and an External client in Testing shows unverified-app warnings and expires refresh tokens after 7 days: see [What the default consent asks for](https://github.com/joe-broadhead/opencode-gws/blob/HEAD/docs/auth.md#what-the-default-consent-asks-for). With several accounts, each `/connect` entry is named **Google Workspace (`<alias>`)**.
31
+ No Google OAuth client ships with the plugin. The first time, the form asks for the client ID and secret of your own Desktop app OAuth client, unless your organisation set one (see [Organisation rollout](#organisation-rollout)). To make one, follow [Create your Desktop client](https://github.com/joe-broadhead/opencode-gws/blob/HEAD/docs/auth.md#create-your-desktop-client). The form lists the exact scopes before Google's consent screen. Read and write is the default for the `default` account and needs an acknowledgment; Read-only is the other choice. The default consent lists 45 scopes (37 for Read-only), and an External client in Testing shows unverified-app warnings and expires refresh tokens after 7 days: see [What the default consent asks for](https://github.com/joe-broadhead/opencode-gws/blob/HEAD/docs/auth.md#what-the-default-consent-asks-for). With several accounts, each `/connect` entry is named **Google Workspace (`<alias>`)**.
32
32
 
33
33
  Details and the scope table: [docs/auth.md](https://github.com/joe-broadhead/opencode-gws/blob/HEAD/docs/auth.md).
34
34
 
@@ -101,7 +101,8 @@ Members then only run `/connect`, choose Google Workspace, and pick an access le
101
101
  | Code | What to do |
102
102
  | --- | --- |
103
103
  | `AUTH_REQUIRED` | Run `/connect` and choose Google Workspace, or run `opencode auth login gws-<alias>`. |
104
- | `AUTH_SCOPE_MISSING` | Sign in again, approve every scope Google lists, and choose Read and write. A method marked `appOnly` needs a Chat app's or Classroom add-on's own credentials and cannot run with a Google sign-in. Nothing ran. |
104
+ | `ADMIN_PRIVILEGE_REQUIRED` | The sign-in is valid, but the account is not a Workspace admin with the privilege the API needs, such as Reports for `admin-reports`. Signing in again as the same account does not help: sign in with an admin account that has it, or remove the service from `enabledServices`. |
105
+ | `AUTH_SCOPE_MISSING` | Sign in again, approve every scope Google lists, and choose Read and write. A method marked `appOnly` (`restriction: "app-only"`) needs a Chat app's or Classroom add-on's own credentials and cannot run with a Google sign-in. A Keep method (`restriction: "domain-wide-delegation"`) needs domain-wide delegation that a Workspace admin sets up. Nothing ran. |
105
106
  | `AUTH_CLIENT_REQUIRED` | Run `/connect` and choose Google Workspace, then enter your Desktop OAuth client ID and secret, or ask your administrator. |
106
107
  | `API_DISABLED` | Enable the API at the URL in the message, wait about a minute, and retry. `/gws-doctor` checks every service. |
107
108
  | `HOST_UNTESTED` | Use OpenCode 2.2.3 or a later 2.2.x, or set `"allowUntestedHost": true`. |
package/dist/index.js CHANGED
@@ -6618,6 +6618,10 @@ var ErrorCode = {
6618
6618
  meaning: "The sign-in has not granted a scope this operation needs. The plugin checks before gws runs, so nothing ran.",
6619
6619
  next: "Ask the user to sign in again with the access the message names, or to add the scope to the account's scopes, then retry. For a method only an app's credentials can run, use another method."
6620
6620
  },
6621
+ ADMIN_PRIVILEGE_REQUIRED: {
6622
+ meaning: "The sign-in is valid, but Google refused the call because the account lacks a Workspace admin privilege the API needs, such as Reports for admin-reports. Signing in again as the same account does not help, and the sign-in is not marked as needing renewal.",
6623
+ next: "Ask the user to sign in with a Workspace admin account that has the needed privilege (Reports for admin-reports), or to remove the service from enabledServices."
6624
+ },
6621
6625
  AUTH_CLIENT_REQUIRED: {
6622
6626
  meaning: "No OAuth client is configured for the sign-in, none was entered in the form, and no earlier sign-in has one.",
6623
6627
  next: "Run /connect and choose Google Workspace, then enter your Desktop OAuth client ID and secret, or ask your administrator."
@@ -6785,11 +6789,11 @@ import { readFileSync } from "node:fs";
6785
6789
  import { dirname, join } from "node:path";
6786
6790
  import { fileURLToPath } from "node:url";
6787
6791
 
6788
- // src/domain/app-only.ts
6789
- var APP_ONLY_SCOPES = ["chat.bot", "chat.import", "classroom.addons.student", "classroom.addons.teacher"].map(
6790
- (name) => `https://www.googleapis.com/auth/${name}`
6791
- );
6792
- var USER_UNUSABLE_METHODS = [
6792
+ // src/domain/restricted.ts
6793
+ var PREFIX = "https://www.googleapis.com/auth/";
6794
+ var APP_ONLY_SCOPES = ["chat.bot", "chat.import", "classroom.addons.student", "classroom.addons.teacher"].map((name) => `${PREFIX}${name}`);
6795
+ var DELEGATION_SCOPES = ["keep", "keep.readonly"].map((name) => `${PREFIX}${name}`);
6796
+ var APP_ONLY_METHODS = [
6793
6797
  // chat: a Chat app's own credentials
6794
6798
  "chat.spaces.completeImport",
6795
6799
  "chat.spaces.messages.attachments.get",
@@ -6822,9 +6826,33 @@ var USER_UNUSABLE_METHODS = [
6822
6826
  "classroom.courses.posts.addOnAttachments.studentSubmissions.patch",
6823
6827
  "classroom.courses.posts.getAddOnContext"
6824
6828
  ];
6825
- var UNUSABLE = new Set(USER_UNUSABLE_METHODS);
6826
- function appOnly(operation) {
6827
- return UNUSABLE.has(operation);
6829
+ var DELEGATION_METHODS = [
6830
+ "keep.media.download",
6831
+ "keep.notes.create",
6832
+ "keep.notes.delete",
6833
+ "keep.notes.get",
6834
+ "keep.notes.list",
6835
+ "keep.notes.permissions.batchCreate",
6836
+ "keep.notes.permissions.batchDelete"
6837
+ ];
6838
+ var DELEGATION_SERVICES = ["keep"];
6839
+ var RESTRICTIONS = new Map([
6840
+ ...APP_ONLY_METHODS.map((id) => [id, "app-only"]),
6841
+ ...DELEGATION_METHODS.map((id) => [id, "domain-wide-delegation"])
6842
+ ]);
6843
+ function restriction(operation) {
6844
+ return RESTRICTIONS.get(operation);
6845
+ }
6846
+ function addsDelegationScope(scopes) {
6847
+ return scopes.some((scope) => DELEGATION_SCOPES.includes(scope));
6848
+ }
6849
+ function delegationRequired(services, scopes) {
6850
+ return addsDelegationScope(scopes) ? [] : services.filter((service) => DELEGATION_SERVICES.includes(service));
6851
+ }
6852
+ function restrictionMarks(operation) {
6853
+ const reason = restriction(operation);
6854
+ if (!reason) return {};
6855
+ return reason === "app-only" ? { appOnly: true, restriction: reason } : { restriction: reason };
6828
6856
  }
6829
6857
 
6830
6858
  // src/domain/helpers.ts
@@ -7350,7 +7378,7 @@ function catalog(input = {}, services) {
7350
7378
  ...method.pathTemplate ? { path: method.pathTemplate } : {},
7351
7379
  executable: methodExecutable(method),
7352
7380
  ...tool && methodExecutable(method) ? { commitTool: tool } : {},
7353
- ...appOnly(method.id) ? { appOnly: true } : {}
7381
+ ...restrictionMarks(method.id)
7354
7382
  });
7355
7383
  }
7356
7384
  }
@@ -7513,7 +7541,8 @@ var DIRECT = {
7513
7541
  forms: { read: ["forms.body.readonly", "forms.responses.readonly"], write: ["forms.body", "forms.responses.readonly", "drive.file"] },
7514
7542
  // meetings.space.created reaches only spaces this client created; write keeps reading the others.
7515
7543
  meet: { read: ["meetings.space.readonly"], write: ["meetings.space.created", "meetings.space.readonly"] },
7516
- keep: { read: ["keep.readonly"], write: ["keep"] },
7544
+ // Google grants Keep's scopes only through domain-wide delegation (restricted.ts), so neither level requests them.
7545
+ keep: { read: [], write: [] },
7517
7546
  // Reports are read-only at both levels.
7518
7547
  "admin-reports": {
7519
7548
  read: ["admin.reports.audit.readonly", "admin.reports.usage.readonly"],
@@ -7601,7 +7630,8 @@ var ClientJsonSchema = union([
7601
7630
  var OAuthClientSchema = union([DesktopClientSchema, ClientJsonSchema]);
7602
7631
  var accessLevel = _enum(["read", "write"]);
7603
7632
  function defaultAccounts() {
7604
- return { default: { mode: "interactive", enabledServices: loadManifest().services.map((service) => service.alias), access: "write" } };
7633
+ const services = loadManifest().services.map((service) => service.alias).filter((service) => !DELEGATION_SERVICES.includes(service));
7634
+ return { default: { mode: "interactive", enabledServices: services, access: "write" } };
7605
7635
  }
7606
7636
  var enabledServices = array(string2().min(1)).min(1);
7607
7637
  var accountProfile = preprocess(
@@ -7812,7 +7842,7 @@ function loadConfig(raw) {
7812
7842
  }
7813
7843
 
7814
7844
  // src/domain/version.ts
7815
- var PLUGIN_VERSION = "0.0.1";
7845
+ var PLUGIN_VERSION = "0.0.2";
7816
7846
  var PLUGIN_ID = "google-workspace.gws";
7817
7847
  var HOST_RANGE = ">=2.2.3 <2.3.0";
7818
7848
  var SEMVER = /^v?(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
@@ -7932,6 +7962,7 @@ var account = object2(
7932
7962
  clientSource: { enum: CLIENT_SOURCES },
7933
7963
  grantedScopes: texts,
7934
7964
  missingScopes: texts,
7965
+ delegationRequired: texts,
7935
7966
  nextStep: text
7936
7967
  },
7937
7968
  ["alias", "mode", "services", "connected"]
@@ -7969,6 +8000,11 @@ function connectionLabel(account2) {
7969
8000
  function openCodeLabel(status2) {
7970
8001
  return status2.hostWarning ? `${status2.hostVersion} (host untested: ${status2.hostWarning})` : `${status2.hostVersion} (tested)`;
7971
8002
  }
8003
+ var DELEGATION_LABEL = "Needs domain-wide delegation";
8004
+ function delegationText(account2) {
8005
+ const services = account2.delegationRequired ?? [];
8006
+ return `${services.join(", ")} (a Workspace admin sets it up; then add the scopes to accounts.${account2.alias}.scopes)`;
8007
+ }
7972
8008
  var BLOCK_PLACEMENT = "Put this block first in that agent's own permissions, followed by your top-level rules for gws actions. Never put it in the top-level permissions: that would loosen Explore.";
7973
8009
 
7974
8010
  // src/auth/setup.ts
@@ -8163,6 +8199,199 @@ var PlanStore = class {
8163
8199
  }
8164
8200
  };
8165
8201
 
8202
+ // src/domain/result.ts
8203
+ function isApiError(value) {
8204
+ if (Array.isArray(value)) return value.some(isApiError);
8205
+ if (!value || typeof value !== "object") return false;
8206
+ const record2 = value;
8207
+ return "error" in record2 && record2.error !== null && typeof record2.error === "object";
8208
+ }
8209
+ function parseJson(text3) {
8210
+ const lines = text3.split("\n");
8211
+ const start2 = lines.findIndex((line) => /^[[{]/.test(line.trim()));
8212
+ if (start2 < 0) return void 0;
8213
+ const candidate = lines.slice(start2).join("\n").trim();
8214
+ try {
8215
+ return JSON.parse(candidate);
8216
+ } catch {
8217
+ const parsed = [];
8218
+ for (const line of lines.slice(start2)) {
8219
+ const value = line.trim();
8220
+ if (!/^[[{]/.test(value)) continue;
8221
+ try {
8222
+ parsed.push(JSON.parse(value));
8223
+ } catch {
8224
+ }
8225
+ }
8226
+ return parsed.length > 0 ? parsed : void 0;
8227
+ }
8228
+ }
8229
+ function inlineCap(maxInlineBytes, mode, requested) {
8230
+ const base2 = mode === "full" ? Math.max(maxInlineBytes, 5 * 1024 * 1024) : maxInlineBytes;
8231
+ return requested === void 0 ? base2 : Math.min(base2, requested);
8232
+ }
8233
+ function parseResult(result, options) {
8234
+ const text3 = result.stdout.toString("utf8");
8235
+ const parsed = parseJson(text3);
8236
+ const serialized = parsed === void 0 ? text3 : JSON.stringify(parsed);
8237
+ const bytes2 = Buffer.byteLength(serialized);
8238
+ const cap = inlineCap(options.maxInlineBytes, options.mode, options.requestedInlineBytes);
8239
+ const truncated = bytes2 > cap;
8240
+ const empty = text3.trim().length === 0;
8241
+ return {
8242
+ // A successful DELETE commonly returns 204 with no body. Empty output
8243
+ // with exit 0 is success; non-JSON text is not a JSON result.
8244
+ ok: result.exitCode === 0 && (empty || parsed !== void 0 && !isApiError(parsed)),
8245
+ data: parsed,
8246
+ raw: parsed === void 0 ? text3.slice(0, cap) : void 0,
8247
+ truncated,
8248
+ diagnostics: { exitCode: result.exitCode, durationMs: result.durationMs, bytes: bytes2 }
8249
+ };
8250
+ }
8251
+ function itemKey(page2) {
8252
+ if (!page2 || typeof page2 !== "object" || Array.isArray(page2)) return void 0;
8253
+ const record2 = page2;
8254
+ if (Array.isArray(record2["items"])) return "items";
8255
+ const arrays = Object.keys(record2).filter((key) => Array.isArray(record2[key]));
8256
+ return arrays.length === 1 ? arrays[0] : void 0;
8257
+ }
8258
+ function capPageItems(data, maxItems) {
8259
+ const pages = data === void 0 ? [] : Array.isArray(data) ? data : [data];
8260
+ const keys = pages.map((page2) => itemKey(page2) ?? (isEmptyPage(page2) ? null : void 0));
8261
+ if (keys.some((key) => key === void 0)) return { data: pages, pages: pages.length, capped: false };
8262
+ const kept = [];
8263
+ let items = 0;
8264
+ let capped = false;
8265
+ for (const [index2, page2] of pages.entries()) {
8266
+ if (items >= maxItems) {
8267
+ capped = true;
8268
+ break;
8269
+ }
8270
+ const key = keys[index2];
8271
+ if (!key) {
8272
+ kept.push(page2);
8273
+ continue;
8274
+ }
8275
+ const record2 = page2;
8276
+ const list = record2[key];
8277
+ const room = maxItems - items;
8278
+ if (list.length > room) {
8279
+ const { nextPageToken: _dropped, ...rest } = record2;
8280
+ kept.push({ ...rest, [key]: list.slice(0, room) });
8281
+ items += room;
8282
+ capped = true;
8283
+ break;
8284
+ }
8285
+ kept.push(page2);
8286
+ items += list.length;
8287
+ }
8288
+ return { data: kept, pages: kept.length, items, capped };
8289
+ }
8290
+ function isEmptyPage(page2) {
8291
+ if (!page2 || typeof page2 !== "object" || Array.isArray(page2)) return false;
8292
+ return !Object.values(page2).some(Array.isArray);
8293
+ }
8294
+ function redactDryRun(value) {
8295
+ if (Array.isArray(value)) return value.map(redactDryRun);
8296
+ if (value && typeof value === "object") {
8297
+ const out = {};
8298
+ for (const [key, child] of Object.entries(value)) {
8299
+ if (/^(body|raw|content)$/i.test(key)) out[key] = "[redacted]";
8300
+ else out[key] = redactDryRun(child);
8301
+ }
8302
+ return out;
8303
+ }
8304
+ return value;
8305
+ }
8306
+ function apiErrorOf(result, parsed) {
8307
+ const data = parsed ? parsed.data : parseJson(result.stdout.toString("utf8"));
8308
+ const found = Array.isArray(data) ? data.find(isApiError) : data;
8309
+ if (!isApiError(found)) return void 0;
8310
+ return found.error;
8311
+ }
8312
+ function stderrText(result) {
8313
+ return result.stderr.toString("utf8").split("\n").filter((line) => line.trim() && !/^Using keyring backend:/i.test(line)).join("\n").trim();
8314
+ }
8315
+ var EXIT = { auth: 2, validation: 3, discovery: 4, internal: 5 };
8316
+ var SCOPE_REASONS = /* @__PURE__ */ new Set(["ACCESS_TOKEN_SCOPE_INSUFFICIENT", "insufficientPermissions"]);
8317
+ var PRIVILEGE_REFUSALS = /\bnot authori[sz]ed to (?:read|access|view|list|use|manage|perform)\b|\bcaller does not have access to the customers? reporting data\b/i;
8318
+ function privilegeRefused(result, parsed) {
8319
+ if (result.timedOut || result.outputCapped || result.exitCode === 0 || result.exitCode === EXIT.auth) return false;
8320
+ const error2 = apiErrorOf(result, parsed);
8321
+ if (error2?.code !== void 0 && error2.code !== 401 && error2.code !== 403) return false;
8322
+ const text3 = [error2?.message, stderrText(result)].filter(Boolean).join("\n");
8323
+ return !/invalid_grant/i.test(text3) && PRIVILEGE_REFUSALS.test(text3);
8324
+ }
8325
+ var ADMIN_PRIVILEGES = { "admin-reports": "Reports" };
8326
+ function privilegeName(service) {
8327
+ const name = service ? ADMIN_PRIVILEGES[service] : void 0;
8328
+ return name ? `the ${name} privilege` : "the admin privilege it needs";
8329
+ }
8330
+ function authProblem(result, parsed) {
8331
+ if (result.timedOut || result.outputCapped) return void 0;
8332
+ if (privilegeRefused(result, parsed)) return void 0;
8333
+ const error2 = apiErrorOf(result, parsed);
8334
+ if (result.exitCode === 0 && !error2) return void 0;
8335
+ const reasons = [error2?.reason, ...(error2?.errors ?? []).map((item) => item?.reason), ...(error2?.details ?? []).map((item) => item?.reason)];
8336
+ const text3 = [error2?.message, error2?.status, stderrText(result)].filter(Boolean).join("\n");
8337
+ if (reasons.some((reason) => reason !== void 0 && SCOPE_REASONS.has(reason)) || /insufficient authentication scopes|ACCESS_TOKEN_SCOPE_INSUFFICIENT/i.test(text3)) {
8338
+ return "AUTH_SCOPE_MISSING";
8339
+ }
8340
+ if (error2?.code === 401 || error2?.status === "UNAUTHENTICATED" || reasons.includes("authError") || /invalid_grant|\bUNAUTHENTICATED\b|^error\[auth\]/im.test(text3)) {
8341
+ return "AUTH_REQUIRED";
8342
+ }
8343
+ return void 0;
8344
+ }
8345
+ var GENERIC_REMEDIATION = {
8346
+ signIn: "sign in to the account again",
8347
+ consent: "sign in again and grant the scope this operation needs",
8348
+ privilege: "sign in with a Workspace admin account that has the admin privilege this operation needs, or remove the service from enabledServices"
8349
+ };
8350
+ var DISABLED_REASONS = /* @__PURE__ */ new Set(["accessNotConfigured", "SERVICE_DISABLED"]);
8351
+ var CONSOLE_URL = /https:\/\/console\.(?:developers|cloud)\.google\.com\/[^\s"'<>)]+/;
8352
+ function disabledApi(result, parsed) {
8353
+ const error2 = apiErrorOf(result, parsed);
8354
+ const reasons = [error2?.reason, ...(error2?.errors ?? []).map((item) => item?.reason), ...(error2?.details ?? []).map((item) => item?.reason)];
8355
+ const text3 = [error2?.message, stderrText(result)].filter(Boolean).join("\n");
8356
+ if (!reasons.some((reason) => reason !== void 0 && DISABLED_REASONS.has(reason)) && !/accessnotconfigured|has not been used|is disabled/i.test(text3)) {
8357
+ return void 0;
8358
+ }
8359
+ const enableUrl = (typeof error2?.enable_url === "string" && CONSOLE_URL.test(error2.enable_url) ? error2.enable_url : CONSOLE_URL.exec(text3)?.[0])?.replace(/[.,;]+$/, "");
8360
+ const project = (enableUrl ? /[?&]project=([\w.:-]+)/.exec(enableUrl)?.[1] : void 0) ?? /\bin project ([\w.:-]+)/.exec(text3)?.[1];
8361
+ return { ...enableUrl ? { enableUrl } : {}, ...project ? { project } : {} };
8362
+ }
8363
+ function processError(result, parsed, remediation = GENERIC_REMEDIATION) {
8364
+ if (result.timedOut) return new GwsError("TIMEOUT", `gws did not finish within the configured execution.timeoutMs (${result.durationMs}ms elapsed)`);
8365
+ if (result.outputCapped) return new GwsError("OUTPUT_LIMIT", "gws output exceeded execution.maxStdoutBytes/maxStderrBytes; narrow fields or pagination");
8366
+ const stderr = stderrText(result);
8367
+ const payload = apiErrorOf(result, parsed);
8368
+ const message = payload?.message || stderr || "gws command failed";
8369
+ if (privilegeRefused(result, parsed)) {
8370
+ return new GwsError(
8371
+ "ADMIN_PRIVILEGE_REQUIRED",
8372
+ `Google accepted the sign-in but refused the call, because the account lacks a Workspace admin privilege; signing in again as the same account cannot help (gws: ${message.slice(0, 300)})`,
8373
+ { next: `${remediation.privilege.charAt(0).toUpperCase()}${remediation.privilege.slice(1)}.` }
8374
+ );
8375
+ }
8376
+ const problem = authProblem(result, parsed) ?? (result.exitCode === EXIT.auth ? "AUTH_REQUIRED" : void 0);
8377
+ if (problem === "AUTH_SCOPE_MISSING") {
8378
+ return new GwsError("AUTH_SCOPE_MISSING", `the credential lacks a scope for this operation; ${remediation.consent} (gws: ${message.slice(0, 300)})`);
8379
+ }
8380
+ if (problem === "AUTH_REQUIRED") {
8381
+ return new GwsError("AUTH_REQUIRED", `the Google Workspace sign-in is missing or no longer valid; ${remediation.signIn} (gws: ${message.slice(0, 300)})`);
8382
+ }
8383
+ const disabled = disabledApi(result, parsed);
8384
+ if (disabled) {
8385
+ const where = disabled.project ? ` for project ${disabled.project}` : "";
8386
+ const next = disabled.enableUrl ? `Ask the user to open ${disabled.enableUrl} and enable the API${where}, then wait about a minute and retry.` : `Ask the user to enable the API${where} in the Google Cloud console, then wait about a minute and retry.`;
8387
+ return new GwsError("API_DISABLED", message.slice(0, 500), { next, ...disabled.enableUrl ? { enableUrl: disabled.enableUrl } : {} });
8388
+ }
8389
+ if (result.exitCode === EXIT.validation) return new GwsError("GWS_VALIDATION_ERROR", `gws rejected the request: ${message.slice(0, 500)}`);
8390
+ if (result.exitCode === EXIT.discovery) return new GwsError("GWS_DISCOVERY_ERROR", `gws could not load the API description: ${message.slice(0, 500)}`);
8391
+ if (result.exitCode === EXIT.internal) return new GwsError("GWS_INTERNAL_ERROR", `gws failed: ${message.slice(0, 500)}`);
8392
+ return new GwsError("GWS_API_ERROR", message.slice(0, 500));
8393
+ }
8394
+
8166
8395
  // src/host/commands.ts
8167
8396
  var COMMANDS = {
8168
8397
  status: { name: "gws-status", description: "Google Workspace: sign-in, access, pending plans, setup steps, and unguarded agents" },
@@ -8219,6 +8448,7 @@ function accountLines(account2) {
8219
8448
  lines.push(`Services: ${account2.services.join(", ") || "none"}`);
8220
8449
  if (account2.grantedScopes) lines.push(`Granted scopes: ${scopeList(account2.grantedScopes)}`);
8221
8450
  if (account2.missingScopes?.length) lines.push(`Scopes not granted: ${scopeList(account2.missingScopes)}`);
8451
+ if (account2.delegationRequired?.length) lines.push(`${DELEGATION_LABEL}: ${delegationText(account2)}`);
8222
8452
  return lines;
8223
8453
  }
8224
8454
  function formatStatus(status2, plans = [], now = Date.now()) {
@@ -8289,6 +8519,8 @@ function nextFor(outcome) {
8289
8519
  return `enable it at ${outcome.enableUrl}, wait about a minute, and run /gws-doctor again`;
8290
8520
  case "DENIED":
8291
8521
  return "allow gws.read for this account in your permissions, or skip the check";
8522
+ case "ADMIN_PRIVILEGE_REQUIRED":
8523
+ return `sign in with a Workspace admin account that has ${privilegeName(outcome.service)}, or remove ${outcome.service} from accounts.${outcome.account}.enabledServices`;
8292
8524
  default:
8293
8525
  return void 0;
8294
8526
  }
@@ -8424,6 +8656,11 @@ function accessOptions(profile) {
8424
8656
  if (!offersWrite(profile)) return [read];
8425
8657
  return [read, { value: "write", label: "Read and write", description: `Requests: ${signInScopes(profile, "write").join(" ")}` }];
8426
8658
  }
8659
+ function delegationNote(alias, profile) {
8660
+ const services = delegationRequired(profile.enabledServices, profile.scopes);
8661
+ if (services.length === 0) return "";
8662
+ return ` ${services.join(", ")} needs domain-wide delegation, which a Workspace admin sets up, so this sign-in requests none of its scopes; with delegation, add them to accounts.${alias}.scopes.`;
8663
+ }
8427
8664
  function defaultAccess(profile) {
8428
8665
  const access = profile.access;
8429
8666
  return access === "write" || typeof access === "object" && Object.values(access).includes("write") ? "write" : "read";
@@ -8500,7 +8737,7 @@ function signInForm(alias, profile, configured, redirect = {}) {
8500
8737
  key: "access",
8501
8738
  type: "string",
8502
8739
  title: "Access",
8503
- description: `What ${alias} may do in Google Workspace. Read-only cannot change, send, share, or delete anything.`,
8740
+ description: `What ${alias} may do in Google Workspace. Read-only cannot change, send, share, or delete anything.${delegationNote(alias, profile)}`,
8504
8741
  required: true,
8505
8742
  default: defaultAccess(profile),
8506
8743
  options: accessOptions(profile)
@@ -9304,6 +9541,9 @@ var Diagnostics = object({
9304
9541
  bytes: number2().describe("Serialized size of data")
9305
9542
  }).describe("gws process facts");
9306
9543
  var FileInfo = object({ path: string2(), bytes: number2(), sha256: string2() }).describe("A local file the plugin delivered");
9544
+ var Restriction = _enum(["app-only", "domain-wide-delegation"]).describe(
9545
+ "Why an ordinary Google sign-in cannot run this method: app-only needs a Chat app's or Classroom add-on's own credentials; domain-wide-delegation needs scopes a Workspace admin grants only through domain-wide delegation"
9546
+ );
9307
9547
  var CatalogEntry = object({
9308
9548
  kind: _enum(["service", "method", "helper"]),
9309
9549
  id: string2().describe("Pass a method or helper id as operation"),
@@ -9315,7 +9555,8 @@ var CatalogEntry = object({
9315
9555
  executable: boolean2().describe("The plugin can run it (a service: at least one of its operations); false for denied, deploy, and watch entries"),
9316
9556
  commitTool: string2().optional().describe('The tool that commits its plan, e.g. "gws_send"; absent for reads'),
9317
9557
  alternative: string2().optional().describe("For a helper that cannot run: the method id that does the same job"),
9318
- appOnly: literal(true).optional().describe("Only an app's own credentials (a Chat app or a Classroom add-on) can run it; a Google sign-in cannot")
9558
+ appOnly: literal(true).optional().describe("Only an app's own credentials (a Chat app or a Classroom add-on) can run it; a Google sign-in cannot"),
9559
+ restriction: Restriction.optional()
9319
9560
  });
9320
9561
  var CatalogOutput = object({
9321
9562
  entries: array(CatalogEntry),
@@ -9343,6 +9584,7 @@ var SchemaOutput = object({
9343
9584
  account: string2(),
9344
9585
  schema: ResolvedSchema.optional().describe("Present for a method"),
9345
9586
  appOnly: literal(true).optional().describe("Only an app's own credentials (a Chat app or a Classroom add-on) can run this method; a Google sign-in cannot"),
9587
+ restriction: Restriction.optional(),
9346
9588
  helper: HelperEntry.extend({
9347
9589
  input: unknown().optional().describe("JSON Schema of the helper's input flags; absent when the helper cannot run")
9348
9590
  }).optional().describe("Present for a helper")
@@ -9392,177 +9634,6 @@ var CommitOutput = object({
9392
9634
  cleanupDegraded: boolean2().optional().describe("The change succeeded, but private staging cleanup failed; do not retry the mutation")
9393
9635
  });
9394
9636
 
9395
- // src/domain/result.ts
9396
- function isApiError(value) {
9397
- if (Array.isArray(value)) return value.some(isApiError);
9398
- if (!value || typeof value !== "object") return false;
9399
- const record2 = value;
9400
- return "error" in record2 && record2.error !== null && typeof record2.error === "object";
9401
- }
9402
- function parseJson(text3) {
9403
- const lines = text3.split("\n");
9404
- const start2 = lines.findIndex((line) => /^[[{]/.test(line.trim()));
9405
- if (start2 < 0) return void 0;
9406
- const candidate = lines.slice(start2).join("\n").trim();
9407
- try {
9408
- return JSON.parse(candidate);
9409
- } catch {
9410
- const parsed = [];
9411
- for (const line of lines.slice(start2)) {
9412
- const value = line.trim();
9413
- if (!/^[[{]/.test(value)) continue;
9414
- try {
9415
- parsed.push(JSON.parse(value));
9416
- } catch {
9417
- }
9418
- }
9419
- return parsed.length > 0 ? parsed : void 0;
9420
- }
9421
- }
9422
- function inlineCap(maxInlineBytes, mode, requested) {
9423
- const base2 = mode === "full" ? Math.max(maxInlineBytes, 5 * 1024 * 1024) : maxInlineBytes;
9424
- return requested === void 0 ? base2 : Math.min(base2, requested);
9425
- }
9426
- function parseResult(result, options) {
9427
- const text3 = result.stdout.toString("utf8");
9428
- const parsed = parseJson(text3);
9429
- const serialized = parsed === void 0 ? text3 : JSON.stringify(parsed);
9430
- const bytes2 = Buffer.byteLength(serialized);
9431
- const cap = inlineCap(options.maxInlineBytes, options.mode, options.requestedInlineBytes);
9432
- const truncated = bytes2 > cap;
9433
- const empty = text3.trim().length === 0;
9434
- return {
9435
- // A successful DELETE commonly returns 204 with no body. Empty output
9436
- // with exit 0 is success; non-JSON text is not a JSON result.
9437
- ok: result.exitCode === 0 && (empty || parsed !== void 0 && !isApiError(parsed)),
9438
- data: parsed,
9439
- raw: parsed === void 0 ? text3.slice(0, cap) : void 0,
9440
- truncated,
9441
- diagnostics: { exitCode: result.exitCode, durationMs: result.durationMs, bytes: bytes2 }
9442
- };
9443
- }
9444
- function itemKey(page2) {
9445
- if (!page2 || typeof page2 !== "object" || Array.isArray(page2)) return void 0;
9446
- const record2 = page2;
9447
- if (Array.isArray(record2["items"])) return "items";
9448
- const arrays = Object.keys(record2).filter((key) => Array.isArray(record2[key]));
9449
- return arrays.length === 1 ? arrays[0] : void 0;
9450
- }
9451
- function capPageItems(data, maxItems) {
9452
- const pages = data === void 0 ? [] : Array.isArray(data) ? data : [data];
9453
- const keys = pages.map((page2) => itemKey(page2) ?? (isEmptyPage(page2) ? null : void 0));
9454
- if (keys.some((key) => key === void 0)) return { data: pages, pages: pages.length, capped: false };
9455
- const kept = [];
9456
- let items = 0;
9457
- let capped = false;
9458
- for (const [index2, page2] of pages.entries()) {
9459
- if (items >= maxItems) {
9460
- capped = true;
9461
- break;
9462
- }
9463
- const key = keys[index2];
9464
- if (!key) {
9465
- kept.push(page2);
9466
- continue;
9467
- }
9468
- const record2 = page2;
9469
- const list = record2[key];
9470
- const room = maxItems - items;
9471
- if (list.length > room) {
9472
- const { nextPageToken: _dropped, ...rest } = record2;
9473
- kept.push({ ...rest, [key]: list.slice(0, room) });
9474
- items += room;
9475
- capped = true;
9476
- break;
9477
- }
9478
- kept.push(page2);
9479
- items += list.length;
9480
- }
9481
- return { data: kept, pages: kept.length, items, capped };
9482
- }
9483
- function isEmptyPage(page2) {
9484
- if (!page2 || typeof page2 !== "object" || Array.isArray(page2)) return false;
9485
- return !Object.values(page2).some(Array.isArray);
9486
- }
9487
- function redactDryRun(value) {
9488
- if (Array.isArray(value)) return value.map(redactDryRun);
9489
- if (value && typeof value === "object") {
9490
- const out = {};
9491
- for (const [key, child] of Object.entries(value)) {
9492
- if (/^(body|raw|content)$/i.test(key)) out[key] = "[redacted]";
9493
- else out[key] = redactDryRun(child);
9494
- }
9495
- return out;
9496
- }
9497
- return value;
9498
- }
9499
- function apiErrorOf(result, parsed) {
9500
- const data = parsed ? parsed.data : parseJson(result.stdout.toString("utf8"));
9501
- const found = Array.isArray(data) ? data.find(isApiError) : data;
9502
- if (!isApiError(found)) return void 0;
9503
- return found.error;
9504
- }
9505
- function stderrText(result) {
9506
- return result.stderr.toString("utf8").split("\n").filter((line) => line.trim() && !/^Using keyring backend:/i.test(line)).join("\n").trim();
9507
- }
9508
- var SCOPE_REASONS = /* @__PURE__ */ new Set(["ACCESS_TOKEN_SCOPE_INSUFFICIENT", "insufficientPermissions"]);
9509
- function authProblem(result, parsed) {
9510
- if (result.timedOut || result.outputCapped) return void 0;
9511
- const error2 = apiErrorOf(result, parsed);
9512
- if (result.exitCode === 0 && !error2) return void 0;
9513
- const reasons = [error2?.reason, ...(error2?.errors ?? []).map((item) => item?.reason), ...(error2?.details ?? []).map((item) => item?.reason)];
9514
- const text3 = [error2?.message, error2?.status, stderrText(result)].filter(Boolean).join("\n");
9515
- if (reasons.some((reason) => reason !== void 0 && SCOPE_REASONS.has(reason)) || /insufficient authentication scopes|ACCESS_TOKEN_SCOPE_INSUFFICIENT/i.test(text3)) {
9516
- return "AUTH_SCOPE_MISSING";
9517
- }
9518
- if (error2?.code === 401 || error2?.status === "UNAUTHENTICATED" || reasons.includes("authError") || /invalid_grant|\bUNAUTHENTICATED\b|^error\[auth\]/im.test(text3)) {
9519
- return "AUTH_REQUIRED";
9520
- }
9521
- return void 0;
9522
- }
9523
- var GENERIC_REMEDIATION = {
9524
- signIn: "sign in to the account again",
9525
- consent: "sign in again and grant the scope this operation needs"
9526
- };
9527
- var EXIT = { auth: 2, validation: 3, discovery: 4, internal: 5 };
9528
- var DISABLED_REASONS = /* @__PURE__ */ new Set(["accessNotConfigured", "SERVICE_DISABLED"]);
9529
- var CONSOLE_URL = /https:\/\/console\.(?:developers|cloud)\.google\.com\/[^\s"'<>)]+/;
9530
- function disabledApi(result, parsed) {
9531
- const error2 = apiErrorOf(result, parsed);
9532
- const reasons = [error2?.reason, ...(error2?.errors ?? []).map((item) => item?.reason), ...(error2?.details ?? []).map((item) => item?.reason)];
9533
- const text3 = [error2?.message, stderrText(result)].filter(Boolean).join("\n");
9534
- if (!reasons.some((reason) => reason !== void 0 && DISABLED_REASONS.has(reason)) && !/accessnotconfigured|has not been used|is disabled/i.test(text3)) {
9535
- return void 0;
9536
- }
9537
- const enableUrl = (typeof error2?.enable_url === "string" && CONSOLE_URL.test(error2.enable_url) ? error2.enable_url : CONSOLE_URL.exec(text3)?.[0])?.replace(/[.,;]+$/, "");
9538
- const project = (enableUrl ? /[?&]project=([\w.:-]+)/.exec(enableUrl)?.[1] : void 0) ?? /\bin project ([\w.:-]+)/.exec(text3)?.[1];
9539
- return { ...enableUrl ? { enableUrl } : {}, ...project ? { project } : {} };
9540
- }
9541
- function processError(result, parsed, remediation = GENERIC_REMEDIATION) {
9542
- if (result.timedOut) return new GwsError("TIMEOUT", `gws did not finish within the configured execution.timeoutMs (${result.durationMs}ms elapsed)`);
9543
- if (result.outputCapped) return new GwsError("OUTPUT_LIMIT", "gws output exceeded execution.maxStdoutBytes/maxStderrBytes; narrow fields or pagination");
9544
- const stderr = stderrText(result);
9545
- const payload = apiErrorOf(result, parsed);
9546
- const message = payload?.message || stderr || "gws command failed";
9547
- const problem = authProblem(result, parsed) ?? (result.exitCode === EXIT.auth ? "AUTH_REQUIRED" : void 0);
9548
- if (problem === "AUTH_SCOPE_MISSING") {
9549
- return new GwsError("AUTH_SCOPE_MISSING", `the credential lacks a scope for this operation; ${remediation.consent} (gws: ${message.slice(0, 300)})`);
9550
- }
9551
- if (problem === "AUTH_REQUIRED") {
9552
- return new GwsError("AUTH_REQUIRED", `the Google Workspace sign-in is missing or no longer valid; ${remediation.signIn} (gws: ${message.slice(0, 300)})`);
9553
- }
9554
- const disabled = disabledApi(result, parsed);
9555
- if (disabled) {
9556
- const where = disabled.project ? ` for project ${disabled.project}` : "";
9557
- const next = disabled.enableUrl ? `Ask the user to open ${disabled.enableUrl} and enable the API${where}, then wait about a minute and retry.` : `Ask the user to enable the API${where} in the Google Cloud console, then wait about a minute and retry.`;
9558
- return new GwsError("API_DISABLED", message.slice(0, 500), { next, ...disabled.enableUrl ? { enableUrl: disabled.enableUrl } : {} });
9559
- }
9560
- if (result.exitCode === EXIT.validation) return new GwsError("GWS_VALIDATION_ERROR", `gws rejected the request: ${message.slice(0, 500)}`);
9561
- if (result.exitCode === EXIT.discovery) return new GwsError("GWS_DISCOVERY_ERROR", `gws could not load the API description: ${message.slice(0, 500)}`);
9562
- if (result.exitCode === EXIT.internal) return new GwsError("GWS_INTERNAL_ERROR", `gws failed: ${message.slice(0, 500)}`);
9563
- return new GwsError("GWS_API_ERROR", message.slice(0, 500));
9564
- }
9565
-
9566
9637
  // src/domain/status.ts
9567
9638
  var AccountStatus = object({
9568
9639
  alias: string2(),
@@ -9575,6 +9646,7 @@ var AccountStatus = object({
9575
9646
  clientSource: _enum(["account", "plugin", "pasted"]).optional().describe("Where the sign-in's OAuth client comes from"),
9576
9647
  grantedScopes: array(string2()).optional().describe("Scopes Google granted to the current sign-in"),
9577
9648
  missingScopes: array(string2()).optional().describe("Scopes the enabled services need at this access level that were not granted"),
9649
+ delegationRequired: array(string2()).optional().describe("Enabled services that need domain-wide delegation, which a Workspace admin sets up; the sign-in requests none of their scopes unless accounts.<alias>.scopes adds them"),
9578
9650
  nextStep: string2().optional().describe("What to do next; absent when nothing is needed")
9579
9651
  });
9580
9652
  var StatusOutput = object({
@@ -9821,6 +9893,12 @@ var Description = strictObject({
9821
9893
  })
9822
9894
  )
9823
9895
  });
9896
+ function sessionNotFound(error2) {
9897
+ if (error2 === null || typeof error2 !== "object") return false;
9898
+ const record2 = error2;
9899
+ const tag = record2["_tag"] ?? record2["name"];
9900
+ return tag === "Session.NotFoundError" || tag === "SessionNotFoundError" || record2["status"] === 404 || record2["statusCode"] === 404;
9901
+ }
9824
9902
  async function registerScripting(ctx, runtime, status2) {
9825
9903
  const assert2 = async (request) => {
9826
9904
  try {
@@ -9836,8 +9914,8 @@ async function registerScripting(ctx, runtime, status2) {
9836
9914
  try {
9837
9915
  agent = (await runtime.wait(() => ctx.session.get({ sessionID }), signal)).agent;
9838
9916
  } catch (error2) {
9839
- if (GwsError.from(error2)?.code === "CANCELLED") throw error2;
9840
- throw new GwsError("SESSION_UNKNOWN", `there is no session '${sessionID}'`);
9917
+ if (sessionNotFound(error2)) throw new GwsError("SESSION_UNKNOWN", `there is no session '${sessionID}'`);
9918
+ throw error2;
9841
9919
  }
9842
9920
  if (!agent) throw new GwsError("SESSION_AGENT_REQUIRED", `session '${sessionID}' names no agent`);
9843
9921
  return { sessionID, agent, script: true };
@@ -10438,10 +10516,17 @@ var PROBES = {
10438
10516
  "admin-reports": { operation: "admin-reports.activities.list", params: { userKey: "all", applicationName: "login", maxResults: 1, fields: "items(id)" } },
10439
10517
  script: { operation: "script.processes.list", params: { pageSize: 1, fields: "processes(processType)" } }
10440
10518
  };
10441
- function doctorTarget(account2, service) {
10519
+ function doctorTarget(account2, service, needsDelegation = false) {
10520
+ if (needsDelegation) return { account: account2, service, needsDelegation: true };
10442
10521
  const probe = PROBES[service];
10443
10522
  return probe ? { account: account2, service, operation: probe.operation } : { account: account2, service };
10444
10523
  }
10524
+ function skipDetail(target) {
10525
+ if (target.needsDelegation) {
10526
+ return `${target.service} needs domain-wide delegation, which a Workspace admin sets up; with it, add ${target.service} or ${target.service}.readonly to accounts.${target.account}.scopes and sign in again`;
10527
+ }
10528
+ return "no read-only check exists for this service";
10529
+ }
10445
10530
  function probeArgv(target) {
10446
10531
  const probe = PROBES[target.service];
10447
10532
  const entry = probe ? commandIndex().methods.get(probe.operation) : void 0;
@@ -10452,6 +10537,9 @@ var DISABLED = /accessNotConfigured|SERVICE_DISABLED|has not been used in projec
10452
10537
  function probeOutcome(target, result) {
10453
10538
  const parsed = parseResult(result, { maxInlineBytes: 64 * 1024 });
10454
10539
  if (parsed.ok) return { ...target, status: "OK" };
10540
+ if (privilegeRefused(result, parsed)) {
10541
+ return { ...target, status: "ADMIN_PRIVILEGE_REQUIRED", detail: "Google accepted the sign-in, but the account lacks the Workspace admin privilege this service needs" };
10542
+ }
10455
10543
  const problem = authProblem(result, parsed);
10456
10544
  if (problem === "AUTH_REQUIRED") return { ...target, status: problem, detail: "Google rejected the sign-in" };
10457
10545
  if (problem === "AUTH_SCOPE_MISSING") return { ...target, status: problem, detail: "Google says the sign-in lacks a scope for this service" };
@@ -10546,6 +10634,7 @@ var Accounts = class {
10546
10634
  mode: profile.mode,
10547
10635
  services: profile.enabledServices,
10548
10636
  ...profile.mode === "interactive" ? { integrationID } : {},
10637
+ ...delegationField(profile),
10549
10638
  ...signIn
10550
10639
  };
10551
10640
  })
@@ -10612,9 +10701,14 @@ var Accounts = class {
10612
10701
  return credential;
10613
10702
  }
10614
10703
  scopeMissing(account2, profile, operation, accepted) {
10615
- if (appOnly(operation)) {
10704
+ const reason = restriction(operation);
10705
+ if (reason === "app-only") {
10616
10706
  return `AUTH_SCOPE_MISSING: ${operation} needs a scope Google grants only to an app's own credentials (a Chat app or a Classroom add-on), never to a person's sign-in, so '${account2}' cannot run it. Next step: Tell the user this method cannot run with a Google sign-in, and use another method.`;
10617
10707
  }
10708
+ if (reason === "domain-wide-delegation") {
10709
+ const named2 = accepted.map(shortScope).join(" or ");
10710
+ return `AUTH_SCOPE_MISSING: ${operation} needs ${named2}, which Google grants only through domain-wide delegation that a Workspace admin sets up, and the sign-in for '${account2}' has not granted it. Next step: Tell the user this method needs domain-wide delegation; once their admin has set it up, they add ${named2} to accounts.${account2}.scopes and sign in again.`;
10711
+ }
10618
10712
  const read = signInScopes(profile, "read");
10619
10713
  const named = accepted.slice(0, 5).map(shortScope).join(", ");
10620
10714
  const connect2 = this.connect(account2);
@@ -10631,24 +10725,27 @@ var Accounts = class {
10631
10725
  accountDir(account2) {
10632
10726
  return ownedDir(join6(this.runtime.paths.cacheDir, "accounts", this.integrationId(account2).replace(/^gws-/, "")));
10633
10727
  }
10634
- /** Remediation text for an account's sign-in and consent problems, by credential mode. */
10635
- remediation(account2, scopes) {
10728
+ /** Remediation text for an account's sign-in, consent and admin privilege problems, by credential mode. */
10729
+ remediation(account2, scopes, service) {
10636
10730
  const profile = this.runtime.config.accounts[account2];
10637
10731
  const named = scopes && scopes.length > 0 ? scopes.slice(0, 5).map(shortScope).join(", ") : void 0;
10638
10732
  const grant = named ? `include one of: ${named}` : "include the scope this operation needs";
10733
+ const admin = `a Workspace admin account that has ${privilegeName(service)}`;
10734
+ const remove = `remove ${service ?? "the service"} from accounts.${account2}.enabledServices`;
10639
10735
  if (profile?.mode === "credentials_file") {
10640
10736
  const source = profile.credentialsFileEnv ? `the credentials file named by ${profile.credentialsFileEnv}` : "the credentials file";
10641
- return { signIn: `replace ${source}`, consent: `replace ${source} with one whose grant can ${grant}` };
10737
+ return { signIn: `replace ${source}`, consent: `replace ${source} with one whose grant can ${grant}`, privilege: `replace ${source} with one for ${admin}, or ${remove}` };
10642
10738
  }
10643
10739
  if (profile?.mode === "access_token") {
10644
10740
  const source = profile.tokenEnv ? `the access token in ${profile.tokenEnv}` : "the access token";
10645
- return { signIn: `refresh ${source}`, consent: `mint ${source} with scopes that ${grant}` };
10741
+ return { signIn: `refresh ${source}`, consent: `mint ${source} with scopes that ${grant}`, privilege: `mint ${source} for ${admin}, or ${remove}` };
10646
10742
  }
10647
10743
  const connect2 = this.connect(account2);
10648
10744
  const extra = named ? `, or add one of ${named} to accounts.${account2}.scopes` : "";
10649
10745
  return {
10650
10746
  signIn: `to sign in again, ${connect2}`,
10651
- consent: `to grant the scope, ${connect2}, sign in again, and approve every scope, choosing Read and write for a change${extra}`
10747
+ consent: `to grant the scope, ${connect2}, sign in again, and approve every scope, choosing Read and write for a change${extra}`,
10748
+ privilege: `sign in with ${admin} (${connect2}), or ${remove}`
10652
10749
  };
10653
10750
  }
10654
10751
  authMessage(account2, code2, scopes) {
@@ -10667,11 +10764,13 @@ var Accounts = class {
10667
10764
  /** Every account and enabled service /gws-doctor checks, in config order. */
10668
10765
  doctorTargets() {
10669
10766
  this.runtime.assertActive();
10670
- return Object.entries(this.runtime.config.accounts).flatMap(([alias, profile]) => profile.enabledServices.map((service) => doctorTarget(alias, service)));
10767
+ return Object.entries(this.runtime.config.accounts).flatMap(
10768
+ ([alias, profile]) => profile.enabledServices.map((service) => doctorTarget(alias, service, profile.mode === "interactive" && delegationRequired([service], profile.scopes).length > 0))
10769
+ );
10671
10770
  }
10672
10771
  /** Run one /gws-doctor probe as the account. A failure becomes an outcome, never a throw, except cancellation. */
10673
10772
  async probe(target, ctx) {
10674
- if (!target.operation) return { ...target, status: "SKIPPED", detail: "no read-only check exists for this service" };
10773
+ if (!target.operation) return { ...target, status: "SKIPPED", detail: skipDetail(target) };
10675
10774
  const started = Date.now();
10676
10775
  try {
10677
10776
  const result = await this.run(probeArgv(target), target.account, target.operation, ctx);
@@ -10712,6 +10811,11 @@ function report2(ctx, update) {
10712
10811
  } catch {
10713
10812
  }
10714
10813
  }
10814
+ function delegationField(profile) {
10815
+ if (profile.mode !== "interactive") return {};
10816
+ const services = delegationRequired(profile.enabledServices, profile.scopes);
10817
+ return services.length > 0 ? { delegationRequired: services } : {};
10818
+ }
10715
10819
  function capitalize(text3) {
10716
10820
  return text3.charAt(0).toUpperCase() + text3.slice(1);
10717
10821
  }
@@ -11371,7 +11475,7 @@ var Operations = class {
11371
11475
  report2(ctx, { status: "running", operation: entry.id });
11372
11476
  const schema = await this.runtime.schema.resolve(entry.service, [...entry.path, entry.method], ctx.signal);
11373
11477
  this.runtime.assertActive(ctx.signal);
11374
- return { ok: true, account: target.account, schema, ...appOnly(entry.id) ? { appOnly: true } : {} };
11478
+ return { ok: true, account: target.account, schema, ...restrictionMarks(entry.id) };
11375
11479
  }
11376
11480
  /** Validate a commit against its stored plan without consuming it. */
11377
11481
  inspectPlan(category, input, ctx) {
@@ -11396,7 +11500,7 @@ var Operations = class {
11396
11500
  requestedInlineBytes: helper ? void 0 : request.response?.maxInlineBytes
11397
11501
  });
11398
11502
  const failed = helper ? result.exitCode !== 0 || isApiError(parsed.data) : !parsed.ok;
11399
- if (failed) throw processError(result, parsed, this.accounts.remediation(request.account, entry.scopes));
11503
+ if (failed) throw processError(result, parsed, this.accounts.remediation(request.account, entry.scopes, entry.service));
11400
11504
  const pages = paged ? capPageItems(parsed.data, itemCap(request, limits)) : void 0;
11401
11505
  const cap = inlineCap(this.runtime.config.execution.maxInlineBytes, request.response?.mode, request.response?.maxInlineBytes);
11402
11506
  const truncated = pages ? Buffer.byteLength(JSON.stringify(pages.data)) > cap : parsed.truncated;
@@ -11460,7 +11564,7 @@ var Operations = class {
11460
11564
  const dry = await this.accounts.run(dryRunArgv, request.account, entry.id, ctx, { scopes: entry.scopes, dryRun: true, cwd: stage?.dir });
11461
11565
  report2(ctx, { status: "parsing", operation: entry.id });
11462
11566
  parsed = parseResult(dry, { maxInlineBytes: this.runtime.config.execution.maxInlineBytes });
11463
- if (dry.exitCode !== 0 || !parsed.ok) throw processError(dry, parsed, this.accounts.remediation(request.account, entry.scopes));
11567
+ if (dry.exitCode !== 0 || !parsed.ok) throw processError(dry, parsed, this.accounts.remediation(request.account, entry.scopes, entry.service));
11464
11568
  } finally {
11465
11569
  await stage?.dispose();
11466
11570
  }
@@ -11518,11 +11622,12 @@ var Operations = class {
11518
11622
  refuseChanged(plan.files.inputs, staged);
11519
11623
  if (plan.files.output) await stage.prepareOutput(plan.files.output);
11520
11624
  }
11521
- const scopes = commandIndex().methods.get(plan.operation)?.scopes;
11625
+ const method = commandIndex().methods.get(plan.operation);
11626
+ const scopes = method?.scopes;
11522
11627
  const result = await this.accounts.run(plan.argv, plan.account, plan.operation, ctx, { scopes, cwd: stage?.dir });
11523
11628
  report2(ctx, { status: "parsing", operation: plan.operation });
11524
11629
  const parsed = parseResult(result, { maxInlineBytes: this.runtime.config.execution.maxInlineBytes });
11525
- if (!parsed.ok) throw processError(result, parsed, this.accounts.remediation(plan.account, scopes));
11630
+ if (!parsed.ok) throw processError(result, parsed, this.accounts.remediation(plan.account, scopes, method?.service));
11526
11631
  const delivered = stage && plan.files?.output ? await stage.deliver(plan.files.output, plan.files.limits) : void 0;
11527
11632
  const file = delivered ? { path: delivered.path, bytes: delivered.bytes, sha256: delivered.sha256 } : void 0;
11528
11633
  this.runtime.plans.complete(plan.id);
package/dist/tui.js CHANGED
@@ -31,6 +31,7 @@ var account = object(
31
31
  clientSource: { enum: CLIENT_SOURCES },
32
32
  grantedScopes: texts,
33
33
  missingScopes: texts,
34
+ delegationRequired: texts,
34
35
  nextStep: text
35
36
  },
36
37
  ["alias", "mode", "services", "connected"]
@@ -65,7 +66,7 @@ var isStrings = (value) => Array.isArray(value) && value.every((entry) => typeof
65
66
  var optional = (value, check) => value === void 0 || check(value);
66
67
  var isText = (value) => typeof value === "string";
67
68
  function isAccount(value) {
68
- return isRecord(value) && isText(value["alias"]) && MODES.includes(value["mode"]) && isStrings(value["services"]) && typeof value["connected"] === "boolean" && optional(value["integrationID"], isText) && optional(value["needsAuth"], (entry) => entry === true) && optional(value["access"], (entry) => ACCESS.includes(entry)) && optional(value["clientSource"], (entry) => CLIENT_SOURCES.includes(entry)) && optional(value["grantedScopes"], isStrings) && optional(value["missingScopes"], isStrings) && optional(value["nextStep"], isText);
69
+ return isRecord(value) && isText(value["alias"]) && MODES.includes(value["mode"]) && isStrings(value["services"]) && typeof value["connected"] === "boolean" && optional(value["integrationID"], isText) && optional(value["needsAuth"], (entry) => entry === true) && optional(value["access"], (entry) => ACCESS.includes(entry)) && optional(value["clientSource"], (entry) => CLIENT_SOURCES.includes(entry)) && optional(value["grantedScopes"], isStrings) && optional(value["missingScopes"], isStrings) && optional(value["delegationRequired"], isStrings) && optional(value["nextStep"], isText);
69
70
  }
70
71
  function isStatus(value) {
71
72
  return isRecord(value) && isText(value["pluginVersion"]) && isText(value["hostVersion"]) && optional(value["hostWarning"], isText) && isRecord(value["gws"]) && isText(value["gws"]["path"]) && isText(value["gws"]["version"]) && isStrings(value["services"]) && Array.isArray(value["accounts"]) && value["accounts"].every(isAccount) && optional(value["auditDegraded"], (entry) => typeof entry === "boolean") && isStrings(value["unguardedAgents"]) && optional(value["permissionsBlock"], isText) && optional(value["nextStep"], isText);
@@ -78,6 +79,11 @@ function connectionLabel(account2) {
78
79
  function openCodeLabel(status2) {
79
80
  return status2.hostWarning ? `${status2.hostVersion} (host untested: ${status2.hostWarning})` : `${status2.hostVersion} (tested)`;
80
81
  }
82
+ var DELEGATION_LABEL = "Needs domain-wide delegation";
83
+ function delegationText(account2) {
84
+ const services = account2.delegationRequired ?? [];
85
+ return `${services.join(", ")} (a Workspace admin sets it up; then add the scopes to accounts.${account2.alias}.scopes)`;
86
+ }
81
87
  var BLOCK_PLACEMENT = "Put this block first in that agent's own permissions, followed by your top-level rules for gws actions. Never put it in the top-level permissions: that would loosen Explore.";
82
88
 
83
89
  // src/tui.tsx
@@ -248,6 +254,23 @@ function AccountStatus(props) {
248
254
  }
249
255
  })
250
256
  }), null);
257
+ _$insert(_el$4, _$createComponent(Show, {
258
+ get when() {
259
+ return _$memo(() => !!props.account.delegationRequired?.length)() ? props.account : void 0;
260
+ },
261
+ children: (account2) => _$createComponent(Row, {
262
+ label: DELEGATION_LABEL,
263
+ get value() {
264
+ return delegationText(account2());
265
+ },
266
+ get fg() {
267
+ return muted();
268
+ },
269
+ get muted() {
270
+ return muted();
271
+ }
272
+ })
273
+ }), null);
251
274
  return _el$4;
252
275
  })();
253
276
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joe-broadhead/opencode-gws",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Unofficial OpenCode plugin for Google Workspace (gws CLI)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -50,10 +50,10 @@
50
50
  "docs:coverage": "node scripts/generate-docs.mjs"
51
51
  },
52
52
  "optionalDependencies": {
53
- "@joe-broadhead/opencode-gws-bin-darwin-arm64": "0.0.1",
54
- "@joe-broadhead/opencode-gws-bin-darwin-x64": "0.0.1",
55
- "@joe-broadhead/opencode-gws-bin-linux-arm64-gnu": "0.0.1",
56
- "@joe-broadhead/opencode-gws-bin-linux-x64-gnu": "0.0.1"
53
+ "@joe-broadhead/opencode-gws-bin-darwin-arm64": "0.0.2",
54
+ "@joe-broadhead/opencode-gws-bin-darwin-x64": "0.0.2",
55
+ "@joe-broadhead/opencode-gws-bin-linux-arm64-gnu": "0.0.2",
56
+ "@joe-broadhead/opencode-gws-bin-linux-x64-gnu": "0.0.2"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@babel/core": "7.29.7",
@@ -13,7 +13,7 @@ Call `gws_status` before the first Workspace call in a session. It lists account
13
13
 
14
14
  Omit `account` when `gws_status` shows one account. Pass the alias when it shows more than one. A missing alias is `ACCOUNT_REQUIRED`. An unknown alias is `ACCOUNT_UNKNOWN`.
15
15
 
16
- An account signs in Read-only or Read and write; `gws_status` shows which. The `default` account defaults to Read and write, which covers every method a Google sign-in can run. An operation whose scopes were not granted fails with `AUTH_SCOPE_MISSING` before anything runs: ask the user to sign in again, approve every scope Google lists, and choose Read and write if the error says so. Do not invent a scope. A method that `gws_catalog` or `gws_schema` marks `appOnly` needs a Chat app's or Classroom add-on's own credentials; no sign-in can run it, so use another method.
16
+ An account signs in Read-only or Read and write; `gws_status` shows which. The `default` account defaults to Read and write, which covers every method a Google sign-in can run. An operation whose scopes were not granted fails with `AUTH_SCOPE_MISSING` before anything runs: ask the user to sign in again, approve every scope Google lists, and choose Read and write if the error says so. Do not invent a scope. A method that `gws_catalog` or `gws_schema` marks `appOnly` (`restriction: "app-only"`) needs a Chat app's or Classroom add-on's own credentials; no sign-in can run it, so use another method. A method marked `restriction: "domain-wide-delegation"` (every Keep method) runs only when a Workspace admin has set up domain-wide delegation and `accounts.<alias>.scopes` adds a Keep scope; otherwise tell the user so.
17
17
 
18
18
  Not signed in: `AUTH_REQUIRED`. The next step is to run `/connect` and choose Google Workspace, or `opencode auth login gws-<alias>`.
19
19
 
@@ -115,6 +115,7 @@ An error is `CODE: What happened. Next step: What to do.` Do the next step once.
115
115
  | --- | --- |
116
116
  | `AUTH_REQUIRED` | Ask the user to sign in as the message says, then retry. |
117
117
  | `AUTH_SCOPE_MISSING` | Ask the user to sign in again with the access the message names, or to add the scope. Nothing ran. |
118
+ | `ADMIN_PRIVILEGE_REQUIRED` | The sign-in is valid; the account lacks the Workspace admin privilege the API needs. Do not ask the user to sign in again as the same account. Tell them the message's next step. |
118
119
  | `API_DISABLED` | Ask the user to enable the API at the URL in the message, wait about a minute, and retry. |
119
120
  | `OPERATION_UNKNOWN` | The id is not in the catalog. Search again with `gws_catalog`. |
120
121
  | `HELPER_UNSUPPORTED` | The helper cannot run. Use the method `gws_catalog` names as `alternative`. |
@@ -26,7 +26,7 @@ Plugin policy cannot allow an admin call. A policy rule can only deny.
26
26
 
27
27
  ## Admin reports
28
28
 
29
- Read-only at both access levels. The sign-in still needs the admin report scopes from the auth doc.
29
+ Read-only at both access levels. The sign-in still needs the admin report scopes from the auth doc, and the account must be a Workspace admin with the Reports privilege: otherwise every call fails with `ADMIN_PRIVILEGE_REQUIRED`, which signing in again as the same account does not fix.
30
30
 
31
31
  Activity list, read. `fields` is required because this is a list:
32
32