@joe-broadhead/opencode-gws 0.0.6 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +1 -1
- package/dist/index.js +281 -229
- package/dist/tui.js +26 -1
- package/package.json +5 -5
- package/skills/opencode-gws/SKILL.md +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
4
4
|
|
|
5
|
+
## [0.0.8] - 2026-11-06
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- The `/connect` sign-in mark on an account now clears. The plugin read the mark back from
|
|
10
|
+
`integration.connection.active`, which returns the credential without its status, so it never saw its own report: after
|
|
11
|
+
Google rejected a sign-in, the account kept asking for a new one in `/gws-status`, the status RPC and `gws_status` once
|
|
12
|
+
it worked again. It now reads the status from `integration.get`, which projects it.
|
|
13
|
+
|
|
14
|
+
## [0.0.7] - 2026-11-05
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- Status names any other skill that tells agents to run `gws` in a shell, such as the gws CLI's own `gws-*` skills, which lead around the plugin's plans, approvals and policy. A skill counts when a line in one of its code blocks, or an inline code span, starts with `gws ` and a word (`gws gmail +triage`); prose that only names gws does not. They appear in the status RPC's new `shellSkills`, in a **Shell gws skills** row in `/gws-status` and the dialog, and in a next step to remove each one or move it out of its skills folder.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- When no account here can use an admin-only service such as `admin-reports` (the `default` account leaves it out), its refusal's next step and `gws_catalog`'s `unavailable` reason now say the same thing: only a Workspace admin's account with the needed privilege can use it, so add a separate account alias for that admin with `enabledServices` including the service, not the service to an existing account. Before, the next step said to add the service to the plugin's services or to this account, which for anyone who isn't an admin ends in `ADMIN_PRIVILEGE_REQUIRED`.
|
|
23
|
+
- `opencode-gws` says a script creates its own session and keeps its id, never one found through history or a session list, and that an answer that only shows a preview names the tool that would commit it.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- A `fields` mask whose parentheses don't balance is refused with `FIELDS_INVALID`, which names the extra `)` or the unclosed `(`, before gws runs. Google's own error for it names no argument.
|
|
28
|
+
|
|
5
29
|
## [0.0.6] - 2026-11-05
|
|
6
30
|
|
|
7
31
|
### Fixed
|
package/README.md
CHANGED
|
@@ -114,7 +114,7 @@ Members then only run `/connect`, choose Google Workspace, and pick an access le
|
|
|
114
114
|
| `POLICY_DENIED` | A policy rule or the admin gate refused the call. A deny is final. |
|
|
115
115
|
| `RECIPIENTS_UNKNOWN` | A recipient rule applies, and the plugin could not tell who the send or share reaches. |
|
|
116
116
|
|
|
117
|
-
A skill of your own named `opencode-gws` or `opencode-gws-recipes` (for example in `~/.claude/skills`) hides the plugin's skill of that name; `/gws-status` names it under **Hidden skills**, and the fix is to move or rename it.
|
|
117
|
+
A skill of your own named `opencode-gws` or `opencode-gws-recipes` (for example in `~/.claude/skills`) hides the plugin's skill of that name; `/gws-status` names it under **Hidden skills**, and the fix is to move or rename it. A skill that tells agents to run `gws` in a shell, such as the gws CLI's own `gws-*` skills, leads them around the plugin's plans and approvals; `/gws-status` names each one under **Shell gws skills**, and the fix is to remove it or move it out of its skills folder.
|
|
118
118
|
|
|
119
119
|
Each OpenCode server keeps a private work folder, `$TMPDIR/opencode-gws-XXXXXX`, and removes it when it stops. A server that stopped without closing leaves its folder behind; the next start removes such folders once they are a day old and their process is gone.
|
|
120
120
|
|
package/dist/index.js
CHANGED
|
@@ -6571,6 +6571,10 @@ var ErrorCode = {
|
|
|
6571
6571
|
meaning: "The request is malformed: a schema violation, a field helpers do not take, a missing field mask on list or search, a relative path, a download on gws_read, or arguments gws rejected.",
|
|
6572
6572
|
next: "Fix the request as the message says; gws_schema shows the parameters and request body."
|
|
6573
6573
|
},
|
|
6574
|
+
FIELDS_INVALID: {
|
|
6575
|
+
meaning: "The fields mask's parentheses do not balance, so gws was not called.",
|
|
6576
|
+
next: "Give each '(' one ')' after it, or write a single path with slashes, such as body/content(paragraph/elements/textRun/content), then retry."
|
|
6577
|
+
},
|
|
6574
6578
|
POLICY_DENIED: {
|
|
6575
6579
|
meaning: "Plugin policy refused the call: a deny rule, a category the plugin never runs (deploy, watch), the service, recipients, public sharing, the admin gate, or a path outside the allowed roots. A deny is final.",
|
|
6576
6580
|
next: "Do not work around it; tell the user, who can change the plugin policy."
|
|
@@ -7272,6 +7276,223 @@ function helperAdapter(id) {
|
|
|
7272
7276
|
return ADAPTERS.get(id);
|
|
7273
7277
|
}
|
|
7274
7278
|
|
|
7279
|
+
// src/domain/result.ts
|
|
7280
|
+
function isApiError(value) {
|
|
7281
|
+
if (Array.isArray(value)) return value.some(isApiError);
|
|
7282
|
+
if (!value || typeof value !== "object") return false;
|
|
7283
|
+
const record3 = value;
|
|
7284
|
+
return "error" in record3 && record3.error !== null && typeof record3.error === "object";
|
|
7285
|
+
}
|
|
7286
|
+
function parseJson(text4) {
|
|
7287
|
+
const lines = text4.split("\n");
|
|
7288
|
+
const start2 = lines.findIndex((line) => /^[[{]/.test(line.trim()));
|
|
7289
|
+
if (start2 < 0) return void 0;
|
|
7290
|
+
const candidate = lines.slice(start2).join("\n").trim();
|
|
7291
|
+
try {
|
|
7292
|
+
return JSON.parse(candidate);
|
|
7293
|
+
} catch {
|
|
7294
|
+
const parsed = [];
|
|
7295
|
+
for (const line of lines.slice(start2)) {
|
|
7296
|
+
const value = line.trim();
|
|
7297
|
+
if (!/^[[{]/.test(value)) continue;
|
|
7298
|
+
try {
|
|
7299
|
+
parsed.push(JSON.parse(value));
|
|
7300
|
+
} catch {
|
|
7301
|
+
}
|
|
7302
|
+
}
|
|
7303
|
+
return parsed.length > 0 ? parsed : void 0;
|
|
7304
|
+
}
|
|
7305
|
+
}
|
|
7306
|
+
function inlineCap(maxInlineBytes, mode, requested) {
|
|
7307
|
+
const base2 = mode === "full" ? Math.max(maxInlineBytes, 5 * 1024 * 1024) : maxInlineBytes;
|
|
7308
|
+
return requested === void 0 ? base2 : Math.min(base2, requested);
|
|
7309
|
+
}
|
|
7310
|
+
function parseResult(result, options) {
|
|
7311
|
+
const text4 = result.stdout.toString("utf8");
|
|
7312
|
+
const parsed = parseJson(text4);
|
|
7313
|
+
const serialized = parsed === void 0 ? text4 : JSON.stringify(parsed);
|
|
7314
|
+
const bytes2 = Buffer.byteLength(serialized);
|
|
7315
|
+
const cap = inlineCap(options.maxInlineBytes, options.mode, options.requestedInlineBytes);
|
|
7316
|
+
const truncated = bytes2 > cap;
|
|
7317
|
+
const empty = text4.trim().length === 0;
|
|
7318
|
+
return {
|
|
7319
|
+
// A successful DELETE commonly returns 204 with no body. Empty output
|
|
7320
|
+
// with exit 0 is success; non-JSON text is not a JSON result.
|
|
7321
|
+
ok: result.exitCode === 0 && (empty || parsed !== void 0 && !isApiError(parsed)),
|
|
7322
|
+
data: parsed,
|
|
7323
|
+
raw: parsed === void 0 ? text4.slice(0, cap) : void 0,
|
|
7324
|
+
truncated,
|
|
7325
|
+
diagnostics: { exitCode: result.exitCode, durationMs: result.durationMs, bytes: bytes2 }
|
|
7326
|
+
};
|
|
7327
|
+
}
|
|
7328
|
+
function withoutSavedBody(data, next) {
|
|
7329
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) return data;
|
|
7330
|
+
const record3 = data;
|
|
7331
|
+
const keys = Object.keys(record3);
|
|
7332
|
+
if (typeof record3["saved_file"] !== "string" || typeof record3["bytes"] !== "number" || record3["status"] !== "success") return data;
|
|
7333
|
+
if (!keys.every((key) => ["bytes", "mimeType", "saved_file", "status"].includes(key))) return data;
|
|
7334
|
+
if (record3["bytes"] === 0) return "";
|
|
7335
|
+
return {
|
|
7336
|
+
...typeof record3["mimeType"] === "string" ? { mimeType: record3["mimeType"] } : {},
|
|
7337
|
+
bytes: record3["bytes"],
|
|
7338
|
+
saved: false,
|
|
7339
|
+
...next ? { next } : {}
|
|
7340
|
+
};
|
|
7341
|
+
}
|
|
7342
|
+
function itemKey(page2) {
|
|
7343
|
+
if (!page2 || typeof page2 !== "object" || Array.isArray(page2)) return void 0;
|
|
7344
|
+
const record3 = page2;
|
|
7345
|
+
if (Array.isArray(record3["items"])) return "items";
|
|
7346
|
+
const arrays = Object.keys(record3).filter((key) => Array.isArray(record3[key]));
|
|
7347
|
+
return arrays.length === 1 ? arrays[0] : void 0;
|
|
7348
|
+
}
|
|
7349
|
+
function capPageItems(data, maxItems) {
|
|
7350
|
+
const pages = data === void 0 ? [] : Array.isArray(data) ? data : [data];
|
|
7351
|
+
const keys = pages.map((page2) => itemKey(page2) ?? (isEmptyPage(page2) ? null : void 0));
|
|
7352
|
+
if (keys.some((key) => key === void 0)) return { data: pages, pages: pages.length, capped: false };
|
|
7353
|
+
const kept = [];
|
|
7354
|
+
let items = 0;
|
|
7355
|
+
let capped = false;
|
|
7356
|
+
for (const [index2, page2] of pages.entries()) {
|
|
7357
|
+
if (items >= maxItems) {
|
|
7358
|
+
capped = true;
|
|
7359
|
+
break;
|
|
7360
|
+
}
|
|
7361
|
+
const key = keys[index2];
|
|
7362
|
+
if (!key) {
|
|
7363
|
+
kept.push(page2);
|
|
7364
|
+
continue;
|
|
7365
|
+
}
|
|
7366
|
+
const record3 = page2;
|
|
7367
|
+
const list = record3[key];
|
|
7368
|
+
const room = maxItems - items;
|
|
7369
|
+
if (list.length > room) {
|
|
7370
|
+
const { nextPageToken: _dropped, ...rest } = record3;
|
|
7371
|
+
kept.push({ ...rest, [key]: list.slice(0, room) });
|
|
7372
|
+
items += room;
|
|
7373
|
+
capped = true;
|
|
7374
|
+
break;
|
|
7375
|
+
}
|
|
7376
|
+
kept.push(page2);
|
|
7377
|
+
items += list.length;
|
|
7378
|
+
}
|
|
7379
|
+
return { data: kept, pages: kept.length, items, capped };
|
|
7380
|
+
}
|
|
7381
|
+
function isEmptyPage(page2) {
|
|
7382
|
+
if (!page2 || typeof page2 !== "object" || Array.isArray(page2)) return false;
|
|
7383
|
+
return !Object.values(page2).some(Array.isArray);
|
|
7384
|
+
}
|
|
7385
|
+
function redactDryRun(value) {
|
|
7386
|
+
if (Array.isArray(value)) return value.map(redactDryRun);
|
|
7387
|
+
if (value && typeof value === "object") {
|
|
7388
|
+
const out = {};
|
|
7389
|
+
for (const [key, child] of Object.entries(value)) {
|
|
7390
|
+
if (/^(body|raw|content)$/i.test(key)) out[key] = "[redacted]";
|
|
7391
|
+
else out[key] = redactDryRun(child);
|
|
7392
|
+
}
|
|
7393
|
+
return out;
|
|
7394
|
+
}
|
|
7395
|
+
return value;
|
|
7396
|
+
}
|
|
7397
|
+
function apiErrorOf(result, parsed) {
|
|
7398
|
+
const data = parsed ? parsed.data : parseJson(result.stdout.toString("utf8"));
|
|
7399
|
+
const found = Array.isArray(data) ? data.find(isApiError) : data;
|
|
7400
|
+
if (!isApiError(found)) return void 0;
|
|
7401
|
+
return found.error;
|
|
7402
|
+
}
|
|
7403
|
+
function stderrText(result) {
|
|
7404
|
+
return result.stderr.toString("utf8").split("\n").filter((line) => line.trim() && !/^Using keyring backend:/i.test(line)).join("\n").trim();
|
|
7405
|
+
}
|
|
7406
|
+
var EXIT = { auth: 2, validation: 3, discovery: 4, internal: 5 };
|
|
7407
|
+
var SCOPE_REASONS = /* @__PURE__ */ new Set(["ACCESS_TOKEN_SCOPE_INSUFFICIENT", "insufficientPermissions"]);
|
|
7408
|
+
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;
|
|
7409
|
+
function privilegeRefused(result, service, parsed) {
|
|
7410
|
+
if (!isAdminService(service)) return false;
|
|
7411
|
+
if (result.timedOut || result.outputCapped || result.exitCode === 0 || result.exitCode === EXIT.auth) return false;
|
|
7412
|
+
const error2 = apiErrorOf(result, parsed);
|
|
7413
|
+
if (error2?.code !== void 0 && error2.code !== 401 && error2.code !== 403) return false;
|
|
7414
|
+
const text4 = [error2?.message, stderrText(result)].filter(Boolean).join("\n");
|
|
7415
|
+
return !/invalid_grant/i.test(text4) && PRIVILEGE_REFUSALS.test(text4);
|
|
7416
|
+
}
|
|
7417
|
+
function isAdminService(service) {
|
|
7418
|
+
return service.startsWith("admin-");
|
|
7419
|
+
}
|
|
7420
|
+
var ADMIN_PRIVILEGES = { "admin-reports": "Reports" };
|
|
7421
|
+
function privilegeName(service) {
|
|
7422
|
+
const name = service ? ADMIN_PRIVILEGES[service] : void 0;
|
|
7423
|
+
return name ? `the ${name} privilege` : "the admin privilege it needs";
|
|
7424
|
+
}
|
|
7425
|
+
function adminServiceStep(service) {
|
|
7426
|
+
return `Only a Workspace admin's account with ${privilegeName(service)} can use ${service}: the user adds a separate account alias for that admin with enabledServices ["${service}"], not the service to an existing account, and signs in to it from /connect.`;
|
|
7427
|
+
}
|
|
7428
|
+
function adminAccountStep(account2, service) {
|
|
7429
|
+
return `add a separate account alias for a Workspace admin who has ${privilegeName(service)}, with enabledServices ["${service}"], and sign in to it from /connect, or remove ${service} from accounts.${account2}.enabledServices`;
|
|
7430
|
+
}
|
|
7431
|
+
function authProblem(result, service, parsed) {
|
|
7432
|
+
if (result.timedOut || result.outputCapped) return void 0;
|
|
7433
|
+
if (privilegeRefused(result, service, parsed)) return void 0;
|
|
7434
|
+
const error2 = apiErrorOf(result, parsed);
|
|
7435
|
+
if (result.exitCode === 0 && !error2) return void 0;
|
|
7436
|
+
const reasons = [error2?.reason, ...(error2?.errors ?? []).map((item) => item?.reason), ...(error2?.details ?? []).map((item) => item?.reason)];
|
|
7437
|
+
const text4 = [error2?.message, error2?.status, stderrText(result)].filter(Boolean).join("\n");
|
|
7438
|
+
if (reasons.some((reason) => reason !== void 0 && SCOPE_REASONS.has(reason)) || /insufficient authentication scopes|ACCESS_TOKEN_SCOPE_INSUFFICIENT/i.test(text4)) {
|
|
7439
|
+
return "AUTH_SCOPE_MISSING";
|
|
7440
|
+
}
|
|
7441
|
+
if (error2?.code === 401 || error2?.status === "UNAUTHENTICATED" || reasons.includes("authError") || /invalid_grant|\bUNAUTHENTICATED\b|^error\[auth\]/im.test(text4)) {
|
|
7442
|
+
return "AUTH_REQUIRED";
|
|
7443
|
+
}
|
|
7444
|
+
return void 0;
|
|
7445
|
+
}
|
|
7446
|
+
var GENERIC_REMEDIATION = {
|
|
7447
|
+
signIn: "sign in to the account again",
|
|
7448
|
+
consent: "sign in again and grant the scope this operation needs",
|
|
7449
|
+
privilege: "add a separate account alias for a Workspace admin who has the admin privilege this operation needs, and sign in to it, or remove the service from enabledServices"
|
|
7450
|
+
};
|
|
7451
|
+
var DISABLED_REASONS = /* @__PURE__ */ new Set(["accessNotConfigured", "SERVICE_DISABLED"]);
|
|
7452
|
+
var CONSOLE_URL = /https:\/\/console\.(?:developers|cloud)\.google\.com\/[^\s"'<>)]+/;
|
|
7453
|
+
function disabledApi(result, parsed) {
|
|
7454
|
+
const error2 = apiErrorOf(result, parsed);
|
|
7455
|
+
const reasons = [error2?.reason, ...(error2?.errors ?? []).map((item) => item?.reason), ...(error2?.details ?? []).map((item) => item?.reason)];
|
|
7456
|
+
const text4 = [error2?.message, stderrText(result)].filter(Boolean).join("\n");
|
|
7457
|
+
if (!reasons.some((reason) => reason !== void 0 && DISABLED_REASONS.has(reason)) && !/accessnotconfigured|has not been used|is disabled/i.test(text4)) {
|
|
7458
|
+
return void 0;
|
|
7459
|
+
}
|
|
7460
|
+
const enableUrl = (typeof error2?.enable_url === "string" && CONSOLE_URL.test(error2.enable_url) ? error2.enable_url : CONSOLE_URL.exec(text4)?.[0])?.replace(/[.,;]+$/, "");
|
|
7461
|
+
const project = (enableUrl ? /[?&]project=([\w.:-]+)/.exec(enableUrl)?.[1] : void 0) ?? /\bin project ([\w.:-]+)/.exec(text4)?.[1];
|
|
7462
|
+
return { ...enableUrl ? { enableUrl } : {}, ...project ? { project } : {} };
|
|
7463
|
+
}
|
|
7464
|
+
function processError(result, parsed, service, remediation = GENERIC_REMEDIATION) {
|
|
7465
|
+
if (result.timedOut) return new GwsError("TIMEOUT", `gws did not finish within the configured execution.timeoutMs (${result.durationMs}ms elapsed)`);
|
|
7466
|
+
if (result.outputCapped) return new GwsError("OUTPUT_LIMIT", "gws output exceeded execution.maxStdoutBytes/maxStderrBytes; narrow fields or pagination");
|
|
7467
|
+
const stderr = stderrText(result);
|
|
7468
|
+
const payload = apiErrorOf(result, parsed);
|
|
7469
|
+
const message = payload?.message || stderr || "gws command failed";
|
|
7470
|
+
if (privilegeRefused(result, service, parsed)) {
|
|
7471
|
+
return new GwsError(
|
|
7472
|
+
"ADMIN_PRIVILEGE_REQUIRED",
|
|
7473
|
+
`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)})`,
|
|
7474
|
+
{ next: `${remediation.privilege.charAt(0).toUpperCase()}${remediation.privilege.slice(1)}.` }
|
|
7475
|
+
);
|
|
7476
|
+
}
|
|
7477
|
+
const problem = authProblem(result, service, parsed) ?? (result.exitCode === EXIT.auth ? "AUTH_REQUIRED" : void 0);
|
|
7478
|
+
if (problem === "AUTH_SCOPE_MISSING") {
|
|
7479
|
+
return new GwsError("AUTH_SCOPE_MISSING", `the credential lacks a scope for this operation; ${remediation.consent} (gws: ${message.slice(0, 300)})`);
|
|
7480
|
+
}
|
|
7481
|
+
if (problem === "AUTH_REQUIRED") {
|
|
7482
|
+
return new GwsError("AUTH_REQUIRED", `the Google Workspace sign-in is missing or no longer valid; ${remediation.signIn} (gws: ${message.slice(0, 300)})`);
|
|
7483
|
+
}
|
|
7484
|
+
const disabled = disabledApi(result, parsed);
|
|
7485
|
+
if (disabled) {
|
|
7486
|
+
const where = disabled.project ? ` for project ${disabled.project}` : "";
|
|
7487
|
+
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.`;
|
|
7488
|
+
return new GwsError("API_DISABLED", message.slice(0, 500), { next, ...disabled.enableUrl ? { enableUrl: disabled.enableUrl } : {} });
|
|
7489
|
+
}
|
|
7490
|
+
if (result.exitCode === EXIT.validation) return new GwsError("GWS_VALIDATION_ERROR", `gws rejected the request: ${message.slice(0, 500)}`);
|
|
7491
|
+
if (result.exitCode === EXIT.discovery) return new GwsError("GWS_DISCOVERY_ERROR", `gws could not load the API description: ${message.slice(0, 500)}`);
|
|
7492
|
+
if (result.exitCode === EXIT.internal) return new GwsError("GWS_INTERNAL_ERROR", `gws failed: ${message.slice(0, 500)}`);
|
|
7493
|
+
return new GwsError("GWS_API_ERROR", message.slice(0, 500));
|
|
7494
|
+
}
|
|
7495
|
+
|
|
7275
7496
|
// src/domain/manifest.ts
|
|
7276
7497
|
var ROOT = join(dirname(fileURLToPath(new URL("./domain/manifest.js", import.meta.url).href)), "..", "..");
|
|
7277
7498
|
var SERVICE_ALIASES = {
|
|
@@ -7413,9 +7634,7 @@ function unavailableReason(service) {
|
|
|
7413
7634
|
if (DELEGATION_SERVICES.includes(service)) {
|
|
7414
7635
|
return `Needs domain-wide delegation, which a Workspace admin sets up; then the user adds ${service} to accounts.<alias>.enabledServices and its scopes to accounts.<alias>.scopes.`;
|
|
7415
7636
|
}
|
|
7416
|
-
if (service
|
|
7417
|
-
return `Needs a Workspace admin with the privilege it reads; the user adds a separate account alias for that admin with enabledServices ["${service}"].`;
|
|
7418
|
-
}
|
|
7637
|
+
if (isAdminService(service)) return adminServiceStep(service);
|
|
7419
7638
|
return `Not enabled for any account; the user can add ${service} to accounts.<alias>.enabledServices.`;
|
|
7420
7639
|
}
|
|
7421
7640
|
function unavailableServices(tokens, only, allowed, weak) {
|
|
@@ -7959,7 +8178,7 @@ function checkHost(version2, range, allowUntested) {
|
|
|
7959
8178
|
}
|
|
7960
8179
|
|
|
7961
8180
|
// src/domain/version.ts
|
|
7962
|
-
var PLUGIN_VERSION = "0.0.
|
|
8181
|
+
var PLUGIN_VERSION = "0.0.8";
|
|
7963
8182
|
var PLUGIN_ID = "google-workspace.gws";
|
|
7964
8183
|
|
|
7965
8184
|
// src/host/agents.ts
|
|
@@ -8065,6 +8284,7 @@ var status = object2(
|
|
|
8065
8284
|
accounts: { type: "array", items: account },
|
|
8066
8285
|
auditDegraded: { type: "boolean" },
|
|
8067
8286
|
shadowedSkills: { type: "array", items: object2({ id: text, path: text }, ["id", "path"]) },
|
|
8287
|
+
shellSkills: { type: "array", items: object2({ id: text, path: text }, ["id", "path"]) },
|
|
8068
8288
|
unguardedAgents: texts,
|
|
8069
8289
|
permissionsBlock: text,
|
|
8070
8290
|
nextStep: text
|
|
@@ -8094,6 +8314,10 @@ var SHADOWED_LABEL = "Hidden skills";
|
|
|
8094
8314
|
function shadowedText(status2) {
|
|
8095
8315
|
return (status2.shadowedSkills ?? []).map((skill) => `${skill.id} (hidden by ${skill.path})`).join(", ");
|
|
8096
8316
|
}
|
|
8317
|
+
var SHELL_LABEL = "Shell gws skills";
|
|
8318
|
+
function shellText(status2) {
|
|
8319
|
+
return (status2.shellSkills ?? []).map((skill) => `${skill.id} (${skill.path})`).join(", ");
|
|
8320
|
+
}
|
|
8097
8321
|
var DELEGATION_LABEL = "Needs domain-wide delegation";
|
|
8098
8322
|
function delegationText(account2) {
|
|
8099
8323
|
const services = account2.delegationRequired ?? [];
|
|
@@ -8293,220 +8517,6 @@ var PlanStore = class {
|
|
|
8293
8517
|
}
|
|
8294
8518
|
};
|
|
8295
8519
|
|
|
8296
|
-
// src/domain/result.ts
|
|
8297
|
-
function isApiError(value) {
|
|
8298
|
-
if (Array.isArray(value)) return value.some(isApiError);
|
|
8299
|
-
if (!value || typeof value !== "object") return false;
|
|
8300
|
-
const record3 = value;
|
|
8301
|
-
return "error" in record3 && record3.error !== null && typeof record3.error === "object";
|
|
8302
|
-
}
|
|
8303
|
-
function parseJson(text4) {
|
|
8304
|
-
const lines = text4.split("\n");
|
|
8305
|
-
const start2 = lines.findIndex((line) => /^[[{]/.test(line.trim()));
|
|
8306
|
-
if (start2 < 0) return void 0;
|
|
8307
|
-
const candidate = lines.slice(start2).join("\n").trim();
|
|
8308
|
-
try {
|
|
8309
|
-
return JSON.parse(candidate);
|
|
8310
|
-
} catch {
|
|
8311
|
-
const parsed = [];
|
|
8312
|
-
for (const line of lines.slice(start2)) {
|
|
8313
|
-
const value = line.trim();
|
|
8314
|
-
if (!/^[[{]/.test(value)) continue;
|
|
8315
|
-
try {
|
|
8316
|
-
parsed.push(JSON.parse(value));
|
|
8317
|
-
} catch {
|
|
8318
|
-
}
|
|
8319
|
-
}
|
|
8320
|
-
return parsed.length > 0 ? parsed : void 0;
|
|
8321
|
-
}
|
|
8322
|
-
}
|
|
8323
|
-
function inlineCap(maxInlineBytes, mode, requested) {
|
|
8324
|
-
const base2 = mode === "full" ? Math.max(maxInlineBytes, 5 * 1024 * 1024) : maxInlineBytes;
|
|
8325
|
-
return requested === void 0 ? base2 : Math.min(base2, requested);
|
|
8326
|
-
}
|
|
8327
|
-
function parseResult(result, options) {
|
|
8328
|
-
const text4 = result.stdout.toString("utf8");
|
|
8329
|
-
const parsed = parseJson(text4);
|
|
8330
|
-
const serialized = parsed === void 0 ? text4 : JSON.stringify(parsed);
|
|
8331
|
-
const bytes2 = Buffer.byteLength(serialized);
|
|
8332
|
-
const cap = inlineCap(options.maxInlineBytes, options.mode, options.requestedInlineBytes);
|
|
8333
|
-
const truncated = bytes2 > cap;
|
|
8334
|
-
const empty = text4.trim().length === 0;
|
|
8335
|
-
return {
|
|
8336
|
-
// A successful DELETE commonly returns 204 with no body. Empty output
|
|
8337
|
-
// with exit 0 is success; non-JSON text is not a JSON result.
|
|
8338
|
-
ok: result.exitCode === 0 && (empty || parsed !== void 0 && !isApiError(parsed)),
|
|
8339
|
-
data: parsed,
|
|
8340
|
-
raw: parsed === void 0 ? text4.slice(0, cap) : void 0,
|
|
8341
|
-
truncated,
|
|
8342
|
-
diagnostics: { exitCode: result.exitCode, durationMs: result.durationMs, bytes: bytes2 }
|
|
8343
|
-
};
|
|
8344
|
-
}
|
|
8345
|
-
function withoutSavedBody(data, next) {
|
|
8346
|
-
if (!data || typeof data !== "object" || Array.isArray(data)) return data;
|
|
8347
|
-
const record3 = data;
|
|
8348
|
-
const keys = Object.keys(record3);
|
|
8349
|
-
if (typeof record3["saved_file"] !== "string" || typeof record3["bytes"] !== "number" || record3["status"] !== "success") return data;
|
|
8350
|
-
if (!keys.every((key) => ["bytes", "mimeType", "saved_file", "status"].includes(key))) return data;
|
|
8351
|
-
if (record3["bytes"] === 0) return "";
|
|
8352
|
-
return {
|
|
8353
|
-
...typeof record3["mimeType"] === "string" ? { mimeType: record3["mimeType"] } : {},
|
|
8354
|
-
bytes: record3["bytes"],
|
|
8355
|
-
saved: false,
|
|
8356
|
-
...next ? { next } : {}
|
|
8357
|
-
};
|
|
8358
|
-
}
|
|
8359
|
-
function itemKey(page2) {
|
|
8360
|
-
if (!page2 || typeof page2 !== "object" || Array.isArray(page2)) return void 0;
|
|
8361
|
-
const record3 = page2;
|
|
8362
|
-
if (Array.isArray(record3["items"])) return "items";
|
|
8363
|
-
const arrays = Object.keys(record3).filter((key) => Array.isArray(record3[key]));
|
|
8364
|
-
return arrays.length === 1 ? arrays[0] : void 0;
|
|
8365
|
-
}
|
|
8366
|
-
function capPageItems(data, maxItems) {
|
|
8367
|
-
const pages = data === void 0 ? [] : Array.isArray(data) ? data : [data];
|
|
8368
|
-
const keys = pages.map((page2) => itemKey(page2) ?? (isEmptyPage(page2) ? null : void 0));
|
|
8369
|
-
if (keys.some((key) => key === void 0)) return { data: pages, pages: pages.length, capped: false };
|
|
8370
|
-
const kept = [];
|
|
8371
|
-
let items = 0;
|
|
8372
|
-
let capped = false;
|
|
8373
|
-
for (const [index2, page2] of pages.entries()) {
|
|
8374
|
-
if (items >= maxItems) {
|
|
8375
|
-
capped = true;
|
|
8376
|
-
break;
|
|
8377
|
-
}
|
|
8378
|
-
const key = keys[index2];
|
|
8379
|
-
if (!key) {
|
|
8380
|
-
kept.push(page2);
|
|
8381
|
-
continue;
|
|
8382
|
-
}
|
|
8383
|
-
const record3 = page2;
|
|
8384
|
-
const list = record3[key];
|
|
8385
|
-
const room = maxItems - items;
|
|
8386
|
-
if (list.length > room) {
|
|
8387
|
-
const { nextPageToken: _dropped, ...rest } = record3;
|
|
8388
|
-
kept.push({ ...rest, [key]: list.slice(0, room) });
|
|
8389
|
-
items += room;
|
|
8390
|
-
capped = true;
|
|
8391
|
-
break;
|
|
8392
|
-
}
|
|
8393
|
-
kept.push(page2);
|
|
8394
|
-
items += list.length;
|
|
8395
|
-
}
|
|
8396
|
-
return { data: kept, pages: kept.length, items, capped };
|
|
8397
|
-
}
|
|
8398
|
-
function isEmptyPage(page2) {
|
|
8399
|
-
if (!page2 || typeof page2 !== "object" || Array.isArray(page2)) return false;
|
|
8400
|
-
return !Object.values(page2).some(Array.isArray);
|
|
8401
|
-
}
|
|
8402
|
-
function redactDryRun(value) {
|
|
8403
|
-
if (Array.isArray(value)) return value.map(redactDryRun);
|
|
8404
|
-
if (value && typeof value === "object") {
|
|
8405
|
-
const out = {};
|
|
8406
|
-
for (const [key, child] of Object.entries(value)) {
|
|
8407
|
-
if (/^(body|raw|content)$/i.test(key)) out[key] = "[redacted]";
|
|
8408
|
-
else out[key] = redactDryRun(child);
|
|
8409
|
-
}
|
|
8410
|
-
return out;
|
|
8411
|
-
}
|
|
8412
|
-
return value;
|
|
8413
|
-
}
|
|
8414
|
-
function apiErrorOf(result, parsed) {
|
|
8415
|
-
const data = parsed ? parsed.data : parseJson(result.stdout.toString("utf8"));
|
|
8416
|
-
const found = Array.isArray(data) ? data.find(isApiError) : data;
|
|
8417
|
-
if (!isApiError(found)) return void 0;
|
|
8418
|
-
return found.error;
|
|
8419
|
-
}
|
|
8420
|
-
function stderrText(result) {
|
|
8421
|
-
return result.stderr.toString("utf8").split("\n").filter((line) => line.trim() && !/^Using keyring backend:/i.test(line)).join("\n").trim();
|
|
8422
|
-
}
|
|
8423
|
-
var EXIT = { auth: 2, validation: 3, discovery: 4, internal: 5 };
|
|
8424
|
-
var SCOPE_REASONS = /* @__PURE__ */ new Set(["ACCESS_TOKEN_SCOPE_INSUFFICIENT", "insufficientPermissions"]);
|
|
8425
|
-
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;
|
|
8426
|
-
function privilegeRefused(result, service, parsed) {
|
|
8427
|
-
if (!isAdminService(service)) return false;
|
|
8428
|
-
if (result.timedOut || result.outputCapped || result.exitCode === 0 || result.exitCode === EXIT.auth) return false;
|
|
8429
|
-
const error2 = apiErrorOf(result, parsed);
|
|
8430
|
-
if (error2?.code !== void 0 && error2.code !== 401 && error2.code !== 403) return false;
|
|
8431
|
-
const text4 = [error2?.message, stderrText(result)].filter(Boolean).join("\n");
|
|
8432
|
-
return !/invalid_grant/i.test(text4) && PRIVILEGE_REFUSALS.test(text4);
|
|
8433
|
-
}
|
|
8434
|
-
function isAdminService(service) {
|
|
8435
|
-
return service.startsWith("admin-");
|
|
8436
|
-
}
|
|
8437
|
-
var ADMIN_PRIVILEGES = { "admin-reports": "Reports" };
|
|
8438
|
-
function privilegeName(service) {
|
|
8439
|
-
const name = service ? ADMIN_PRIVILEGES[service] : void 0;
|
|
8440
|
-
return name ? `the ${name} privilege` : "the admin privilege it needs";
|
|
8441
|
-
}
|
|
8442
|
-
function adminAccountStep(account2, service) {
|
|
8443
|
-
return `add a separate account alias for a Workspace admin who has ${privilegeName(service)}, with enabledServices ["${service}"], and sign in to it from /connect, or remove ${service} from accounts.${account2}.enabledServices`;
|
|
8444
|
-
}
|
|
8445
|
-
function authProblem(result, service, parsed) {
|
|
8446
|
-
if (result.timedOut || result.outputCapped) return void 0;
|
|
8447
|
-
if (privilegeRefused(result, service, parsed)) return void 0;
|
|
8448
|
-
const error2 = apiErrorOf(result, parsed);
|
|
8449
|
-
if (result.exitCode === 0 && !error2) return void 0;
|
|
8450
|
-
const reasons = [error2?.reason, ...(error2?.errors ?? []).map((item) => item?.reason), ...(error2?.details ?? []).map((item) => item?.reason)];
|
|
8451
|
-
const text4 = [error2?.message, error2?.status, stderrText(result)].filter(Boolean).join("\n");
|
|
8452
|
-
if (reasons.some((reason) => reason !== void 0 && SCOPE_REASONS.has(reason)) || /insufficient authentication scopes|ACCESS_TOKEN_SCOPE_INSUFFICIENT/i.test(text4)) {
|
|
8453
|
-
return "AUTH_SCOPE_MISSING";
|
|
8454
|
-
}
|
|
8455
|
-
if (error2?.code === 401 || error2?.status === "UNAUTHENTICATED" || reasons.includes("authError") || /invalid_grant|\bUNAUTHENTICATED\b|^error\[auth\]/im.test(text4)) {
|
|
8456
|
-
return "AUTH_REQUIRED";
|
|
8457
|
-
}
|
|
8458
|
-
return void 0;
|
|
8459
|
-
}
|
|
8460
|
-
var GENERIC_REMEDIATION = {
|
|
8461
|
-
signIn: "sign in to the account again",
|
|
8462
|
-
consent: "sign in again and grant the scope this operation needs",
|
|
8463
|
-
privilege: "add a separate account alias for a Workspace admin who has the admin privilege this operation needs, and sign in to it, or remove the service from enabledServices"
|
|
8464
|
-
};
|
|
8465
|
-
var DISABLED_REASONS = /* @__PURE__ */ new Set(["accessNotConfigured", "SERVICE_DISABLED"]);
|
|
8466
|
-
var CONSOLE_URL = /https:\/\/console\.(?:developers|cloud)\.google\.com\/[^\s"'<>)]+/;
|
|
8467
|
-
function disabledApi(result, parsed) {
|
|
8468
|
-
const error2 = apiErrorOf(result, parsed);
|
|
8469
|
-
const reasons = [error2?.reason, ...(error2?.errors ?? []).map((item) => item?.reason), ...(error2?.details ?? []).map((item) => item?.reason)];
|
|
8470
|
-
const text4 = [error2?.message, stderrText(result)].filter(Boolean).join("\n");
|
|
8471
|
-
if (!reasons.some((reason) => reason !== void 0 && DISABLED_REASONS.has(reason)) && !/accessnotconfigured|has not been used|is disabled/i.test(text4)) {
|
|
8472
|
-
return void 0;
|
|
8473
|
-
}
|
|
8474
|
-
const enableUrl = (typeof error2?.enable_url === "string" && CONSOLE_URL.test(error2.enable_url) ? error2.enable_url : CONSOLE_URL.exec(text4)?.[0])?.replace(/[.,;]+$/, "");
|
|
8475
|
-
const project = (enableUrl ? /[?&]project=([\w.:-]+)/.exec(enableUrl)?.[1] : void 0) ?? /\bin project ([\w.:-]+)/.exec(text4)?.[1];
|
|
8476
|
-
return { ...enableUrl ? { enableUrl } : {}, ...project ? { project } : {} };
|
|
8477
|
-
}
|
|
8478
|
-
function processError(result, parsed, service, remediation = GENERIC_REMEDIATION) {
|
|
8479
|
-
if (result.timedOut) return new GwsError("TIMEOUT", `gws did not finish within the configured execution.timeoutMs (${result.durationMs}ms elapsed)`);
|
|
8480
|
-
if (result.outputCapped) return new GwsError("OUTPUT_LIMIT", "gws output exceeded execution.maxStdoutBytes/maxStderrBytes; narrow fields or pagination");
|
|
8481
|
-
const stderr = stderrText(result);
|
|
8482
|
-
const payload = apiErrorOf(result, parsed);
|
|
8483
|
-
const message = payload?.message || stderr || "gws command failed";
|
|
8484
|
-
if (privilegeRefused(result, service, parsed)) {
|
|
8485
|
-
return new GwsError(
|
|
8486
|
-
"ADMIN_PRIVILEGE_REQUIRED",
|
|
8487
|
-
`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)})`,
|
|
8488
|
-
{ next: `${remediation.privilege.charAt(0).toUpperCase()}${remediation.privilege.slice(1)}.` }
|
|
8489
|
-
);
|
|
8490
|
-
}
|
|
8491
|
-
const problem = authProblem(result, service, parsed) ?? (result.exitCode === EXIT.auth ? "AUTH_REQUIRED" : void 0);
|
|
8492
|
-
if (problem === "AUTH_SCOPE_MISSING") {
|
|
8493
|
-
return new GwsError("AUTH_SCOPE_MISSING", `the credential lacks a scope for this operation; ${remediation.consent} (gws: ${message.slice(0, 300)})`);
|
|
8494
|
-
}
|
|
8495
|
-
if (problem === "AUTH_REQUIRED") {
|
|
8496
|
-
return new GwsError("AUTH_REQUIRED", `the Google Workspace sign-in is missing or no longer valid; ${remediation.signIn} (gws: ${message.slice(0, 300)})`);
|
|
8497
|
-
}
|
|
8498
|
-
const disabled = disabledApi(result, parsed);
|
|
8499
|
-
if (disabled) {
|
|
8500
|
-
const where = disabled.project ? ` for project ${disabled.project}` : "";
|
|
8501
|
-
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.`;
|
|
8502
|
-
return new GwsError("API_DISABLED", message.slice(0, 500), { next, ...disabled.enableUrl ? { enableUrl: disabled.enableUrl } : {} });
|
|
8503
|
-
}
|
|
8504
|
-
if (result.exitCode === EXIT.validation) return new GwsError("GWS_VALIDATION_ERROR", `gws rejected the request: ${message.slice(0, 500)}`);
|
|
8505
|
-
if (result.exitCode === EXIT.discovery) return new GwsError("GWS_DISCOVERY_ERROR", `gws could not load the API description: ${message.slice(0, 500)}`);
|
|
8506
|
-
if (result.exitCode === EXIT.internal) return new GwsError("GWS_INTERNAL_ERROR", `gws failed: ${message.slice(0, 500)}`);
|
|
8507
|
-
return new GwsError("GWS_API_ERROR", message.slice(0, 500));
|
|
8508
|
-
}
|
|
8509
|
-
|
|
8510
8520
|
// src/host/commands.ts
|
|
8511
8521
|
var COMMANDS = {
|
|
8512
8522
|
status: { name: "gws-status", description: "Google Workspace: sign-in, access, pending plans, setup steps, and unguarded agents" },
|
|
@@ -8574,6 +8584,7 @@ function formatStatus(status2, plans = [], now = Date.now()) {
|
|
|
8574
8584
|
if (setup.length > 0) lines.push(...setup, "");
|
|
8575
8585
|
lines.push(...planLines(plans, now), "");
|
|
8576
8586
|
if (status2.shadowedSkills?.length) lines.push(`${SHADOWED_LABEL}: ${shadowedText(status2)}`);
|
|
8587
|
+
if (status2.shellSkills?.length) lines.push(`${SHELL_LABEL}: ${shellText(status2)}`);
|
|
8577
8588
|
lines.push(`Tools: gws tools for ${status2.services.join(", ") || "no services"} (${status2.gws.version})`);
|
|
8578
8589
|
if (status2.unguardedAgents.length === 0) {
|
|
8579
8590
|
lines.push("Agents without default rules: none");
|
|
@@ -9443,6 +9454,15 @@ function integrationTokenProvider(ctx, config2, ids) {
|
|
|
9443
9454
|
return { access: credential.access, grantedScopes: signIn.grantedScopes };
|
|
9444
9455
|
};
|
|
9445
9456
|
}
|
|
9457
|
+
async function showsNeedsAuth(ctx, integrationID, connectionID) {
|
|
9458
|
+
try {
|
|
9459
|
+
const { data } = await ctx.integration.get({ integrationID });
|
|
9460
|
+
const shown = data.connections?.find((item) => item.type === "credential" && item.id === connectionID);
|
|
9461
|
+
return shown?.type === "credential" && shown.status?.status === "needs_auth";
|
|
9462
|
+
} catch {
|
|
9463
|
+
return false;
|
|
9464
|
+
}
|
|
9465
|
+
}
|
|
9446
9466
|
function integrationConnection(ctx, ids) {
|
|
9447
9467
|
const lifetime = new Lifecycle();
|
|
9448
9468
|
return async (account2, signal) => {
|
|
@@ -9450,7 +9470,7 @@ function integrationConnection(ctx, ids) {
|
|
|
9450
9470
|
if (!id) return { connected: false, needsAuth: false };
|
|
9451
9471
|
const connection = await lifetime.wait(() => ctx.integration.connection.active(id), signal);
|
|
9452
9472
|
if (!connection) return { connected: false, needsAuth: false };
|
|
9453
|
-
const needsAuth = connection.
|
|
9473
|
+
const needsAuth = connection.type === "credential" && await lifetime.wait(() => showsNeedsAuth(ctx, id, connection.id), signal);
|
|
9454
9474
|
let credential;
|
|
9455
9475
|
try {
|
|
9456
9476
|
credential = await lifetime.wait(() => ctx.integration.connection.resolve(connection), signal);
|
|
@@ -9510,6 +9530,20 @@ function ownSkillPath(id) {
|
|
|
9510
9530
|
function shadowedSkills(listed) {
|
|
9511
9531
|
return listed.filter((skill) => SKILL_IDS.includes(skill.id) && skill.path !== ownSkillPath(skill.id)).map((skill) => ({ id: skill.id, path: skill.path }));
|
|
9512
9532
|
}
|
|
9533
|
+
var SHELL_LINE = /^\s*(?:\$\s+)?gws\s+\S/;
|
|
9534
|
+
var SHELL_SPAN = /`gws\s+[^`\s][^`]*`/;
|
|
9535
|
+
function shellSkills(listed) {
|
|
9536
|
+
return listed.filter((skill) => skill.path !== ownSkillPath(skill.id) && teachesShell(skill.content ?? "")).map((skill) => ({ id: skill.id, path: skill.path }));
|
|
9537
|
+
}
|
|
9538
|
+
function teachesShell(content) {
|
|
9539
|
+
if (SHELL_SPAN.test(content)) return true;
|
|
9540
|
+
let fenced = false;
|
|
9541
|
+
for (const line of content.split(/\r?\n/)) {
|
|
9542
|
+
if (/^\s*(```|~~~)/.test(line)) fenced = !fenced;
|
|
9543
|
+
else if (fenced && SHELL_LINE.test(line)) return true;
|
|
9544
|
+
}
|
|
9545
|
+
return false;
|
|
9546
|
+
}
|
|
9513
9547
|
function skillLister(ctx) {
|
|
9514
9548
|
return async () => {
|
|
9515
9549
|
const directory = ctx.location?.directory;
|
|
@@ -9548,11 +9582,14 @@ async function registerSkills(ctx, config2) {
|
|
|
9548
9582
|
}
|
|
9549
9583
|
|
|
9550
9584
|
// src/host/rpc.ts
|
|
9551
|
-
function nextStep(accounts, shadowed, agents) {
|
|
9585
|
+
function nextStep(accounts, shadowed, shell, agents) {
|
|
9552
9586
|
const account2 = accounts.find((entry) => entry.nextStep);
|
|
9553
9587
|
if (account2?.nextStep) return accounts.length > 1 ? `${account2.alias}: ${account2.nextStep}` : account2.nextStep;
|
|
9554
9588
|
const [skill] = shadowed;
|
|
9555
9589
|
if (skill) return `Move or rename the skill at ${skill.path}: its id ${skill.id} hides the plugin's own skill.`;
|
|
9590
|
+
const why = "run gws in a shell, which skips plans, approvals and policy.";
|
|
9591
|
+
if (shell.length === 1) return `Remove the skill at ${shell[0].path}, or move it out of its skills folder: it tells agents to ${why}`;
|
|
9592
|
+
if (shell.length > 1) return `Remove the ${shell.length} skills listed under ${SHELL_LABEL}, or move them out of their skills folders: they tell agents to ${why}`;
|
|
9556
9593
|
if (agents.length > 0) return UNGUARDED_NEXT_STEP;
|
|
9557
9594
|
return void 0;
|
|
9558
9595
|
}
|
|
@@ -9565,8 +9602,10 @@ function statusSource(runtime, listAgents, listSkills = async () => []) {
|
|
|
9565
9602
|
return async (signal) => {
|
|
9566
9603
|
const base2 = await runtime.status(signal);
|
|
9567
9604
|
const agents = unguardedAgents(await runtime.wait(() => listAgents(), signal));
|
|
9568
|
-
const
|
|
9569
|
-
const
|
|
9605
|
+
const skills = await runtime.wait(() => listSkills(), signal);
|
|
9606
|
+
const shadowed = shadowedSkills(skills);
|
|
9607
|
+
const shell = shellSkills(skills);
|
|
9608
|
+
const next = nextStep(base2.accounts, shadowed, shell, agents);
|
|
9570
9609
|
return {
|
|
9571
9610
|
version: base2.version,
|
|
9572
9611
|
host: base2.host,
|
|
@@ -9576,6 +9615,7 @@ function statusSource(runtime, listAgents, listSkills = async () => []) {
|
|
|
9576
9615
|
accounts: base2.accounts,
|
|
9577
9616
|
auditDegraded: base2.auditDegraded,
|
|
9578
9617
|
...shadowed.length > 0 ? { shadowedSkills: shadowed } : {},
|
|
9618
|
+
...shell.length > 0 ? { shellSkills: shell } : {},
|
|
9579
9619
|
unguardedAgents: agents,
|
|
9580
9620
|
...agents.length > 0 ? { permissionsBlock: permissionsBlock(agents) } : {},
|
|
9581
9621
|
nextStep: next ?? NOTHING_TO_DO
|
|
@@ -9867,7 +9907,7 @@ var COMMIT_CATEGORIES2 = [
|
|
|
9867
9907
|
{ category: "admin", description: "Commits one admin plan from gws_plan. Refused unless the plugin's admin setting allows the operation." }
|
|
9868
9908
|
];
|
|
9869
9909
|
async function compactStatus(status2, signal) {
|
|
9870
|
-
const { unguardedAgents: unguardedAgents2, permissionsBlock: _block, shadowedSkills: _shadowed, auditDegraded, ...rest } = await status2(signal);
|
|
9910
|
+
const { unguardedAgents: unguardedAgents2, permissionsBlock: _block, shadowedSkills: _shadowed, shellSkills: _shell, auditDegraded, ...rest } = await status2(signal);
|
|
9871
9911
|
const count = unguardedAgents2.length;
|
|
9872
9912
|
return {
|
|
9873
9913
|
...rest,
|
|
@@ -11460,6 +11500,7 @@ function buildCommand(entry, request, config2, options) {
|
|
|
11460
11500
|
{ next: "Retry with fields naming only what the user needs; gws_schema shows the response fields." }
|
|
11461
11501
|
);
|
|
11462
11502
|
}
|
|
11503
|
+
if (typeof params["fields"] === "string") checkMask(params["fields"]);
|
|
11463
11504
|
if (request.upload && entry.httpMethod === "GET") {
|
|
11464
11505
|
throw new GwsError("GWS_VALIDATION_ERROR", `${entry.id} is a read and cannot upload media`);
|
|
11465
11506
|
}
|
|
@@ -11517,6 +11558,14 @@ function buildCommand(entry, request, config2, options) {
|
|
|
11517
11558
|
}
|
|
11518
11559
|
};
|
|
11519
11560
|
}
|
|
11561
|
+
function checkMask(mask) {
|
|
11562
|
+
let open3 = 0;
|
|
11563
|
+
for (const [index2, char] of [...mask].entries()) {
|
|
11564
|
+
if (char === "(") open3 += 1;
|
|
11565
|
+
else if (char === ")" && --open3 < 0) throw new GwsError("FIELDS_INVALID", `'${mask}' has an extra ')' at character ${index2 + 1}`);
|
|
11566
|
+
}
|
|
11567
|
+
if (open3 > 0) throw new GwsError("FIELDS_INVALID", `'${mask}' leaves ${open3} '(' unclosed`);
|
|
11568
|
+
}
|
|
11520
11569
|
function redactParams(params) {
|
|
11521
11570
|
const out = {};
|
|
11522
11571
|
for (const [key, value] of Object.entries(params)) {
|
|
@@ -11896,8 +11945,11 @@ var METHOD_FIELDS = ["params", "body", "fields", "pagination", "upload", "downlo
|
|
|
11896
11945
|
var PLAN_INSTEAD = "Call gws_plan with the same request, then commit with the tool named by the plan's category.";
|
|
11897
11946
|
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.";
|
|
11898
11947
|
var ADDRESS_TTL_MS = 10 * 60 * 1e3;
|
|
11899
|
-
function policyError(decision, account2) {
|
|
11948
|
+
function policyError(decision, account2, service) {
|
|
11900
11949
|
const detail = decision.violations.join("; ");
|
|
11950
|
+
if (service && isAdminService(service) && decision.violations.includes("service-disabled")) {
|
|
11951
|
+
return new GwsError(decision.code, detail, { next: adminServiceStep(service) });
|
|
11952
|
+
}
|
|
11901
11953
|
if (decision.code === "ACCOUNT_DOMAIN_UNKNOWN") {
|
|
11902
11954
|
return new GwsError("ACCOUNT_DOMAIN_UNKNOWN", detail, {
|
|
11903
11955
|
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.`
|
|
@@ -11935,7 +11987,7 @@ var Operations = class {
|
|
|
11935
11987
|
visible(service) {
|
|
11936
11988
|
if (!this.runtime.isVisible(service)) {
|
|
11937
11989
|
throw new GwsError("POLICY_DENIED", `service '${service}' is not in the model-facing services list`, {
|
|
11938
|
-
next: "Tell the user; they can add the service to the plugin's services option."
|
|
11990
|
+
next: isAdminService(service) ? adminServiceStep(service) : "Tell the user; they can add the service to the plugin's services option."
|
|
11939
11991
|
});
|
|
11940
11992
|
}
|
|
11941
11993
|
}
|
|
@@ -11988,7 +12040,7 @@ var Operations = class {
|
|
|
11988
12040
|
}
|
|
11989
12041
|
const facts = { recipients: recipientPlan(entry.id, "read", request).known, accountDomains: this.runtime.config.accounts[request.account]?.domains };
|
|
11990
12042
|
const decision2 = evaluatePolicy(this.runtime.config, entry, "read", request, facts);
|
|
11991
|
-
if (decision2.effect === "deny") throw policyError(decision2, request.account);
|
|
12043
|
+
if (decision2.effect === "deny") throw policyError(decision2, request.account, entry.service);
|
|
11992
12044
|
return { category: "read", decision: decision2 };
|
|
11993
12045
|
}
|
|
11994
12046
|
const category = helper ? helperCategory(helper, request.input) : effectiveCategory(entry, request);
|
|
@@ -11997,7 +12049,7 @@ var Operations = class {
|
|
|
11997
12049
|
throw new GwsError("POLICY_DENIED", `'${entry.id}' is ${category} and cannot be planned for commit`, { next: "Tell the user the plugin never runs it." });
|
|
11998
12050
|
}
|
|
11999
12051
|
const decision = evaluatePolicy(this.runtime.config, entry, category, request, "pending");
|
|
12000
|
-
if (decision.effect === "deny") throw policyError(decision, request.account);
|
|
12052
|
+
if (decision.effect === "deny") throw policyError(decision, request.account, entry.service);
|
|
12001
12053
|
if (category === "admin" && (!this.runtime.config.admin.enabled || decision.effect !== "allow")) {
|
|
12002
12054
|
throw new GwsError("POLICY_DENIED", "admin operations require admin.enabled, an allowed service, and a reason when one is required");
|
|
12003
12055
|
}
|
|
@@ -12031,7 +12083,7 @@ var Operations = class {
|
|
|
12031
12083
|
const target = "method" in found ? { account: account2, operation: found.method.id, service: found.method.service, entry: found.method } : { account: account2, operation: found.helper.id, service: found.helper.service, helper: found.helper };
|
|
12032
12084
|
if (!profile.enabledServices.includes(target.service)) {
|
|
12033
12085
|
throw new GwsError("POLICY_DENIED", `service '${target.service}' is not enabled for '${account2}'`, {
|
|
12034
|
-
next: `Use an account that enables ${target.service}, or ask the user to add it to accounts.${account2}.enabledServices.`
|
|
12086
|
+
next: isAdminService(target.service) ? adminServiceStep(target.service) : `Use an account that enables ${target.service}, or ask the user to add it to accounts.${account2}.enabledServices.`
|
|
12035
12087
|
});
|
|
12036
12088
|
}
|
|
12037
12089
|
this.visible(target.service);
|
|
@@ -12109,7 +12161,7 @@ var Operations = class {
|
|
|
12109
12161
|
const recipientsPlan = recipientPlan(entry.id, category, request);
|
|
12110
12162
|
const facts = await this.policyFacts(resolved, category, recipientsPlan, ctx);
|
|
12111
12163
|
const decision = evaluatePolicy(this.runtime.config, entry, category, request, facts);
|
|
12112
|
-
if (decision.effect === "deny") throw policyError(decision, request.account);
|
|
12164
|
+
if (decision.effect === "deny") throw policyError(decision, request.account, entry.service);
|
|
12113
12165
|
const limits = fileLimits(decision);
|
|
12114
12166
|
let dryRunArgv;
|
|
12115
12167
|
let commitArgv;
|
package/dist/tui.js
CHANGED
|
@@ -47,6 +47,7 @@ var status = object(
|
|
|
47
47
|
accounts: { type: "array", items: account },
|
|
48
48
|
auditDegraded: { type: "boolean" },
|
|
49
49
|
shadowedSkills: { type: "array", items: object({ id: text, path: text }, ["id", "path"]) },
|
|
50
|
+
shellSkills: { type: "array", items: object({ id: text, path: text }, ["id", "path"]) },
|
|
50
51
|
unguardedAgents: texts,
|
|
51
52
|
permissionsBlock: text,
|
|
52
53
|
nextStep: text
|
|
@@ -67,11 +68,12 @@ var isRecord = (value) => typeof value === "object" && value !== null && !Array.
|
|
|
67
68
|
var isStrings = (value) => Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
68
69
|
var optional = (value, check) => value === void 0 || check(value);
|
|
69
70
|
var isText = (value) => typeof value === "string";
|
|
71
|
+
var isSkills = (value) => Array.isArray(value) && value.every((skill) => isRecord(skill) && isText(skill["id"]) && isText(skill["path"]));
|
|
70
72
|
function isAccount(value) {
|
|
71
73
|
return isRecord(value) && isText(value["alias"]) && MODES.includes(value["mode"]) && isStrings(value["services"]) && typeof value["connected"] === "boolean" && optional(value["integrationID"], isText) && optional(value["needsAuth"], (entry) => entry === true) && optional(value["access"], (entry) => ACCESS.includes(entry)) && optional(value["clientSource"], (entry) => CLIENT_SOURCES.includes(entry)) && optional(value["grantedScopes"], isStrings) && optional(value["missingScopes"], isStrings) && optional(value["delegationRequired"], isStrings) && optional(value["nextStep"], isText);
|
|
72
74
|
}
|
|
73
75
|
function isStatus(value) {
|
|
74
|
-
return isRecord(value) && isText(value["version"]) && isRecord(value["host"]) && (value["host"]["version"] === null || isText(value["host"]["version"])) && isText(value["host"]["range"]) && typeof value["host"]["tested"] === "boolean" && STATES.includes(value["state"]) && isRecord(value["gws"]) && isText(value["gws"]["path"]) && isText(value["gws"]["version"]) && isStrings(value["services"]) && Array.isArray(value["accounts"]) && value["accounts"].every(isAccount) && optional(value["auditDegraded"], (entry) => typeof entry === "boolean") && optional(value["shadowedSkills"],
|
|
76
|
+
return isRecord(value) && isText(value["version"]) && isRecord(value["host"]) && (value["host"]["version"] === null || isText(value["host"]["version"])) && isText(value["host"]["range"]) && typeof value["host"]["tested"] === "boolean" && STATES.includes(value["state"]) && isRecord(value["gws"]) && isText(value["gws"]["path"]) && isText(value["gws"]["version"]) && isStrings(value["services"]) && Array.isArray(value["accounts"]) && value["accounts"].every(isAccount) && optional(value["auditDegraded"], (entry) => typeof entry === "boolean") && optional(value["shadowedSkills"], isSkills) && optional(value["shellSkills"], isSkills) && isStrings(value["unguardedAgents"]) && optional(value["permissionsBlock"], isText) && isText(value["nextStep"]);
|
|
75
77
|
}
|
|
76
78
|
function connectionLabel(account2) {
|
|
77
79
|
if (account2.connected) return "connected";
|
|
@@ -85,6 +87,10 @@ var SHADOWED_LABEL = "Hidden skills";
|
|
|
85
87
|
function shadowedText(status2) {
|
|
86
88
|
return (status2.shadowedSkills ?? []).map((skill) => `${skill.id} (hidden by ${skill.path})`).join(", ");
|
|
87
89
|
}
|
|
90
|
+
var SHELL_LABEL = "Shell gws skills";
|
|
91
|
+
function shellText(status2) {
|
|
92
|
+
return (status2.shellSkills ?? []).map((skill) => `${skill.id} (${skill.path})`).join(", ");
|
|
93
|
+
}
|
|
88
94
|
var DELEGATION_LABEL = "Needs domain-wide delegation";
|
|
89
95
|
function delegationText(account2) {
|
|
90
96
|
const services = account2.delegationRequired ?? [];
|
|
@@ -338,6 +344,25 @@ function StatusBody(props) {
|
|
|
338
344
|
});
|
|
339
345
|
}
|
|
340
346
|
}), null);
|
|
347
|
+
_$insert(_el$6, _$createComponent(Show, {
|
|
348
|
+
get when() {
|
|
349
|
+
return props.status.shellSkills?.length;
|
|
350
|
+
},
|
|
351
|
+
get children() {
|
|
352
|
+
return _$createComponent(Row, {
|
|
353
|
+
label: SHELL_LABEL,
|
|
354
|
+
get value() {
|
|
355
|
+
return shellText(props.status);
|
|
356
|
+
},
|
|
357
|
+
get fg() {
|
|
358
|
+
return tone("warning");
|
|
359
|
+
},
|
|
360
|
+
get muted() {
|
|
361
|
+
return muted();
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}), null);
|
|
341
366
|
_$insert(_el$6, _$createComponent(Row, {
|
|
342
367
|
label: "Tools",
|
|
343
368
|
get value() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@joe-broadhead/opencode-gws",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.8",
|
|
4
4
|
"description": "Unofficial OpenCode plugin for Google Workspace (gws CLI)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -52,10 +52,10 @@
|
|
|
52
52
|
"docs:coverage": "node scripts/generate-docs.mjs"
|
|
53
53
|
},
|
|
54
54
|
"optionalDependencies": {
|
|
55
|
-
"@joe-broadhead/opencode-gws-bin-darwin-arm64": "0.0.
|
|
56
|
-
"@joe-broadhead/opencode-gws-bin-darwin-x64": "0.0.
|
|
57
|
-
"@joe-broadhead/opencode-gws-bin-linux-arm64-gnu": "0.0.
|
|
58
|
-
"@joe-broadhead/opencode-gws-bin-linux-x64-gnu": "0.0.
|
|
55
|
+
"@joe-broadhead/opencode-gws-bin-darwin-arm64": "0.0.8",
|
|
56
|
+
"@joe-broadhead/opencode-gws-bin-darwin-x64": "0.0.8",
|
|
57
|
+
"@joe-broadhead/opencode-gws-bin-linux-arm64-gnu": "0.0.8",
|
|
58
|
+
"@joe-broadhead/opencode-gws-bin-linux-x64-gnu": "0.0.8"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@babel/core": "7.29.7",
|
|
@@ -39,7 +39,7 @@ If `gws_catalog` returns `unavailable`, or finds nothing for a service, this set
|
|
|
39
39
|
## Change: plan, then commit
|
|
40
40
|
|
|
41
41
|
1. `gws_plan` with the same request shape as a read. Nothing changes yet. It returns `planId`, a redacted `preview` and `category`.
|
|
42
|
-
2. Show the preview. If the user only asked to see what would happen, stop here
|
|
42
|
+
2. Show the preview. If the user only asked to see what would happen, stop here: say nothing changed, and name the tool that would commit it, where the user would approve it.
|
|
43
43
|
3. Commit with the tool for the plan's category, passing only `{"planId": "..."}`.
|
|
44
44
|
|
|
45
45
|
| Category | Tool | Asks |
|
|
@@ -77,4 +77,4 @@ If `gws_status` shows `unguardedAgentCount` above 0, tell the user once to run `
|
|
|
77
77
|
|
|
78
78
|
## Scripts
|
|
79
79
|
|
|
80
|
-
For a job that runs without you, such as a cron job, a script can call the RPC `google-workspace.gws.tools`. Its methods `status`, `catalog`, `schema`, `read` and `plan` take the same input as the tools, and `describe` lists them. It runs as a session that names an agent, under that agent's rules. Create
|
|
80
|
+
For a job that runs without you, such as a cron job, a script can call the RPC `google-workspace.gws.tools`. Its methods `status`, `catalog`, `schema`, `read` and `plan` take the same input as the tools, and `describe` lists them. It runs as a session that names an agent, under that agent's rules. Create a session for the script once with `opencode api post /api/session` and a body such as `{"title":"digest","agent":"build"}`, and keep its `data.id`. Never borrow a session found through history or a session list: the script would run as that session's agent, under its rules. Then call `opencode api post /api/rpc/google-workspace.gws.tools/read` with `{"input": {"sessionID": "...", "input": {...}}}`. A script can plan but never commit. A call its rules would ask about is refused with `PERMISSION_DENIED`, because no one is there to answer.
|