@meffecta/agent 0.0.1

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.
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env node
2
+ // One-time, local: mint the OAuth refresh token the agent uses to read a Microsoft
3
+ // 365 mailbox through Microsoft Graph. Opens a browser consent flow via a loopback
4
+ // redirect and prints the refresh token — store it as MSGRAPH_<NAME>_REFRESH_TOKEN.
5
+ //
6
+ // Delegated, never app-only. An app-only Mail.Read grant reaches EVERY mailbox in
7
+ // the tenant and has to be fenced back in with an Exchange ApplicationAccessPolicy;
8
+ // a delegated token can only ever reach the mailbox that signs in below. For a
9
+ // mailbox in someone else's company that difference is the whole point.
10
+ //
11
+ // Prerequisites (once, in the target tenant — you must be able to register an app):
12
+ // 1. Entra admin center → App registrations → New registration.
13
+ // - Supported account types: "Accounts in this organizational directory only".
14
+ // - Platform "Web", Redirect URI EXACTLY http://localhost:3014/
15
+ // Entra matches redirect URIs exactly — there is no random-loopback-port
16
+ // exemption as with Google, which is why the port here is pinned.
17
+ // 2. Certificates & secrets → New client secret; copy the VALUE, not the id.
18
+ // The secret is what makes this a confidential client, whose refresh tokens
19
+ // live until revoked. Without one, Entra caps refresh tokens at 90 days and
20
+ // this breaks every quarter. Client secrets themselves expire (24 months max)
21
+ // — note the date somewhere you will see it.
22
+ // 3. API permissions → Microsoft Graph → Delegated → Mail.Read AND Calendars.Read.
23
+ // Grant admin consent if the tenant requires it. (--no-calendar mints without
24
+ // Calendars.Read; the calendar is normally the more useful half, so it is on
25
+ // by default and dropping it later means a fresh consent round.)
26
+ // 4. Note the Application (client) ID and the Directory (tenant) ID.
27
+ //
28
+ // Usage:
29
+ // node scripts/mint-graph-token.mjs --account MOXSEA --tenant <tenant-id> \
30
+ // --client-id <id> --client-secret <secret> [--no-calendar]
31
+ //
32
+ // Sign in as the mailbox the token is for when the browser opens — the everyday
33
+ // account, NOT a tenant admin account. An Admin-* identity usually carries no
34
+ // Exchange licence, so the consent succeeds and every later mail call returns 404
35
+ // "mailbox is either inactive, soft-deleted, or is hosted on-premise". If the
36
+ // browser is already signed in as the admin, use "Use another account" or a private
37
+ // window. Scopes are read-only by construction: this mints no Mail.Send and no write
38
+ // scope of any kind, so a token minted here cannot send, reply, or delete.
39
+
40
+ import { createHash, randomBytes } from "node:crypto";
41
+ import { createServer } from "node:http";
42
+ import { parseArgs } from "node:util";
43
+
44
+ const { values: args } = parseArgs({
45
+ options: {
46
+ account: { type: "string" },
47
+ tenant: { type: "string" },
48
+ "client-id": { type: "string" },
49
+ "client-secret": { type: "string" },
50
+ "no-calendar": { type: "boolean", default: false },
51
+ },
52
+ });
53
+
54
+ // Entra's redirect URI must match the registration exactly, so this is fixed.
55
+ const PORT = 3014;
56
+ const REDIRECT_URI = `http://localhost:${PORT}/`;
57
+
58
+ const accountName = args.account?.toUpperCase();
59
+ if (!accountName || !/^[A-Z0-9]+$/.test(accountName)) {
60
+ console.error(
61
+ "--account is required and must be a single alphanumeric word (it becomes MSGRAPH_<NAME>_REFRESH_TOKEN)",
62
+ );
63
+ process.exit(1);
64
+ }
65
+ const tokenVar = `MSGRAPH_${accountName}_REFRESH_TOKEN`;
66
+
67
+ const tenant = args.tenant ?? process.env.MSGRAPH_TENANT_ID;
68
+ const clientId = args["client-id"] ?? process.env.MSGRAPH_CLIENT_ID;
69
+ const clientSecret = args["client-secret"] ?? process.env.MSGRAPH_CLIENT_SECRET;
70
+ if (!tenant || !clientId || !clientSecret) {
71
+ console.error("usage: mint-graph-token.mjs --account <NAME> --tenant <id> --client-id <id> --client-secret <secret>");
72
+ process.exit(1);
73
+ }
74
+
75
+ const SCOPES = [
76
+ "offline_access",
77
+ "https://graph.microsoft.com/Mail.Read",
78
+ ...(args["no-calendar"] ? [] : ["https://graph.microsoft.com/Calendars.Read"]),
79
+ ];
80
+
81
+ // PKCE: required for public clients, harmless here, and it keeps the authorization
82
+ // code useless to anything that intercepts the loopback redirect.
83
+ const verifier = randomBytes(32).toString("base64url");
84
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
85
+
86
+ const server = createServer();
87
+ await new Promise((resolve, reject) => {
88
+ server.once("error", (err) =>
89
+ reject(
90
+ err.code === "EADDRINUSE"
91
+ ? new Error(`port ${PORT} is busy — free it; the redirect URI is registered against that exact port`)
92
+ : err,
93
+ ),
94
+ );
95
+ server.listen(PORT, "127.0.0.1", resolve);
96
+ });
97
+
98
+ const authUrl = new URL(`https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`);
99
+ authUrl.search = new URLSearchParams({
100
+ client_id: clientId,
101
+ response_type: "code",
102
+ redirect_uri: REDIRECT_URI,
103
+ response_mode: "query",
104
+ scope: SCOPES.join(" "),
105
+ code_challenge: challenge,
106
+ code_challenge_method: "S256",
107
+ // Force account choice: signing in as the wrong identity is the easy mistake here.
108
+ prompt: "select_account",
109
+ }).toString();
110
+
111
+ console.error(`Open this URL and sign in as the ${accountName} mailbox (read-only scopes: ${SCOPES.join(", ")}).`);
112
+ console.error("Use the everyday account, not a tenant admin account — admin identities have no mailbox.\n");
113
+ console.error(authUrl.toString());
114
+ console.error("\nWaiting for the redirect...");
115
+
116
+ const code = await new Promise((resolve, reject) => {
117
+ server.on("request", (req, res) => {
118
+ const url = new URL(req.url, REDIRECT_URI);
119
+ const err = url.searchParams.get("error");
120
+ const description = url.searchParams.get("error_description");
121
+ const c = url.searchParams.get("code");
122
+ res.writeHead(200, { "content-type": "text/html" });
123
+ res.end(err ? `<h3>Failed: ${err}</h3><p>${description ?? ""}</p>` : "<h3>Done — you can close this tab.</h3>");
124
+ if (err) reject(new Error(`${err}: ${description ?? ""}`));
125
+ else if (c) resolve(c);
126
+ });
127
+ });
128
+ server.close();
129
+
130
+ const res = await fetch(`https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`, {
131
+ method: "POST",
132
+ headers: { "content-type": "application/x-www-form-urlencoded" },
133
+ body: new URLSearchParams({
134
+ client_id: clientId,
135
+ client_secret: clientSecret,
136
+ code,
137
+ grant_type: "authorization_code",
138
+ redirect_uri: REDIRECT_URI,
139
+ code_verifier: verifier,
140
+ scope: SCOPES.join(" "),
141
+ }),
142
+ });
143
+ const json = await res.json();
144
+ if (!res.ok || !json.refresh_token) {
145
+ console.error(`token exchange failed (${res.status}): ${JSON.stringify(json.error_description ?? json)}`);
146
+ process.exit(1);
147
+ }
148
+
149
+ // Refresh token to stdout (pipe straight into gcloud), everything else to stderr.
150
+ console.error(`\nRefresh token minted — it is ${tokenVar}. Store it without it touching your shell history:`);
151
+ console.error(` node scripts/mint-graph-token.mjs ... | gcloud secrets create ${tokenVar} --data-file=-\n`);
152
+ console.log(json.refresh_token);
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Stop a deployment's Artifact Registry filling up with engine images it will never use
5
+ # again.
6
+ #
7
+ # The images there are a **cache**, not an archive: deploy.sh mirrors them from
8
+ # ghcr.io/meffecta/agent because Cloud Run cannot pull from GHCR directly. GHCR keeps the
9
+ # real copies, so anything deleted here comes back on the next `deploy.sh --tag <that one>`.
10
+ # That is what makes an aggressive policy correct for this repo and wrong for a repo whose
11
+ # images have nowhere else to come from.
12
+ #
13
+ # The policy (artifact-cleanup-policy.json, beside this script):
14
+ # keep-recent-versions the newest 5 per image, always
15
+ # delete-untagged superseded pushes — the previous :latest, mostly — after a day
16
+ # delete-superseded anything at all, once it is 30 days old
17
+ #
18
+ # Keep rules win over delete rules in Artifact Registry, which is the safety property this
19
+ # leans on: **the image the live revision pins must never be deleted.** A Cloud Run revision
20
+ # pins a digest, and under scale-to-zero every cold start pulls it — so deleting the running
21
+ # image does not merely block the next deploy, it takes the service down at the next wake-up.
22
+ # The newest 5 covers the live one with four deploys of slack.
23
+ #
24
+ # Usage:
25
+ # scripts/set-artifact-cleanup.sh # apply the policy; deletions are real
26
+ # scripts/set-artifact-cleanup.sh --observe # apply it in Artifact Registry's own
27
+ # # dry-run mode: it logs what it would
28
+ # # delete and deletes nothing
29
+ # scripts/set-artifact-cleanup.sh --dry-run # change nothing at all, just show
30
+ # scripts/set-artifact-cleanup.sh --off # remove the policy
31
+ #
32
+ # Which deployment: ./deployment.env, or --config <file>. See deployment.env.example.
33
+
34
+ . "$(dirname "${BASH_SOURCE[0]}")/lib/deployment.sh"
35
+
36
+ POLICY_FILE="$(dirname "${BASH_SOURCE[0]}")/artifact-cleanup-policy.json"
37
+ DRY_RUN=false
38
+ OBSERVE=false
39
+ REMOVE=false
40
+
41
+ while [ $# -gt 0 ]; do
42
+ case "$1" in
43
+ --config) CONFIG_FILE="${2:?--config needs a file}"; shift 2 ;;
44
+ --policy) POLICY_FILE="${2:?--policy needs a file}"; shift 2 ;;
45
+ --observe) OBSERVE=true; shift ;;
46
+ --dry-run) DRY_RUN=true; shift ;;
47
+ --off) REMOVE=true; shift ;;
48
+ -h|--help) sed -n '3,33p' "$0"; exit 0 ;;
49
+ *) echo "Unknown argument: $1" >&2; exit 2 ;;
50
+ esac
51
+ done
52
+
53
+ require_gcloud
54
+ resolve_deployment
55
+ announce_target "Setting the image cleanup policy on ${ARTIFACT_REPO}"
56
+
57
+ [ -f "${POLICY_FILE}" ] || { echo "Policy file not found: ${POLICY_FILE}" >&2; exit 1; }
58
+
59
+ GC="gcloud --project=${PROJECT}"
60
+ [ -n "${ACCOUNT:-}" ] && GC="${GC} --account=${ACCOUNT}"
61
+
62
+ # gcloud prints the size only in its default layout, not as a --format key.
63
+ SIZE_BEFORE=$(${GC} artifacts repositories describe "${ARTIFACT_REPO}" --location="${REGION}" 2>/dev/null |
64
+ sed -n 's/^Repository Size: //p' || echo "")
65
+ [ -n "${SIZE_BEFORE}" ] && echo " current size: ${SIZE_BEFORE}"
66
+
67
+ if [ "${REMOVE}" = true ]; then
68
+ echo "🧹 Removing the cleanup policy — nothing will be deleted automatically any more."
69
+ if [ "${DRY_RUN}" = true ]; then
70
+ echo " (dry run: nothing was changed)"
71
+ exit 0
72
+ fi
73
+ ${GC} artifacts repositories delete-cleanup-policies "${ARTIFACT_REPO}" --location="${REGION}" \
74
+ --policynames=keep-recent-versions,delete-untagged,delete-superseded
75
+ exit 0
76
+ fi
77
+
78
+ echo "🧹 Policy:"
79
+ sed 's/^/ /' "${POLICY_FILE}"
80
+
81
+ FLAGS=(--policy="${POLICY_FILE}")
82
+ if [ "${OBSERVE}" = true ]; then
83
+ # Artifact Registry evaluates the policy and logs what it would remove, without removing
84
+ # it. The log filter is printed below — give it a few hours to run.
85
+ FLAGS+=(--dry-run)
86
+ echo " mode: observe (Artifact Registry logs deletions instead of making them)"
87
+ else
88
+ FLAGS+=(--no-dry-run)
89
+ echo " mode: enforcing (deletions are real, and the images come back from GHCR if needed)"
90
+ fi
91
+
92
+ if [ "${DRY_RUN}" = true ]; then
93
+ echo " (dry run: nothing was changed)"
94
+ exit 0
95
+ fi
96
+
97
+ ${GC} artifacts repositories set-cleanup-policies "${ARTIFACT_REPO}" --location="${REGION}" "${FLAGS[@]}"
98
+
99
+ echo ""
100
+ echo "✅ Applied. Artifact Registry evaluates the policy on its own schedule, so the size"
101
+ echo " drops over the following hours rather than immediately."
102
+ if [ "${OBSERVE}" = true ]; then
103
+ echo ""
104
+ echo " Watch what it would delete:"
105
+ echo " gcloud logging read 'resource.type=\"artifactregistry.googleapis.com/Repository\"" \
106
+ "AND jsonPayload.policyName!=\"\"' --project ${PROJECT} --limit 50"
107
+ echo " Then re-run without --observe to let it act."
108
+ fi
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Set plain (non-secret) environment variables on the deployment's Cloud Run service.
5
+ # Several at once become one revision instead of one each.
6
+ #
7
+ # Anything sensitive belongs in scripts/set-secret.sh instead — values passed here are
8
+ # visible in your shell history and in the service's configuration.
9
+ #
10
+ # Usage:
11
+ # scripts/set-env.sh GIT_REPO_URL=https://github.com/acme/agent-home.git
12
+ # scripts/set-env.sh GMAIL_ADDRESS=a@b.com TIMEZONE=Europe/Stockholm
13
+ # scripts/set-env.sh --unset SOME_OLD_VAR
14
+ #
15
+ # --config <file> use another deployment's config (default: ./deployment.env)
16
+
17
+ . "$(dirname "${BASH_SOURCE[0]}")/lib/deployment.sh"
18
+
19
+ PAIRS=()
20
+ UNSET_KEYS=()
21
+ DRY_RUN=false
22
+
23
+ while [ $# -gt 0 ]; do
24
+ case "$1" in
25
+ --config) CONFIG_FILE="${2:?--config needs a file}"; shift 2 ;;
26
+ --dry-run) DRY_RUN=true; shift ;;
27
+ --unset) UNSET_KEYS+=("${2:?--unset needs a name}"); shift 2 ;;
28
+ -h|--help) sed -n '4,16p' "$0"; exit 0 ;;
29
+ -*) echo "Unknown argument: $1" >&2; exit 2 ;;
30
+ *)
31
+ case "$1" in
32
+ *=*) PAIRS+=("$1") ;;
33
+ *) echo "Expected NAME=VALUE, got '$1'" >&2; exit 2 ;;
34
+ esac
35
+ shift ;;
36
+ esac
37
+ done
38
+
39
+ if [ ${#PAIRS[@]} -eq 0 ] && [ ${#UNSET_KEYS[@]} -eq 0 ]; then
40
+ echo "Nothing to do: pass NAME=VALUE pairs, or --unset NAME." >&2
41
+ exit 2
42
+ fi
43
+
44
+ require_gcloud
45
+ resolve_deployment
46
+ announce_target "Updating environment on ${SERVICE}"
47
+
48
+ ARGS=()
49
+ if [ ${#PAIRS[@]} -gt 0 ]; then
50
+ # gcloud splits --update-env-vars on commas unless a custom delimiter is declared as
51
+ # ^delim^. Values here legitimately contain commas (a job list) and @ (an address), so
52
+ # pick a delimiter none of them uses rather than assuming.
53
+ DELIM=""
54
+ for candidate in ',' '|' '#' '~'; do
55
+ if ! printf '%s\n' "${PAIRS[@]}" | grep -qF "${candidate}"; then
56
+ DELIM="${candidate}"
57
+ break
58
+ fi
59
+ done
60
+ [ -n "${DELIM}" ] || { echo "Values use every candidate delimiter; set these one at a time." >&2; exit 1; }
61
+
62
+ JOINED=""
63
+ for pair in "${PAIRS[@]}"; do
64
+ [ -n "${JOINED}" ] && JOINED="${JOINED}${DELIM}"
65
+ JOINED="${JOINED}${pair}"
66
+ echo " set ${pair%%=*}"
67
+ done
68
+ if [ "${DELIM}" = ',' ]; then
69
+ ARGS+=(--update-env-vars "${JOINED}")
70
+ else
71
+ ARGS+=(--update-env-vars "^${DELIM}^${JOINED}")
72
+ fi
73
+ fi
74
+
75
+ if [ ${#UNSET_KEYS[@]} -gt 0 ]; then
76
+ REMOVE=""
77
+ for key in "${UNSET_KEYS[@]}"; do
78
+ [ -n "${REMOVE}" ] && REMOVE="${REMOVE},"
79
+ REMOVE="${REMOVE}${key}"
80
+ echo " unset ${key}"
81
+ done
82
+ ARGS+=(--remove-env-vars "${REMOVE}")
83
+ fi
84
+
85
+ echo ""
86
+ if [ "${DRY_RUN}" = true ]; then
87
+ echo "(dry run: nothing was changed)"
88
+ exit 0
89
+ fi
90
+ gcloud run services update "${SERVICE}" --project "${PROJECT}" --region "${REGION}" \
91
+ "${ARGS[@]}" >/dev/null
92
+ echo "✔ Updated (new revision rolling out)."
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Put a secret in Secret Manager and bind it to the deployment's Cloud Run service.
5
+ # Creates the secret on first use, adds a new version afterwards, and wires the service
6
+ # to :latest — so re-running it rotates a value in one step.
7
+ #
8
+ # The value never appears in your shell history, in the process list, or on screen.
9
+ #
10
+ # Usage:
11
+ # scripts/set-secret.sh AGENT_WEBHOOK_SECRET --random # generate one, 32 random bytes
12
+ # scripts/set-secret.sh GITHUB_TOKEN # prompt (hidden), or read stdin
13
+ # scripts/set-secret.sh GMAIL_REFRESH_TOKEN --from-file token.txt
14
+ # cat token.txt | scripts/set-secret.sh GMAIL_REFRESH_TOKEN
15
+ #
16
+ # --config <file> use another deployment's config (default: ./deployment.env)
17
+ # --no-bind store the version only, leave the service untouched
18
+
19
+ . "$(dirname "${BASH_SOURCE[0]}")/lib/deployment.sh"
20
+
21
+ NAME=""
22
+ RANDOM_VALUE=false
23
+ FROM_FILE=""
24
+ BIND=true
25
+
26
+ while [ $# -gt 0 ]; do
27
+ case "$1" in
28
+ --config) CONFIG_FILE="${2:?--config needs a file}"; shift 2 ;;
29
+ --random) RANDOM_VALUE=true; shift ;;
30
+ --from-file) FROM_FILE="${2:?--from-file needs a path}"; shift 2 ;;
31
+ --no-bind) BIND=false; shift ;;
32
+ -h|--help) sed -n '4,20p' "$0"; exit 0 ;;
33
+ -*) echo "Unknown argument: $1" >&2; exit 2 ;;
34
+ *) NAME="$1"; shift ;;
35
+ esac
36
+ done
37
+
38
+ [ -n "${NAME}" ] || { echo "Which secret? e.g. scripts/set-secret.sh GITHUB_TOKEN" >&2; exit 2; }
39
+ case "${NAME}" in
40
+ [A-Z]*[A-Z0-9]|[A-Z]) ;;
41
+ *) echo "Secret names are UPPER_SNAKE_CASE: got '${NAME}'" >&2; exit 2 ;;
42
+ esac
43
+
44
+ require_gcloud
45
+ resolve_deployment
46
+ announce_target "Setting secret ${NAME}"
47
+
48
+ # --- Collect the value without ever putting it on the command line ---
49
+ VALUE_FILE=$(mktemp)
50
+ trap 'rm -f "${VALUE_FILE}"' EXIT
51
+
52
+ if [ "${RANDOM_VALUE}" = true ]; then
53
+ command -v openssl >/dev/null || { echo "openssl is required for --random." >&2; exit 1; }
54
+ openssl rand -hex 32 | tr -d '\n' >"${VALUE_FILE}"
55
+ echo "Generated a new 32-byte random value."
56
+ elif [ -n "${FROM_FILE}" ]; then
57
+ [ -f "${FROM_FILE}" ] || { echo "No such file: ${FROM_FILE}" >&2; exit 1; }
58
+ tr -d '\n' <"${FROM_FILE}" >"${VALUE_FILE}"
59
+ elif [ ! -t 0 ]; then
60
+ tr -d '\n' >"${VALUE_FILE}" # piped in
61
+ else
62
+ printf 'Value for %s (input hidden): ' "${NAME}" >&2
63
+ IFS= read -rs TYPED
64
+ echo "" >&2
65
+ printf '%s' "${TYPED}" >"${VALUE_FILE}"
66
+ unset TYPED
67
+ fi
68
+
69
+ [ -s "${VALUE_FILE}" ] || { echo "Refusing to store an empty value for ${NAME}." >&2; exit 1; }
70
+
71
+ if gcloud secrets describe "${NAME}" --project "${PROJECT}" >/dev/null 2>&1; then
72
+ gcloud secrets versions add "${NAME}" --project "${PROJECT}" --data-file="${VALUE_FILE}" >/dev/null
73
+ echo "✔ Added a new version of ${NAME}."
74
+ else
75
+ gcloud secrets create "${NAME}" --project "${PROJECT}" --data-file="${VALUE_FILE}" \
76
+ --replication-policy=automatic >/dev/null
77
+ echo "✔ Created secret ${NAME}."
78
+ fi
79
+
80
+ if [ "${BIND}" = true ]; then
81
+ gcloud run services update "${SERVICE}" --project "${PROJECT}" --region "${REGION}" \
82
+ --update-secrets "${NAME}=${NAME}:latest" >/dev/null
83
+ echo "✔ Bound ${NAME} on ${SERVICE} (new revision rolling out)."
84
+ else
85
+ echo "• Service untouched (--no-bind)."
86
+ fi