Commit Diff


commit - aefa0f7b7f4b2b1a1e2882c03ef8fb5f2f4c32aa
commit + c227bd7f6c0f7d41565ed3a1fd4a788ff9476e76
blob - 8014b4dbbe132b6bad147ecf8b5c02a8c1fc7153
blob + b5023a2431dac3c9ef2c8c84710e5f84baf8e910
--- docs/adr/0010-experimental-codex-subscription-compatibility.md
+++ docs/adr/0010-experimental-codex-subscription-compatibility.md
@@ -41,6 +41,14 @@ drift, expiry, routing regimes, and header injection, 
 one bounded account id. This is not signature verification: the fixed TLS
 endpoint remains authoritative for token authenticity and entitlement.
 
+As of 2026-07-21, current personal access tokens encode the exact API audience
+as either a string or a singleton array and may carry
+`chatgpt_compute_residency: "no_constraint"` in the namespaced auth claim.
+Those two equivalent no-routing shapes are accepted. Any other audience-array
+shape, residency value or location, FedRAMP marker, or routing claim still
+fails before network use; Fugu does not derive or guess a routing header from
+the token.
+
 The first profile performs no login or refresh. Expiry and HTTP 401/403 fail
 once with a bounded manual-replacement hint. Automatic refresh is rejected
 until a separate design can atomically persist rotated credentials without
blob - 62ba0f58e01ba2a656f2a119f9ac37a0a7c89274
blob + c22dd7c09bb3884d673c9a8d485196b39b36e143
--- docs/design/codex-subscription.md
+++ docs/design/codex-subscription.md
@@ -134,10 +134,14 @@ decodes the bounded JWT payload and extracts the one C
 carried by the token; absence, ambiguity, an unsupported claim shape, or an
 expired token fails closed before inference.
 
-The first profile supports an ordinary personal account only. If token claims
-indicate FedRAMP, data-residency, compute-residency, or another routing regime,
-Fugu reports that the account profile is unsupported and sends nothing. It
-does not guess or omit a routing header required by that account.
+The first profile supports an ordinary personal account only. The exact
+audience may be a string or a singleton array containing the same one profile
+audience. The namespaced auth claim may omit compute residency or contain the
+exact string `chatgpt_compute_residency: "no_constraint"`; both mean that no
+routing header is required. Any other audience-array shape, FedRAMP marker,
+data-residency claim, compute-residency value or location, or other routing
+regime is unsupported and sends nothing. Fugu does not guess or omit a routing
+header required by an account.
 
 This local JWT parse is not signature verification and must never be described
 as authentication. Fugu has no reason to import an RSA/JWK stack or use raw
