crewly 1.20.205 → 1.20.207
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/config/hooks/credential-guard/guard.sh +243 -0
- package/config/skills/agent/core/docs-comment/SKILL.md +121 -0
- package/config/skills/agent/core/docs-comment/execute.sh +131 -0
- package/config/skills/agent/core/docs-comment/execute.test.sh +105 -0
- package/dist/backend/backend/src/constants.d.ts +67 -2
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +71 -1
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/controllers/agent-hooks/agent-hooks.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/agent-hooks/agent-hooks.controller.js +17 -1
- package/dist/backend/backend/src/controllers/agent-hooks/agent-hooks.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts +19 -5
- package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/cloud/cloud.controller.js +71 -5
- package/dist/backend/backend/src/controllers/cloud/cloud.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/cloud/cloud.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/cloud/cloud.routes.js +4 -1
- package/dist/backend/backend/src/controllers/cloud/cloud.routes.js.map +1 -1
- package/dist/backend/backend/src/controllers/google/google.controller.d.ts +30 -0
- package/dist/backend/backend/src/controllers/google/google.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/google/google.controller.js +75 -0
- package/dist/backend/backend/src/controllers/google/google.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/google/google.routes.d.ts +4 -0
- package/dist/backend/backend/src/controllers/google/google.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/google/google.routes.js +9 -1
- package/dist/backend/backend/src/controllers/google/google.routes.js.map +1 -1
- package/dist/backend/backend/src/controllers/workspace/workspace.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/workspace/workspace.routes.js +10 -2
- package/dist/backend/backend/src/controllers/workspace/workspace.routes.js.map +1 -1
- package/dist/backend/backend/src/index.d.ts +8 -0
- package/dist/backend/backend/src/index.d.ts.map +1 -1
- package/dist/backend/backend/src/index.js +39 -1
- package/dist/backend/backend/src/index.js.map +1 -1
- package/dist/backend/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/agent-registration.service.js +4 -0
- package/dist/backend/backend/src/services/agent/agent-registration.service.js.map +1 -1
- package/dist/backend/backend/src/services/agent/codex-daemon.utils.d.ts +16 -0
- package/dist/backend/backend/src/services/agent/codex-daemon.utils.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/codex-daemon.utils.js +44 -0
- package/dist/backend/backend/src/services/agent/codex-daemon.utils.js.map +1 -1
- package/dist/backend/backend/src/services/agent/control-plane-guard.service.d.ts +14 -2
- package/dist/backend/backend/src/services/agent/control-plane-guard.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/control-plane-guard.service.js +18 -3
- package/dist/backend/backend/src/services/agent/control-plane-guard.service.js.map +1 -1
- package/dist/backend/backend/src/services/agent/credential-guard.service.d.ts +153 -0
- package/dist/backend/backend/src/services/agent/credential-guard.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/agent/credential-guard.service.js +314 -0
- package/dist/backend/backend/src/services/agent/credential-guard.service.js.map +1 -0
- package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js +5 -0
- package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js.map +1 -1
- package/dist/backend/backend/src/services/agent/runtime-agent.service.abstract.d.ts +39 -0
- package/dist/backend/backend/src/services/agent/runtime-agent.service.abstract.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/runtime-agent.service.abstract.js +132 -3
- package/dist/backend/backend/src/services/agent/runtime-agent.service.abstract.js.map +1 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js +1 -0
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
- package/dist/backend/backend/src/services/core/credential-files.d.ts +26 -0
- package/dist/backend/backend/src/services/core/credential-files.d.ts.map +1 -0
- package/dist/backend/backend/src/services/core/credential-files.js +60 -0
- package/dist/backend/backend/src/services/core/credential-files.js.map +1 -0
- package/dist/backend/backend/src/services/google/docs-comments.service.d.ts +171 -0
- package/dist/backend/backend/src/services/google/docs-comments.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/google/docs-comments.service.js +218 -0
- package/dist/backend/backend/src/services/google/docs-comments.service.js.map +1 -0
- package/dist/backend/backend/src/services/google/docs.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/google/docs.service.js +14 -1
- package/dist/backend/backend/src/services/google/docs.service.js.map +1 -1
- package/dist/backend/backend/src/services/google/drive.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/google/drive.service.js +6 -4
- package/dist/backend/backend/src/services/google/drive.service.js.map +1 -1
- package/dist/backend/backend/src/services/google/google-api.client.d.ts +16 -3
- package/dist/backend/backend/src/services/google/google-api.client.d.ts.map +1 -1
- package/dist/backend/backend/src/services/google/google-api.client.js +5 -2
- package/dist/backend/backend/src/services/google/google-api.client.js.map +1 -1
- package/dist/backend/backend/src/services/google/google-workspace-token.service.d.ts +10 -0
- package/dist/backend/backend/src/services/google/google-workspace-token.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/google/google-workspace-token.service.js +10 -0
- package/dist/backend/backend/src/services/google/google-workspace-token.service.js.map +1 -1
- package/dist/backend/backend/src/services/monitoring/credential-guard-alerts.d.ts +68 -0
- package/dist/backend/backend/src/services/monitoring/credential-guard-alerts.d.ts.map +1 -0
- package/dist/backend/backend/src/services/monitoring/credential-guard-alerts.js +95 -0
- package/dist/backend/backend/src/services/monitoring/credential-guard-alerts.js.map +1 -0
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/constants.d.ts +67 -2
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +71 -1
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/agent/agent-registration.service.js +4 -0
- package/dist/cli/backend/src/services/agent/agent-registration.service.js.map +1 -1
- package/dist/cli/backend/src/services/agent/codex-daemon.utils.d.ts +16 -0
- package/dist/cli/backend/src/services/agent/codex-daemon.utils.d.ts.map +1 -1
- package/dist/cli/backend/src/services/agent/codex-daemon.utils.js +44 -0
- package/dist/cli/backend/src/services/agent/codex-daemon.utils.js.map +1 -1
- package/dist/cli/backend/src/services/agent/control-plane-guard.service.d.ts +14 -2
- package/dist/cli/backend/src/services/agent/control-plane-guard.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/agent/control-plane-guard.service.js +18 -3
- package/dist/cli/backend/src/services/agent/control-plane-guard.service.js.map +1 -1
- package/dist/cli/backend/src/services/agent/credential-guard.service.d.ts +153 -0
- package/dist/cli/backend/src/services/agent/credential-guard.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/agent/credential-guard.service.js +314 -0
- package/dist/cli/backend/src/services/agent/credential-guard.service.js.map +1 -0
- package/dist/cli/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js +5 -0
- package/dist/cli/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js.map +1 -1
- package/dist/cli/backend/src/services/agent/runtime-agent.service.abstract.d.ts +39 -0
- package/dist/cli/backend/src/services/agent/runtime-agent.service.abstract.d.ts.map +1 -1
- package/dist/cli/backend/src/services/agent/runtime-agent.service.abstract.js +132 -3
- package/dist/cli/backend/src/services/agent/runtime-agent.service.abstract.js.map +1 -1
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
- package/dist/cli/backend/src/services/core/credential-files.d.ts +26 -0
- package/dist/cli/backend/src/services/core/credential-files.d.ts.map +1 -0
- package/dist/cli/backend/src/services/core/credential-files.js +60 -0
- package/dist/cli/backend/src/services/core/credential-files.js.map +1 -0
- package/package.json +1 -1
- package/packages/crewly-agent/src/runtime/credential-guard.test.ts +48 -0
- package/packages/crewly-agent/src/runtime/credential-guard.ts +56 -0
- package/packages/crewly-agent/src/runtime/tool-registry.ts +15 -0
- package/packages/crewly-agent/src/runtime/web-search.tool.test.ts +18 -0
- package/packages/crewly-agent/src/runtime/web-search.tool.ts +36 -14
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Crewly credential guard — a pre-tool hook that refuses an agent's tool call
|
|
3
|
+
# when it reads Crewly's own credentials.
|
|
4
|
+
#
|
|
5
|
+
# Spec: specs/2026-10-04-agent-credential-isolation.md (layer 2).
|
|
6
|
+
#
|
|
7
|
+
# Usage (wired by the backend through a generated wrapper; hook JSON on stdin):
|
|
8
|
+
# bash guard.sh <format> [<paths-file>]
|
|
9
|
+
# format: claude | codex | gemini | antigravity
|
|
10
|
+
# paths-file: written by the backend; defaults to $CREWLY_CREDENTIAL_GUARD_PATHS
|
|
11
|
+
#
|
|
12
|
+
# Paths-file lines (tab-separated):
|
|
13
|
+
# home - <CREWLY_HOME>
|
|
14
|
+
# abs <id> an absolute credential path (a file, or a directory = its subtree)
|
|
15
|
+
#
|
|
16
|
+
# What it judges — only REAL PATHS, resolved the way the call would resolve them:
|
|
17
|
+
# - shell commands (Bash, run_shell_command, agy run_command) are tokenised
|
|
18
|
+
# like a shell: quotes, escapes, `$(…)` and backticks (recursed into),
|
|
19
|
+
# ~ / $HOME / ${HOME} / $CREWLY_HOME expansion (not inside single quotes;
|
|
20
|
+
# ~ not inside double quotes). A word is a candidate path only when it
|
|
21
|
+
# starts with `/`, or contains `/` and is resolved against the call's cwd,
|
|
22
|
+
# and only when the whole word (or the value after `=`) is that path. A
|
|
23
|
+
# bare word (`cloud`, `credentials`, `api-token`) never matches, and a
|
|
24
|
+
# quoted sentence that mentions a path (a commit message) is one word
|
|
25
|
+
# that is not a path.
|
|
26
|
+
# - file tools: only argument fields whose NAME says path/file/dir
|
|
27
|
+
# (file_path, path, AbsolutePath, …), resolved against the cwd. Content
|
|
28
|
+
# fields (write_file / write_to_file bodies) are never read.
|
|
29
|
+
# - `security find-generic-password|find-internet-password|export|-i`
|
|
30
|
+
# in a command that names `crewly`, and any `security dump-keychain`.
|
|
31
|
+
#
|
|
32
|
+
# Deny contract:
|
|
33
|
+
# claude, codex, gemini exit 2, reason on stderr (fed back to the model)
|
|
34
|
+
# antigravity stdout {"decision":"deny","reason":"..."}, exit 0;
|
|
35
|
+
# an allowed call prints NOTHING (agy reads `{}` and
|
|
36
|
+
# a non-zero exit as deny)
|
|
37
|
+
#
|
|
38
|
+
# Fails open: no CREWLY_SESSION_NAME (the owner's own runtime), no node, no
|
|
39
|
+
# paths file, unparsable input -> allowed (agy) / non-blocking error (others).
|
|
40
|
+
#
|
|
41
|
+
# Coverage limits: paths built at runtime ($(echo …), base64, globs, other
|
|
42
|
+
# variables), a script the agent writes and then runs, an interpreter reading
|
|
43
|
+
# a computed path. It is a speed bump, not a boundary: same OS user.
|
|
44
|
+
|
|
45
|
+
set -u
|
|
46
|
+
|
|
47
|
+
FORMAT="${1:-claude}"
|
|
48
|
+
PATHS_FILE="${2:-${CREWLY_CREDENTIAL_GUARD_PATHS:-}}"
|
|
49
|
+
PREFIX="credential-guard:"
|
|
50
|
+
REASON_TAIL="Crewly credentials are not available to agents. Use the connector skills instead (docs-read, docs-comment, drive-read, sheets-read, gmail-search, ...) — they act for you without handing out a token. If a skill cannot do what you need, tell the owner instead of working around it."
|
|
51
|
+
|
|
52
|
+
INPUT="$(cat)"
|
|
53
|
+
|
|
54
|
+
# Owner's own runtime (no agent session): never judged.
|
|
55
|
+
SESSION="${CREWLY_SESSION_NAME:-}"
|
|
56
|
+
[ -z "$SESSION" ] && exit 0
|
|
57
|
+
|
|
58
|
+
if [ -z "$PATHS_FILE" ] || [ ! -f "$PATHS_FILE" ]; then
|
|
59
|
+
echo "$PREFIX NO PATHS CHECKED — paths file missing (${PATHS_FILE:-<none>})" >&2
|
|
60
|
+
[ "$FORMAT" = "antigravity" ] && exit 0
|
|
61
|
+
exit 1
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
65
|
+
echo "$PREFIX node not found — nothing checked" >&2
|
|
66
|
+
[ "$FORMAT" = "antigravity" ] && exit 0
|
|
67
|
+
exit 1
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
# The matcher prints the rule id of the first guarded path the call reads, or nothing.
|
|
71
|
+
read -r -d '' MATCHER <<'JS'
|
|
72
|
+
const fs = require('fs');
|
|
73
|
+
const path = require('path');
|
|
74
|
+
const [pathsFile] = process.argv.slice(1);
|
|
75
|
+
let input = '';
|
|
76
|
+
process.stdin.on('data', (c) => { input += c; });
|
|
77
|
+
process.stdin.on('end', () => {
|
|
78
|
+
let o;
|
|
79
|
+
try { o = JSON.parse(input); } catch { return; }
|
|
80
|
+
let home = '';
|
|
81
|
+
const guarded = [];
|
|
82
|
+
for (const line of fs.readFileSync(pathsFile, 'utf8').split('\n')) {
|
|
83
|
+
const [kind, id, value] = line.split('\t');
|
|
84
|
+
if (kind === 'home') home = value;
|
|
85
|
+
else if (kind === 'abs' && id && value) guarded.push({ id, p: path.resolve(value) });
|
|
86
|
+
}
|
|
87
|
+
const HOME = process.env.HOME || '';
|
|
88
|
+
const CH = home || process.env.CREWLY_HOME || '';
|
|
89
|
+
|
|
90
|
+
const tool = String(o.tool_name || (o.toolCall && o.toolCall.name) || '');
|
|
91
|
+
const args = (o.tool_input || (o.toolCall && o.toolCall.args) || {});
|
|
92
|
+
const cwd = String(o.cwd || (args && args.Cwd) || (Array.isArray(o.workspacePaths) && o.workspacePaths[0]) || process.cwd());
|
|
93
|
+
|
|
94
|
+
const hit = (abs) => {
|
|
95
|
+
const p = path.resolve(abs);
|
|
96
|
+
for (const g of guarded) if (p === g.p || p.startsWith(g.p + '/')) return g.id;
|
|
97
|
+
return null;
|
|
98
|
+
};
|
|
99
|
+
const expandVars = (s) => s
|
|
100
|
+
.replace(/\$\{HOME\}|\$HOME(?![A-Za-z0-9_])/g, HOME)
|
|
101
|
+
.replace(/\$\{CREWLY_HOME\}|\$CREWLY_HOME(?![A-Za-z0-9_])/g, CH);
|
|
102
|
+
// bareOk: a path FIELD (file tools) — a bare name is a path, and ~ is
|
|
103
|
+
// expanded here. Shell words arrive already expanded by the tokeniser
|
|
104
|
+
// (a quoted ~ stays literal, as in a shell).
|
|
105
|
+
const asPath = (s, bareOk) => {
|
|
106
|
+
if (!s) return null;
|
|
107
|
+
if (bareOk && s.startsWith('~/')) s = HOME + s.slice(1);
|
|
108
|
+
if (s.startsWith('/')) return s;
|
|
109
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) return null; // URL
|
|
110
|
+
if (s.includes('/') || bareOk) return path.resolve(cwd, s);
|
|
111
|
+
return null;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// ---- shell tokeniser ----
|
|
115
|
+
const OPS = new Set([';', '&', '|', '<', '>', '(', ')', '\n']);
|
|
116
|
+
function tokenize(cmd) {
|
|
117
|
+
const out = []; // { w: string|null (null = operator), q: bool quoted }
|
|
118
|
+
const subs = [];
|
|
119
|
+
let w = '', started = false, quotedAll = true, i = 0;
|
|
120
|
+
const push = () => { if (started) out.push({ w, q: quotedAll }); w = ''; started = false; quotedAll = true; };
|
|
121
|
+
const readSub = (openIdx) => { // $( ... ) with nesting
|
|
122
|
+
let depth = 1, j = openIdx;
|
|
123
|
+
while (j < cmd.length && depth > 0) { if (cmd[j] === '(') depth++; else if (cmd[j] === ')') depth--; j++; }
|
|
124
|
+
subs.push(cmd.slice(openIdx, j - 1));
|
|
125
|
+
return j;
|
|
126
|
+
};
|
|
127
|
+
while (i < cmd.length) {
|
|
128
|
+
const c = cmd[i];
|
|
129
|
+
if (c === '\\' && i + 1 < cmd.length) { w += cmd[i + 1]; started = true; quotedAll = false; i += 2; continue; }
|
|
130
|
+
if (c === "'") { const j = cmd.indexOf("'", i + 1); const end = j < 0 ? cmd.length : j; w += cmd.slice(i + 1, end); started = true; i = end + 1; continue; }
|
|
131
|
+
if (c === '"') {
|
|
132
|
+
let j = i + 1, seg = '';
|
|
133
|
+
while (j < cmd.length && cmd[j] !== '"') {
|
|
134
|
+
if (cmd[j] === '\\' && j + 1 < cmd.length) { seg += cmd[j + 1]; j += 2; continue; }
|
|
135
|
+
if (cmd[j] === '$' && cmd[j + 1] === '(') { j = readSub(j + 2); seg += '\u0000'; continue; }
|
|
136
|
+
if (cmd[j] === '`') { const k = cmd.indexOf('`', j + 1); const e = k < 0 ? cmd.length : k; subs.push(cmd.slice(j + 1, e)); seg += '\u0000'; j = e + 1; continue; }
|
|
137
|
+
seg += cmd[j]; j++;
|
|
138
|
+
}
|
|
139
|
+
w += expandVars(seg); started = true; i = j + 1; continue;
|
|
140
|
+
}
|
|
141
|
+
if (c === '$' && cmd[i + 1] === '(') { i = readSub(i + 2); w += '\u0000'; started = true; quotedAll = false; continue; }
|
|
142
|
+
if (c === '`') { const k = cmd.indexOf('`', i + 1); const e = k < 0 ? cmd.length : k; subs.push(cmd.slice(i + 1, e)); w += '\u0000'; started = true; quotedAll = false; i = e + 1; continue; }
|
|
143
|
+
if (/\s/.test(c) && c !== '\n') { push(); i++; continue; }
|
|
144
|
+
if (OPS.has(c)) { push(); out.push({ w: null }); i++; continue; }
|
|
145
|
+
// unquoted text: ~ at the start of a word, and variables
|
|
146
|
+
// ~ at the start of a word, or right after `=` / `:` (bash expands --opt=~/x too)
|
|
147
|
+
if (c === '~' && (!started || /[=:]$/.test(w)) && (cmd[i + 1] === '/' || i + 1 === cmd.length)) { w += HOME; started = true; quotedAll = false; i++; continue; }
|
|
148
|
+
if (c === '$') {
|
|
149
|
+
const m = /^\$\{(HOME|CREWLY_HOME)\}|^\$(HOME|CREWLY_HOME)(?![A-Za-z0-9_])/.exec(cmd.slice(i));
|
|
150
|
+
if (m) { w += (m[1] || m[2]) === 'HOME' ? HOME : CH; started = true; quotedAll = false; i += m[0].length; continue; }
|
|
151
|
+
}
|
|
152
|
+
w += c; started = true; quotedAll = false; i++;
|
|
153
|
+
}
|
|
154
|
+
push();
|
|
155
|
+
return { tokens: out, subs };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const SKIP = new Set(['sudo', 'command', 'env', 'nohup', 'time', 'xargs', 'exec', 'builtin']);
|
|
159
|
+
function checkCommand(cmd, depth) {
|
|
160
|
+
if (depth > 4) return null;
|
|
161
|
+
const { tokens, subs } = tokenize(cmd);
|
|
162
|
+
for (const s of subs) { const r = checkCommand(s, depth + 1); if (r) return r; }
|
|
163
|
+
// simple commands
|
|
164
|
+
let simple = [];
|
|
165
|
+
const cmds = [];
|
|
166
|
+
for (const t of tokens) { if (t.w === null) { if (simple.length) cmds.push(simple); simple = []; } else simple.push(t.w); }
|
|
167
|
+
if (simple.length) cmds.push(simple);
|
|
168
|
+
const lowerAll = cmd.toLowerCase();
|
|
169
|
+
for (const words of cmds) {
|
|
170
|
+
let k = 0;
|
|
171
|
+
while (k < words.length && (SKIP.has(words[k]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[k]))) k++;
|
|
172
|
+
const verb = (words[k] || '').split('/').pop();
|
|
173
|
+
const rest = words.slice(k + 1).map((x) => x.toLowerCase());
|
|
174
|
+
if (verb === 'security') {
|
|
175
|
+
if (rest.includes('dump-keychain')) return 'keychain';
|
|
176
|
+
if (rest.some((x) => ['find-generic-password', 'find-internet-password', 'export', '-i'].includes(x)) && lowerAll.includes('crewly')) return 'keychain';
|
|
177
|
+
}
|
|
178
|
+
for (const word of words) {
|
|
179
|
+
if (word.includes('\u0000')) continue; // contains a substitution: judged through the substitution
|
|
180
|
+
const cands = [word];
|
|
181
|
+
const eq = word.indexOf('=');
|
|
182
|
+
if (eq > 0) cands.push(word.slice(eq + 1));
|
|
183
|
+
for (const c of cands) {
|
|
184
|
+
if (/\s/.test(c)) continue; // a sentence, not a path
|
|
185
|
+
const p = asPath(c, false);
|
|
186
|
+
if (p) { const id = hit(p); if (id) return id; }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function checkPathFields(obj) {
|
|
194
|
+
if (!obj || typeof obj !== 'object') return null;
|
|
195
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
196
|
+
if (!/path|file|dir/i.test(k)) continue; // never content fields
|
|
197
|
+
const vals = Array.isArray(v) ? v : [v];
|
|
198
|
+
for (const x of vals) {
|
|
199
|
+
if (typeof x !== 'string') continue;
|
|
200
|
+
const p = asPath(expandVars(x), true);
|
|
201
|
+
if (p) { const id = hit(p); if (id) return id; }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const shellCmd = typeof args.command === 'string' ? args.command : typeof args.CommandLine === 'string' ? args.CommandLine : null;
|
|
208
|
+
const isShell = /^(bash|run_shell_command|run_command|shell|exec_command)$/i.test(tool) || (shellCmd !== null && !/write|replace|edit/i.test(tool));
|
|
209
|
+
let id = null;
|
|
210
|
+
if (isShell && shellCmd !== null) id = checkCommand(shellCmd, 0);
|
|
211
|
+
if (!id && !/write|replace|edit|create/i.test(tool)) id = checkPathFields(args);
|
|
212
|
+
if (id) process.stdout.write(id);
|
|
213
|
+
});
|
|
214
|
+
JS
|
|
215
|
+
|
|
216
|
+
MATCH_ID="$(printf '%s' "$INPUT" | node -e "$MATCHER" "$PATHS_FILE" 2>/dev/null)"
|
|
217
|
+
MATCH_ID="$(printf '%s' "$MATCH_ID" | tr -cd 'A-Za-z0-9_-' | cut -c1-64)"
|
|
218
|
+
|
|
219
|
+
[ -z "$MATCH_ID" ] && exit 0
|
|
220
|
+
|
|
221
|
+
REASON="Blocked: this reads Crewly's own credentials (${MATCH_ID}). ${REASON_TAIL}"
|
|
222
|
+
|
|
223
|
+
# ---- tell the backend (warning in its log; the owner hears once a day) ------
|
|
224
|
+
API_URL="${CREWLY_API_URL:-http://localhost:${WEB_PORT:-8787}}"
|
|
225
|
+
if command -v curl >/dev/null 2>&1; then
|
|
226
|
+
CURL_ARGS=(-s -o /dev/null --max-time 2 -X POST "$API_URL/api/agent-hooks"
|
|
227
|
+
-H "Content-Type: application/json"
|
|
228
|
+
-H "User-Agent: crewly-credential-guard/1"
|
|
229
|
+
-H "X-Agent-Session: $SESSION")
|
|
230
|
+
if [ -n "${CREWLY_AGENT_BADGE:-}" ]; then
|
|
231
|
+
CURL_ARGS+=(-H "X-Agent-Badge: $CREWLY_AGENT_BADGE")
|
|
232
|
+
fi
|
|
233
|
+
curl "${CURL_ARGS[@]}" --data "{\"event\":\"CredentialAccessBlocked\",\"rule\":\"$MATCH_ID\",\"runtime\":\"$(printf '%s' "$FORMAT" | tr -cd 'a-z')\"}" >/dev/null 2>&1 || true
|
|
234
|
+
fi
|
|
235
|
+
|
|
236
|
+
if [ "$FORMAT" = "antigravity" ]; then
|
|
237
|
+
ESCAPED="$(printf '%s' "$REASON" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')"
|
|
238
|
+
printf '{"decision":"deny","reason":"%s"}\n' "$ESCAPED"
|
|
239
|
+
exit 0
|
|
240
|
+
fi
|
|
241
|
+
|
|
242
|
+
echo "$REASON" >&2
|
|
243
|
+
exit 2
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Docs Comment
|
|
3
|
+
description: Handle feedback left as comments in a Google Doc — list comments and replies, reply, resolve, or add a comment (via the Google Workspace grant held by Crewly Cloud). Writes as the owner's Google account — say what you posted.
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
category: productivity
|
|
6
|
+
skillType: claude-skill
|
|
7
|
+
assignableRoles:
|
|
8
|
+
- orchestrator
|
|
9
|
+
- team-leader
|
|
10
|
+
- developer
|
|
11
|
+
- operations
|
|
12
|
+
- ops
|
|
13
|
+
- sales
|
|
14
|
+
- support
|
|
15
|
+
- generalist
|
|
16
|
+
triggers:
|
|
17
|
+
- comments in the doc
|
|
18
|
+
- google doc comments
|
|
19
|
+
- reply to the comment
|
|
20
|
+
- resolve the comment
|
|
21
|
+
- feedback in the doc
|
|
22
|
+
- add a comment to the doc
|
|
23
|
+
tags:
|
|
24
|
+
- google
|
|
25
|
+
- docs
|
|
26
|
+
- comments
|
|
27
|
+
execution:
|
|
28
|
+
type: script
|
|
29
|
+
script:
|
|
30
|
+
file: execute.sh
|
|
31
|
+
interpreter: bash
|
|
32
|
+
timeoutMs: 60000
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
# Docs Comment
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
bash execute.sh list --doc "https://docs.google.com/document/d/1AbC…/edit"
|
|
39
|
+
bash execute.sh list --doc 1AbC… --include-resolved
|
|
40
|
+
bash execute.sh reply --doc 1AbC… --comment AAAB… --text "Updated the intro, see paragraph 2."
|
|
41
|
+
bash execute.sh resolve --doc 1AbC… --comment AAAB… --text "Done in v2."
|
|
42
|
+
bash execute.sh resolve --doc 1AbC… --comment AAAB…
|
|
43
|
+
bash execute.sh add --doc 1AbC… --text "Is this number from Q2?" --quote "grew 40%"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`--doc` takes a document id or its docs.google.com URL. `--account` picks a
|
|
47
|
+
connected Google account (see below).
|
|
48
|
+
|
|
49
|
+
## Working through feedback
|
|
50
|
+
|
|
51
|
+
1. `list` the open comments. Each one has the text it is about (`quote`),
|
|
52
|
+
who wrote it and when, and any replies.
|
|
53
|
+
2. Make the change the comment asks for (for example with `docs-write`), or
|
|
54
|
+
decide it needs the owner.
|
|
55
|
+
3. `resolve` the comment with a one-line note of what changed, or `reply`
|
|
56
|
+
with a question if it is unclear. Do not resolve a comment you did not
|
|
57
|
+
address.
|
|
58
|
+
|
|
59
|
+
Everything you post appears under the owner's Google name. Tell the owner
|
|
60
|
+
what you replied to or resolved.
|
|
61
|
+
|
|
62
|
+
## Output
|
|
63
|
+
|
|
64
|
+
`list`:
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{"success":true,"docId":"1AbC…","count":1,"truncated":false,"comments":[{"id":"AAAB…","author":"Steve","createdTime":"2026-10-03T09:12:00.000Z","content":"Too long","quote":"In this document we…","anchor":"kix.x1","resolved":false,"replies":[{"id":"AAAC…","author":"Ella","createdTime":"…","content":"Trimmed."}]}]}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Resolved comments are left out unless you pass `--include-resolved`.
|
|
71
|
+
`truncated: true` means the document has more than 500 comments and only the
|
|
72
|
+
first 500 were read.
|
|
73
|
+
|
|
74
|
+
`reply` / `resolve`:
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
{"success":true,"action":"reply","docId":"1AbC…","commentId":"AAAB…","replyId":"AAAD…","content":"Updated the intro."}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`add`:
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{"success":true,"action":"add","docId":"1AbC…","commentId":"AAAE…","content":"Is this number from Q2?","quote":"grew 40%"}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Comments you add are not anchored
|
|
87
|
+
|
|
88
|
+
Google Docs does not show a comment created through the API as attached to
|
|
89
|
+
text. `--quote` is sent to Google as the quoted text, but in the Docs UI the
|
|
90
|
+
comment **may appear as a general (unanchored) comment** on the whole
|
|
91
|
+
document. When the location matters, quote the passage in the comment text
|
|
92
|
+
too, for example `--text "On 'grew 40%': is this Q2?"`.
|
|
93
|
+
|
|
94
|
+
## Failures
|
|
95
|
+
|
|
96
|
+
`{"success":false,"reason":"reauth_required",…}` (exit 1): the Google grant
|
|
97
|
+
can read comments but not write them on this document. Reply, resolve and add
|
|
98
|
+
on a document Crewly did not create need Google Drive edit access, which
|
|
99
|
+
older connections do not include. Run `google-connect --product drive --channel <chat-channel-id>` to
|
|
100
|
+
post a one-tap card for the owner, and tell them in one line what you will do
|
|
101
|
+
once it is granted. Do not paste a link yourself.
|
|
102
|
+
|
|
103
|
+
`{"success":false,"reason":"not_connected","hint":"…"}` (exit 1): Google
|
|
104
|
+
Drive is not connected. Use `google-connect --product drive` the same way.
|
|
105
|
+
|
|
106
|
+
`{"success":false,"reason":"not_permitted",…}` (exit 1): this Google
|
|
107
|
+
connection belongs to someone else and is not shared with the person you are
|
|
108
|
+
working for. Say so; do not try another account.
|
|
109
|
+
|
|
110
|
+
`reason: "google_error"` with a 404 means the document id or comment id is
|
|
111
|
+
wrong, or the account cannot see the document.
|
|
112
|
+
|
|
113
|
+
## Choosing a Google account
|
|
114
|
+
|
|
115
|
+
Several Google accounts can be connected at once. Without `--account` the call uses the default one (the first you connected, or whichever you marked default on the Connections page). Name one explicitly when it matters:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
bash execute.sh list --doc 1AbC… --account work@company.com
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The account must be connected *for this product* — Google consent is per product (Gmail / Calendar / Drive), so an account connected only for Calendar cannot read Drive.
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# =============================================================================
|
|
3
|
+
# docs-comment — List, reply to, resolve or add comments in a Google Doc
|
|
4
|
+
#
|
|
5
|
+
# Backed by:
|
|
6
|
+
# GET /api/google/docs/:id/comments[?includeResolved=1]
|
|
7
|
+
# POST /api/google/docs/:id/comments { text, quote? }
|
|
8
|
+
# POST /api/google/docs/:id/comments/:commentId/replies { text }
|
|
9
|
+
# POST /api/google/docs/:id/comments/:commentId/resolve { text? }
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# bash execute.sh list --doc <id|url> [--include-resolved]
|
|
13
|
+
# bash execute.sh reply --doc <id|url> --comment <id> --text "…"
|
|
14
|
+
# bash execute.sh resolve --doc <id|url> --comment <id> [--text "…"]
|
|
15
|
+
# bash execute.sh add --doc <id|url> --text "…" [--quote "exact text"]
|
|
16
|
+
# bash execute.sh '{"command":"list","doc":"<id>"}'
|
|
17
|
+
# =============================================================================
|
|
18
|
+
set -euo pipefail
|
|
19
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
20
|
+
source "${SCRIPT_DIR}/../../_common/lib.sh"
|
|
21
|
+
|
|
22
|
+
print_usage() {
|
|
23
|
+
cat <<'EOF_USAGE'
|
|
24
|
+
Usage:
|
|
25
|
+
bash execute.sh list --doc <id|url> [--include-resolved]
|
|
26
|
+
bash execute.sh reply --doc <id|url> --comment <id> --text "…"
|
|
27
|
+
bash execute.sh resolve --doc <id|url> --comment <id> [--text "…"]
|
|
28
|
+
bash execute.sh add --doc <id|url> --text "…" [--quote "exact text in the doc"]
|
|
29
|
+
bash execute.sh '{"command":"reply","doc":"<id>","comment":"<id>","text":"…"}'
|
|
30
|
+
|
|
31
|
+
Options:
|
|
32
|
+
--doc Document id or docs.google.com URL
|
|
33
|
+
--comment Comment id (from list)
|
|
34
|
+
--text Reply / comment text (optional for resolve)
|
|
35
|
+
--quote Exact document text the new comment is about (add only;
|
|
36
|
+
Google Docs may still show it as an unanchored comment)
|
|
37
|
+
--include-resolved list: include resolved comments too
|
|
38
|
+
--account Which connected Google account to act as (default: your primary)
|
|
39
|
+
--help | -h Show this help
|
|
40
|
+
EOF_USAGE
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
fail_from() {
|
|
44
|
+
printf '%s' "$1" | jq -c '{success: false, reason: (.details.error // .details // .error // "unknown"), hint: (.details.hint // ""), message: (.details.message // "")}' 2>/dev/null \
|
|
45
|
+
|| jq -n --arg r "$1" '{success: false, reason: $r}'
|
|
46
|
+
exit 1
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
uri() { jq -rn --arg v "$1" '$v|@uri'; }
|
|
50
|
+
|
|
51
|
+
# api_call may print a one-line warning to stderr (no CREWLY_SESSION_NAME);
|
|
52
|
+
# the backend answer is always the last line. On failure print the mapped
|
|
53
|
+
# failure JSON (fail_from) and return 1.
|
|
54
|
+
call() {
|
|
55
|
+
local out
|
|
56
|
+
out=$(api_call "$@" 2>&1) || { fail_from "$(printf '%s\n' "$out" | tail -n 1)"; }
|
|
57
|
+
printf '%s\n' "$out" | tail -n 1
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
INPUT_JSON=""
|
|
61
|
+
COMMAND=""
|
|
62
|
+
if [[ $# -gt 0 && ${1:0:1} == '{' ]]; then
|
|
63
|
+
INPUT_JSON="$1"
|
|
64
|
+
shift || true
|
|
65
|
+
elif [[ $# -gt 0 && ${1:0:1} != '-' ]]; then
|
|
66
|
+
COMMAND="$1"
|
|
67
|
+
shift
|
|
68
|
+
fi
|
|
69
|
+
DOC=""; COMMENT=""; TEXT=""; TEXT_SET=0; QUOTE=""; INCLUDE_RESOLVED=0; ACCOUNT=""
|
|
70
|
+
while [[ $# -gt 0 ]]; do
|
|
71
|
+
case "$1" in
|
|
72
|
+
--doc|--id) [ $# -ge 2 ] || error_exit "$1 requires a value"; DOC="$2"; shift 2 ;;
|
|
73
|
+
--comment) [ $# -ge 2 ] || error_exit "--comment requires a value"; COMMENT="$2"; shift 2 ;;
|
|
74
|
+
--text) [ $# -ge 2 ] || error_exit "--text requires a value"; TEXT="$2"; TEXT_SET=1; shift 2 ;;
|
|
75
|
+
--quote) [ $# -ge 2 ] || error_exit "--quote requires a value"; QUOTE="$2"; shift 2 ;;
|
|
76
|
+
--include-resolved) INCLUDE_RESOLVED=1; shift ;;
|
|
77
|
+
--account) [ $# -ge 2 ] || error_exit "--account requires a value"; ACCOUNT="$2"; shift 2 ;;
|
|
78
|
+
--help|-h) print_usage; exit 0 ;;
|
|
79
|
+
*) error_exit "Unknown option: $1" ;;
|
|
80
|
+
esac
|
|
81
|
+
done
|
|
82
|
+
if [ -n "$INPUT_JSON" ]; then
|
|
83
|
+
INPUT=$(read_json_input "$INPUT_JSON")
|
|
84
|
+
[ -z "$COMMAND" ] && COMMAND=$(printf '%s' "$INPUT" | jq -r '.command // .action // empty')
|
|
85
|
+
[ -z "$DOC" ] && DOC=$(printf '%s' "$INPUT" | jq -r '.doc // .id // .documentId // empty')
|
|
86
|
+
[ -z "$COMMENT" ] && COMMENT=$(printf '%s' "$INPUT" | jq -r '.comment // .commentId // empty')
|
|
87
|
+
[ "$TEXT_SET" -eq 0 ] && TEXT=$(printf '%s' "$INPUT" | jq -r '.text // empty')
|
|
88
|
+
[ -z "$QUOTE" ] && QUOTE=$(printf '%s' "$INPUT" | jq -r '.quote // empty')
|
|
89
|
+
[ "$INCLUDE_RESOLVED" -eq 0 ] && [ "$(printf '%s' "$INPUT" | jq -r '.includeResolved // false')" = "true" ] && INCLUDE_RESOLVED=1
|
|
90
|
+
[ -z "$ACCOUNT" ] && ACCOUNT=$(printf '%s' "$INPUT" | jq -r '.account // empty')
|
|
91
|
+
fi
|
|
92
|
+
# Route the call at one connected Google account; unset means the default.
|
|
93
|
+
[ -n "$ACCOUNT" ] && export CREWLY_GOOGLE_ACCOUNT="$ACCOUNT"
|
|
94
|
+
# Accept a full docs.google.com URL too.
|
|
95
|
+
DOC=$(printf '%s' "$DOC" | sed -E 's#.*/document/d/([^/?]+).*#\1#')
|
|
96
|
+
|
|
97
|
+
[ -n "$COMMAND" ] || error_exit "a command is required: list, reply, resolve or add (see --help)"
|
|
98
|
+
require_param "doc (--doc)" "$DOC"
|
|
99
|
+
BASE="/google/docs/$(uri "$DOC")/comments"
|
|
100
|
+
|
|
101
|
+
case "$COMMAND" in
|
|
102
|
+
list)
|
|
103
|
+
QS=""; [ "$INCLUDE_RESOLVED" -eq 1 ] && QS="?includeResolved=1"
|
|
104
|
+
RESPONSE=$(call GET "${BASE}${QS}") || { printf '%s\n' "$RESPONSE"; exit 1; }
|
|
105
|
+
# Oversized answers come back as the output-cap envelope; pass it through.
|
|
106
|
+
if printf '%s' "$RESPONSE" | jq -e '.truncated == true and has("file")' >/dev/null 2>&1; then printf '%s\n' "$RESPONSE"; exit 0; fi
|
|
107
|
+
printf '%s' "$RESPONSE" | jq -c '{success: (.success // false), docId: .data.docId, count: (.data.comments | length), truncated: (.data.truncated // false), comments: .data.comments}'
|
|
108
|
+
;;
|
|
109
|
+
reply)
|
|
110
|
+
require_param "comment (--comment)" "$COMMENT"
|
|
111
|
+
require_param "text (--text)" "$TEXT"
|
|
112
|
+
BODY=$(jq -cn --arg text "$TEXT" '{text: $text}')
|
|
113
|
+
RESPONSE=$(call POST "${BASE}/$(uri "$COMMENT")/replies" "$BODY") || { printf '%s\n' "$RESPONSE"; exit 1; }
|
|
114
|
+
printf '%s' "$RESPONSE" | jq -c '{success: (.success // false), action: "reply", docId: .data.docId, commentId: .data.commentId, replyId: .data.id, content: .data.content}'
|
|
115
|
+
;;
|
|
116
|
+
resolve)
|
|
117
|
+
require_param "comment (--comment)" "$COMMENT"
|
|
118
|
+
BODY=$(jq -cn --arg text "$TEXT" 'if $text != "" then {text: $text} else {} end')
|
|
119
|
+
RESPONSE=$(call POST "${BASE}/$(uri "$COMMENT")/resolve" "$BODY") || { printf '%s\n' "$RESPONSE"; exit 1; }
|
|
120
|
+
printf '%s' "$RESPONSE" | jq -c '{success: (.success // false), action: "resolve", docId: .data.docId, commentId: .data.commentId, replyId: .data.id, content: (.data.content // ""), resolved: true}'
|
|
121
|
+
;;
|
|
122
|
+
add)
|
|
123
|
+
require_param "text (--text)" "$TEXT"
|
|
124
|
+
BODY=$(jq -cn --arg text "$TEXT" --arg quote "$QUOTE" '{text: $text} + (if $quote != "" then {quote: $quote} else {} end)')
|
|
125
|
+
RESPONSE=$(call POST "${BASE}" "$BODY") || { printf '%s\n' "$RESPONSE"; exit 1; }
|
|
126
|
+
printf '%s' "$RESPONSE" | jq -c '{success: (.success // false), action: "add", docId: .data.docId, commentId: .data.id, content: .data.content} + (if .data.quote then {quote: .data.quote} else {} end)'
|
|
127
|
+
;;
|
|
128
|
+
*)
|
|
129
|
+
error_exit "Unknown command: $COMMAND (expected list, reply, resolve or add)"
|
|
130
|
+
;;
|
|
131
|
+
esac
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Tests for docs-comment — run with: bash execute.test.sh
|
|
3
|
+
# Python HTTP stub as the backend; asserts the request the skill sends and
|
|
4
|
+
# the JSON it prints. No Google call is made. Exit 0 on pass.
|
|
5
|
+
set -u
|
|
6
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
7
|
+
EXEC="$HERE/execute.sh"
|
|
8
|
+
PASS=0; FAIL=0
|
|
9
|
+
check() {
|
|
10
|
+
local name="$1"; local got="$2"; local want="$3"
|
|
11
|
+
if [ "$got" = "$want" ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); echo "FAIL: $name"; echo " want: $want"; echo " got: $got"; fi
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
PORT=18831
|
|
15
|
+
STUB_LOG="$(mktemp)"; export STUB_LOG; export STUB_PORT="$PORT"
|
|
16
|
+
python3 -u <<'PY' >/dev/null 2>&1 &
|
|
17
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
18
|
+
import json, os
|
|
19
|
+
LOG = os.environ['STUB_LOG']; PORT = int(os.environ['STUB_PORT'])
|
|
20
|
+
COMMENT = {'id': 'c1', 'author': 'Steve', 'createdTime': 't0', 'content': 'Too long', 'quote': 'intro',
|
|
21
|
+
'resolved': False, 'replies': [{'id': 'r0', 'author': 'Ella', 'createdTime': 't1', 'content': 'Trimmed'}]}
|
|
22
|
+
class H(BaseHTTPRequestHandler):
|
|
23
|
+
def _send(self, status, payload):
|
|
24
|
+
self.send_response(status); self.send_header('Content-Type', 'application/json'); self.end_headers()
|
|
25
|
+
self.wfile.write(json.dumps(payload).encode())
|
|
26
|
+
def _reply(self, method, data=None):
|
|
27
|
+
open(LOG, 'w').write(json.dumps({'method': method, 'path': self.path, 'body': data,
|
|
28
|
+
'account': self.headers.get('X-Google-Account')}))
|
|
29
|
+
if '/docs/narrow/' in self.path:
|
|
30
|
+
return self._send(403, {'success': False, 'error': 'reauth_required',
|
|
31
|
+
'message': 'Replying to or adding comments on this document needs Google Drive edit access, which this Google account has not granted yet.',
|
|
32
|
+
'hint': 'Ask the owner to reconnect Google Drive: run the google-connect skill with --product drive.'})
|
|
33
|
+
if method == 'GET':
|
|
34
|
+
return self._send(200, {'success': True, 'data': {'docId': 'd1', 'comments': [COMMENT], 'truncated': False}})
|
|
35
|
+
if self.path.endswith('/replies'):
|
|
36
|
+
return self._send(200, {'success': True, 'data': {'docId': 'd1', 'commentId': 'c1', 'id': 'r9', 'content': data['text']}})
|
|
37
|
+
if self.path.endswith('/resolve'):
|
|
38
|
+
return self._send(200, {'success': True, 'data': {'docId': 'd1', 'commentId': 'c1', 'id': 'r10', 'action': 'resolve', 'content': data.get('text', ''), 'resolved': True}})
|
|
39
|
+
out = {'docId': 'd1', 'id': 'c7', 'content': data['text'], 'resolved': False, 'replies': []}
|
|
40
|
+
if 'quote' in data: out['quote'] = data['quote']
|
|
41
|
+
return self._send(200, {'success': True, 'data': out})
|
|
42
|
+
def do_GET(self): self._reply('GET')
|
|
43
|
+
def do_POST(self):
|
|
44
|
+
n = int(self.headers.get('content-length', '0')); body = self.rfile.read(n).decode() if n else ''
|
|
45
|
+
self._reply('POST', json.loads(body or '{}'))
|
|
46
|
+
def log_message(self, *a, **k): pass
|
|
47
|
+
HTTPServer(('127.0.0.1', PORT), H).serve_forever()
|
|
48
|
+
PY
|
|
49
|
+
STUB_PID=$!
|
|
50
|
+
disown "$STUB_PID" 2>/dev/null || true
|
|
51
|
+
trap 'kill "$STUB_PID" >/dev/null 2>&1; rm -f "$STUB_LOG"' EXIT
|
|
52
|
+
for i in $(seq 1 30); do curl -s -o /dev/null -m 0.2 "http://127.0.0.1:${PORT}/" 2>/dev/null && break; sleep 0.1; done
|
|
53
|
+
|
|
54
|
+
run() { CREWLY_API_URL="http://127.0.0.1:${PORT}" bash "$EXEC" "$@" 2>/dev/null; }
|
|
55
|
+
run_err() { CREWLY_API_URL="http://127.0.0.1:${PORT}" bash "$EXEC" "$@" 2>&1 >/dev/null; }
|
|
56
|
+
req() { jq -r '.method + " " + .path + " " + (.body|tojson)' "$STUB_LOG"; }
|
|
57
|
+
|
|
58
|
+
# list
|
|
59
|
+
OUT=$(run list --doc "https://docs.google.com/document/d/d1/edit?tab=t.0")
|
|
60
|
+
check "list: request" "$(req)" 'GET /api/google/docs/d1/comments null'
|
|
61
|
+
check "list: output" "$(printf '%s' "$OUT" | jq -c '{success, docId, count, truncated, first: .comments[0].id, quote: .comments[0].quote, replies: (.comments[0].replies|length)}')" \
|
|
62
|
+
'{"success":true,"docId":"d1","count":1,"truncated":false,"first":"c1","quote":"intro","replies":1}'
|
|
63
|
+
run list --doc d1 --include-resolved >/dev/null
|
|
64
|
+
check "list: --include-resolved" "$(req)" 'GET /api/google/docs/d1/comments?includeResolved=1 null'
|
|
65
|
+
|
|
66
|
+
# reply
|
|
67
|
+
OUT=$(run reply --doc d1 --comment c1 --text "Fixed, see para 2")
|
|
68
|
+
check "reply: request" "$(req)" 'POST /api/google/docs/d1/comments/c1/replies {"text":"Fixed, see para 2"}'
|
|
69
|
+
check "reply: output" "$OUT" '{"success":true,"action":"reply","docId":"d1","commentId":"c1","replyId":"r9","content":"Fixed, see para 2"}'
|
|
70
|
+
|
|
71
|
+
# resolve, with and without a message
|
|
72
|
+
OUT=$(run resolve --doc d1 --comment c1 --text "Done")
|
|
73
|
+
check "resolve: request" "$(req)" 'POST /api/google/docs/d1/comments/c1/resolve {"text":"Done"}'
|
|
74
|
+
check "resolve: output" "$OUT" '{"success":true,"action":"resolve","docId":"d1","commentId":"c1","replyId":"r10","content":"Done","resolved":true}'
|
|
75
|
+
run resolve --doc d1 --comment c1 >/dev/null
|
|
76
|
+
check "resolve: no text" "$(req)" 'POST /api/google/docs/d1/comments/c1/resolve {}'
|
|
77
|
+
|
|
78
|
+
# add, with and without a quote
|
|
79
|
+
OUT=$(run add --doc d1 --text "Source?" --quote "grew 40%")
|
|
80
|
+
check "add: request" "$(req)" 'POST /api/google/docs/d1/comments {"text":"Source?","quote":"grew 40%"}'
|
|
81
|
+
check "add: output" "$OUT" '{"success":true,"action":"add","docId":"d1","commentId":"c7","content":"Source?","quote":"grew 40%"}'
|
|
82
|
+
run add --doc d1 --text "General" >/dev/null
|
|
83
|
+
check "add: no quote" "$(req)" 'POST /api/google/docs/d1/comments {"text":"General"}'
|
|
84
|
+
|
|
85
|
+
# JSON input + --account
|
|
86
|
+
run '{"command":"reply","doc":"d1","comment":"c1","text":"via json","account":"work@x.com"}' >/dev/null
|
|
87
|
+
check "json input: request" "$(req)" 'POST /api/google/docs/d1/comments/c1/replies {"text":"via json"}'
|
|
88
|
+
check "json input: account header" "$(jq -r '.account' "$STUB_LOG")" 'work@x.com'
|
|
89
|
+
|
|
90
|
+
# reauth_required is passed through with its English hint, exit 1
|
|
91
|
+
OUT=$(run reply --doc narrow --comment c1 --text "x"); CODE=$?
|
|
92
|
+
check "reauth: exit code" "$CODE" "1"
|
|
93
|
+
check "reauth: reason" "$(printf '%s' "$OUT" | jq -r '.reason')" "reauth_required"
|
|
94
|
+
check "reauth: hint names the one-tap path" "$(printf '%s' "$OUT" | jq -r '.hint' | grep -c 'google-connect skill with --product drive')" "1"
|
|
95
|
+
|
|
96
|
+
# argument validation (no request sent)
|
|
97
|
+
check "no command" "$(run_err --doc d1 | grep -c 'command is required')" "1"
|
|
98
|
+
check "unknown command" "$(run_err delete --doc d1 | grep -c 'Unknown command')" "1"
|
|
99
|
+
check "reply needs --comment" "$(run_err reply --doc d1 --text x | grep -c 'comment')" "1"
|
|
100
|
+
check "reply needs --text" "$(run_err reply --doc d1 --comment c1 | grep -c 'text')" "1"
|
|
101
|
+
check "add needs --text" "$(run_err add --doc d1 | grep -c 'text')" "1"
|
|
102
|
+
check "needs --doc" "$(run_err list | grep -c 'doc')" "1"
|
|
103
|
+
|
|
104
|
+
echo "docs-comment: $PASS passed, $FAIL failed"
|
|
105
|
+
[ "$FAIL" -eq 0 ]
|