@joe-broadhead/opencode-gws 0.0.1 → 0.0.3

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/index.js CHANGED
@@ -5320,8 +5320,8 @@ function rewriteKeyNames(ctx) {
5320
5320
  bySchema.set(entry.schema, entry);
5321
5321
  }
5322
5322
  const rewrites = /* @__PURE__ */ new Map();
5323
- for (const record2 of pendingRecords.get(ctx) ?? []) {
5324
- const seen = ctx.seen.get(record2);
5323
+ for (const record3 of pendingRecords.get(ctx) ?? []) {
5324
+ const seen = ctx.seen.get(record3);
5325
5325
  const names = (seen?.def ?? seen?.schema)?.propertyNames;
5326
5326
  if (!names || names === true || rewrites.has(names))
5327
5327
  continue;
@@ -6575,9 +6575,17 @@ var ErrorCode = {
6575
6575
  meaning: "Plugin policy refused the call: the category, the service, recipients, public sharing, the admin gate, or a path outside the allowed roots. A deny is final.",
6576
6576
  next: "Do not work around it. If the message says to use gws_plan, plan the change; otherwise tell the user, who can change the plugin policy."
6577
6577
  },
6578
+ RECIPIENTS_UNKNOWN: {
6579
+ meaning: "A policy rule limits recipients, and the plugin could not tell who this send or share reaches: a raw message without readable To, Cc or Bcc headers, a draft, message or event it could not read, or a method whose recipients it does not know. Nothing ran.",
6580
+ next: "Name the recipients in the request (to, cc and bcc, or the To, Cc and Bcc headers of raw), or tell the user the policy rule refuses this method."
6581
+ },
6582
+ ACCOUNT_DOMAIN_UNKNOWN: {
6583
+ meaning: "A policy rule sets allowExternalRecipients to false, and the account's own email domain is unknown: accounts.<alias>.domains is unset and the signed-in address could not be read. Nothing ran.",
6584
+ next: "Ask the user to set accounts.<alias>.domains to the account's email domains, or to enable Gmail, Drive or Calendar for the account so the plugin can read its address."
6585
+ },
6578
6586
  // ── Plans ─────────────────────────────────────────────────────────────
6579
6587
  PLAN_NOT_FOUND: {
6580
- meaning: "No plan has this planId, or it was removed.",
6588
+ meaning: "No plan has this planId: it never existed, or its time limit passed and it was removed.",
6581
6589
  next: "Create a plan with gws_plan, then commit its planId."
6582
6590
  },
6583
6591
  PLAN_ALREADY_USED: {
@@ -6599,15 +6607,15 @@ var ErrorCode = {
6599
6607
  // ── Scripts (the tools RPC) ───────────────────────────────────────────
6600
6608
  SESSION_UNKNOWN: {
6601
6609
  meaning: "A script named a session that does not exist. A script runs as a session, under that session's agent and its permission rules.",
6602
- next: "Pass the id of an existing session, or create one for the agent the script should run as."
6610
+ next: 'Create a session that names an agent, such as POST /api/session with {"agent":"build"}, and pass its id.'
6603
6611
  },
6604
6612
  SESSION_AGENT_REQUIRED: {
6605
6613
  meaning: "A script named a session that names no agent. A script runs as its session's agent, so its permissions and plans never depend on the default agent.",
6606
- next: `Create the script's session with an agent, for example POST /api/session with {"agent":"build"}.`
6614
+ next: 'Create a session that names an agent, such as POST /api/session with {"agent":"build"}, and pass its id.'
6607
6615
  },
6608
6616
  PERMISSION_DENIED: {
6609
6617
  meaning: "The permission rules of the session's agent deny the call, or ask for an approval that a script cannot give, so the call did not run.",
6610
- next: "Run the script as a session whose agent allows the action, or add an allow rule for that action to the agent."
6618
+ next: "Add an allow rule for the tool to the session's agent or to the session, or use a session whose agent allows it."
6611
6619
  },
6612
6620
  // ── Sign-in ───────────────────────────────────────────────────────────
6613
6621
  AUTH_REQUIRED: {
@@ -6618,6 +6626,10 @@ var ErrorCode = {
6618
6626
  meaning: "The sign-in has not granted a scope this operation needs. The plugin checks before gws runs, so nothing ran.",
6619
6627
  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
6628
  },
6629
+ ADMIN_PRIVILEGE_REQUIRED: {
6630
+ meaning: "The sign-in is valid, but an Admin SDK service (admin-reports) refused the call because the account lacks a Workspace admin privilege the API needs, such as Reports. Signing in again as the same account does not help, and the sign-in is not marked as needing renewal. Another service's refusal is never this code.",
6631
+ 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."
6632
+ },
6621
6633
  AUTH_CLIENT_REQUIRED: {
6622
6634
  meaning: "No OAuth client is configured for the sign-in, none was entered in the form, and no earlier sign-in has one.",
6623
6635
  next: "Run /connect and choose Google Workspace, then enter your Desktop OAuth client ID and secret, or ask your administrator."
@@ -6702,8 +6714,8 @@ var ErrorCode = {
6702
6714
  },
6703
6715
  // ── Setup: these stop the plugin from loading ────────────────────────
6704
6716
  HOST_UNTESTED: {
6705
- meaning: "The OpenCode version is outside the tested range in package.json opencode.host.",
6706
- next: "Use a tested OpenCode release, or set allowUntestedHost to true to run anyway with a status warning."
6717
+ meaning: "The OpenCode version is outside the supported range in package.json opencode.host (>=2.2.3 <2.3.0).",
6718
+ next: "Use OpenCode 2.2.3, 2.2.4 or 2.2.5 (tested) or another release in the range, or set allowUntestedHost to true to run anyway; status then shows the host as untested."
6707
6719
  },
6708
6720
  OPTIONS_INVALID: {
6709
6721
  meaning: "The plugin options are not valid: an unknown key, a wrong type, an unknown service, or access or scopes that name something the account does not enable.",
@@ -6768,8 +6780,8 @@ var GwsError = class _GwsError extends Error {
6768
6780
  return stated ? new _GwsError(code2, stated[1], { next: stated[2] }) : new _GwsError(code2, match[2]);
6769
6781
  }
6770
6782
  };
6771
- function sentence(text3) {
6772
- const trimmed = text3.trim();
6783
+ function sentence(text4) {
6784
+ const trimmed = text4.trim();
6773
6785
  const capital = trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
6774
6786
  return /[.!?]$/.test(capital) ? capital : `${capital}.`;
6775
6787
  }
@@ -6785,11 +6797,11 @@ import { readFileSync } from "node:fs";
6785
6797
  import { dirname, join } from "node:path";
6786
6798
  import { fileURLToPath } from "node:url";
6787
6799
 
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 = [
6800
+ // src/domain/restricted.ts
6801
+ var PREFIX = "https://www.googleapis.com/auth/";
6802
+ var APP_ONLY_SCOPES = ["chat.bot", "chat.import", "classroom.addons.student", "classroom.addons.teacher"].map((name) => `${PREFIX}${name}`);
6803
+ var DELEGATION_SCOPES = ["keep", "keep.readonly"].map((name) => `${PREFIX}${name}`);
6804
+ var APP_ONLY_METHODS = [
6793
6805
  // chat: a Chat app's own credentials
6794
6806
  "chat.spaces.completeImport",
6795
6807
  "chat.spaces.messages.attachments.get",
@@ -6822,9 +6834,33 @@ var USER_UNUSABLE_METHODS = [
6822
6834
  "classroom.courses.posts.addOnAttachments.studentSubmissions.patch",
6823
6835
  "classroom.courses.posts.getAddOnContext"
6824
6836
  ];
6825
- var UNUSABLE = new Set(USER_UNUSABLE_METHODS);
6826
- function appOnly(operation) {
6827
- return UNUSABLE.has(operation);
6837
+ var DELEGATION_METHODS = [
6838
+ "keep.media.download",
6839
+ "keep.notes.create",
6840
+ "keep.notes.delete",
6841
+ "keep.notes.get",
6842
+ "keep.notes.list",
6843
+ "keep.notes.permissions.batchCreate",
6844
+ "keep.notes.permissions.batchDelete"
6845
+ ];
6846
+ var DELEGATION_SERVICES = ["keep"];
6847
+ var RESTRICTIONS = new Map([
6848
+ ...APP_ONLY_METHODS.map((id) => [id, "app-only"]),
6849
+ ...DELEGATION_METHODS.map((id) => [id, "domain-wide-delegation"])
6850
+ ]);
6851
+ function restriction(operation) {
6852
+ return RESTRICTIONS.get(operation);
6853
+ }
6854
+ function addsDelegationScope(scopes) {
6855
+ return scopes.some((scope) => DELEGATION_SCOPES.includes(scope));
6856
+ }
6857
+ function delegationRequired(services, scopes) {
6858
+ return addsDelegationScope(scopes) ? [] : services.filter((service) => DELEGATION_SERVICES.includes(service));
6859
+ }
6860
+ function restrictionMarks(operation) {
6861
+ const reason = restriction(operation);
6862
+ if (!reason) return {};
6863
+ return reason === "app-only" ? { appOnly: true, restriction: reason } : { restriction: reason };
6828
6864
  }
6829
6865
 
6830
6866
  // src/domain/helpers.ts
@@ -6945,22 +6981,29 @@ var replyBase = {
6945
6981
  messageId: string2().min(1),
6946
6982
  body: string2().min(1).max(1e5),
6947
6983
  from: string2().email().optional(),
6984
+ to: union([string2(), array(string2())]).optional().describe("Extra To recipients, besides the ones the reply goes to"),
6948
6985
  cc: union([string2(), array(string2())]).optional(),
6949
6986
  bcc: union([string2(), array(string2())]).optional(),
6950
6987
  html: boolean2().optional(),
6951
6988
  draft: boolean2().optional(),
6952
6989
  attachments: array(absolutePath).max(20).optional()
6953
6990
  };
6954
- function replyAdapter(id, name) {
6991
+ var replyAllBase = {
6992
+ ...replyBase,
6993
+ remove: union([string2(), array(string2())]).optional().describe("Addresses to leave out of the reply-all, the sender's included")
6994
+ };
6995
+ function replyAdapter(id, name, all) {
6955
6996
  return {
6956
6997
  id,
6957
6998
  service: "gmail",
6958
6999
  category: "send",
6959
- inputSchema: strictObject(replyBase),
7000
+ inputSchema: strictObject(all ? replyAllBase : replyBase),
6960
7001
  build(input, request) {
6961
7002
  const parsed = this.inputSchema.parse(input);
6962
7003
  const argv = ["gmail", name, "--message-id", parsed.messageId, "--body", parsed.body];
6963
7004
  if (parsed.from) argv.push("--from", parsed.from);
7005
+ if (parsed.to) argv.push("--to", emails(parsed.to).join(","));
7006
+ if (parsed.remove) argv.push("--remove", emails(parsed.remove).join(","));
6964
7007
  if (parsed.cc) argv.push("--cc", emails(parsed.cc).join(","));
6965
7008
  if (parsed.bcc) argv.push("--bcc", emails(parsed.bcc).join(","));
6966
7009
  if (parsed.html) argv.push("--html");
@@ -6973,9 +7016,9 @@ function replyAdapter(id, name) {
6973
7016
  files,
6974
7017
  preview: {
6975
7018
  method: `gmail ${name}`,
6976
- params: { messageId: parsed.messageId, cc: emails(parsed.cc), bcc: emails(parsed.bcc) },
7019
+ params: { messageId: parsed.messageId, to: emails(parsed.to), cc: emails(parsed.cc), bcc: emails(parsed.bcc), ...all ? { remove: emails(parsed.remove) } : {}, draft: parsed.draft ?? false },
6977
7020
  bodySummary: bytes(parsed.body),
6978
- recipients: [...emails(parsed.cc), ...emails(parsed.bcc)],
7021
+ recipients: [...emails(parsed.to), ...emails(parsed.cc), ...emails(parsed.bcc)],
6979
7022
  uploads: parsed.attachments,
6980
7023
  account: request.account,
6981
7024
  category: "send"
@@ -6984,30 +7027,45 @@ function replyAdapter(id, name) {
6984
7027
  }
6985
7028
  };
6986
7029
  }
7030
+ var forwardSchema = strictObject({
7031
+ messageId: string2().min(1),
7032
+ to: union([string2(), array(string2())]),
7033
+ cc: union([string2(), array(string2())]).optional(),
7034
+ bcc: union([string2(), array(string2())]).optional(),
7035
+ from: string2().email().optional(),
7036
+ body: string2().max(1e5).optional().describe("A note above the forwarded message"),
7037
+ html: boolean2().optional(),
7038
+ draft: boolean2().optional(),
7039
+ attachments: array(absolutePath).max(20).optional(),
7040
+ noOriginalAttachments: boolean2().optional().describe("Leave out the original message's attachments")
7041
+ });
6987
7042
  var gmailForward = {
6988
7043
  id: "gmail.forward",
6989
7044
  service: "gmail",
6990
7045
  category: "send",
6991
- inputSchema: strictObject({
6992
- messageId: string2().min(1),
6993
- to: union([string2(), array(string2())]),
6994
- body: string2().max(1e5).optional(),
6995
- html: boolean2().optional()
6996
- }),
7046
+ inputSchema: forwardSchema,
6997
7047
  build(input, request) {
6998
7048
  const parsed = this.inputSchema.parse(input);
6999
7049
  const argv = ["gmail", "+forward", "--message-id", parsed.messageId, "--to", emails(parsed.to).join(",")];
7050
+ if (parsed.cc) argv.push("--cc", emails(parsed.cc).join(","));
7051
+ if (parsed.bcc) argv.push("--bcc", emails(parsed.bcc).join(","));
7052
+ if (parsed.from) argv.push("--from", parsed.from);
7000
7053
  if (parsed.body) argv.push("--body", parsed.body);
7001
7054
  if (parsed.html) argv.push("--html");
7055
+ if (parsed.draft) argv.push("--draft");
7056
+ if (parsed.noOriginalAttachments) argv.push("--no-original-attachments");
7057
+ const files = attach(argv, parsed.attachments);
7002
7058
  return {
7003
7059
  id: this.id,
7004
7060
  category: "send",
7005
7061
  argv,
7062
+ files,
7006
7063
  preview: {
7007
7064
  method: "gmail +forward",
7008
- params: { messageId: parsed.messageId, to: emails(parsed.to) },
7065
+ params: { messageId: parsed.messageId, to: emails(parsed.to), cc: emails(parsed.cc), bcc: emails(parsed.bcc), draft: parsed.draft ?? false },
7009
7066
  bodySummary: parsed.body ? bytes(parsed.body) : void 0,
7010
- recipients: emails(parsed.to),
7067
+ recipients: [...emails(parsed.to), ...emails(parsed.cc), ...emails(parsed.bcc)],
7068
+ uploads: parsed.attachments,
7011
7069
  account: request.account,
7012
7070
  category: "send"
7013
7071
  }
@@ -7021,7 +7079,8 @@ var gmailTriage = {
7021
7079
  inputSchema: strictObject({
7022
7080
  max: number2().int().positive().max(200).optional(),
7023
7081
  query: string2().max(2e3).optional(),
7024
- labels: boolean2().optional()
7082
+ labels: boolean2().optional(),
7083
+ format: outputFormat.optional()
7025
7084
  }),
7026
7085
  build(input, request) {
7027
7086
  const parsed = this.inputSchema.parse(input);
@@ -7029,6 +7088,7 @@ var gmailTriage = {
7029
7088
  if (parsed.max) argv.push("--max", String(parsed.max));
7030
7089
  if (parsed.query) argv.push("--query", parsed.query);
7031
7090
  if (parsed.labels) argv.push("--labels");
7091
+ if (parsed.format) argv.push("--format", parsed.format);
7032
7092
  return {
7033
7093
  id: this.id,
7034
7094
  category: "read",
@@ -7189,8 +7249,8 @@ var weeklyDigest = {
7189
7249
  var ADAPTERS = /* @__PURE__ */ new Map([
7190
7250
  [gmailSend.id, gmailSend],
7191
7251
  [gmailRead.id, gmailRead],
7192
- ["gmail.reply", replyAdapter("gmail.reply", "+reply")],
7193
- ["gmail.reply-all", replyAdapter("gmail.reply-all", "+reply-all")],
7252
+ ["gmail.reply", replyAdapter("gmail.reply", "+reply", false)],
7253
+ ["gmail.reply-all", replyAdapter("gmail.reply-all", "+reply-all", true)],
7194
7254
  [gmailForward.id, gmailForward],
7195
7255
  [gmailTriage.id, gmailTriage],
7196
7256
  [calendarAgenda.id, calendarAgenda],
@@ -7290,9 +7350,9 @@ function commitTool(category) {
7290
7350
  function words(query) {
7291
7351
  return (query ?? "").toLowerCase().split(/\s+/).filter(Boolean);
7292
7352
  }
7293
- function startsWord(text3, token) {
7294
- for (let index2 = text3.indexOf(token); index2 !== -1; index2 = text3.indexOf(token, index2 + 1)) {
7295
- if (index2 === 0 || !/[a-z0-9]/.test(text3[index2 - 1])) return true;
7353
+ function startsWord(text4, token) {
7354
+ for (let index2 = text4.indexOf(token); index2 !== -1; index2 = text4.indexOf(token, index2 + 1)) {
7355
+ if (index2 === 0 || !/[a-z0-9]/.test(text4[index2 - 1])) return true;
7296
7356
  }
7297
7357
  return false;
7298
7358
  }
@@ -7350,7 +7410,7 @@ function catalog(input = {}, services) {
7350
7410
  ...method.pathTemplate ? { path: method.pathTemplate } : {},
7351
7411
  executable: methodExecutable(method),
7352
7412
  ...tool && methodExecutable(method) ? { commitTool: tool } : {},
7353
- ...appOnly(method.id) ? { appOnly: true } : {}
7413
+ ...restrictionMarks(method.id)
7354
7414
  });
7355
7415
  }
7356
7416
  }
@@ -7513,7 +7573,8 @@ var DIRECT = {
7513
7573
  forms: { read: ["forms.body.readonly", "forms.responses.readonly"], write: ["forms.body", "forms.responses.readonly", "drive.file"] },
7514
7574
  // meetings.space.created reaches only spaces this client created; write keeps reading the others.
7515
7575
  meet: { read: ["meetings.space.readonly"], write: ["meetings.space.created", "meetings.space.readonly"] },
7516
- keep: { read: ["keep.readonly"], write: ["keep"] },
7576
+ // Google grants Keep's scopes only through domain-wide delegation (restricted.ts), so neither level requests them.
7577
+ keep: { read: [], write: [] },
7517
7578
  // Reports are read-only at both levels.
7518
7579
  "admin-reports": {
7519
7580
  read: ["admin.reports.audit.readonly", "admin.reports.usage.readonly"],
@@ -7601,15 +7662,18 @@ var ClientJsonSchema = union([
7601
7662
  var OAuthClientSchema = union([DesktopClientSchema, ClientJsonSchema]);
7602
7663
  var accessLevel = _enum(["read", "write"]);
7603
7664
  function defaultAccounts() {
7604
- return { default: { mode: "interactive", enabledServices: loadManifest().services.map((service) => service.alias), access: "write" } };
7665
+ const services = loadManifest().services.map((service) => service.alias).filter((service) => !DELEGATION_SERVICES.includes(service));
7666
+ return { default: { mode: "interactive", enabledServices: services, access: "write" } };
7605
7667
  }
7606
7668
  var enabledServices = array(string2().min(1)).min(1);
7669
+ var domains = array(string2().regex(/^[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)+$/u, "must be a domain such as example.com")).min(1);
7607
7670
  var accountProfile = preprocess(
7608
7671
  (value) => value !== null && typeof value === "object" && !Array.isArray(value) && !("mode" in value) ? { ...value, mode: "interactive" } : value,
7609
7672
  discriminatedUnion("mode", [
7610
7673
  strictObject({
7611
7674
  mode: literal("interactive"),
7612
7675
  enabledServices,
7676
+ domains: domains.optional(),
7613
7677
  access: union([accessLevel, record(string2().min(1), accessLevel)]).optional(),
7614
7678
  scopes: array(string2().min(1)).optional(),
7615
7679
  oauthClient: OAuthClientSchema.optional()
@@ -7617,11 +7681,13 @@ var accountProfile = preprocess(
7617
7681
  strictObject({
7618
7682
  mode: literal("credentials_file"),
7619
7683
  enabledServices,
7684
+ domains: domains.optional(),
7620
7685
  credentialsFileEnv: envVarName
7621
7686
  }),
7622
7687
  strictObject({
7623
7688
  mode: literal("access_token"),
7624
7689
  enabledServices,
7690
+ domains: domains.optional(),
7625
7691
  tokenEnv: envVarName
7626
7692
  })
7627
7693
  ])
@@ -7745,6 +7811,7 @@ function loadConfig(raw) {
7745
7811
  accounts[alias] = {
7746
7812
  mode: profile.mode,
7747
7813
  enabledServices: enabled,
7814
+ ...profile.domains ? { domains: [...new Set(profile.domains.map((domain) => domain.toLowerCase()))] } : {},
7748
7815
  access,
7749
7816
  ...accessSpecified ? { accessSpecified } : {},
7750
7817
  scopes: [...new Set(scopes)],
@@ -7811,10 +7878,9 @@ function loadConfig(raw) {
7811
7878
  };
7812
7879
  }
7813
7880
 
7814
- // src/domain/version.ts
7815
- var PLUGIN_VERSION = "0.0.1";
7816
- var PLUGIN_ID = "google-workspace.gws";
7881
+ // src/domain/host.ts
7817
7882
  var HOST_RANGE = ">=2.2.3 <2.3.0";
7883
+ var TESTED_HOSTS = ["2.2.3", "2.2.4", "2.2.5"];
7818
7884
  var SEMVER = /^v?(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
7819
7885
  var RANGE = /^>=(\d+)\.(\d+)\.(\d+) <(\d+)\.(\d+)\.(\d+)$/;
7820
7886
  function compare(a, b) {
@@ -7833,18 +7899,20 @@ function satisfies(version2, range) {
7833
7899
  return compare(triple, floor) >= 0 && compare(triple, ceiling) < 0;
7834
7900
  }
7835
7901
  function checkHost(version2, range, allowUntested) {
7836
- const text3 = typeof version2 === "string" && version2.trim() ? version2.trim() : "unknown";
7837
- if (satisfies(text3, range)) return { version: text3 };
7838
- const problem = `tested with OpenCode ${range}, found ${text3}`;
7839
- if (!allowUntested) {
7902
+ const text4 = typeof version2 === "string" && version2.trim() ? version2.trim() : void 0;
7903
+ if (!(text4 && satisfies(text4, range)) && !allowUntested) {
7840
7904
  const [major, minor, patch] = bounds(range).floor;
7841
7905
  throw new Error(
7842
- `HOST_UNTESTED: Tested with OpenCode ${range}, found ${text3}. Next step: Use OpenCode ${major}.${minor}.${patch} or a later ${major}.${minor}.x release, or set "allowUntestedHost": true.`
7906
+ `HOST_UNTESTED: Supports OpenCode ${range}, found ${text4 ?? "unknown"}. Next step: Use OpenCode ${major}.${minor}.${patch} or a later ${major}.${minor}.x release, or set "allowUntestedHost": true.`
7843
7907
  );
7844
7908
  }
7845
- return { version: text3, warning: `${problem}; running anyway because allowUntestedHost is true` };
7909
+ return { version: text4 ?? null, range, tested: text4 !== void 0 && TESTED_HOSTS.includes(text4.replace(/^v/, "")) };
7846
7910
  }
7847
7911
 
7912
+ // src/domain/version.ts
7913
+ var PLUGIN_VERSION = "0.0.3";
7914
+ var PLUGIN_ID = "google-workspace.gws";
7915
+
7848
7916
  // src/host/agents.ts
7849
7917
  var ASK_ACTIONS = ["gws.write", "gws.export", "gws.send", "gws.share", "gws.delete", "gws.admin"];
7850
7918
  var ASK_RULES = ASK_ACTIONS.map((action) => ({ action, resource: "*", effect: "ask" }));
@@ -7920,6 +7988,7 @@ var object2 = (properties, required2) => ({ type: "object", properties, required
7920
7988
  var MODES = ["interactive", "credentials_file", "access_token"];
7921
7989
  var ACCESS = ["read", "write"];
7922
7990
  var CLIENT_SOURCES = ["account", "plugin", "pasted"];
7991
+ var STATES = ["connected", "sign_in_required", "not_configured", "host_untested"];
7923
7992
  var account = object2(
7924
7993
  {
7925
7994
  alias: text,
@@ -7932,15 +8001,16 @@ var account = object2(
7932
8001
  clientSource: { enum: CLIENT_SOURCES },
7933
8002
  grantedScopes: texts,
7934
8003
  missingScopes: texts,
8004
+ delegationRequired: texts,
7935
8005
  nextStep: text
7936
8006
  },
7937
8007
  ["alias", "mode", "services", "connected"]
7938
8008
  );
7939
8009
  var status = object2(
7940
8010
  {
7941
- pluginVersion: text,
7942
- hostVersion: text,
7943
- hostWarning: text,
8011
+ version: text,
8012
+ host: object2({ version: { type: ["string", "null"] }, range: text, tested: { type: "boolean" } }, ["version", "range", "tested"]),
8013
+ state: { enum: STATES },
7944
8014
  gws: object2({ path: text, version: text }, ["path", "version"]),
7945
8015
  services: texts,
7946
8016
  accounts: { type: "array", items: account },
@@ -7949,7 +8019,7 @@ var status = object2(
7949
8019
  permissionsBlock: text,
7950
8020
  nextStep: text
7951
8021
  },
7952
- ["pluginVersion", "hostVersion", "gws", "services", "accounts", "unguardedAgents"]
8022
+ ["version", "host", "state", "gws", "services", "accounts", "unguardedAgents", "nextStep"]
7953
8023
  );
7954
8024
  var Gws = Rpc.define({
7955
8025
  id: RPC_ID,
@@ -7967,7 +8037,13 @@ function connectionLabel(account2) {
7967
8037
  return account2.mode === "interactive" ? "sign-in required" : "not configured";
7968
8038
  }
7969
8039
  function openCodeLabel(status2) {
7970
- return status2.hostWarning ? `${status2.hostVersion} (host untested: ${status2.hostWarning})` : `${status2.hostVersion} (tested)`;
8040
+ const version2 = status2.host.version ?? "unknown";
8041
+ return status2.host.tested ? `${version2} (tested)` : `${version2} (host untested; supported range ${status2.host.range})`;
8042
+ }
8043
+ var DELEGATION_LABEL = "Needs domain-wide delegation";
8044
+ function delegationText(account2) {
8045
+ const services = account2.delegationRequired ?? [];
8046
+ return `${services.join(", ")} (a Workspace admin sets it up; then add the scopes to accounts.${account2.alias}.scopes)`;
7971
8047
  }
7972
8048
  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
8049
 
@@ -8064,10 +8140,11 @@ var Lifecycle = class {
8064
8140
  // src/domain/plans.ts
8065
8141
  import { randomBytes } from "node:crypto";
8066
8142
  var MAX_PLANS = 512;
8067
- function redactedPreview(plan) {
8068
- const { method, recipients, destination, bodySummary, reason } = plan.preview;
8143
+ function redactedPreview(plan2) {
8144
+ const { method, recipients, recipientsUnknown, destination, bodySummary, reason } = plan2.preview;
8069
8145
  const view = { method };
8070
8146
  if (recipients && recipients.length > 0) view.recipients = [...recipients];
8147
+ if (recipientsUnknown !== void 0) view.recipientsUnknown = recipientsUnknown;
8071
8148
  if (destination !== void 0) view.destination = destination;
8072
8149
  if (bodySummary !== void 0) view.bodySummary = bodySummary;
8073
8150
  if (reason !== void 0) view.reason = reason;
@@ -8079,14 +8156,14 @@ var PlanStore = class {
8079
8156
  this.sweep();
8080
8157
  const now = Date.now();
8081
8158
  const { ttlSeconds, ...fields } = input;
8082
- const plan = {
8159
+ const plan2 = {
8083
8160
  ...fields,
8084
8161
  id: `plan_${randomBytes(18).toString("base64url")}`,
8085
8162
  state: "ready",
8086
8163
  createdAt: now,
8087
8164
  expiresAt: now + ttlSeconds * 1e3
8088
8165
  };
8089
- this.plans.set(plan.id, plan);
8166
+ this.plans.set(plan2.id, plan2);
8090
8167
  while (this.plans.size > MAX_PLANS) {
8091
8168
  const oldest = this.plans.keys().next().value;
8092
8169
  if (oldest === void 0) break;
@@ -8094,90 +8171,300 @@ var PlanStore = class {
8094
8171
  if (evicted) this.expire(evicted);
8095
8172
  this.plans.delete(oldest);
8096
8173
  }
8097
- return plan;
8174
+ return plan2;
8098
8175
  }
8099
8176
  get(id) {
8100
- const plan = this.plans.get(id);
8101
- if (!plan) return void 0;
8102
- if (plan.state === "ready" && Date.now() >= plan.expiresAt) this.expire(plan);
8103
- return plan;
8177
+ const plan2 = this.plans.get(id);
8178
+ if (!plan2) return void 0;
8179
+ if (plan2.state === "ready" && Date.now() >= plan2.expiresAt) this.expire(plan2);
8180
+ return plan2;
8104
8181
  }
8105
8182
  /** Ready (committable) plans, oldest first, optionally for one session. */
8106
8183
  list(filter = {}) {
8107
8184
  const ready = [];
8108
8185
  for (const id of [...this.plans.keys()]) {
8109
- const plan = this.get(id);
8110
- if (plan?.state !== "ready") continue;
8111
- if (filter.sessionId !== void 0 && plan.sessionId !== filter.sessionId) continue;
8112
- ready.push(plan);
8186
+ const plan2 = this.get(id);
8187
+ if (plan2?.state !== "ready") continue;
8188
+ if (filter.sessionId !== void 0 && plan2.sessionId !== filter.sessionId) continue;
8189
+ ready.push(plan2);
8113
8190
  }
8114
8191
  return ready.sort((a, b) => a.createdAt - b.createdAt);
8115
8192
  }
8116
8193
  /** Validate a commit binding without consuming the plan. */
8117
8194
  check(id, binding) {
8118
- const plan = this.get(id);
8119
- if (!plan) throw new GwsError("PLAN_NOT_FOUND", "run gws_plan first");
8120
- if (plan.state === "consumed") throw new GwsError("PLAN_ALREADY_USED", "create a new plan");
8121
- if (plan.state === "expired" || Date.now() >= plan.expiresAt) throw new GwsError("PLAN_EXPIRED", "create a new plan");
8122
- if (plan.state === "committing") throw new GwsError("PLAN_ALREADY_USED", "plan is committing", { next: "Wait for the running commit to finish; do not commit this plan again." });
8123
- if (plan.sessionId !== binding.sessionId || plan.agentId !== binding.agentId) {
8195
+ const plan2 = this.get(id);
8196
+ if (!plan2) throw new GwsError("PLAN_NOT_FOUND", "run gws_plan first");
8197
+ if (plan2.state === "consumed") {
8198
+ throw new GwsError("PLAN_ALREADY_USED", "this plan was already committed", { next: "Create a new plan to repeat the change; check the earlier result with gws_read first." });
8199
+ }
8200
+ if (plan2.state === "expired" || Date.now() >= plan2.expiresAt) throw new GwsError("PLAN_EXPIRED", "create a new plan");
8201
+ if (plan2.state === "committing") throw new GwsError("PLAN_ALREADY_USED", "plan is committing", { next: "Wait for the running commit to finish; do not commit this plan again." });
8202
+ if (plan2.sessionId !== binding.sessionId || plan2.agentId !== binding.agentId) {
8124
8203
  throw new GwsError("PLAN_CALLER_MISMATCH", "a plan is bound to the session and agent that created it");
8125
8204
  }
8126
- if (plan.category !== binding.category) {
8205
+ if (plan2.category !== binding.category) {
8127
8206
  throw new GwsError("CATEGORY_MISMATCH", "use the commit tool that matches the plan category", { next: "Commit with the tool named by the plan's category, passing only {planId}." });
8128
8207
  }
8129
- return plan;
8208
+ return plan2;
8130
8209
  }
8131
8210
  acquire(id, binding) {
8132
- const plan = this.check(id, binding);
8133
- if (plan.account !== binding.account) {
8211
+ const plan2 = this.check(id, binding);
8212
+ if (plan2.account !== binding.account) {
8134
8213
  throw new GwsError("PLAN_CALLER_MISMATCH", "a plan is bound to the account that created it");
8135
8214
  }
8136
- plan.state = "committing";
8137
- return plan;
8215
+ plan2.state = "committing";
8216
+ return plan2;
8138
8217
  }
8139
8218
  complete(id) {
8140
- const plan = this.plans.get(id);
8141
- if (plan?.state !== "committing") return;
8142
- plan.state = "consumed";
8219
+ const plan2 = this.plans.get(id);
8220
+ if (plan2?.state !== "committing") return;
8221
+ plan2.state = "consumed";
8143
8222
  }
8144
8223
  release(id) {
8145
- const plan = this.plans.get(id);
8146
- if (plan && plan.state === "committing") this.expire(plan);
8224
+ const plan2 = this.plans.get(id);
8225
+ if (plan2 && plan2.state === "committing") this.expire(plan2);
8147
8226
  }
8148
8227
  invalidateAll() {
8149
- for (const plan of this.plans.values()) this.expire(plan);
8228
+ for (const plan2 of this.plans.values()) this.expire(plan2);
8150
8229
  }
8151
- expire(plan) {
8152
- if (plan.state !== "ready" && plan.state !== "committing") return;
8153
- plan.state = "expired";
8230
+ expire(plan2) {
8231
+ if (plan2.state !== "ready" && plan2.state !== "committing") return;
8232
+ plan2.state = "expired";
8154
8233
  }
8155
8234
  sweep() {
8156
8235
  const now = Date.now();
8157
- for (const [id, plan] of this.plans) {
8158
- if (plan.state === "ready" && now >= plan.expiresAt) this.expire(plan);
8159
- if (plan.state === "consumed" || plan.state === "expired" || now >= plan.expiresAt) {
8160
- this.plans.delete(id);
8236
+ for (const [id, plan2] of this.plans) {
8237
+ if (now >= plan2.expiresAt && plan2.state !== "committing") this.plans.delete(id);
8238
+ }
8239
+ }
8240
+ };
8241
+
8242
+ // src/domain/result.ts
8243
+ function isApiError(value) {
8244
+ if (Array.isArray(value)) return value.some(isApiError);
8245
+ if (!value || typeof value !== "object") return false;
8246
+ const record3 = value;
8247
+ return "error" in record3 && record3.error !== null && typeof record3.error === "object";
8248
+ }
8249
+ function parseJson(text4) {
8250
+ const lines = text4.split("\n");
8251
+ const start2 = lines.findIndex((line) => /^[[{]/.test(line.trim()));
8252
+ if (start2 < 0) return void 0;
8253
+ const candidate = lines.slice(start2).join("\n").trim();
8254
+ try {
8255
+ return JSON.parse(candidate);
8256
+ } catch {
8257
+ const parsed = [];
8258
+ for (const line of lines.slice(start2)) {
8259
+ const value = line.trim();
8260
+ if (!/^[[{]/.test(value)) continue;
8261
+ try {
8262
+ parsed.push(JSON.parse(value));
8263
+ } catch {
8161
8264
  }
8162
8265
  }
8266
+ return parsed.length > 0 ? parsed : void 0;
8267
+ }
8268
+ }
8269
+ function inlineCap(maxInlineBytes, mode, requested) {
8270
+ const base2 = mode === "full" ? Math.max(maxInlineBytes, 5 * 1024 * 1024) : maxInlineBytes;
8271
+ return requested === void 0 ? base2 : Math.min(base2, requested);
8272
+ }
8273
+ function parseResult(result, options) {
8274
+ const text4 = result.stdout.toString("utf8");
8275
+ const parsed = parseJson(text4);
8276
+ const serialized = parsed === void 0 ? text4 : JSON.stringify(parsed);
8277
+ const bytes2 = Buffer.byteLength(serialized);
8278
+ const cap = inlineCap(options.maxInlineBytes, options.mode, options.requestedInlineBytes);
8279
+ const truncated = bytes2 > cap;
8280
+ const empty = text4.trim().length === 0;
8281
+ return {
8282
+ // A successful DELETE commonly returns 204 with no body. Empty output
8283
+ // with exit 0 is success; non-JSON text is not a JSON result.
8284
+ ok: result.exitCode === 0 && (empty || parsed !== void 0 && !isApiError(parsed)),
8285
+ data: parsed,
8286
+ raw: parsed === void 0 ? text4.slice(0, cap) : void 0,
8287
+ truncated,
8288
+ diagnostics: { exitCode: result.exitCode, durationMs: result.durationMs, bytes: bytes2 }
8289
+ };
8290
+ }
8291
+ function withoutSavedBody(data, next) {
8292
+ if (!data || typeof data !== "object" || Array.isArray(data)) return data;
8293
+ const record3 = data;
8294
+ const keys = Object.keys(record3);
8295
+ if (typeof record3["saved_file"] !== "string" || typeof record3["bytes"] !== "number" || record3["status"] !== "success") return data;
8296
+ if (!keys.every((key) => ["bytes", "mimeType", "saved_file", "status"].includes(key))) return data;
8297
+ if (record3["bytes"] === 0) return "";
8298
+ return {
8299
+ ...typeof record3["mimeType"] === "string" ? { mimeType: record3["mimeType"] } : {},
8300
+ bytes: record3["bytes"],
8301
+ saved: false,
8302
+ ...next ? { next } : {}
8303
+ };
8304
+ }
8305
+ function itemKey(page2) {
8306
+ if (!page2 || typeof page2 !== "object" || Array.isArray(page2)) return void 0;
8307
+ const record3 = page2;
8308
+ if (Array.isArray(record3["items"])) return "items";
8309
+ const arrays = Object.keys(record3).filter((key) => Array.isArray(record3[key]));
8310
+ return arrays.length === 1 ? arrays[0] : void 0;
8311
+ }
8312
+ function capPageItems(data, maxItems) {
8313
+ const pages = data === void 0 ? [] : Array.isArray(data) ? data : [data];
8314
+ const keys = pages.map((page2) => itemKey(page2) ?? (isEmptyPage(page2) ? null : void 0));
8315
+ if (keys.some((key) => key === void 0)) return { data: pages, pages: pages.length, capped: false };
8316
+ const kept = [];
8317
+ let items = 0;
8318
+ let capped = false;
8319
+ for (const [index2, page2] of pages.entries()) {
8320
+ if (items >= maxItems) {
8321
+ capped = true;
8322
+ break;
8323
+ }
8324
+ const key = keys[index2];
8325
+ if (!key) {
8326
+ kept.push(page2);
8327
+ continue;
8328
+ }
8329
+ const record3 = page2;
8330
+ const list = record3[key];
8331
+ const room = maxItems - items;
8332
+ if (list.length > room) {
8333
+ const { nextPageToken: _dropped, ...rest } = record3;
8334
+ kept.push({ ...rest, [key]: list.slice(0, room) });
8335
+ items += room;
8336
+ capped = true;
8337
+ break;
8338
+ }
8339
+ kept.push(page2);
8340
+ items += list.length;
8163
8341
  }
8342
+ return { data: kept, pages: kept.length, items, capped };
8343
+ }
8344
+ function isEmptyPage(page2) {
8345
+ if (!page2 || typeof page2 !== "object" || Array.isArray(page2)) return false;
8346
+ return !Object.values(page2).some(Array.isArray);
8347
+ }
8348
+ function redactDryRun(value) {
8349
+ if (Array.isArray(value)) return value.map(redactDryRun);
8350
+ if (value && typeof value === "object") {
8351
+ const out = {};
8352
+ for (const [key, child] of Object.entries(value)) {
8353
+ if (/^(body|raw|content)$/i.test(key)) out[key] = "[redacted]";
8354
+ else out[key] = redactDryRun(child);
8355
+ }
8356
+ return out;
8357
+ }
8358
+ return value;
8359
+ }
8360
+ function apiErrorOf(result, parsed) {
8361
+ const data = parsed ? parsed.data : parseJson(result.stdout.toString("utf8"));
8362
+ const found = Array.isArray(data) ? data.find(isApiError) : data;
8363
+ if (!isApiError(found)) return void 0;
8364
+ return found.error;
8365
+ }
8366
+ function stderrText(result) {
8367
+ return result.stderr.toString("utf8").split("\n").filter((line) => line.trim() && !/^Using keyring backend:/i.test(line)).join("\n").trim();
8368
+ }
8369
+ var EXIT = { auth: 2, validation: 3, discovery: 4, internal: 5 };
8370
+ var SCOPE_REASONS = /* @__PURE__ */ new Set(["ACCESS_TOKEN_SCOPE_INSUFFICIENT", "insufficientPermissions"]);
8371
+ 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;
8372
+ function privilegeRefused(result, service, parsed) {
8373
+ if (!isAdminService(service)) return false;
8374
+ if (result.timedOut || result.outputCapped || result.exitCode === 0 || result.exitCode === EXIT.auth) return false;
8375
+ const error2 = apiErrorOf(result, parsed);
8376
+ if (error2?.code !== void 0 && error2.code !== 401 && error2.code !== 403) return false;
8377
+ const text4 = [error2?.message, stderrText(result)].filter(Boolean).join("\n");
8378
+ return !/invalid_grant/i.test(text4) && PRIVILEGE_REFUSALS.test(text4);
8379
+ }
8380
+ function isAdminService(service) {
8381
+ return service.startsWith("admin-");
8382
+ }
8383
+ var ADMIN_PRIVILEGES = { "admin-reports": "Reports" };
8384
+ function privilegeName(service) {
8385
+ const name = service ? ADMIN_PRIVILEGES[service] : void 0;
8386
+ return name ? `the ${name} privilege` : "the admin privilege it needs";
8387
+ }
8388
+ function authProblem(result, service, parsed) {
8389
+ if (result.timedOut || result.outputCapped) return void 0;
8390
+ if (privilegeRefused(result, service, parsed)) return void 0;
8391
+ const error2 = apiErrorOf(result, parsed);
8392
+ if (result.exitCode === 0 && !error2) return void 0;
8393
+ const reasons = [error2?.reason, ...(error2?.errors ?? []).map((item) => item?.reason), ...(error2?.details ?? []).map((item) => item?.reason)];
8394
+ const text4 = [error2?.message, error2?.status, stderrText(result)].filter(Boolean).join("\n");
8395
+ if (reasons.some((reason) => reason !== void 0 && SCOPE_REASONS.has(reason)) || /insufficient authentication scopes|ACCESS_TOKEN_SCOPE_INSUFFICIENT/i.test(text4)) {
8396
+ return "AUTH_SCOPE_MISSING";
8397
+ }
8398
+ if (error2?.code === 401 || error2?.status === "UNAUTHENTICATED" || reasons.includes("authError") || /invalid_grant|\bUNAUTHENTICATED\b|^error\[auth\]/im.test(text4)) {
8399
+ return "AUTH_REQUIRED";
8400
+ }
8401
+ return void 0;
8402
+ }
8403
+ var GENERIC_REMEDIATION = {
8404
+ signIn: "sign in to the account again",
8405
+ consent: "sign in again and grant the scope this operation needs",
8406
+ privilege: "sign in with a Workspace admin account that has the admin privilege this operation needs, or remove the service from enabledServices"
8164
8407
  };
8408
+ var DISABLED_REASONS = /* @__PURE__ */ new Set(["accessNotConfigured", "SERVICE_DISABLED"]);
8409
+ var CONSOLE_URL = /https:\/\/console\.(?:developers|cloud)\.google\.com\/[^\s"'<>)]+/;
8410
+ function disabledApi(result, parsed) {
8411
+ const error2 = apiErrorOf(result, parsed);
8412
+ const reasons = [error2?.reason, ...(error2?.errors ?? []).map((item) => item?.reason), ...(error2?.details ?? []).map((item) => item?.reason)];
8413
+ const text4 = [error2?.message, stderrText(result)].filter(Boolean).join("\n");
8414
+ if (!reasons.some((reason) => reason !== void 0 && DISABLED_REASONS.has(reason)) && !/accessnotconfigured|has not been used|is disabled/i.test(text4)) {
8415
+ return void 0;
8416
+ }
8417
+ const enableUrl = (typeof error2?.enable_url === "string" && CONSOLE_URL.test(error2.enable_url) ? error2.enable_url : CONSOLE_URL.exec(text4)?.[0])?.replace(/[.,;]+$/, "");
8418
+ const project = (enableUrl ? /[?&]project=([\w.:-]+)/.exec(enableUrl)?.[1] : void 0) ?? /\bin project ([\w.:-]+)/.exec(text4)?.[1];
8419
+ return { ...enableUrl ? { enableUrl } : {}, ...project ? { project } : {} };
8420
+ }
8421
+ function processError(result, parsed, service, remediation = GENERIC_REMEDIATION) {
8422
+ if (result.timedOut) return new GwsError("TIMEOUT", `gws did not finish within the configured execution.timeoutMs (${result.durationMs}ms elapsed)`);
8423
+ if (result.outputCapped) return new GwsError("OUTPUT_LIMIT", "gws output exceeded execution.maxStdoutBytes/maxStderrBytes; narrow fields or pagination");
8424
+ const stderr = stderrText(result);
8425
+ const payload = apiErrorOf(result, parsed);
8426
+ const message = payload?.message || stderr || "gws command failed";
8427
+ if (privilegeRefused(result, service, parsed)) {
8428
+ return new GwsError(
8429
+ "ADMIN_PRIVILEGE_REQUIRED",
8430
+ `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)})`,
8431
+ { next: `${remediation.privilege.charAt(0).toUpperCase()}${remediation.privilege.slice(1)}.` }
8432
+ );
8433
+ }
8434
+ const problem = authProblem(result, service, parsed) ?? (result.exitCode === EXIT.auth ? "AUTH_REQUIRED" : void 0);
8435
+ if (problem === "AUTH_SCOPE_MISSING") {
8436
+ return new GwsError("AUTH_SCOPE_MISSING", `the credential lacks a scope for this operation; ${remediation.consent} (gws: ${message.slice(0, 300)})`);
8437
+ }
8438
+ if (problem === "AUTH_REQUIRED") {
8439
+ return new GwsError("AUTH_REQUIRED", `the Google Workspace sign-in is missing or no longer valid; ${remediation.signIn} (gws: ${message.slice(0, 300)})`);
8440
+ }
8441
+ const disabled = disabledApi(result, parsed);
8442
+ if (disabled) {
8443
+ const where = disabled.project ? ` for project ${disabled.project}` : "";
8444
+ 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.`;
8445
+ return new GwsError("API_DISABLED", message.slice(0, 500), { next, ...disabled.enableUrl ? { enableUrl: disabled.enableUrl } : {} });
8446
+ }
8447
+ if (result.exitCode === EXIT.validation) return new GwsError("GWS_VALIDATION_ERROR", `gws rejected the request: ${message.slice(0, 500)}`);
8448
+ if (result.exitCode === EXIT.discovery) return new GwsError("GWS_DISCOVERY_ERROR", `gws could not load the API description: ${message.slice(0, 500)}`);
8449
+ if (result.exitCode === EXIT.internal) return new GwsError("GWS_INTERNAL_ERROR", `gws failed: ${message.slice(0, 500)}`);
8450
+ return new GwsError("GWS_API_ERROR", message.slice(0, 500));
8451
+ }
8165
8452
 
8166
8453
  // src/host/commands.ts
8167
8454
  var COMMANDS = {
8168
8455
  status: { name: "gws-status", description: "Google Workspace: sign-in, access, pending plans, setup steps, and unguarded agents" },
8169
8456
  doctor: { name: "gws-doctor", description: "Google Workspace: check every account's services with one small read each" }
8170
8457
  };
8171
- function planSummary(plan) {
8458
+ function planSummary(plan2) {
8172
8459
  return {
8173
- planId: plan.id,
8174
- sessionID: plan.sessionId,
8175
- category: plan.category,
8176
- operation: plan.operation,
8177
- account: plan.account,
8178
- preview: redactedPreview(plan),
8179
- createdAt: new Date(plan.createdAt).toISOString(),
8180
- expiresAt: new Date(plan.expiresAt).toISOString()
8460
+ planId: plan2.id,
8461
+ sessionID: plan2.sessionId,
8462
+ category: plan2.category,
8463
+ operation: plan2.operation,
8464
+ account: plan2.account,
8465
+ preview: redactedPreview(plan2),
8466
+ createdAt: new Date(plan2.createdAt).toISOString(),
8467
+ expiresAt: new Date(plan2.expiresAt).toISOString()
8181
8468
  };
8182
8469
  }
8183
8470
  function summarizeStatus(status2, plans = []) {
@@ -8219,6 +8506,7 @@ function accountLines(account2) {
8219
8506
  lines.push(`Services: ${account2.services.join(", ") || "none"}`);
8220
8507
  if (account2.grantedScopes) lines.push(`Granted scopes: ${scopeList(account2.grantedScopes)}`);
8221
8508
  if (account2.missingScopes?.length) lines.push(`Scopes not granted: ${scopeList(account2.missingScopes)}`);
8509
+ if (account2.delegationRequired?.length) lines.push(`${DELEGATION_LABEL}: ${delegationText(account2)}`);
8222
8510
  return lines;
8223
8511
  }
8224
8512
  function formatStatus(status2, plans = [], now = Date.now()) {
@@ -8235,7 +8523,7 @@ function formatStatus(status2, plans = [], now = Date.now()) {
8235
8523
  lines.push(`Agents without default rules: ${status2.unguardedAgents.join(", ")}`, status2.permissionsBlock ?? "", BLOCK_PLACEMENT);
8236
8524
  }
8237
8525
  if (status2.auditDegraded) lines.push("Audit: some records could not be saved, so the audit log may be incomplete.");
8238
- lines.push(`OpenCode: ${openCodeLabel(status2)}`, `Next step: ${status2.nextStep ?? NOTHING_TO_DO}`);
8526
+ lines.push(`OpenCode: ${openCodeLabel(status2)}`, `Next step: ${status2.nextStep}`);
8239
8527
  return lines.join("\n");
8240
8528
  }
8241
8529
  function planLines(plans, now) {
@@ -8244,14 +8532,15 @@ function planLines(plans, now) {
8244
8532
  lines.push("No pending plans in this session.");
8245
8533
  } else {
8246
8534
  lines.push(`Pending plans in this session (${plans.length}):`);
8247
- for (const plan of plans) {
8535
+ for (const plan2 of plans) {
8248
8536
  const details = [
8249
- plan.preview.recipients?.length ? `to ${plan.preview.recipients.join(", ")}` : void 0,
8250
- plan.preview.destination ? `destination ${plan.preview.destination}` : void 0,
8251
- plan.preview.bodySummary ? `body ${plan.preview.bodySummary}` : void 0,
8252
- `expires ${remaining(plan.expiresAt, now)}`
8537
+ plan2.preview.recipients?.length ? `to ${plan2.preview.recipients.join(", ")}` : void 0,
8538
+ plan2.preview.recipientsUnknown ? `recipients unknown: ${plan2.preview.recipientsUnknown}` : void 0,
8539
+ plan2.preview.destination ? `destination ${plan2.preview.destination}` : void 0,
8540
+ plan2.preview.bodySummary ? `body ${plan2.preview.bodySummary}` : void 0,
8541
+ `expires ${remaining(plan2.expiresAt, now)}`
8253
8542
  ].filter(Boolean);
8254
- lines.push(`- ${plan.category} ${plan.operation} as ${plan.account}: ${details.join(" \xB7 ")} (${plan.planId})`);
8543
+ lines.push(`- ${plan2.category} ${plan2.operation} as ${plan2.account}: ${details.join(" \xB7 ")} (${plan2.planId})`);
8255
8544
  }
8256
8545
  lines.push("Approval happens only in the OpenCode permission prompt when the agent commits a plan. Pending plans expire automatically.");
8257
8546
  }
@@ -8289,6 +8578,8 @@ function nextFor(outcome) {
8289
8578
  return `enable it at ${outcome.enableUrl}, wait about a minute, and run /gws-doctor again`;
8290
8579
  case "DENIED":
8291
8580
  return "allow gws.read for this account in your permissions, or skip the check";
8581
+ case "ADMIN_PRIVILEGE_REQUIRED":
8582
+ return `sign in with a Workspace admin account that has ${privilegeName(outcome.service)}, or remove ${outcome.service} from accounts.${outcome.account}.enabledServices`;
8292
8583
  default:
8293
8584
  return void 0;
8294
8585
  }
@@ -8298,8 +8589,8 @@ function formatDoctor(outcomes) {
8298
8589
  if (outcomes.length === 0) lines.push("No account enables a service.");
8299
8590
  for (const outcome of outcomes) {
8300
8591
  const next = nextFor(outcome);
8301
- const text3 = [outcome.detail, next ? `Next step: ${next.charAt(0).toUpperCase()}${next.slice(1)}` : void 0].filter(Boolean).join(". ");
8302
- lines.push(`- ${outcome.account} ${outcome.service}: ${outcome.status}${text3 ? ` \xB7 ${text3}` : ""}`);
8592
+ const text4 = [outcome.detail, next ? `Next step: ${next.charAt(0).toUpperCase()}${next.slice(1)}` : void 0].filter(Boolean).join(". ");
8593
+ lines.push(`- ${outcome.account} ${outcome.service}: ${outcome.status}${text4 ? ` \xB7 ${text4}` : ""}`);
8303
8594
  }
8304
8595
  return lines.join("\n");
8305
8596
  }
@@ -8310,9 +8601,9 @@ function summarizeDoctor(outcomes) {
8310
8601
  }
8311
8602
  async function registerCommands(ctx, sources, signal) {
8312
8603
  const lifetime = new Lifecycle();
8313
- const post = (sessionID, delivery, text3, description, command) => lifetime.wait(() => ctx.session.synthetic({
8604
+ const post = (sessionID, delivery, text4, description, command) => lifetime.wait(() => ctx.session.synthetic({
8314
8605
  sessionID,
8315
- text: text3,
8606
+ text: text4,
8316
8607
  description,
8317
8608
  metadata: { source: "opencode-gws", command },
8318
8609
  delivery,
@@ -8424,6 +8715,11 @@ function accessOptions(profile) {
8424
8715
  if (!offersWrite(profile)) return [read];
8425
8716
  return [read, { value: "write", label: "Read and write", description: `Requests: ${signInScopes(profile, "write").join(" ")}` }];
8426
8717
  }
8718
+ function delegationNote(alias, profile) {
8719
+ const services = delegationRequired(profile.enabledServices, profile.scopes);
8720
+ if (services.length === 0) return "";
8721
+ 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.`;
8722
+ }
8427
8723
  function defaultAccess(profile) {
8428
8724
  const access = profile.access;
8429
8725
  return access === "write" || typeof access === "object" && Object.values(access).includes("write") ? "write" : "read";
@@ -8500,7 +8796,7 @@ function signInForm(alias, profile, configured, redirect = {}) {
8500
8796
  key: "access",
8501
8797
  type: "string",
8502
8798
  title: "Access",
8503
- description: `What ${alias} may do in Google Workspace. Read-only cannot change, send, share, or delete anything.`,
8799
+ description: `What ${alias} may do in Google Workspace. Read-only cannot change, send, share, or delete anything.${delegationNote(alias, profile)}`,
8504
8800
  required: true,
8505
8801
  default: defaultAccess(profile),
8506
8802
  options: accessOptions(profile)
@@ -8659,8 +8955,8 @@ function tokenError(status2, json, secrets) {
8659
8955
  message = scrubSecrets(message, secrets);
8660
8956
  return new Error(message);
8661
8957
  }
8662
- function scrubSecrets(text3, secrets) {
8663
- let out = text3;
8958
+ function scrubSecrets(text4, secrets) {
8959
+ let out = text4;
8664
8960
  for (const secret of secrets) {
8665
8961
  if (secret) out = out.split(secret).join("[redacted]");
8666
8962
  }
@@ -8682,15 +8978,15 @@ async function postToken(endpoint, body, fetchImpl, secrets) {
8682
8978
  } catch {
8683
8979
  throw new Error("Google token request failed: could not reach the token endpoint.");
8684
8980
  }
8685
- let text3 = "";
8981
+ let text4 = "";
8686
8982
  try {
8687
- text3 = await response.text();
8983
+ text4 = await response.text();
8688
8984
  } catch {
8689
8985
  }
8690
8986
  let json = {};
8691
- if (text3.trim()) {
8987
+ if (text4.trim()) {
8692
8988
  try {
8693
- const parsed = JSON.parse(text3);
8989
+ const parsed = JSON.parse(text4);
8694
8990
  if (isRecord(parsed)) json = parsed;
8695
8991
  } catch {
8696
8992
  if (response.ok) throw new Error("Google token endpoint returned an invalid response.");
@@ -9150,22 +9446,27 @@ function nextStep(accounts, agents) {
9150
9446
  if (agents.length > 0) return "Put the permission block first in the own permissions of each agent without default rules, followed by your top-level rules for gws actions.";
9151
9447
  return void 0;
9152
9448
  }
9449
+ function stateOf(accounts, tested) {
9450
+ if (accounts.some((account2) => account2.needsAuth || !account2.connected && account2.mode === "interactive")) return "sign_in_required";
9451
+ if (accounts.some((account2) => !account2.connected)) return "not_configured";
9452
+ return tested ? "connected" : "host_untested";
9453
+ }
9153
9454
  function statusSource(runtime, listAgents) {
9154
9455
  return async (signal) => {
9155
9456
  const base2 = await runtime.status(signal);
9156
9457
  const agents = unguardedAgents(await runtime.wait(() => listAgents(), signal));
9157
9458
  const next = nextStep(base2.accounts, agents);
9158
9459
  return {
9159
- pluginVersion: base2.pluginVersion,
9160
- hostVersion: base2.hostVersion,
9161
- ...base2.hostWarning ? { hostWarning: base2.hostWarning } : {},
9460
+ version: base2.version,
9461
+ host: base2.host,
9462
+ state: stateOf(base2.accounts, base2.host.tested),
9162
9463
  gws: { path: base2.binary.path, version: base2.binary.version },
9163
9464
  services: base2.services,
9164
9465
  accounts: base2.accounts,
9165
9466
  auditDegraded: base2.auditDegraded,
9166
9467
  unguardedAgents: agents,
9167
9468
  ...agents.length > 0 ? { permissionsBlock: permissionsBlock(agents) } : {},
9168
- ...next ? { nextStep: next } : {}
9469
+ nextStep: next ?? NOTHING_TO_DO
9169
9470
  };
9170
9471
  };
9171
9472
  }
@@ -9210,25 +9511,25 @@ function operationRequest(call2, action, target) {
9210
9511
  metadata: compact({ ...mark(call2), account: target.account, operation: target.operation, service: target.service, category: target.category })
9211
9512
  };
9212
9513
  }
9213
- function planMetadata(plan) {
9514
+ function planMetadata(plan2) {
9214
9515
  return compact({
9215
- planId: plan.id,
9216
- category: plan.category,
9217
- operation: plan.operation,
9218
- account: plan.account,
9219
- ...redactedPreview(plan),
9220
- expiresAt: new Date(plan.expiresAt).toISOString()
9516
+ planId: plan2.id,
9517
+ category: plan2.category,
9518
+ operation: plan2.operation,
9519
+ account: plan2.account,
9520
+ ...redactedPreview(plan2),
9521
+ expiresAt: new Date(plan2.expiresAt).toISOString()
9221
9522
  });
9222
9523
  }
9223
9524
  var SAVABLE = /* @__PURE__ */ new Set(["write", "export"]);
9224
- function commitRequests(call2, action, plan) {
9225
- const id = resource(plan.account, plan.operation);
9226
- const request = { ...base(call2, action), resources: [id], metadata: planMetadata(plan) };
9227
- if (SAVABLE.has(plan.category)) request.save = [id];
9525
+ function commitRequests(call2, action, plan2) {
9526
+ const id = resource(plan2.account, plan2.operation);
9527
+ const request = { ...base(call2, action), resources: [id], metadata: planMetadata(plan2) };
9528
+ if (SAVABLE.has(plan2.category)) request.save = [id];
9228
9529
  return [request];
9229
9530
  }
9230
- function compact(record2) {
9231
- return Object.fromEntries(Object.entries(record2).filter(([, value]) => value !== void 0));
9531
+ function compact(record3) {
9532
+ return Object.fromEntries(Object.entries(record3).filter(([, value]) => value !== void 0));
9232
9533
  }
9233
9534
 
9234
9535
  // src/host/tools.ts
@@ -9304,6 +9605,9 @@ var Diagnostics = object({
9304
9605
  bytes: number2().describe("Serialized size of data")
9305
9606
  }).describe("gws process facts");
9306
9607
  var FileInfo = object({ path: string2(), bytes: number2(), sha256: string2() }).describe("A local file the plugin delivered");
9608
+ var Restriction = _enum(["app-only", "domain-wide-delegation"]).describe(
9609
+ "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"
9610
+ );
9307
9611
  var CatalogEntry = object({
9308
9612
  kind: _enum(["service", "method", "helper"]),
9309
9613
  id: string2().describe("Pass a method or helper id as operation"),
@@ -9315,7 +9619,8 @@ var CatalogEntry = object({
9315
9619
  executable: boolean2().describe("The plugin can run it (a service: at least one of its operations); false for denied, deploy, and watch entries"),
9316
9620
  commitTool: string2().optional().describe('The tool that commits its plan, e.g. "gws_send"; absent for reads'),
9317
9621
  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")
9622
+ 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"),
9623
+ restriction: Restriction.optional()
9319
9624
  });
9320
9625
  var CatalogOutput = object({
9321
9626
  entries: array(CatalogEntry),
@@ -9343,6 +9648,7 @@ var SchemaOutput = object({
9343
9648
  account: string2(),
9344
9649
  schema: ResolvedSchema.optional().describe("Present for a method"),
9345
9650
  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"),
9651
+ restriction: Restriction.optional(),
9346
9652
  helper: HelperEntry.extend({
9347
9653
  input: unknown().optional().describe("JSON Schema of the helper's input flags; absent when the helper cannot run")
9348
9654
  }).optional().describe("Present for a helper")
@@ -9366,7 +9672,8 @@ var Preview = object({
9366
9672
  url: string2().nullable().optional(),
9367
9673
  params: JsonObject.describe("Parameters with secret-like values redacted"),
9368
9674
  bodySummary: string2().optional().describe("Body keys and size; never the body"),
9369
- recipients: array(string2()).optional(),
9675
+ recipients: array(string2()).optional().describe("Every address a send or share reaches, including those read for a reply, a draft or an event; *@domain for a whole domain"),
9676
+ recipientsUnknown: string2().optional().describe("Why the plugin could not tell everyone this send or share reaches"),
9370
9677
  uploads: array(string2()).optional().describe("Local files the commit uploads or attaches"),
9371
9678
  destination: string2().optional().describe("Local file the export writes"),
9372
9679
  overwrite: boolean2().optional().describe("The export replaces an existing file at destination"),
@@ -9392,177 +9699,6 @@ var CommitOutput = object({
9392
9699
  cleanupDegraded: boolean2().optional().describe("The change succeeded, but private staging cleanup failed; do not retry the mutation")
9393
9700
  });
9394
9701
 
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
9702
  // src/domain/status.ts
9567
9703
  var AccountStatus = object({
9568
9704
  alias: string2(),
@@ -9575,19 +9711,26 @@ var AccountStatus = object({
9575
9711
  clientSource: _enum(["account", "plugin", "pasted"]).optional().describe("Where the sign-in's OAuth client comes from"),
9576
9712
  grantedScopes: array(string2()).optional().describe("Scopes Google granted to the current sign-in"),
9577
9713
  missingScopes: array(string2()).optional().describe("Scopes the enabled services need at this access level that were not granted"),
9714
+ 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
9715
  nextStep: string2().optional().describe("What to do next; absent when nothing is needed")
9579
9716
  });
9717
+ var Host = object({
9718
+ version: string2().nullable().describe("The OpenCode version; null when unknown"),
9719
+ range: string2().describe("The supported range, as in package.json opencode.host"),
9720
+ tested: boolean2().describe("True only for a release the plugin is tested on")
9721
+ });
9722
+ var STATES2 = ["connected", "sign_in_required", "not_configured", "host_untested"];
9580
9723
  var StatusOutput = object({
9581
- pluginVersion: string2(),
9582
- hostVersion: string2(),
9583
- hostWarning: string2().optional().describe("Present when the OpenCode host is outside the tested range and allowUntestedHost is set"),
9724
+ version: string2().describe("The plugin's version"),
9725
+ host: Host,
9726
+ state: _enum(STATES2).describe("connected; sign_in_required or not_configured when an account needs it; host_untested on an untested OpenCode"),
9584
9727
  gws: object({ path: string2(), version: string2() }),
9585
9728
  services: array(string2()).describe("Model-facing services"),
9586
9729
  accounts: array(AccountStatus),
9587
9730
  auditDegraded: boolean2().describe("Audit records were dropped, failed, or exceeded the sink deadline; sticky until reload"),
9588
9731
  unguardedAgents: number2().int().describe("How many agents missed the default ask rules"),
9589
9732
  permissions: string2().optional().describe("Present when an agent missed the ask rules: where to find the fix"),
9590
- nextStep: string2().optional().describe("The one thing to do next; absent when nothing is needed")
9733
+ nextStep: string2().describe('The one thing to do next; "Nothing to do." when nothing is needed')
9591
9734
  });
9592
9735
 
9593
9736
  // src/host/tools.ts
@@ -9691,6 +9834,7 @@ function toolSpecs(runtime, assert2, status2) {
9691
9834
  report(call2, input.operation);
9692
9835
  const target = runtime.inspect(input, "plan");
9693
9836
  await authorize(call2, operationRequest(call2.host, "gws.plan", target));
9837
+ for (const operation of target.reads ?? []) await authorize(call2, operationRequest(call2.host, "gws.read", { ...target, operation, category: "read" }));
9694
9838
  const output = await runtime.plan(input, call2.runtime);
9695
9839
  return { output, metadata: { operation: target.operation, category: output.category, account: target.account } };
9696
9840
  }
@@ -9704,11 +9848,11 @@ function toolSpecs(runtime, assert2, status2) {
9704
9848
  input: CommitInput,
9705
9849
  output: CommitOutput,
9706
9850
  run: async (input, call2) => {
9707
- const plan = runtime.inspectPlan(category, input, call2.runtime);
9708
- report(call2, plan.operation);
9709
- for (const request of commitRequests(call2.host, `gws.${category}`, plan)) await authorize(call2, request);
9851
+ const plan2 = runtime.inspectPlan(category, input, call2.runtime);
9852
+ report(call2, plan2.operation);
9853
+ for (const request of commitRequests(call2.host, `gws.${category}`, plan2)) await authorize(call2, request);
9710
9854
  const output = await runtime.commit(category, input, call2.runtime);
9711
- return { output, metadata: { operation: plan.operation, category, account: plan.account } };
9855
+ return { output, metadata: { operation: plan2.operation, category, account: plan2.account } };
9712
9856
  }
9713
9857
  })
9714
9858
  )
@@ -9785,28 +9929,51 @@ function report(call2, operation) {
9785
9929
  call2.runtime.progress?.({ status: "resolving", operation });
9786
9930
  }
9787
9931
  function render(output, maxBytes) {
9788
- const text3 = JSON.stringify(output) ?? "";
9789
- const bytes2 = Buffer.byteLength(text3);
9790
- if (bytes2 <= maxBytes) return { text: text3, truncated: false };
9791
- const cut = Buffer.from(text3).subarray(0, maxBytes).toString("utf8");
9932
+ const text4 = JSON.stringify(output) ?? "";
9933
+ const bytes2 = Buffer.byteLength(text4);
9934
+ if (bytes2 <= maxBytes) return { text: text4, truncated: false };
9935
+ const cut = Buffer.from(text4).subarray(0, maxBytes).toString("utf8");
9792
9936
  return {
9793
9937
  text: `${cut}
9794
9938
  \u2026[truncated: ${maxBytes} of ${bytes2} bytes shown; narrow fields, params or pagination to see the rest]`,
9795
9939
  truncated: true
9796
9940
  };
9797
9941
  }
9798
- function compact2(record2) {
9799
- return Object.fromEntries(Object.entries(record2).filter(([, value]) => value !== void 0));
9942
+ function compact2(record3) {
9943
+ return Object.fromEntries(Object.entries(record3).filter(([, value]) => value !== void 0));
9800
9944
  }
9801
9945
 
9802
9946
  // src/host/scripting.ts
9803
9947
  var TOOLS_RPC_ID = `${RPC_ID}.tools`;
9804
9948
  var COMMIT_TOOLS = /* @__PURE__ */ new Set(["write", "send", "share", "delete", "export", "admin"]);
9805
9949
  var UNATTENDED = "A script cannot answer an approval prompt; this needs an allow rule for the session's agent.";
9806
- var Envelope = strictObject({
9807
- sessionID: string2().min(1),
9808
- input: unknown()
9809
- });
9950
+ var ENVELOPE_MESSAGES = {
9951
+ notObject: `the envelope must be an object, such as {"sessionID":"<session>","input":<the tool's input>}`,
9952
+ unknownKey: (key) => `"${key}" is not allowed`,
9953
+ sessionID: `"sessionID" must be a non-empty string`,
9954
+ inputMissing: `"input" is missing`,
9955
+ describe: "describe takes no input"
9956
+ };
9957
+ var plainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
9958
+ function strict(schema, validate2) {
9959
+ return Object.defineProperty({ ...schema }, "~standard", { value: { version: 1, vendor: "opencode-gws", validate: validate2 }, enumerable: false });
9960
+ }
9961
+ var ENVELOPE = strict(
9962
+ { type: "object", properties: { sessionID: { type: "string", minLength: 1 }, input: {} }, required: ["sessionID", "input"], additionalProperties: false },
9963
+ (value) => {
9964
+ if (!plainObject(value)) return { issues: [{ message: ENVELOPE_MESSAGES.notObject }] };
9965
+ const found = [
9966
+ ...Object.keys(value).filter((key) => key !== "sessionID" && key !== "input").map(ENVELOPE_MESSAGES.unknownKey),
9967
+ ...typeof value["sessionID"] === "string" && value["sessionID"] !== "" ? [] : [ENVELOPE_MESSAGES.sessionID],
9968
+ ..."input" in value ? [] : [ENVELOPE_MESSAGES.inputMissing]
9969
+ ];
9970
+ return found.length === 0 ? { value } : { issues: found.map((message) => ({ message })) };
9971
+ }
9972
+ );
9973
+ var DESCRIBE_INPUT = strict(
9974
+ { type: "object", properties: {}, additionalProperties: false },
9975
+ (value) => value === void 0 || value === null || plainObject(value) && Object.keys(value).length === 0 ? { value } : { issues: [{ message: ENVELOPE_MESSAGES.describe }] }
9976
+ );
9810
9977
  var Failure = strictObject({ code: string2(), next: string2() });
9811
9978
  var Description = strictObject({
9812
9979
  id: string2(),
@@ -9821,6 +9988,12 @@ var Description = strictObject({
9821
9988
  })
9822
9989
  )
9823
9990
  });
9991
+ function sessionNotFound(error2) {
9992
+ if (error2 === null || typeof error2 !== "object") return false;
9993
+ const record3 = error2;
9994
+ const tag = record3["_tag"] ?? record3["name"];
9995
+ return tag === "Session.NotFoundError" || tag === "SessionNotFoundError" || record3["status"] === 404 || record3["statusCode"] === 404;
9996
+ }
9824
9997
  async function registerScripting(ctx, runtime, status2) {
9825
9998
  const assert2 = async (request) => {
9826
9999
  try {
@@ -9836,8 +10009,8 @@ async function registerScripting(ctx, runtime, status2) {
9836
10009
  try {
9837
10010
  agent = (await runtime.wait(() => ctx.session.get({ sessionID }), signal)).agent;
9838
10011
  } catch (error2) {
9839
- if (GwsError.from(error2)?.code === "CANCELLED") throw error2;
9840
- throw new GwsError("SESSION_UNKNOWN", `there is no session '${sessionID}'`);
10012
+ if (sessionNotFound(error2)) throw new GwsError("SESSION_UNKNOWN", `there is no session '${sessionID}'`);
10013
+ throw error2;
9841
10014
  }
9842
10015
  if (!agent) throw new GwsError("SESSION_AGENT_REQUIRED", `session '${sessionID}' names no agent`);
9843
10016
  return { sessionID, agent, script: true };
@@ -9859,8 +10032,8 @@ async function registerScripting(ctx, runtime, status2) {
9859
10032
  const contract = Rpc2.define({
9860
10033
  id: TOOLS_RPC_ID,
9861
10034
  methods: {
9862
- describe: { input: strictObject({}).optional(), output: Description },
9863
- ...Object.fromEntries(specs.map((spec2) => [spec2.name, { input: Envelope, output: spec2.output, errors: { failed: Failure } }]))
10035
+ describe: { input: DESCRIBE_INPUT, output: Description },
10036
+ ...Object.fromEntries(specs.map((spec2) => [spec2.name, { input: ENVELOPE, output: spec2.output, errors: { failed: Failure } }]))
9864
10037
  },
9865
10038
  events: {}
9866
10039
  });
@@ -9963,12 +10136,12 @@ async function registerSkills(ctx, config2, visibleServices) {
9963
10136
  }
9964
10137
 
9965
10138
  // src/runtime/index.ts
9966
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
9967
- import { tmpdir } from "node:os";
9968
- import { join as join8 } from "node:path";
10139
+ import { mkdtempSync as mkdtempSync2, rmSync as rmSync2 } from "node:fs";
10140
+ import { tmpdir as tmpdir2 } from "node:os";
10141
+ import { join as join9 } from "node:path";
9969
10142
 
9970
10143
  // src/domain/audit.ts
9971
- import { writeFile, mkdir } from "node:fs/promises";
10144
+ import { mkdir, rename, stat, writeFile } from "node:fs/promises";
9972
10145
  import { write } from "node:fs";
9973
10146
  import { dirname as dirname3 } from "node:path";
9974
10147
  var EVENTS = /* @__PURE__ */ new Set(["plugin.loaded", "operation.read", "helper.read", "operation.plan", "operation.commit"]);
@@ -9977,6 +10150,7 @@ var UNKNOWN_ERROR = "UNKNOWN_ERROR";
9977
10150
  function auditErrorCode(error2) {
9978
10151
  return GwsError.from(error2)?.code ?? UNKNOWN_ERROR;
9979
10152
  }
10153
+ var AUDIT_MAX_BYTES = 5 * 1024 * 1024;
9980
10154
  var MAX_QUEUE = 64;
9981
10155
  var WRITE_TIMEOUT_MS = 100;
9982
10156
  var Audit = class {
@@ -9987,6 +10161,9 @@ var Audit = class {
9987
10161
  if (this.config.sink === "jsonl" && this.config.path) {
9988
10162
  await mkdir(dirname3(this.config.path), { recursive: true, mode: 448 });
9989
10163
  if (signal.aborted) return;
10164
+ const size = await stat(this.config.path).then((found) => found.size, () => 0);
10165
+ if (size > 0 && size + Buffer.byteLength(line) > AUDIT_MAX_BYTES) await rename(this.config.path, `${this.config.path}.1`);
10166
+ if (signal.aborted) return;
9990
10167
  await writeFile(this.config.path, line, { flag: "a", mode: 384, signal });
9991
10168
  } else {
9992
10169
  await writeStderr(line, signal);
@@ -10106,9 +10283,9 @@ function assertTrusted(path, projectDirectory) {
10106
10283
  if (!isAbsolute4(path)) throw new Error(`BINARY_TRUST_FAILED: Binary path must be absolute: ${path}`);
10107
10284
  if (!existsSync2(path)) throw new Error(`GWS_UNAVAILABLE: The binary.path option names a missing file: ${path}. Next step: S${OVERRIDE.slice(1)}.`);
10108
10285
  const real = realpathSync(path);
10109
- const stat2 = statSync(real);
10110
- if (!stat2.isFile()) throw new Error(`BINARY_TRUST_FAILED: Not a regular file: ${real}`);
10111
- if ((stat2.mode & 73) === 0 || (stat2.mode & 18) !== 0) {
10286
+ const stat3 = statSync(real);
10287
+ if (!stat3.isFile()) throw new Error(`BINARY_TRUST_FAILED: Not a regular file: ${real}`);
10288
+ if ((stat3.mode & 73) === 0 || (stat3.mode & 18) !== 0) {
10112
10289
  throw new Error("BINARY_TRUST_FAILED: Binary must be executable and not group/world writable");
10113
10290
  }
10114
10291
  if (projectDirectory && isUnder(resolve(projectDirectory), real)) {
@@ -10119,13 +10296,13 @@ function assertTrusted(path, projectDirectory) {
10119
10296
  if (typeof process.getgroups === "function") for (const gid of process.getgroups()) groups.add(gid);
10120
10297
  let dir = dirname4(real);
10121
10298
  for (; ; ) {
10122
- const stat3 = statSync(dir);
10123
- const mode = stat3.mode & 511;
10299
+ const stat4 = statSync(dir);
10300
+ const mode = stat4.mode & 511;
10124
10301
  if ((mode & 2) !== 0) {
10125
10302
  throw new Error(`BINARY_TRUST_FAILED: World-writable ancestor: ${dir} (mode ${mode.toString(8)})`);
10126
10303
  }
10127
- if ((mode & 16) !== 0 && !groups.has(stat3.gid)) {
10128
- throw new Error(`BINARY_TRUST_FAILED: Group-writable ancestor owned by a foreign group: ${dir} (mode ${mode.toString(8)}, gid ${stat3.gid})`);
10304
+ if ((mode & 16) !== 0 && !groups.has(stat4.gid)) {
10305
+ throw new Error(`BINARY_TRUST_FAILED: Group-writable ancestor owned by a foreign group: ${dir} (mode ${mode.toString(8)}, gid ${stat4.gid})`);
10129
10306
  }
10130
10307
  const parent = dirname4(dir);
10131
10308
  if (parent === dir) break;
@@ -10163,12 +10340,12 @@ function resolvePackagedBinary(platform = currentPlatform(), from = new URL("./p
10163
10340
  }
10164
10341
  const path = join3(dirname4(realpathSync(manifest)), "bin", "gws");
10165
10342
  if (!existsSync2(path)) throw new Error(`GWS_UNAVAILABLE: ${name} has no bin/gws. Next step: Reinstall the plugin, or ${OVERRIDE}.`);
10166
- const stat2 = lstatSync(path);
10167
- const mode = stat2.mode & 4095;
10168
- if (!stat2.isFile() || realpathSync(path) !== path || (mode & 320) !== 320 || (mode & ~493) !== 0) {
10343
+ const stat3 = lstatSync(path);
10344
+ const mode = stat3.mode & 4095;
10345
+ if (!stat3.isFile() || realpathSync(path) !== path || (mode & 320) !== 320 || (mode & ~493) !== 0) {
10169
10346
  throw new Error(`BINARY_TRUST_FAILED: ${name} bin/gws must be a regular file, not a link, with owner read/execute and no permissions outside 0755`);
10170
10347
  }
10171
- const identity = `${stat2.size}:${stat2.mtimeMs}:${stat2.ino}`;
10348
+ const identity = `${stat3.size}:${stat3.mtimeMs}:${stat3.ino}`;
10172
10349
  if (admitted.get(path) === identity) return path;
10173
10350
  if (createHash2("sha256").update(readFileSync3(path)).digest("hex") !== GWS_TARGETS[target].sha256) {
10174
10351
  throw new Error(`BINARY_TRUST_FAILED: ${name} bin/gws does not match the pinned ${target} release of gws 0.22.5`);
@@ -10354,6 +10531,62 @@ function runProcess(options) {
10354
10531
  });
10355
10532
  }
10356
10533
 
10534
+ // src/process/workdir.ts
10535
+ import { lstatSync as lstatSync2, mkdtempSync, readdirSync, readFileSync as readFileSync4, rmSync, writeFileSync } from "node:fs";
10536
+ import { tmpdir } from "node:os";
10537
+ import { join as join6 } from "node:path";
10538
+ var PREFIX2 = "opencode-gws-";
10539
+ var OWNER = "owner.pid";
10540
+ var DAY_MS = 24 * 60 * 60 * 1e3;
10541
+ var UNOWNED_MS = 7 * DAY_MS;
10542
+ function createWorkDir(root = tmpdir()) {
10543
+ const dir = mkdtempSync(join6(root, PREFIX2));
10544
+ writeFileSync(join6(dir, ".env"), "");
10545
+ writeFileSync(join6(dir, OWNER), `${process.pid}
10546
+ `, { mode: 384 });
10547
+ return dir;
10548
+ }
10549
+ function alive(pid) {
10550
+ try {
10551
+ process.kill(pid, 0);
10552
+ return true;
10553
+ } catch (error2) {
10554
+ return error2.code === "EPERM";
10555
+ }
10556
+ }
10557
+ function removeStaleWorkDirs(root = tmpdir(), now = Date.now()) {
10558
+ const removed = [];
10559
+ let names;
10560
+ try {
10561
+ names = readdirSync(root);
10562
+ } catch {
10563
+ return removed;
10564
+ }
10565
+ const uid = process.getuid?.();
10566
+ for (const name of names) {
10567
+ if (!/^opencode-gws-(?:probe-)?[A-Za-z0-9]{6}$/.test(name)) continue;
10568
+ const dir = join6(root, name);
10569
+ try {
10570
+ const stat3 = lstatSync2(dir);
10571
+ if (!stat3.isDirectory() || uid !== void 0 && stat3.uid !== uid) continue;
10572
+ const age = now - stat3.mtimeMs;
10573
+ if (age < DAY_MS) continue;
10574
+ let owner;
10575
+ try {
10576
+ owner = Number.parseInt(readFileSync4(join6(dir, OWNER), "utf8"), 10);
10577
+ } catch {
10578
+ owner = void 0;
10579
+ }
10580
+ const stale = owner !== void 0 && Number.isInteger(owner) && owner > 0 ? !alive(owner) : age >= UNOWNED_MS;
10581
+ if (!stale) continue;
10582
+ rmSync(dir, { recursive: true, force: true });
10583
+ removed.push(dir);
10584
+ } catch {
10585
+ }
10586
+ }
10587
+ return removed;
10588
+ }
10589
+
10357
10590
  // src/domain/schema.ts
10358
10591
  function parseSchemaOutput(id, stdout, stderr) {
10359
10592
  const start2 = stdout.indexOf("{");
@@ -10408,15 +10641,16 @@ var SchemaClient = class {
10408
10641
  };
10409
10642
 
10410
10643
  // src/runtime/accounts.ts
10411
- import { join as join6 } from "node:path";
10644
+ import { mkdtemp, rm } from "node:fs/promises";
10645
+ import { join as join7 } from "node:path";
10412
10646
 
10413
10647
  // src/domain/digest.ts
10414
10648
  function canonicalJson(value) {
10415
10649
  if (value === null || typeof value !== "object") return JSON.stringify(value);
10416
10650
  if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
10417
- const record2 = value;
10418
- const keys = Object.keys(record2).sort();
10419
- return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record2[key])}`).join(",")}}`;
10651
+ const record3 = value;
10652
+ const keys = Object.keys(record3).sort();
10653
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record3[key])}`).join(",")}}`;
10420
10654
  }
10421
10655
 
10422
10656
  // src/auth/doctor.ts
@@ -10438,10 +10672,17 @@ var PROBES = {
10438
10672
  "admin-reports": { operation: "admin-reports.activities.list", params: { userKey: "all", applicationName: "login", maxResults: 1, fields: "items(id)" } },
10439
10673
  script: { operation: "script.processes.list", params: { pageSize: 1, fields: "processes(processType)" } }
10440
10674
  };
10441
- function doctorTarget(account2, service) {
10675
+ function doctorTarget(account2, service, needsDelegation = false) {
10676
+ if (needsDelegation) return { account: account2, service, needsDelegation: true };
10442
10677
  const probe = PROBES[service];
10443
10678
  return probe ? { account: account2, service, operation: probe.operation } : { account: account2, service };
10444
10679
  }
10680
+ function skipDetail(target) {
10681
+ if (target.needsDelegation) {
10682
+ 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`;
10683
+ }
10684
+ return "no read-only check exists for this service";
10685
+ }
10445
10686
  function probeArgv(target) {
10446
10687
  const probe = PROBES[target.service];
10447
10688
  const entry = probe ? commandIndex().methods.get(probe.operation) : void 0;
@@ -10452,14 +10693,17 @@ var DISABLED = /accessNotConfigured|SERVICE_DISABLED|has not been used in projec
10452
10693
  function probeOutcome(target, result) {
10453
10694
  const parsed = parseResult(result, { maxInlineBytes: 64 * 1024 });
10454
10695
  if (parsed.ok) return { ...target, status: "OK" };
10455
- const problem = authProblem(result, parsed);
10696
+ if (privilegeRefused(result, target.service, parsed)) {
10697
+ return { ...target, status: "ADMIN_PRIVILEGE_REQUIRED", detail: "Google accepted the sign-in, but the account lacks the Workspace admin privilege this service needs" };
10698
+ }
10699
+ const problem = authProblem(result, target.service, parsed);
10456
10700
  if (problem === "AUTH_REQUIRED") return { ...target, status: problem, detail: "Google rejected the sign-in" };
10457
10701
  if (problem === "AUTH_SCOPE_MISSING") return { ...target, status: problem, detail: "Google says the sign-in lacks a scope for this service" };
10458
10702
  const error2 = apiError(parsed.data);
10459
- const text3 = `${error2?.message ?? ""}
10703
+ const text4 = `${error2?.message ?? ""}
10460
10704
  ${result.stderr.toString("utf8")}`;
10461
- if (DISABLED.test(text3) || error2?.reasons.some((reason) => DISABLED.test(reason))) {
10462
- const enableUrl = /https:\/\/console\.(?:developers|cloud)\.google\.com\/\S+/.exec(text3)?.[0]?.replace(/[.,)]+$/, "") ?? enableApisUrl([target.service]);
10705
+ if (DISABLED.test(text4) || error2?.reasons.some((reason) => DISABLED.test(reason))) {
10706
+ const enableUrl = /https:\/\/console\.(?:developers|cloud)\.google\.com\/\S+/.exec(text4)?.[0]?.replace(/[.,)]+$/, "") ?? enableApisUrl([target.service]);
10463
10707
  return { ...target, status: "API_DISABLED", detail: "the API is not enabled in the OAuth client's Google Cloud project", enableUrl };
10464
10708
  }
10465
10709
  if (PROBES[target.service]?.notFoundIsOk && (error2?.code === 404 || error2?.code === 400)) return { ...target, status: "OK" };
@@ -10546,15 +10790,15 @@ var Accounts = class {
10546
10790
  mode: profile.mode,
10547
10791
  services: profile.enabledServices,
10548
10792
  ...profile.mode === "interactive" ? { integrationID } : {},
10793
+ ...delegationField(profile),
10549
10794
  ...signIn
10550
10795
  };
10551
10796
  })
10552
10797
  );
10553
10798
  return {
10554
10799
  auditDegraded: this.runtime.audit.degraded,
10555
- pluginVersion: options.pluginVersion,
10556
- hostVersion: options.hostVersion ?? "unknown",
10557
- ...options.hostWarning ? { hostWarning: options.hostWarning } : {},
10800
+ version: options.pluginVersion,
10801
+ host: options.host ?? { version: null, range: HOST_RANGE, tested: false },
10558
10802
  binary: { path: this.runtime.binary, version: this.runtime.version },
10559
10803
  services: [...this.runtime.visibleServices()].sort(),
10560
10804
  accounts
@@ -10579,9 +10823,15 @@ var Accounts = class {
10579
10823
  this.runtime.assertActive(ctx.signal);
10580
10824
  const env = buildChildEnv(profile, this.accountDir(account2), this.runtime.workDir, credential?.access);
10581
10825
  report2(ctx, { status: "running", operation });
10582
- const result = await this.spawn(argv, env, operation, ctx, options.paged === true, options.cwd ?? this.runtime.workDir);
10826
+ const scratch = options.cwd ? void 0 : await mkdtemp(join7(this.runtime.workDir, "call-"));
10827
+ let result;
10828
+ try {
10829
+ result = await this.spawn(argv, env, operation, ctx, options.paged === true, options.cwd ?? scratch);
10830
+ } finally {
10831
+ if (scratch) await rm(scratch, { recursive: true, force: true }).catch(() => void 0);
10832
+ }
10583
10833
  if (credential) {
10584
- const problem = authProblem(result);
10834
+ const problem = authProblem(result, serviceOf(operation));
10585
10835
  if (problem) this.observeAuth(account2, { status: "needs_auth", code: problem, message: this.authMessage(account2, problem, options.scopes) });
10586
10836
  else if (result.exitCode === 0 && options.dryRun !== true) this.observeAuth(account2, { status: "ok" });
10587
10837
  }
@@ -10612,9 +10862,14 @@ var Accounts = class {
10612
10862
  return credential;
10613
10863
  }
10614
10864
  scopeMissing(account2, profile, operation, accepted) {
10615
- if (appOnly(operation)) {
10865
+ const reason = restriction(operation);
10866
+ if (reason === "app-only") {
10616
10867
  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
10868
  }
10869
+ if (reason === "domain-wide-delegation") {
10870
+ const named2 = accepted.map(shortScope).join(" or ");
10871
+ 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.`;
10872
+ }
10618
10873
  const read = signInScopes(profile, "read");
10619
10874
  const named = accepted.slice(0, 5).map(shortScope).join(", ");
10620
10875
  const connect2 = this.connect(account2);
@@ -10629,26 +10884,29 @@ var Accounts = class {
10629
10884
  }
10630
10885
  /** The plugin-owned per-account gws directory: its Discovery cache, and in credentials_file mode its token cache. */
10631
10886
  accountDir(account2) {
10632
- return ownedDir(join6(this.runtime.paths.cacheDir, "accounts", this.integrationId(account2).replace(/^gws-/, "")));
10887
+ return ownedDir(join7(this.runtime.paths.cacheDir, "accounts", this.integrationId(account2).replace(/^gws-/, "")));
10633
10888
  }
10634
- /** Remediation text for an account's sign-in and consent problems, by credential mode. */
10635
- remediation(account2, scopes) {
10889
+ /** Remediation text for an account's sign-in, consent and admin privilege problems, by credential mode. */
10890
+ remediation(account2, scopes, service) {
10636
10891
  const profile = this.runtime.config.accounts[account2];
10637
10892
  const named = scopes && scopes.length > 0 ? scopes.slice(0, 5).map(shortScope).join(", ") : void 0;
10638
10893
  const grant = named ? `include one of: ${named}` : "include the scope this operation needs";
10894
+ const admin = `a Workspace admin account that has ${privilegeName(service)}`;
10895
+ const remove = `remove ${service ?? "the service"} from accounts.${account2}.enabledServices`;
10639
10896
  if (profile?.mode === "credentials_file") {
10640
10897
  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}` };
10898
+ return { signIn: `replace ${source}`, consent: `replace ${source} with one whose grant can ${grant}`, privilege: `replace ${source} with one for ${admin}, or ${remove}` };
10642
10899
  }
10643
10900
  if (profile?.mode === "access_token") {
10644
10901
  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}` };
10902
+ return { signIn: `refresh ${source}`, consent: `mint ${source} with scopes that ${grant}`, privilege: `mint ${source} for ${admin}, or ${remove}` };
10646
10903
  }
10647
10904
  const connect2 = this.connect(account2);
10648
10905
  const extra = named ? `, or add one of ${named} to accounts.${account2}.scopes` : "";
10649
10906
  return {
10650
10907
  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}`
10908
+ consent: `to grant the scope, ${connect2}, sign in again, and approve every scope, choosing Read and write for a change${extra}`,
10909
+ privilege: `sign in with ${admin} (${connect2}), or ${remove}`
10652
10910
  };
10653
10911
  }
10654
10912
  authMessage(account2, code2, scopes) {
@@ -10667,11 +10925,13 @@ var Accounts = class {
10667
10925
  /** Every account and enabled service /gws-doctor checks, in config order. */
10668
10926
  doctorTargets() {
10669
10927
  this.runtime.assertActive();
10670
- return Object.entries(this.runtime.config.accounts).flatMap(([alias, profile]) => profile.enabledServices.map((service) => doctorTarget(alias, service)));
10928
+ return Object.entries(this.runtime.config.accounts).flatMap(
10929
+ ([alias, profile]) => profile.enabledServices.map((service) => doctorTarget(alias, service, profile.mode === "interactive" && delegationRequired([service], profile.scopes).length > 0))
10930
+ );
10671
10931
  }
10672
10932
  /** Run one /gws-doctor probe as the account. A failure becomes an outcome, never a throw, except cancellation. */
10673
10933
  async probe(target, ctx) {
10674
- if (!target.operation) return { ...target, status: "SKIPPED", detail: "no read-only check exists for this service" };
10934
+ if (!target.operation) return { ...target, status: "SKIPPED", detail: skipDetail(target) };
10675
10935
  const started = Date.now();
10676
10936
  try {
10677
10937
  const result = await this.run(probeArgv(target), target.account, target.operation, ctx);
@@ -10712,16 +10972,25 @@ function report2(ctx, update) {
10712
10972
  } catch {
10713
10973
  }
10714
10974
  }
10715
- function capitalize(text3) {
10716
- return text3.charAt(0).toUpperCase() + text3.slice(1);
10975
+ function delegationField(profile) {
10976
+ if (profile.mode !== "interactive") return {};
10977
+ const services = delegationRequired(profile.enabledServices, profile.scopes);
10978
+ return services.length > 0 ? { delegationRequired: services } : {};
10979
+ }
10980
+ function serviceOf(operation) {
10981
+ return operation.slice(0, operation.indexOf("."));
10982
+ }
10983
+ function capitalize(text4) {
10984
+ return text4.charAt(0).toUpperCase() + text4.slice(1);
10717
10985
  }
10718
10986
 
10719
10987
  // src/domain/classify.ts
10720
- var NOTIFYING_UPDATES = /* @__PURE__ */ new Set(["all", "externalonly"]);
10721
10988
  function emailsAttendees(entry, request) {
10722
10989
  if (entry.service !== "calendar" || entry.path.join(".") !== "events") return false;
10723
- const value = request.params?.["sendUpdates"];
10724
- return typeof value === "string" && NOTIFYING_UPDATES.has(value.toLowerCase());
10990
+ const updates = request.params?.["sendUpdates"];
10991
+ const legacy = request.params?.["sendNotifications"];
10992
+ const off = (value, none) => value === void 0 || value === false || typeof value === "string" && value.toLowerCase() === none;
10993
+ return !off(updates, "none") || !off(legacy, "false");
10725
10994
  }
10726
10995
  function effectiveCategory(entry, request) {
10727
10996
  if (entry.category === "deny" || entry.schemaError) return "deny";
@@ -10737,27 +11006,390 @@ function isCommittable(category) {
10737
11006
 
10738
11007
  // src/domain/command.ts
10739
11008
  import { isAbsolute as isAbsolute6 } from "node:path";
10740
- function recipientsOf(request) {
10741
- const out = /* @__PURE__ */ new Set();
10742
- const visit = (value, key) => {
11009
+
11010
+ // src/domain/recipients.ts
11011
+ function addressesIn(text4) {
11012
+ const addresses = [];
11013
+ const unreadable = [];
11014
+ for (const match of text4.matchAll(/([A-Za-z0-9!#$%&'*+/=?^_`{|}~.-]*)@(\[[^\]]*\]?|[^\s<>()[\]\\,;:"@]*)/g)) {
11015
+ const local = match[1].replace(/^\.+/, "");
11016
+ const domain = match[2].toLowerCase().replace(/\.+$/, "");
11017
+ if (!local && !domain) continue;
11018
+ if (!local || !/^[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)+$/u.test(domain)) {
11019
+ unreadable.push(match[0]);
11020
+ continue;
11021
+ }
11022
+ addresses.push(`${local.toLowerCase()}@${domain}`);
11023
+ }
11024
+ return { addresses, unreadable };
11025
+ }
11026
+ function domainOf(address) {
11027
+ return address.slice(address.lastIndexOf("@") + 1);
11028
+ }
11029
+ var Collector = class {
11030
+ addresses = /* @__PURE__ */ new Set();
11031
+ domains = /* @__PURE__ */ new Set();
11032
+ unknown;
11033
+ public;
11034
+ text(value) {
10743
11035
  if (typeof value === "string") {
10744
- if (key && /^(to|cc|bcc|email|emailAddress|address|recipient)$/i.test(key) && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
10745
- out.add(value.toLowerCase());
10746
- }
11036
+ const found = addressesIn(value);
11037
+ for (const address of found.addresses) this.addresses.add(address);
11038
+ if (found.unreadable.length > 0) this.fail(`an address could not be read: ${found.unreadable[0]}`);
11039
+ return;
11040
+ }
11041
+ if (Array.isArray(value)) for (const item of value) this.text(item);
11042
+ }
11043
+ domain(value) {
11044
+ if (typeof value === "string" && /^[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)+$/u.test(value.trim())) this.domains.add(value.trim().toLowerCase());
11045
+ else this.fail("the domain it shares with could not be read");
11046
+ }
11047
+ add(set) {
11048
+ for (const address of set.addresses) this.addresses.add(address);
11049
+ for (const domain of set.domains) this.domains.add(domain);
11050
+ }
11051
+ fail(reason) {
11052
+ this.unknown ??= reason;
11053
+ }
11054
+ result() {
11055
+ return {
11056
+ addresses: [...this.addresses],
11057
+ domains: [...this.domains],
11058
+ ...this.unknown ? { unknown: this.unknown } : {},
11059
+ ...this.public ? { public: true } : {}
11060
+ };
11061
+ }
11062
+ };
11063
+ var RECIPIENT_KEYS = /* @__PURE__ */ new Set(["to", "cc", "bcc", "email", "emailaddress", "address", "recipient", "recipients", "attendees", "delegateemail", "forwardingemail"]);
11064
+ function scanRecipients(request) {
11065
+ const out = new Collector();
11066
+ const visit = (value, collecting) => {
11067
+ if (typeof value === "string") {
11068
+ if (collecting) out.text(value);
10747
11069
  return;
10748
11070
  }
10749
11071
  if (Array.isArray(value)) {
10750
- for (const item of value) visit(item, key);
11072
+ for (const item of value) visit(item, collecting);
10751
11073
  return;
10752
11074
  }
10753
11075
  if (value && typeof value === "object") {
10754
- for (const [childKey, child] of Object.entries(value)) visit(child, childKey);
11076
+ for (const [key, child] of Object.entries(value)) visit(child, collecting || RECIPIENT_KEYS.has(key.toLowerCase()));
11077
+ }
11078
+ };
11079
+ visit(request.params, false);
11080
+ visit(request.body, false);
11081
+ visit(request.input, false);
11082
+ return out.result();
11083
+ }
11084
+ function record2(value) {
11085
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
11086
+ }
11087
+ function text3(value) {
11088
+ return typeof value === "string" && value.length > 0 ? value : void 0;
11089
+ }
11090
+ function mimeRecipients(raw) {
11091
+ if (typeof raw !== "string" || raw.length === 0 || !/^[A-Za-z0-9+/_=\s-]+$/.test(raw)) return void 0;
11092
+ const message = Buffer.from(raw.replace(/\s+/g, ""), "base64").toString("utf8");
11093
+ const end = /\r?\n\r?\n/.exec(message);
11094
+ const head = (end ? message.slice(0, end.index) : message).replace(/\r?\n[ \t]+/g, " ");
11095
+ const lines = head.split(/\r?\n/);
11096
+ if (!lines.every((line) => line === "" || /^[!-9;-~]+[ \t]*:/.test(line))) return void 0;
11097
+ return lines.filter((line) => /^(?:to|cc|bcc)[ \t]*:/i.test(line)).map((line) => line.slice(line.indexOf(":") + 1));
11098
+ }
11099
+ function headers(message) {
11100
+ const list = record2(record2(message)["payload"])["headers"];
11101
+ if (!Array.isArray(list)) return void 0;
11102
+ const out = /* @__PURE__ */ new Map();
11103
+ for (const header of list) {
11104
+ const name = text3(record2(header)["name"])?.toLowerCase();
11105
+ const value = record2(header)["value"];
11106
+ if (name && typeof value === "string") out.set(name, [...out.get(name) ?? [], value]);
11107
+ }
11108
+ return out;
11109
+ }
11110
+ function setOf(values) {
11111
+ const out = new Collector();
11112
+ out.text(values);
11113
+ return out.unknown ? void 0 : { addresses: [...out.addresses], domains: [] };
11114
+ }
11115
+ function plan(out, lookups = [], needsSelf = false) {
11116
+ return { known: out.result(), lookups, ...needsSelf ? { needsSelf: true } : {} };
11117
+ }
11118
+ function helperSend(fields) {
11119
+ return (request) => {
11120
+ const input = record2(request.input);
11121
+ const out = new Collector();
11122
+ for (const field of fields) out.text(input[field]);
11123
+ if (out.addresses.size === 0) out.fail("no recipient address was found");
11124
+ return plan(out);
11125
+ };
11126
+ }
11127
+ function replySpec(all) {
11128
+ return (request) => {
11129
+ const input = record2(request.input);
11130
+ const out = new Collector();
11131
+ for (const field of ["to", "cc", "bcc"]) out.text(input[field]);
11132
+ const removed = new Set(addressesIn([input["remove"]].flat().filter((item) => typeof item === "string").join(",")).addresses);
11133
+ const messageId = text3(input["messageId"]);
11134
+ if (!messageId) {
11135
+ out.fail("the message being replied to is not named");
11136
+ return plan(out);
11137
+ }
11138
+ const lookup = {
11139
+ operation: "gmail.users.messages.get",
11140
+ params: { userId: "me", id: messageId, format: "metadata", fields: "payload/headers" },
11141
+ what: "the message being replied to",
11142
+ extract(data, self) {
11143
+ const found = headers(data);
11144
+ if (!found) return void 0;
11145
+ const sender = found.get("reply-to") ?? found.get("from") ?? [];
11146
+ const senderSet = setOf(sender);
11147
+ if (!senderSet) return void 0;
11148
+ const values = [...sender];
11149
+ if (all || self !== void 0 && senderSet.addresses.includes(self)) values.push(...found.get("to") ?? []);
11150
+ if (all) values.push(...found.get("cc") ?? []);
11151
+ const set = setOf(values);
11152
+ if (!set) return void 0;
11153
+ const kept = all ? set.addresses.filter((address) => address !== self && !removed.has(address)) : set.addresses;
11154
+ return { addresses: kept, domains: [] };
11155
+ }
11156
+ };
11157
+ return plan(out, [lookup], true);
11158
+ };
11159
+ }
11160
+ function rawInto(out, raw) {
11161
+ const values = mimeRecipients(raw);
11162
+ if (values) out.text(values);
11163
+ else out.fail("the raw message has no readable header block");
11164
+ }
11165
+ var messagesSend = (request) => {
11166
+ const out = new Collector();
11167
+ if (request.upload) out.fail("the message is in an uploaded file");
11168
+ else if (record2(request.body)["raw"] === void 0) out.fail("the message has no raw field");
11169
+ else rawInto(out, record2(request.body)["raw"]);
11170
+ if (!out.unknown && out.addresses.size === 0) out.fail("the raw message has no To, Cc or Bcc header");
11171
+ return plan(out);
11172
+ };
11173
+ var draftsSend = (request) => {
11174
+ const out = new Collector();
11175
+ const body = record2(request.body);
11176
+ const raw = record2(body["message"])["raw"];
11177
+ if (raw !== void 0) rawInto(out, raw);
11178
+ const id = text3(body["id"]);
11179
+ if (!id) {
11180
+ out.fail("the draft is not named (body.id)");
11181
+ return plan(out);
11182
+ }
11183
+ const userId = text3(record2(request.params)["userId"]) ?? "me";
11184
+ const lookup = {
11185
+ operation: "gmail.users.drafts.get",
11186
+ params: { userId, id, format: "metadata", fields: "message/payload/headers" },
11187
+ what: "the draft",
11188
+ extract(data) {
11189
+ const found = headers(record2(data)["message"]);
11190
+ return found ? setOf([...found.get("to") ?? [], ...found.get("cc") ?? [], ...found.get("bcc") ?? []]) : void 0;
11191
+ }
11192
+ };
11193
+ return plan(out, [lookup]);
11194
+ };
11195
+ function attendeesInto(out, body) {
11196
+ const attendees = record2(body)["attendees"];
11197
+ if (attendees === void 0) return;
11198
+ if (!Array.isArray(attendees)) {
11199
+ out.fail("the attendees could not be read");
11200
+ return;
11201
+ }
11202
+ for (const attendee of attendees) {
11203
+ const email2 = record2(attendee)["email"];
11204
+ if (typeof email2 === "string") out.text(email2);
11205
+ else out.fail("an attendee has no email");
11206
+ }
11207
+ }
11208
+ var eventInsert = (request) => {
11209
+ const out = new Collector();
11210
+ attendeesInto(out, request.body);
11211
+ return plan(out);
11212
+ };
11213
+ var eventChange = (request, category) => {
11214
+ const out = new Collector();
11215
+ attendeesInto(out, request.body);
11216
+ if (category !== "send") return plan(out);
11217
+ const params = record2(request.params);
11218
+ const calendarId = text3(params["calendarId"]);
11219
+ const eventId = text3(params["eventId"]);
11220
+ if (!calendarId || !eventId) {
11221
+ out.fail("the event is not named (calendarId and eventId)");
11222
+ return plan(out);
11223
+ }
11224
+ const lookup = {
11225
+ operation: "calendar.events.get",
11226
+ params: { calendarId, eventId, fields: "attendees(email)" },
11227
+ what: "the event's guests",
11228
+ extract(data) {
11229
+ const attendees = record2(data)["attendees"] ?? [];
11230
+ if (!Array.isArray(attendees)) return void 0;
11231
+ const emails2 = attendees.map((attendee) => record2(attendee)["email"]);
11232
+ return emails2.every((email2) => typeof email2 === "string") ? setOf(emails2) : void 0;
10755
11233
  }
10756
11234
  };
10757
- visit(request.params);
10758
- visit(request.body);
10759
- return [...out];
11235
+ return plan(out, [lookup]);
11236
+ };
11237
+ var quickAdd = () => {
11238
+ const out = new Collector();
11239
+ out.fail("Google reads the guests from the text");
11240
+ return plan(out);
11241
+ };
11242
+ function drivePermissionInto(out, permission) {
11243
+ const type = text3(permission["type"]);
11244
+ if (type === "anyone") return;
11245
+ if (type === "domain") out.domain(permission["domain"]);
11246
+ else if (typeof permission["emailAddress"] === "string") out.text(permission["emailAddress"]);
11247
+ else out.fail("the permission names no emailAddress");
11248
+ }
11249
+ var drivePermissionCreate = (request) => {
11250
+ const out = new Collector();
11251
+ drivePermissionInto(out, record2(request.body));
11252
+ return plan(out);
11253
+ };
11254
+ var drivePermissionUpdate = (request) => {
11255
+ const out = new Collector();
11256
+ const params = record2(request.params);
11257
+ const fileId = text3(params["fileId"]);
11258
+ const permissionId = text3(params["permissionId"]);
11259
+ if (!fileId || !permissionId) {
11260
+ out.fail("the permission is not named (fileId and permissionId)");
11261
+ return plan(out);
11262
+ }
11263
+ const lookup = {
11264
+ operation: "drive.permissions.get",
11265
+ // The request's own parameters, so a shared-drive flag it carries applies to the read too.
11266
+ params: { ...params, fields: "type,emailAddress,domain" },
11267
+ what: "the permission",
11268
+ extract(data) {
11269
+ const found = new Collector();
11270
+ drivePermissionInto(found, record2(data));
11271
+ return found.unknown ? void 0 : found.result();
11272
+ }
11273
+ };
11274
+ return plan(out, [lookup]);
11275
+ };
11276
+ function aclScopeInto(out, type, value) {
11277
+ if (type === "default") out.public = true;
11278
+ else if (type === "domain") out.domain(value);
11279
+ else if ((type === "user" || type === "group") && typeof value === "string") out.text(value);
11280
+ else out.fail("the ACL scope could not be read");
11281
+ }
11282
+ var aclChange = (request) => {
11283
+ const out = new Collector();
11284
+ const scope = record2(record2(request.body)["scope"]);
11285
+ if (Object.keys(scope).length > 0) aclScopeInto(out, text3(scope["type"]), scope["value"]);
11286
+ const ruleId = text3(record2(request.params)["ruleId"]);
11287
+ if (ruleId) {
11288
+ const colon = ruleId.indexOf(":");
11289
+ aclScopeInto(out, colon < 0 ? ruleId : ruleId.slice(0, colon), colon < 0 ? void 0 : ruleId.slice(colon + 1));
11290
+ }
11291
+ if (Object.keys(scope).length === 0 && !ruleId) out.fail("the ACL scope could not be read");
11292
+ return plan(out);
11293
+ };
11294
+ function bodyAddress(field, optional2) {
11295
+ return (request) => {
11296
+ const out = new Collector();
11297
+ const body = record2(request.body);
11298
+ if (optional2?.(body)) return plan(out);
11299
+ const value = body[field];
11300
+ if (typeof value === "string" && value.includes("@")) out.text(value);
11301
+ else out.fail(`body.${field} is not an email address`);
11302
+ return plan(out);
11303
+ };
10760
11304
  }
11305
+ var chatMember = (request) => {
11306
+ const out = new Collector();
11307
+ const name = text3(record2(record2(request.body)["member"])["name"]);
11308
+ if (name?.startsWith("users/") && name.includes("@")) out.text(name.slice("users/".length));
11309
+ else out.fail("the member is not named by email (users/<email>)");
11310
+ return plan(out);
11311
+ };
11312
+ var keepPermissions = (request) => {
11313
+ const out = new Collector();
11314
+ const requests = record2(request.body)["requests"];
11315
+ if (!Array.isArray(requests) || requests.length === 0) out.fail("the permission requests could not be read");
11316
+ else {
11317
+ for (const item of requests) {
11318
+ const email2 = record2(record2(item)["permission"])["email"];
11319
+ if (typeof email2 === "string") out.text(email2);
11320
+ else out.fail("a permission names no email");
11321
+ }
11322
+ }
11323
+ return plan(out);
11324
+ };
11325
+ var revoke = () => plan(new Collector());
11326
+ var SPECS = {
11327
+ "gmail.send": helperSend(["to", "cc", "bcc"]),
11328
+ "gmail.forward": helperSend(["to", "cc", "bcc"]),
11329
+ "gmail.reply": replySpec(false),
11330
+ "gmail.reply-all": replySpec(true),
11331
+ "gmail.users.messages.send": messagesSend,
11332
+ "gmail.users.drafts.send": draftsSend,
11333
+ "gmail.users.settings.delegates.create": bodyAddress("delegateEmail"),
11334
+ "gmail.users.settings.forwardingAddresses.create": bodyAddress("forwardingEmail"),
11335
+ "gmail.users.settings.updateAutoForwarding": bodyAddress("emailAddress", (body) => body["enabled"] === false),
11336
+ "gmail.users.settings.delegates.delete": revoke,
11337
+ "gmail.users.settings.forwardingAddresses.delete": revoke,
11338
+ "calendar.events.insert": eventInsert,
11339
+ "calendar.events.import": eventInsert,
11340
+ "calendar.events.quickAdd": quickAdd,
11341
+ "calendar.events.update": eventChange,
11342
+ "calendar.events.patch": eventChange,
11343
+ "calendar.events.delete": eventChange,
11344
+ "calendar.events.move": eventChange,
11345
+ "calendar.acl.insert": aclChange,
11346
+ "calendar.acl.update": aclChange,
11347
+ "calendar.acl.patch": aclChange,
11348
+ "calendar.acl.delete": revoke,
11349
+ "drive.permissions.create": drivePermissionCreate,
11350
+ "drive.permissions.update": drivePermissionUpdate,
11351
+ "drive.permissions.delete": revoke,
11352
+ "chat.spaces.members.create": chatMember,
11353
+ "chat.spaces.members.delete": revoke,
11354
+ "classroom.invitations.create": bodyAddress("userId"),
11355
+ "classroom.invitations.delete": revoke,
11356
+ "classroom.courses.studentGroups.studentGroupMembers.create": bodyAddress("userId"),
11357
+ "meet.spaces.members.create": bodyAddress("email"),
11358
+ "keep.notes.permissions.batchCreate": keepPermissions,
11359
+ "keep.notes.permissions.batchDelete": revoke
11360
+ };
11361
+ function recipientPlan(operation, category, request) {
11362
+ const spec2 = SPECS[operation];
11363
+ if (spec2) return spec2(request, category);
11364
+ const scanned = scanRecipients(request);
11365
+ if (category === "send" || category === "share") {
11366
+ return { known: { ...scanned, unknown: scanned.unknown ?? `the plugin cannot tell who ${operation} reaches` }, lookups: [] };
11367
+ }
11368
+ return { known: scanned, lookups: [] };
11369
+ }
11370
+ function withLookup(recipients, found, what) {
11371
+ if (!found) return { ...recipients, unknown: recipients.unknown ?? `${what} could not be read` };
11372
+ return {
11373
+ ...recipients,
11374
+ addresses: [.../* @__PURE__ */ new Set([...recipients.addresses, ...found.addresses])],
11375
+ domains: [.../* @__PURE__ */ new Set([...recipients.domains, ...found.domains])]
11376
+ };
11377
+ }
11378
+ function recipientLabels(recipients) {
11379
+ return [...recipients.addresses, ...recipients.domains.map((domain) => `*@${domain}`)];
11380
+ }
11381
+ var ACCOUNT_ADDRESS_READS = {
11382
+ gmail: { operation: "gmail.users.getProfile", params: { userId: "me", fields: "emailAddress" }, extract: (data) => ownAddress(record2(data)["emailAddress"]) },
11383
+ drive: { operation: "drive.about.get", params: { fields: "user(emailAddress)" }, extract: (data) => ownAddress(record2(record2(data)["user"])["emailAddress"]) },
11384
+ calendar: { operation: "calendar.calendars.get", params: { calendarId: "primary", fields: "id" }, extract: (data) => ownAddress(record2(data)["id"]) }
11385
+ };
11386
+ function ownAddress(value) {
11387
+ if (typeof value !== "string") return void 0;
11388
+ const found = addressesIn(value);
11389
+ return found.addresses.length === 1 && found.unreadable.length === 0 ? found.addresses[0] : void 0;
11390
+ }
11391
+
11392
+ // src/domain/command.ts
10761
11393
  function wantsPages(request) {
10762
11394
  const pagination = request.pagination ?? {};
10763
11395
  return pagination.all === true || pagination.maxPages !== void 0 || pagination.maxItems !== void 0;
@@ -10836,7 +11468,7 @@ function buildCommand(entry, request, config2, options) {
10836
11468
  url: entry.pathTemplate,
10837
11469
  params: redactParams(params),
10838
11470
  bodySummary: request.body === void 0 ? void 0 : summarizeBody(request.body),
10839
- recipients: recipientsOf(request),
11471
+ recipients: recipientLabels(scanRecipients(request)),
10840
11472
  uploads: request.upload ? [request.upload.path] : void 0,
10841
11473
  destination: request.download?.path,
10842
11474
  overwrite: request.download?.overwrite === true ? true : void 0,
@@ -10874,32 +11506,6 @@ function matches2(rule, category, account2, service, operation) {
10874
11506
  function underRoot2(path, roots) {
10875
11507
  return roots.some((root) => underRoot(path, root));
10876
11508
  }
10877
- function collectEmails(value, out) {
10878
- if (typeof value === "string") {
10879
- if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) out.add(value.toLowerCase());
10880
- return;
10881
- }
10882
- if (Array.isArray(value)) {
10883
- for (const item of value) collectEmails(item, out);
10884
- return;
10885
- }
10886
- if (value && typeof value === "object") {
10887
- for (const [key, item] of Object.entries(value)) {
10888
- if (key === "email" || key === "emailAddress" || key === "address" || key === "to" || key === "cc" || key === "bcc" || key === "recipient" || key === "attendees") {
10889
- collectEmails(item, out);
10890
- } else if (typeof item === "object") {
10891
- collectEmails(item, out);
10892
- }
10893
- }
10894
- }
10895
- }
10896
- function emails2(request) {
10897
- const out = /* @__PURE__ */ new Set();
10898
- collectEmails(request.params, out);
10899
- collectEmails(request.body, out);
10900
- collectEmails(request.input, out);
10901
- return [...out];
10902
- }
10903
11509
  function helperPaths(request, keys) {
10904
11510
  const input = request.input;
10905
11511
  if (!input || typeof input !== "object") return [];
@@ -10917,13 +11523,19 @@ function publicSharing(request) {
10917
11523
  const haystack = JSON.stringify({ params: request.params ?? {}, body: request.body ?? {}, input: request.input ?? {} }).toLowerCase();
10918
11524
  return /"role"\s*:\s*"(anyone|public)"/.test(haystack) || /"type"\s*:\s*"(anyone|public)"/.test(haystack);
10919
11525
  }
10920
- function domain(email2) {
10921
- return email2.slice(email2.lastIndexOf("@") + 1);
11526
+ function limitsRecipients(rule) {
11527
+ return rule.allowedRecipientDomains !== void 0 || rule.allowedRecipients !== void 0 || rule.deniedRecipientDomains !== void 0 || rule.allowExternalRecipients === false;
11528
+ }
11529
+ function matching(config2, entry, category, request) {
11530
+ return config2.policy.rules.filter((rule) => matches2(rule, category, request.account, entry.service, entry.id));
11531
+ }
11532
+ function needsAccountDomains(config2, entry, category, request) {
11533
+ return matching(config2, entry, category, request).some((rule) => rule.allowExternalRecipients === false);
10922
11534
  }
10923
11535
  function constrained(rule) {
10924
11536
  return rule.allowedRecipientDomains !== void 0 || rule.allowedRecipients !== void 0 || rule.deniedRecipientDomains !== void 0 || rule.allowExternalRecipients !== void 0 || rule.allowPublicSharing !== void 0 || rule.allowedReadRoots !== void 0 || rule.allowedWriteRoots !== void 0 || rule.maxPages !== void 0 || rule.maxItems !== void 0;
10925
11537
  }
10926
- function evaluatePolicy(config2, entry, category, request) {
11538
+ function evaluatePolicy(config2, entry, category, request, facts) {
10927
11539
  const constraints = {
10928
11540
  maxPages: config2.execution.maxPages,
10929
11541
  maxItems: config2.execution.maxItems,
@@ -10933,31 +11545,21 @@ function evaluatePolicy(config2, entry, category, request) {
10933
11545
  writeRoots: config2.execution.allowedWriteRoots.length > 0 ? [config2.execution.allowedWriteRoots] : []
10934
11546
  };
10935
11547
  const violations = [];
10936
- if (entry.category === "deny" || entry.schemaError) {
10937
- return { effect: "deny", violations: ["manifest-deny"], constraints };
10938
- }
10939
- if (!config2.accounts[request.account]) {
10940
- return { effect: "deny", violations: ["account-unknown"], constraints };
10941
- }
11548
+ const refuse = (violation) => ({ effect: "deny", violations: [violation], constraints, code: "POLICY_DENIED" });
11549
+ if (entry.category === "deny" || entry.schemaError) return refuse("manifest-deny");
11550
+ if (!config2.accounts[request.account]) return refuse("account-unknown");
10942
11551
  const profile = config2.accounts[request.account];
10943
11552
  const service = entry.service;
10944
- if (!profile.enabledServices.includes(service)) {
10945
- return { effect: "deny", violations: ["service-disabled"], constraints };
10946
- }
10947
- if (category === "deploy" || category === "watch" || category === "deny") {
10948
- return { effect: "deny", violations: ["never-committable"], constraints };
10949
- }
11553
+ if (!profile.enabledServices.includes(service)) return refuse("service-disabled");
11554
+ if (category === "deploy" || category === "watch" || category === "deny") return refuse("never-committable");
10950
11555
  if (category === "admin") {
10951
- if (!config2.admin.enabled) {
10952
- return { effect: "deny", violations: ["admin-disabled"], constraints };
10953
- }
10954
- if (!config2.admin.allowedServices.includes(service)) {
10955
- return { effect: "deny", violations: ["admin-service-not-allowed"], constraints };
10956
- }
11556
+ if (!config2.admin.enabled) return refuse("admin-disabled");
11557
+ if (!config2.admin.allowedServices.includes(service)) return refuse("admin-service-not-allowed");
10957
11558
  }
10958
11559
  let denied2 = false;
10959
11560
  let ruleId;
10960
- const recipients = emails2(request);
11561
+ let unknownRecipients = 0;
11562
+ let unknownDomain = 0;
10961
11563
  const readPaths = [
10962
11564
  ...request.upload?.path ? [request.upload.path] : [],
10963
11565
  ...helperPaths(request, ["path", "file", "filePath", "source", "attachments"])
@@ -10966,28 +11568,44 @@ function evaluatePolicy(config2, entry, category, request) {
10966
11568
  ...request.download?.path ? [request.download.path] : [],
10967
11569
  ...helperPaths(request, ["destination", "output", "outPath"])
10968
11570
  ];
10969
- const external = recipients.filter((email2) => domain(email2) !== domain(recipients[0] ?? ""));
10970
- const isPublic = publicSharing(request);
10971
- for (const rule of config2.policy.rules) {
10972
- if (!matches2(rule, category, request.account, service, entry.id)) continue;
11571
+ const recipients = facts === "pending" ? void 0 : facts.recipients;
11572
+ const targets = recipients ? [
11573
+ ...recipients.addresses.map((address) => ({ label: address, address, domain: domainOf(address) })),
11574
+ ...recipients.domains.map((domain) => ({ label: `*@${domain}`, address: void 0, domain }))
11575
+ ] : [];
11576
+ const own2 = facts === "pending" || !facts.accountDomains ? void 0 : new Set(facts.accountDomains.map((value) => value.toLowerCase()));
11577
+ const isPublic = publicSharing(request) || recipients?.public === true;
11578
+ for (const rule of matching(config2, entry, category, request)) {
10973
11579
  const before = violations.length;
10974
- if (rule.allowedRecipientDomains && recipients.length > 0) {
10975
- const allowed = new Set(rule.allowedRecipientDomains.map((value) => value.toLowerCase()));
10976
- const bad = recipients.filter((email2) => !allowed.has(domain(email2)));
10977
- if (bad.length > 0) violations.push(`recipients outside allowed domains: ${bad.join(", ")}`);
10978
- }
10979
- if (rule.allowedRecipients && recipients.length > 0) {
10980
- const allowed = new Set(rule.allowedRecipients.map((value) => value.trim().toLowerCase()));
10981
- const bad = recipients.filter((email2) => !allowed.has(email2));
10982
- if (bad.length > 0) violations.push(`recipients not in allowedRecipients: ${bad.join(", ")}`);
10983
- }
10984
- if (rule.deniedRecipientDomains) {
10985
- const denied3 = new Set(rule.deniedRecipientDomains.map((value) => value.toLowerCase()));
10986
- const bad = recipients.filter((email2) => denied3.has(domain(email2)));
10987
- if (bad.length > 0) violations.push(`recipients in denied domains: ${bad.join(", ")}`);
10988
- }
10989
- if (rule.allowExternalRecipients === false && external.length > 0) {
10990
- violations.push(`external recipients are disabled: ${external.join(", ")}`);
11580
+ if (recipients && limitsRecipients(rule)) {
11581
+ if (recipients.unknown) {
11582
+ violations.push(`rule ${rule.id} limits recipients, but they could not be determined: ${recipients.unknown}`);
11583
+ unknownRecipients++;
11584
+ }
11585
+ if (rule.allowedRecipientDomains) {
11586
+ const allowed = new Set(rule.allowedRecipientDomains.map((value) => value.toLowerCase()));
11587
+ const bad = targets.filter((target) => !allowed.has(target.domain));
11588
+ if (bad.length > 0) violations.push(`recipients outside allowed domains: ${bad.map((target) => target.label).join(", ")}`);
11589
+ }
11590
+ if (rule.allowedRecipients) {
11591
+ const allowed = new Set(rule.allowedRecipients.map((value) => value.trim().toLowerCase()));
11592
+ const bad = targets.filter((target) => target.address === void 0 || !allowed.has(target.address));
11593
+ if (bad.length > 0) violations.push(`recipients not in allowedRecipients: ${bad.map((target) => target.label).join(", ")}`);
11594
+ }
11595
+ if (rule.deniedRecipientDomains) {
11596
+ const deniedDomains = new Set(rule.deniedRecipientDomains.map((value) => value.toLowerCase()));
11597
+ const bad = targets.filter((target) => deniedDomains.has(target.domain));
11598
+ if (bad.length > 0) violations.push(`recipients in denied domains: ${bad.map((target) => target.label).join(", ")}`);
11599
+ }
11600
+ if (rule.allowExternalRecipients === false && (targets.length > 0 || recipients.unknown)) {
11601
+ if (!own2) {
11602
+ violations.push(`rule ${rule.id} disables external recipients, but the account's own domain is unknown`);
11603
+ unknownDomain++;
11604
+ } else {
11605
+ const external = targets.filter((target) => !own2.has(target.domain));
11606
+ if (external.length > 0) violations.push(`external recipients are disabled: ${external.map((target) => target.label).join(", ")}`);
11607
+ }
11608
+ }
10991
11609
  }
10992
11610
  if (rule.allowPublicSharing === false && isPublic) {
10993
11611
  violations.push("public sharing is disabled");
@@ -11025,16 +11643,18 @@ function evaluatePolicy(config2, entry, category, request) {
11025
11643
  violations.push("admin plans require a non-empty reason");
11026
11644
  }
11027
11645
  if (violations.length > 0 || denied2) {
11028
- return { effect: "deny", ruleId, violations: violations.length > 0 ? violations : [`denied by rule ${ruleId ?? "policy"}`], constraints };
11646
+ const definite = violations.length - unknownRecipients - unknownDomain;
11647
+ const code2 = definite > 0 || violations.length === 0 ? "POLICY_DENIED" : unknownRecipients > 0 ? "RECIPIENTS_UNKNOWN" : "ACCOUNT_DOMAIN_UNKNOWN";
11648
+ return { effect: "deny", ruleId, violations: violations.length > 0 ? violations : [`denied by rule ${ruleId ?? "policy"}`], constraints, code: code2 };
11029
11649
  }
11030
- return { effect: "allow", ruleId, violations, constraints };
11650
+ return { effect: "allow", ruleId, violations, constraints, code: "POLICY_DENIED" };
11031
11651
  }
11032
11652
 
11033
11653
  // src/process/staging.ts
11034
11654
  import { createHash as createHash3 } from "node:crypto";
11035
11655
  import { constants } from "node:fs";
11036
- import { link, lstat, mkdir as mkdir2, mkdtemp, open as open2, realpath, rename, rm, stat, unlink, writeFile as writeFile2 } from "node:fs/promises";
11037
- import { basename as basename2, dirname as dirname5, isAbsolute as isAbsolute7, join as join7 } from "node:path";
11656
+ import { link, lstat, mkdir as mkdir2, mkdtemp as mkdtemp2, open as open2, realpath, rename as rename2, rm as rm2, stat as stat2, unlink, writeFile as writeFile2 } from "node:fs/promises";
11657
+ import { basename as basename2, dirname as dirname5, isAbsolute as isAbsolute7, join as join8 } from "node:path";
11038
11658
  var CHUNK = 64 * 1024;
11039
11659
  var READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0);
11040
11660
  var Stage = class _Stage {
@@ -11046,13 +11666,13 @@ var Stage = class _Stage {
11046
11666
  signal;
11047
11667
  static async create(workDir, signal) {
11048
11668
  throwIfCancelled(signal);
11049
- const dir = await mkdtemp(join7(workDir, "call-"));
11669
+ const dir = await mkdtemp2(join8(workDir, "call-"));
11050
11670
  try {
11051
11671
  throwIfCancelled(signal);
11052
- await writeFile2(join7(dir, ".env"), "", { mode: 384 });
11672
+ await writeFile2(join8(dir, ".env"), "", { mode: 384 });
11053
11673
  return new _Stage(dir, signal);
11054
11674
  } catch (error2) {
11055
- await rm(dir, { recursive: true, force: true }).catch(() => void 0);
11675
+ await rm2(dir, { recursive: true, force: true }).catch(() => void 0);
11056
11676
  throw error2;
11057
11677
  }
11058
11678
  }
@@ -11066,7 +11686,7 @@ var Stage = class _Stage {
11066
11686
  /** Create the directory gws writes the output into. */
11067
11687
  async prepareOutput(output) {
11068
11688
  throwIfCancelled(this.signal);
11069
- await mkdir2(dirname5(join7(this.dir, output.rel)), { recursive: true, mode: 448 });
11689
+ await mkdir2(dirname5(join8(this.dir, output.rel)), { recursive: true, mode: 448 });
11070
11690
  }
11071
11691
  /**
11072
11692
  * Move the staged output to its destination: size cap, then the same
@@ -11076,7 +11696,7 @@ var Stage = class _Stage {
11076
11696
  */
11077
11697
  async deliver(output, limits) {
11078
11698
  throwIfCancelled(this.signal);
11079
- const staged = join7(this.dir, output.rel);
11699
+ const staged = join8(this.dir, output.rel);
11080
11700
  const info = await lstat(staged).catch(() => void 0);
11081
11701
  if (!info?.isFile()) throw new GwsError("DOWNLOAD_INVALID", "gws did not write the output file", { next: "Report it; the plan is used up." });
11082
11702
  if (info.size > limits.maxDownloadBytes) {
@@ -11088,7 +11708,7 @@ var Stage = class _Stage {
11088
11708
  return { path: output.destination, bytes: info.size, sha256, ...cleanupDegraded ? { cleanupDegraded: true } : {} };
11089
11709
  }
11090
11710
  async dispose() {
11091
- await rm(this.dir, { recursive: true, force: true });
11711
+ await rm2(this.dir, { recursive: true, force: true });
11092
11712
  }
11093
11713
  async stageInput(input, maxBytes, roots) {
11094
11714
  throwIfCancelled(this.signal);
@@ -11100,7 +11720,7 @@ var Stage = class _Stage {
11100
11720
  next: "Use a file inside execution.allowedReadRoots, or ask the user to add its folder there."
11101
11721
  });
11102
11722
  }
11103
- if (!(await stat(real)).isFile()) throw new GwsError("UPLOAD_INVALID", `'${input.source}' is not a regular file`);
11723
+ if (!(await stat2(real)).isFile()) throw new GwsError("UPLOAD_INVALID", `'${input.source}' is not a regular file`);
11104
11724
  let source;
11105
11725
  let target;
11106
11726
  try {
@@ -11108,7 +11728,7 @@ var Stage = class _Stage {
11108
11728
  const info = await source.stat();
11109
11729
  if (!info.isFile()) throw new GwsError("UPLOAD_INVALID", `'${input.source}' is not a regular file`);
11110
11730
  if (info.size > maxBytes) throw uploadLimit(input.source, info.size, maxBytes);
11111
- const destination = join7(this.dir, input.rel);
11731
+ const destination = join8(this.dir, input.rel);
11112
11732
  throwIfCancelled(this.signal);
11113
11733
  await mkdir2(dirname5(destination), { recursive: true, mode: 448 });
11114
11734
  throwIfCancelled(this.signal);
@@ -11138,7 +11758,7 @@ async function checkDestination(output, limits) {
11138
11758
  if (!isAbsolute7(output.destination)) throw new GwsError("DOWNLOAD_INVALID", `'${output.destination}' must be an absolute path`);
11139
11759
  const parent = await realpath(dirname5(output.destination)).catch(() => void 0);
11140
11760
  if (!parent) throw new GwsError("DOWNLOAD_INVALID", `the directory for '${output.destination}' does not exist`);
11141
- const target = join7(parent, basename2(output.destination));
11761
+ const target = join8(parent, basename2(output.destination));
11142
11762
  if (!withinRootSets(target, await realRootSets(limits.writeRoots))) {
11143
11763
  throw new GwsError("POLICY_DENIED", `download path '${output.destination}' resolves outside allowed write roots`, {
11144
11764
  next: "Use a destination inside execution.allowedWriteRoots, or ask the user to add its folder there."
@@ -11152,20 +11772,20 @@ async function checkDestination(output, limits) {
11152
11772
  async function place(staged, target, output, signal) {
11153
11773
  throwIfCancelled(signal);
11154
11774
  try {
11155
- if (output.overwrite) await rename(staged, target);
11775
+ if (output.overwrite) await rename2(staged, target);
11156
11776
  else await link(staged, target);
11157
11777
  return false;
11158
11778
  } catch (error2) {
11159
11779
  if (code(error2) === "EEXIST") throw exists(output.destination);
11160
11780
  if (code(error2) !== "EXDEV" && code(error2) !== "EPERM" && code(error2) !== "ENOTSUP") throw error2;
11161
11781
  }
11162
- const temp = join7(dirname5(target), `.${basename2(target)}.${process.pid}.${Date.now()}.partial`);
11782
+ const temp = join8(dirname5(target), `.${basename2(target)}.${process.pid}.${Date.now()}.partial`);
11163
11783
  let cleanupDegraded = false;
11164
11784
  try {
11165
11785
  throwIfCancelled(signal);
11166
11786
  await copyDurable(staged, temp, signal);
11167
11787
  throwIfCancelled(signal);
11168
- if (output.overwrite) await rename(temp, target);
11788
+ if (output.overwrite) await rename2(temp, target);
11169
11789
  else {
11170
11790
  try {
11171
11791
  await link(temp, target);
@@ -11234,6 +11854,17 @@ function code(error2) {
11234
11854
  // src/runtime/operations.ts
11235
11855
  var METHOD_FIELDS = ["params", "body", "fields", "pagination", "upload", "download"];
11236
11856
  var PLAN_INSTEAD = "Call gws_plan with the same request, then commit with the tool named by the plan's category.";
11857
+ var SAVE_INSTEAD = "Google answered with a body that is not JSON. To save it, plan the same request with download.path, then commit it with gws_export.";
11858
+ var ADDRESS_TTL_MS = 10 * 60 * 1e3;
11859
+ function policyError(decision, account2) {
11860
+ const detail = decision.violations.join("; ");
11861
+ if (decision.code === "ACCOUNT_DOMAIN_UNKNOWN") {
11862
+ return new GwsError("ACCOUNT_DOMAIN_UNKNOWN", detail, {
11863
+ next: `Ask the user to set accounts.${account2}.domains to the account's email domains, or to enable Gmail, Drive or Calendar for '${account2}' so the plugin can read its address.`
11864
+ });
11865
+ }
11866
+ return new GwsError(decision.code, detail);
11867
+ }
11237
11868
  var Operations = class {
11238
11869
  constructor(runtime, accounts) {
11239
11870
  this.runtime = runtime;
@@ -11241,6 +11872,8 @@ var Operations = class {
11241
11872
  }
11242
11873
  runtime;
11243
11874
  accounts;
11875
+ /** Each account's own address, once read. */
11876
+ addresses = /* @__PURE__ */ new Map();
11244
11877
  /** The configured account a call names, or the only one when it names none. */
11245
11878
  account(alias) {
11246
11879
  const aliases = Object.keys(this.runtime.config.accounts);
@@ -11312,8 +11945,9 @@ var Operations = class {
11312
11945
  throw new GwsError("POLICY_DENIED", `'${entry.id}' is ${category2}; use gws_plan and the matching commit tool`, { next: PLAN_INSTEAD });
11313
11946
  }
11314
11947
  }
11315
- const decision2 = evaluatePolicy(this.runtime.config, entry, "read", request);
11316
- if (decision2.effect === "deny") throw new GwsError("POLICY_DENIED", decision2.violations.join("; "));
11948
+ const facts = { recipients: recipientPlan(entry.id, "read", request).known, accountDomains: this.runtime.config.accounts[request.account]?.domains };
11949
+ const decision2 = evaluatePolicy(this.runtime.config, entry, "read", request, facts);
11950
+ if (decision2.effect === "deny") throw policyError(decision2, request.account);
11317
11951
  return { category: "read", decision: decision2 };
11318
11952
  }
11319
11953
  const category = helper ? helper.category : effectiveCategory(entry, request);
@@ -11322,8 +11956,8 @@ var Operations = class {
11322
11956
  next: category === "read" ? "Run it with gws_read." : "Tell the user the plugin never runs it."
11323
11957
  });
11324
11958
  }
11325
- const decision = evaluatePolicy(this.runtime.config, entry, category, request);
11326
- if (decision.effect === "deny") throw new GwsError("POLICY_DENIED", decision.violations.join("; "));
11959
+ const decision = evaluatePolicy(this.runtime.config, entry, category, request, "pending");
11960
+ if (decision.effect === "deny") throw policyError(decision, request.account);
11327
11961
  if (category === "admin" && (!this.runtime.config.admin.enabled || decision.effect !== "allow")) {
11328
11962
  throw new GwsError("POLICY_DENIED", "admin operations require admin.enabled, an allowed service, and a reason when one is required");
11329
11963
  }
@@ -11336,11 +11970,13 @@ var Operations = class {
11336
11970
  inspect(input, mode) {
11337
11971
  const resolved = this.resolve(input);
11338
11972
  const { category } = this.gate(resolved, mode);
11973
+ const reads = mode === "plan" ? recipientPlan(resolved.entry.id, category, resolved.request).lookups.map((lookup) => lookup.operation) : [];
11339
11974
  return {
11340
11975
  account: resolved.request.account,
11341
11976
  operation: resolved.entry.id,
11342
11977
  service: resolved.entry.service,
11343
- category
11978
+ category,
11979
+ ...reads.length > 0 ? { reads } : {}
11344
11980
  };
11345
11981
  }
11346
11982
  /**
@@ -11371,7 +12007,7 @@ var Operations = class {
11371
12007
  report2(ctx, { status: "running", operation: entry.id });
11372
12008
  const schema = await this.runtime.schema.resolve(entry.service, [...entry.path, entry.method], ctx.signal);
11373
12009
  this.runtime.assertActive(ctx.signal);
11374
- return { ok: true, account: target.account, schema, ...appOnly(entry.id) ? { appOnly: true } : {} };
12010
+ return { ok: true, account: target.account, schema, ...restrictionMarks(entry.id) };
11375
12011
  }
11376
12012
  /** Validate a commit against its stored plan without consuming it. */
11377
12013
  inspectPlan(category, input, ctx) {
@@ -11396,7 +12032,7 @@ var Operations = class {
11396
12032
  requestedInlineBytes: helper ? void 0 : request.response?.maxInlineBytes
11397
12033
  });
11398
12034
  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));
12035
+ if (failed) throw processError(result, parsed, entry.service, this.accounts.remediation(request.account, entry.scopes, entry.service));
11400
12036
  const pages = paged ? capPageItems(parsed.data, itemCap(request, limits)) : void 0;
11401
12037
  const cap = inlineCap(this.runtime.config.execution.maxInlineBytes, request.response?.mode, request.response?.maxInlineBytes);
11402
12038
  const truncated = pages ? Buffer.byteLength(JSON.stringify(pages.data)) > cap : parsed.truncated;
@@ -11413,7 +12049,7 @@ var Operations = class {
11413
12049
  ok: true,
11414
12050
  operation: entry.id,
11415
12051
  category: "read",
11416
- data: helper ? parsed.data ?? parsed.raw ?? result.stdout.toString("utf8") : pages ? pages.data : parsed.data,
12052
+ data: helper ? parsed.data ?? parsed.raw ?? result.stdout.toString("utf8") : pages ? pages.data : withoutSavedBody(parsed.data, SAVE_INSTEAD),
11417
12053
  truncated,
11418
12054
  diagnostics: parsed.diagnostics,
11419
12055
  pagination: pages ? { pages: pages.pages, items: pages.items, capped: pages.capped } : void 0,
@@ -11428,8 +12064,12 @@ var Operations = class {
11428
12064
  const resolved = this.resolve(input);
11429
12065
  const { entry, helper, request } = resolved;
11430
12066
  const started = Date.now();
11431
- const { category, decision } = this.gate(resolved, "plan");
12067
+ const { category } = this.gate(resolved, "plan");
11432
12068
  const commitCategory = category;
12069
+ const recipientsPlan = recipientPlan(entry.id, category, request);
12070
+ const facts = await this.policyFacts(resolved, category, recipientsPlan, ctx);
12071
+ const decision = evaluatePolicy(this.runtime.config, entry, category, request, facts);
12072
+ if (decision.effect === "deny") throw policyError(decision, request.account);
11433
12073
  const limits = fileLimits(decision);
11434
12074
  let dryRunArgv;
11435
12075
  let commitArgv;
@@ -11460,14 +12100,16 @@ var Operations = class {
11460
12100
  const dry = await this.accounts.run(dryRunArgv, request.account, entry.id, ctx, { scopes: entry.scopes, dryRun: true, cwd: stage?.dir });
11461
12101
  report2(ctx, { status: "parsing", operation: entry.id });
11462
12102
  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));
12103
+ if (dry.exitCode !== 0 || !parsed.ok) throw processError(dry, parsed, entry.service, this.accounts.remediation(request.account, entry.scopes, entry.service));
11464
12104
  } finally {
11465
12105
  await stage?.dispose();
11466
12106
  }
11467
12107
  throwIfCancelled(ctx.signal);
11468
12108
  const reason = request.reason?.trim();
11469
- const planPreview = reason ? { ...preview, reason } : preview;
11470
- const plan = this.runtime.plans.create({
12109
+ const { recipients } = facts;
12110
+ const reached = category === "send" || category === "share" ? { ...preview, recipients: recipientLabels(recipients), ...recipients.unknown ? { recipientsUnknown: recipients.unknown } : {} } : preview;
12111
+ const planPreview = reason ? { ...reached, reason } : reached;
12112
+ const plan2 = this.runtime.plans.create({
11471
12113
  account: request.account,
11472
12114
  sessionId: ctx.sessionId,
11473
12115
  agentId: ctx.agentId,
@@ -11477,6 +12119,8 @@ var Operations = class {
11477
12119
  argv: commitArgv,
11478
12120
  preview: planPreview,
11479
12121
  files: planned,
12122
+ // Recipients read at plan time can change before the commit (a draft, an event's guests): the commit reads them again.
12123
+ ...recipientsPlan.lookups.length > 0 ? { recheck: { request, recipients: facts.recipients.addresses.concat(facts.recipients.domains) } } : {},
11480
12124
  ttlSeconds: this.runtime.config.policy.planTtlSeconds
11481
12125
  });
11482
12126
  this.runtime.audit.emit({
@@ -11490,8 +12134,8 @@ var Operations = class {
11490
12134
  });
11491
12135
  return {
11492
12136
  ok: true,
11493
- planId: plan.id,
11494
- expiresAt: new Date(plan.expiresAt).toISOString(),
12137
+ planId: plan2.id,
12138
+ expiresAt: new Date(plan2.expiresAt).toISOString(),
11495
12139
  category: commitCategory,
11496
12140
  preview: planPreview,
11497
12141
  dryRun: redactDryRun(parsed.data)
@@ -11502,7 +12146,7 @@ var Operations = class {
11502
12146
  throwIfCancelled(ctx.signal);
11503
12147
  if (existing.files?.output) await checkDestination(existing.files.output, existing.files.limits);
11504
12148
  this.runtime.assertActive(ctx.signal);
11505
- const plan = this.runtime.plans.acquire(input.planId, {
12149
+ const plan2 = this.runtime.plans.acquire(input.planId, {
11506
12150
  sessionId: ctx.sessionId,
11507
12151
  agentId: ctx.agentId,
11508
12152
  account: existing.account,
@@ -11512,38 +12156,41 @@ var Operations = class {
11512
12156
  let stage;
11513
12157
  let receipt;
11514
12158
  try {
11515
- if (plan.files) stage = await Stage.create(this.runtime.workDir, ctx.signal);
11516
- if (stage && plan.files) {
11517
- const staged = await stage.stageInputs(plan.files.inputs, plan.files.limits);
11518
- refuseChanged(plan.files.inputs, staged);
11519
- if (plan.files.output) await stage.prepareOutput(plan.files.output);
11520
- }
11521
- const scopes = commandIndex().methods.get(plan.operation)?.scopes;
11522
- const result = await this.accounts.run(plan.argv, plan.account, plan.operation, ctx, { scopes, cwd: stage?.dir });
11523
- report2(ctx, { status: "parsing", operation: plan.operation });
12159
+ if (plan2.recheck) await this.recheck(plan2, ctx);
12160
+ if (plan2.files) stage = await Stage.create(this.runtime.workDir, ctx.signal);
12161
+ if (stage && plan2.files) {
12162
+ const staged = await stage.stageInputs(plan2.files.inputs, plan2.files.limits);
12163
+ refuseChanged(plan2.files.inputs, staged);
12164
+ if (plan2.files.output) await stage.prepareOutput(plan2.files.output);
12165
+ }
12166
+ const method = commandIndex().methods.get(plan2.operation);
12167
+ const scopes = method?.scopes;
12168
+ const result = await this.accounts.run(plan2.argv, plan2.account, plan2.operation, ctx, { scopes, cwd: stage?.dir });
12169
+ report2(ctx, { status: "parsing", operation: plan2.operation });
11524
12170
  const parsed = parseResult(result, { maxInlineBytes: this.runtime.config.execution.maxInlineBytes });
11525
- if (!parsed.ok) throw processError(result, parsed, this.accounts.remediation(plan.account, scopes));
11526
- const delivered = stage && plan.files?.output ? await stage.deliver(plan.files.output, plan.files.limits) : void 0;
12171
+ if (!parsed.ok) throw processError(result, parsed, plan2.service, this.accounts.remediation(plan2.account, scopes, plan2.service));
12172
+ const delivered = stage && plan2.files?.output ? await stage.deliver(plan2.files.output, plan2.files.limits) : void 0;
11527
12173
  const file = delivered ? { path: delivered.path, bytes: delivered.bytes, sha256: delivered.sha256 } : void 0;
11528
- this.runtime.plans.complete(plan.id);
12174
+ this.runtime.plans.complete(plan2.id);
11529
12175
  this.runtime.audit.emit({
11530
12176
  event: "operation.commit",
11531
12177
  outcome: "success",
11532
- account: plan.account,
12178
+ account: plan2.account,
11533
12179
  category,
11534
- operation: plan.operation,
12180
+ operation: plan2.operation,
11535
12181
  durationMs: Date.now() - started
11536
12182
  });
11537
- receipt = { ok: true, planId: plan.id, category, operation: plan.operation, result: parsed.data ?? parsed.raw, file, ...delivered?.cleanupDegraded ? { cleanupDegraded: true } : {} };
12183
+ const answer = plan2.files?.output ? parsed.data : withoutSavedBody(parsed.data);
12184
+ receipt = { ok: true, planId: plan2.id, category, operation: plan2.operation, result: answer ?? parsed.raw, file, ...delivered?.cleanupDegraded ? { cleanupDegraded: true } : {} };
11538
12185
  return receipt;
11539
12186
  } catch (error2) {
11540
- this.runtime.plans.release(plan.id);
12187
+ this.runtime.plans.release(plan2.id);
11541
12188
  this.runtime.audit.emit({
11542
12189
  event: "operation.commit",
11543
12190
  outcome: "error",
11544
- account: plan.account,
12191
+ account: plan2.account,
11545
12192
  category,
11546
- operation: plan.operation,
12193
+ operation: plan2.operation,
11547
12194
  durationMs: Date.now() - started,
11548
12195
  errorCode: auditErrorCode(error2)
11549
12196
  });
@@ -11563,6 +12210,85 @@ var Operations = class {
11563
12210
  }
11564
12211
  }
11565
12212
  }
12213
+ /**
12214
+ * The recipient facts for a plan: what the request carries, the reads
12215
+ * that find the rest, and the account's own domains when a rule needs them.
12216
+ * A read that fails leaves the recipients unknown, which a recipient rule refuses.
12217
+ */
12218
+ async policyFacts(resolved, category, plan2, ctx) {
12219
+ const { entry, request } = resolved;
12220
+ let recipients = plan2.known;
12221
+ if (plan2.lookups.length > 0) {
12222
+ const self = plan2.needsSelf ? await this.ownAddress(request.account, entry.service, ctx) : void 0;
12223
+ for (const lookup of plan2.lookups) recipients = withLookup(recipients, await this.lookup(lookup, request.account, ctx, self), lookup.what);
12224
+ }
12225
+ const configured = this.runtime.config.accounts[request.account]?.domains;
12226
+ const reaches = recipients.addresses.length > 0 || recipients.domains.length > 0 || recipients.unknown !== void 0;
12227
+ if (configured || !reaches || !needsAccountDomains(this.runtime.config, entry, category, request)) return { recipients, accountDomains: configured };
12228
+ const address = await this.ownAddress(request.account, entry.service, ctx);
12229
+ return { recipients, accountDomains: address ? [domainOf(address)] : void 0 };
12230
+ }
12231
+ /** Read the recipients again before a commit whose plan read them, and refuse when the policy now denies or someone was added. */
12232
+ async recheck(plan2, ctx) {
12233
+ const recheck = plan2.recheck;
12234
+ const resolved = this.resolve(recheck.request);
12235
+ const category = plan2.category;
12236
+ const facts = await this.policyFacts(resolved, category, recipientPlan(resolved.entry.id, category, resolved.request), ctx);
12237
+ const decision = evaluatePolicy(this.runtime.config, resolved.entry, category, resolved.request, facts);
12238
+ if (decision.effect === "deny") throw policyError(decision, plan2.account);
12239
+ const planned = new Set(recheck.recipients);
12240
+ const added = [...facts.recipients.addresses, ...facts.recipients.domains].filter((recipient) => !planned.has(recipient));
12241
+ if (added.length > 0) {
12242
+ throw new GwsError("PLAN_EXPIRED", `the recipients changed after the plan was created (added: ${added.join(", ")})`, { next: "Plan again and show the new preview." });
12243
+ }
12244
+ }
12245
+ /** One recipient read; undefined when it fails or answers in an unexpected shape. */
12246
+ async lookup(lookup, account2, ctx, self) {
12247
+ try {
12248
+ return lookup.extract(await this.readJson(lookup.operation, lookup.params, account2, ctx), self);
12249
+ } catch (error2) {
12250
+ throwIfCancelled(ctx.signal);
12251
+ this.runtime.assertActive(ctx.signal);
12252
+ if (GwsError.from(error2) === void 0) throw error2;
12253
+ return void 0;
12254
+ }
12255
+ }
12256
+ /** The account's own address, read with the operation's service first; undefined when no read gives it. */
12257
+ async ownAddress(account2, service, ctx) {
12258
+ const cached3 = this.addresses.get(account2);
12259
+ if (cached3 && Date.now() - cached3.at < ADDRESS_TTL_MS) return cached3.address;
12260
+ const enabled = this.runtime.config.accounts[account2]?.enabledServices ?? [];
12261
+ const services = [.../* @__PURE__ */ new Set([service, ...Object.keys(ACCOUNT_ADDRESS_READS)])].filter((name) => Object.hasOwn(ACCOUNT_ADDRESS_READS, name) && enabled.includes(name));
12262
+ for (const name of services) {
12263
+ const read = ACCOUNT_ADDRESS_READS[name];
12264
+ try {
12265
+ const address = read.extract(await this.readJson(read.operation, read.params, account2, ctx));
12266
+ if (address) {
12267
+ this.addresses.set(account2, { address, at: Date.now() });
12268
+ return address;
12269
+ }
12270
+ } catch (error2) {
12271
+ throwIfCancelled(ctx.signal);
12272
+ this.runtime.assertActive(ctx.signal);
12273
+ if (GwsError.from(error2) === void 0) throw error2;
12274
+ }
12275
+ }
12276
+ return void 0;
12277
+ }
12278
+ /** A field-masked read the plugin makes for itself, under the read policy; its parsed JSON. */
12279
+ async readJson(operation, params, account2, ctx) {
12280
+ const entry = commandIndex().methods.get(operation);
12281
+ if (!entry) throw new GwsError("OPERATION_UNKNOWN", `'${operation}' is not in the gws manifest`);
12282
+ const request = { account: account2, operation, params };
12283
+ const decision = evaluatePolicy(this.runtime.config, entry, "read", request, { recipients: recipientPlan(operation, "read", request).known });
12284
+ if (decision.effect === "deny") throw policyError(decision, account2);
12285
+ const argv = buildCommand(entry, request, this.runtime.config, { dryRun: false, category: "read" }).argv;
12286
+ const result = await this.accounts.run(argv, account2, operation, ctx, { scopes: entry.scopes });
12287
+ this.runtime.assertActive(ctx.signal);
12288
+ const parsed = parseResult(result, { maxInlineBytes: this.runtime.config.execution.maxInlineBytes });
12289
+ if (!parsed.ok) throw processError(result, parsed, entry.service, this.accounts.remediation(account2, entry.scopes, entry.service));
12290
+ return parsed.data;
12291
+ }
11566
12292
  };
11567
12293
  function fileLimits(decision) {
11568
12294
  const { maxUploadBytes, maxDownloadBytes, readRoots, writeRoots } = decision.constraints;
@@ -11597,10 +12323,9 @@ var Runtime = class _Runtime {
11597
12323
  this.version = version2;
11598
12324
  this.binary = binary;
11599
12325
  this.paths = options.paths ?? pluginPaths();
11600
- this.workDir = mkdtempSync(join8(tmpdir(), "opencode-gws-"));
11601
- writeFileSync(join8(this.workDir, ".env"), "");
11602
- this.audit = new Audit({ ...config2.audit, path: config2.audit.path ?? join8(this.paths.stateDir, "audit.jsonl") }, options.pluginVersion);
11603
- this.schema = new SchemaClient(binary, this.workDir, localEnv(this.workDir, ownedDir(join8(this.paths.cacheDir, "discovery"))), config2.execution.schemaTimeoutMs);
12326
+ this.workDir = createWorkDir();
12327
+ this.audit = new Audit({ ...config2.audit, path: config2.audit.path ?? join9(this.paths.stateDir, "audit.jsonl") }, options.pluginVersion);
12328
+ this.schema = new SchemaClient(binary, this.workDir, localEnv(this.workDir, ownedDir(join9(this.paths.cacheDir, "discovery"))), config2.execution.schemaTimeoutMs);
11604
12329
  this.accounts = new Accounts(this);
11605
12330
  this.operations = new Operations(this, this.accounts);
11606
12331
  }
@@ -11638,7 +12363,8 @@ var Runtime = class _Runtime {
11638
12363
  }
11639
12364
  static async create(config2, options) {
11640
12365
  const binary = resolveBinary(config2, options.projectDirectory);
11641
- const probeDir = mkdtempSync(join8(tmpdir(), "opencode-gws-probe-"));
12366
+ removeStaleWorkDirs();
12367
+ const probeDir = mkdtempSync2(join9(tmpdir2(), "opencode-gws-probe-"));
11642
12368
  let version2 = "unknown";
11643
12369
  try {
11644
12370
  const probe = await runProcess({
@@ -11655,7 +12381,7 @@ var Runtime = class _Runtime {
11655
12381
  throw new Error(`GWS_VERSION_MISMATCH: Requires ${GWS_VERSION} and the matching manifest`);
11656
12382
  }
11657
12383
  } finally {
11658
- rmSync(probeDir, { recursive: true, force: true });
12384
+ rmSync2(probeDir, { recursive: true, force: true });
11659
12385
  }
11660
12386
  return new _Runtime(config2, options, binary, version2);
11661
12387
  }
@@ -11719,7 +12445,7 @@ var Runtime = class _Runtime {
11719
12445
  this.plans.invalidateAll();
11720
12446
  this.audit.close();
11721
12447
  this.closing = settled.then(() => {
11722
- rmSync(this.workDir, { recursive: true, force: true });
12448
+ rmSync2(this.workDir, { recursive: true, force: true });
11723
12449
  });
11724
12450
  return this.closing;
11725
12451
  }
@@ -11742,8 +12468,7 @@ async function start(ctx) {
11742
12468
  const ids = integrationIds(config2.accounts);
11743
12469
  const runtime = await Runtime.create(config2, {
11744
12470
  pluginVersion: PLUGIN_VERSION,
11745
- hostVersion: host.version,
11746
- hostWarning: host.warning,
12471
+ host,
11747
12472
  projectDirectory: ctx.location.directory,
11748
12473
  tokenProvider: integrationTokenProvider(ctx, config2, ids),
11749
12474
  connection: integrationConnection(ctx, ids),