blob - 0dcc78adfcf3de859794e024c16eea86f5ea2e1e
blob + 96efc6a322c04ca4a0d98b6178ca4dba1959c005
--- regress/codex_subscription/codex_subscription_test.c
+++ regress/codex_subscription/codex_subscription_test.c
@@ -1378,11 +1378,17 @@ static void
 test_synthetic_jwt_and_exact_headers(void)
 {
 	static const char valid_payload[] =
-	    "{\"iss\":\"https://auth.openai.com\",\"aud\":"
-	    "\"https://api.openai.com/v1\",\"exp\":4102444800,"
+	    "{\"iss\":\"https://auth.openai.com\",\"aud\":["
+	    "\"https://api.openai.com/v1\"],\"exp\":4102444800,"
 	    "\"https://api.openai.com/auth\":{\"chatgpt_plan_type\":\"plus\","
 	    "\"chatgpt_account_id\":\"acct_personal\","
+	    "\"chatgpt_compute_residency\":\"no_constraint\","
 	    "\"chatgpt_account_is_fedramp\":false}}";
+	static const char legacy_payload[] =
+	    "{\"iss\":\"https://auth.openai.com\",\"aud\":"
+	    "\"https://api.openai.com/v1\",\"exp\":4102444800,"
+	    "\"https://api.openai.com/auth\":{"
+	    "\"chatgpt_account_id\":\"acct_personal\"}}";
 	static const char want_suffix[] =
 	    "\r\nChatGPT-Account-ID: acct_personal\r\n"
 	    "User-Agent: fugu/0.3\r\n"
@@ -1413,6 +1419,7 @@ test_synthetic_jwt_and_exact_headers(void)
 	CHECK(strstr(headers, "Cookie:") == NULL &&
 	    strstr(headers, "originator:") == NULL &&
 	    strstr(headers, "OpenAI-Beta:") == NULL &&
+	    strstr(headers, "x-openai-internal-codex-residency:") == NULL &&
 	    strstr(headers, "x-openai-internal-codex-responses-lite:") == NULL &&
 	    strstr(headers, "session") == NULL && strstr(headers, "request-id") == NULL);
 	CHECK(codex_sub_build_headers(small, sizeof(small), &auth, &wire) == -1);
@@ -1445,6 +1452,10 @@ test_synthetic_jwt_and_exact_headers(void)
 	CHECK(codex_sub_auth_parse(&auth, malformed.data, malformed.len,
 	    2000000000) == 0);
 	buf_free(&malformed);
+	synthetic_jwt(&malformed, legacy_payload);
+	CHECK(codex_sub_auth_parse(&auth, malformed.data, malformed.len,
+	    2000000000) == 0);
+	buf_free(&malformed);
 	strlcpy(auth.account_id, "acct", sizeof(auth.account_id));
 	CHECK(!codex_sub_auth_valid(&auth, 2000000000));
 	buf_free(&want);
@@ -1462,10 +1473,14 @@ test_jwt_rejection_matrix(void)
 	    "{\"iss\":\"https://auth.openai.com\",\"aud\":\"other\","
 	    "\"exp\":4102444800,\"https://api.openai.com/auth\":{"
 	    "\"chatgpt_account_id\":\"acct\"}}";
-	static const char audience_array[] =
+	static const char audience_array_multiple[] =
 	    "{\"iss\":\"https://auth.openai.com\",\"aud\":["
-	    "\"https://api.openai.com/v1\"],\"exp\":4102444800,"
+	    "\"https://api.openai.com/v1\",\"other\"],\"exp\":4102444800,"
 	    "\"https://api.openai.com/auth\":{\"chatgpt_account_id\":\"acct\"}}";
+	static const char audience_array_empty[] =
+	    "{\"iss\":\"https://auth.openai.com\",\"aud\":[],"
+	    "\"exp\":4102444800,\"https://api.openai.com/auth\":{"
+	    "\"chatgpt_account_id\":\"acct\"}}";
 	static const char expired[] =
 	    "{\"iss\":\"https://auth.openai.com\",\"aud\":"
 	    "\"https://api.openai.com/v1\",\"exp\":100,"
@@ -1527,6 +1542,21 @@ test_jwt_rejection_matrix(void)
 	    "\"https://api.openai.com/v1\",\"exp\":4102444800,"
 	    "\"https://api.openai.com/auth\":{\"chatgpt_account_id\":\"acct\","
 	    "\"compute_region\":\"us\"}}";
+	static const char required_compute_residency[] =
+	    "{\"iss\":\"https://auth.openai.com\",\"aud\":"
+	    "\"https://api.openai.com/v1\",\"exp\":4102444800,"
+	    "\"https://api.openai.com/auth\":{\"chatgpt_account_id\":\"acct\","
+	    "\"chatgpt_compute_residency\":\"us\"}}";
+	static const char misplaced_compute_residency[] =
+	    "{\"iss\":\"https://auth.openai.com\",\"aud\":"
+	    "\"https://api.openai.com/v1\",\"exp\":4102444800,"
+	    "\"chatgpt_compute_residency\":\"no_constraint\","
+	    "\"https://api.openai.com/auth\":{\"chatgpt_account_id\":\"acct\"}}";
+	static const char wrong_compute_residency_type[] =
+	    "{\"iss\":\"https://auth.openai.com\",\"aud\":"
+	    "\"https://api.openai.com/v1\",\"exp\":4102444800,"
+	    "\"https://api.openai.com/auth\":{\"chatgpt_account_id\":\"acct\","
+	    "\"chatgpt_compute_residency\":false}}";
 	struct codex_sub_auth auth;
 	struct buf payload;
 	struct json_out out;
@@ -1534,7 +1564,8 @@ test_jwt_rejection_matrix(void)
 
 	CHECK(auth_rejected(wrong_issuer, 2000000000));
 	CHECK(auth_rejected(wrong_audience, 2000000000));
-	CHECK(auth_rejected(audience_array, 2000000000));
+	CHECK(auth_rejected(audience_array_multiple, 2000000000));
+	CHECK(auth_rejected(audience_array_empty, 2000000000));
 	CHECK(auth_rejected(expired, 2000000000));
 	CHECK(auth_rejected(missing_account, 2000000000));
 	CHECK(auth_rejected(short_account, 2000000000));
@@ -1548,6 +1579,9 @@ test_jwt_rejection_matrix(void)
 	CHECK(auth_rejected(lf_account, 2000000000));
 	CHECK(auth_rejected(wrong_account_type, 2000000000));
 	CHECK(auth_rejected(compute_residency, 2000000000));
+	CHECK(auth_rejected(required_compute_residency, 2000000000));
+	CHECK(auth_rejected(misplaced_compute_residency, 2000000000));
+	CHECK(auth_rejected(wrong_compute_residency_type, 2000000000));
 	CHECK(codex_sub_auth_parse(&auth, "one.two", 7, 0) == -1);
 	CHECK(codex_sub_auth_parse(&auth, "one.***.three", 13, 0) == -1);
 	CHECK(codex_sub_auth_parse(&auth, "one.two.three.four", 18, 0) == -1);
blob - 11cedc584df9a6b0bb4378ac0b72711f6ed68109
blob + a32b30ae1e7d9f8f90ecaba0fef229f563f73f8c
--- regress/conf/conf_test.c
+++ regress/conf/conf_test.c
@@ -108,6 +108,18 @@ parse_fails(const char *content)
 	return (parse_str(content, &alice, NULL) == -1);
 }
 
+static size_t
+count_substr(const char *haystack, const char *needle)
+{
+	size_t count = 0, len = strlen(needle);
+
+	while ((haystack = strstr(haystack, needle)) != NULL) {
+		count++;
+		haystack += len;
+	}
+	return (count);
+}
+
 /* Churn the heap so freed memory gets junked (verification.md). */
 static void
 churn(void)
@@ -527,6 +539,9 @@ test_providers(void)
 {
 	struct fugu_conf	*c;
 	struct fugu_provider	*p;
+	FILE			*f;
+	char			*dump = NULL, *work, *fast;
+	size_t			 dumpsz = 0;
 
 	CHECK(parse_ok(
 	    "provider \"work\" {\n"
@@ -550,6 +565,18 @@ test_providers(void)
 	CHECK(p != NULL && strcmp(p->name, "fast") == 0);
 	CHECK(p != NULL && p->type == PROVIDER_ANTHROPIC);
 	CHECK(p != NULL && strcmp(p->api_port, "8443") == 0);
+	if ((f = open_memstream(&dump, &dumpsz)) == NULL)
+		err(1, "open_memstream");
+	conf_dump(c, f);
+	fclose(f);
+	work = strstr(dump, "provider \"work\" {\n");
+	fast = strstr(dump, "provider \"fast\" {\n");
+	CHECK(strncmp(dump, "model ", 6) != 0);
+	CHECK(strstr(dump, "provider \"openai\"\n") == NULL);
+	CHECK(count_substr(dump, "provider \"work\" {\n") == 1);
+	CHECK(count_substr(dump, "provider \"fast\" {\n") == 1);
+	CHECK(work != NULL && fast != NULL && work < fast);
+	free(dump);
 	conf_free(c);
 
 	/* a usable flat provider remains slot zero even with named blocks */
blob - 1a3f8a3803e7f54682686df9a69e84053e17b26e
blob + 1c51cf6a13e1a8d05141abcc49e134dbbfbd688b
--- src/common/codex_sub_wire.c
+++ src/common/codex_sub_wire.c
@@ -133,6 +133,21 @@ copy_string(const struct json *json, int token, char *
 }
 
 static int
+claim_string_equal(const struct json *json, int token, const char *expected)
+{
+	char value[128];
+	int item;
+
+	if (copy_string(json, token, value, sizeof(value)) == 0)
+		return (strcmp(value, expected) == 0 ? 0 : -1);
+	if (json_arr_len(json, token) != 1 ||
+	    (item = json_arr_get(json, token, 0)) < 0 ||
+	    copy_string(json, item, value, sizeof(value)) == -1)
+		return (-1);
+	return (strcmp(value, expected) == 0 ? 0 : -1);
+}
+
+static int
 key_equal(const char *key, size_t len, const char *want)
 {
 	return (strlen(want) == len && memcmp(key, want, len) == 0);
@@ -166,7 +181,7 @@ routing_key(const char *key, size_t len)
 
 static int
 scan_claims(const struct json *json, int token, int depth, int *accounts,
-    int *routed, int *fedramp_claims)
+    int *routed, int *fedramp_claims, int *compute_residency_claims)
 {
 	char *key;
 	size_t keylen;
@@ -184,11 +199,14 @@ scan_claims(const struct json *json, int token, int de
 			if (key_equal(key, keylen,
 			    "chatgpt_account_is_fedramp"))
 				(*fedramp_claims)++;
-			if (routing_key(key, keylen))
+			if (key_equal(key, keylen,
+			    "chatgpt_compute_residency"))
+				(*compute_residency_claims)++;
+			else if (routing_key(key, keylen))
 				*routed = 1;
 			free(key);
 			if (scan_claims(json, value, depth + 1, accounts,
-			    routed, fedramp_claims) == -1)
+			    routed, fedramp_claims, compute_residency_claims) == -1)
 				return (-1);
 		}
 		return (0);
@@ -196,7 +214,8 @@ scan_claims(const struct json *json, int token, int de
 	if ((n = json_arr_len(json, token)) >= 0) {
 		for (i = 0; i < n; i++)
 			if (scan_claims(json, json_arr_get(json, token, i),
-			    depth + 1, accounts, routed, fedramp_claims) == -1)
+			    depth + 1, accounts, routed, fedramp_claims,
+			    compute_residency_claims) == -1)
 				return (-1);
 	}
 	return (0);
@@ -233,9 +252,9 @@ parse_claims(struct codex_sub_auth *auth, const void *
 {
 	const struct codex_sub_profile *profile = codex_sub_profile();
 	struct json json;
-	char issuer[128], audience[128];
+	char issuer[128], compute_residency[32];
 	int root, token, claim, accounts = 0, routed = 0, fedramp;
-	int fedramp_claims = 0, valid = -1;
+	int fedramp_claims = 0, compute_residency_claims = 0, valid = -1;
 	int64_t expires;
 
 	if (now < 0 || !json_valid_object(data, len) ||
@@ -243,14 +262,13 @@ parse_claims(struct codex_sub_auth *auth, const void *
 		return (-1);
 	root = json_root(&json);
 	if (scan_claims(&json, root, 0, &accounts, &routed,
-	    &fedramp_claims) == -1 ||
+	    &fedramp_claims, &compute_residency_claims) == -1 ||
 	    accounts != 1 || routed ||
 	    (token = obj_unique(&json, root, "iss")) < 0 ||
 	    copy_string(&json, token, issuer, sizeof(issuer)) == -1 ||
 	    strcmp(issuer, profile->oauth_issuer) != 0 ||
 	    (token = obj_unique(&json, root, "aud")) < 0 ||
-	    copy_string(&json, token, audience, sizeof(audience)) == -1 ||
-	    strcmp(audience, profile->oauth_audience) != 0 ||
+	    claim_string_equal(&json, token, profile->oauth_audience) == -1 ||
 	    (token = obj_unique(&json, root, "exp")) < 0 ||
 	    json_get_num(&json, token, &expires) == -1 || expires <= now ||
 	    (claim = obj_unique(&json, root, profile->auth_claim)) < 0 ||
@@ -265,6 +283,14 @@ parse_claims(struct codex_sub_auth *auth, const void *
 	    (token >= 0 &&
 	    ((fedramp = json_get_bool(&json, token)) == -1 || fedramp)))
 		goto done;
+	token = obj_unique(&json, claim, "chatgpt_compute_residency");
+	if (token == -2 ||
+	    compute_residency_claims != (token >= 0 ? 1 : 0) ||
+	    (token >= 0 &&
+	    (copy_string(&json, token, compute_residency,
+	    sizeof(compute_residency)) == -1 ||
+	    strcmp(compute_residency, "no_constraint") != 0)))
+		goto done;
 	auth->expires_at = expires;
 	valid = 0;
 
blob - 1ef14f37825d275c150fa9163bc9ae33c6a2aaf6
blob + 7be5e751328f33735714ca3cb04dbae3d3588929
--- src/fugu/conf.c
+++ src/fugu/conf.c
@@ -283,34 +283,37 @@ void
 conf_dump(const struct fugu_conf *c, FILE *f)
 {
 	const struct fugu_provider	*p;
-	const struct fugu_provider	*active = NULL;
 	const char			*host, *port, *path, *key;
 
-	if (c->initial_provider_slot > 0)
-		active = TAILQ_FIRST(&c->providers);
-	host = active != NULL ? conf_prov_host(active) : conf_api_host(c);
-	port = active != NULL ? conf_prov_port(active) : conf_api_port(c);
-	path = active != NULL ? conf_prov_path(active) : conf_api_path(c);
-	key = active != NULL ? (conf_provider_uses_oauth(active->type) ?
-	    active->oauth_token : active->api_key) : conf_effective_key(c);
-
-	fprintf(f, "model \"%s\"\n", c->model);
-	fprintf(f, "provider \"%s\"\n", conf_provider_name(c->provider_type));
-	if (!conf_provider_endpoint_fixed(c->provider_type)) {
-		fprintf(f, "api_host \"%s\"\n", host);
-		fprintf(f, "api_port \"%s\"\n", port);
-		fprintf(f, "api_path \"%s\"\n", path);
+	/* A named-only configuration has no implicit slot zero to print.  Its
+	 * first block is selected initially, and the blocks below retain source
+	 * order; synthesizing that block here made -n display it twice. */
+	if (c->initial_provider_slot == 0) {
+		host = conf_api_host(c);
+		port = conf_api_port(c);
+		path = conf_api_path(c);
+		key = conf_effective_key(c);
+		fprintf(f, "model \"%s\"\n", c->model);
+		fprintf(f, "provider \"%s\"\n",
+		    conf_provider_name(c->provider_type));
+		if (!conf_provider_endpoint_fixed(c->provider_type)) {
+			fprintf(f, "api_host \"%s\"\n", host);
+			fprintf(f, "api_port \"%s\"\n", port);
+			fprintf(f, "api_path \"%s\"\n", path);
+		}
+		fprintf(f, "%s %s\n",
+		    conf_provider_uses_oauth(c->provider_type) ?
+		    "oauth_token" : "api_key", key != NULL ? REDACTED :
+		    "(not set)");
+		if (c->provider_type == PROVIDER_CLAUDE)
+			fprintf(f, "# experimental Claude subscription profile %s "
+			    "(snapshot %s)\n", CLAUDE_SUB_PROFILE_ID,
+			    CLAUDE_SUB_PROFILE_DATE);
+		else if (c->provider_type == PROVIDER_CODEX)
+			fprintf(f, "# experimental Codex subscription profile %s "
+			    "(snapshot %s)\n", codex_sub_profile()->id,
+			    codex_sub_profile()->snapshot_date);
 	}
-	fprintf(f, "%s %s\n", conf_provider_uses_oauth(c->provider_type) ?
-	    "oauth_token" : "api_key", key != NULL ? REDACTED : "(not set)");
-	if (c->provider_type == PROVIDER_CLAUDE)
-		fprintf(f, "# experimental Claude subscription profile %s "
-		    "(snapshot %s)\n", CLAUDE_SUB_PROFILE_ID,
-		    CLAUDE_SUB_PROFILE_DATE);
-	else if (c->provider_type == PROVIDER_CODEX)
-		fprintf(f, "# experimental Codex subscription profile %s "
-		    "(snapshot %s)\n", codex_sub_profile()->id,
-		    codex_sub_profile()->snapshot_date);
 	fprintf(f, "kagi_token %s\n", c->kagi_token != NULL ?
 	    REDACTED : "(not set)");
 	fprintf(f, "max_tokens %lld\n", (long long)c->max_tokens);
@@ -370,6 +373,14 @@ conf_dump(const struct fugu_conf *c, FILE *f)
 		}
 		fprintf(f, "\t%s %s\n", conf_provider_uses_oauth(p->type) ?
 		    "oauth_token" : "api_key", REDACTED);
+		if (p->type == PROVIDER_CLAUDE)
+			fprintf(f, "\t# experimental Claude subscription profile "
+			    "%s (snapshot %s)\n", CLAUDE_SUB_PROFILE_ID,
+			    CLAUDE_SUB_PROFILE_DATE);
+		else if (p->type == PROVIDER_CODEX)
+			fprintf(f, "\t# experimental Codex subscription profile "
+			    "%s (snapshot %s)\n", codex_sub_profile()->id,
+			    codex_sub_profile()->snapshot_date);
 		fprintf(f, "}\n");
 	}
 }
blob - bfc4f08bda7c3ccfcec4f400e53c9daa68b7f6e7
blob + c67bb9e8f4d39179fe5b07178ccb7244c97a758c
--- src/fugu/fugu.1
+++ src/fugu/fugu.1
@@ -93,6 +93,8 @@ Parse the configuration, resolve defaults and the firs
 .Cm match
 block for the invoking user, print the effective configuration with all
 secrets redacted, and exit.
+When only named Provider blocks are configured, they are printed once in
+declaration order; the first remains the initial Provider.
 .It Fl o Ar format
 Select machine-readable output.
 The supported formats are